vienalatina/apps/board/uploads.py
Claude 5ab9e3cab5
Phase C: private messages, with blocking and photos
An inbox between two members — conversations, per-person unread marks,
photos, blocking. Not live chat: that needs a connection held open per
signed-in member, which the sync workers cannot do.

Membership of the conversation is the whole access rule and is checked on
every hit, answering 404 rather than 403 so a member cannot tell a
conversation that is not theirs from one that does not exist. A picture
in a private message is checked the same way: on the board being signed
in is enough, here it is nowhere near.

Blocking is symmetric. One row stops both directions, and you can only
lift your own. A block that silenced only the blocked person would leave
the blocker writing freely, which is a megaphone rather than a safety
feature. Enforced in the handlers, with a test that posts from a page
held open from before the block.

Erasing a member deletes their private messages, both sides, and their
pictures off disk. A thread outlives its author because other people
replied; a two-party exchange has no remainder, and keeping half of
erased correspondence is what erasure exists to prevent. The guard added
in c9c549e did its job: it failed the moment the new tables landed and
named all four columns.

The part that needed care: schema.sql is all CREATE TABLE IF NOT EXISTS,
so it can add a table and nothing else. Every change so far happened to
be a new table. Letting an attachment belong to a message is not — and
SQLite cannot do it in place, because the table carries a CHECK
constraint and there is no DROP CONSTRAINT. Verified before building on
it: ALTER TABLE ADD COLUMN succeeds and the next insert is refused.

So migrations.py, numbered steps recorded in PRAGMA user_version, run
after the schema so a fresh database finds its work already done. Step 1
rebuilds attachments the documented way. Tested against a database built
in the old shape with rows in it, because a migration tested only on a
fresh database is tested against the one case it was never needed for —
including that the rebuilt CHECK is as strict as the one it replaced.

229 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-28 16:10:52 +00:00

246 lines
9.1 KiB
Python

"""Pictures on the board.
Three decisions worth stating, because each one is a place this could have
gone wrong quietly.
**Not in the site repository.** `content.py` uploads pictures by committing
them, which is right for a post that is about to be published. A photo attached
to a thread in here is the opposite: it is private, and committing it would put
it through the build pipeline and out onto vienalatina.com. These go to a
directory on disk that only this app serves, behind the same login as the
thread they belong to.
**The file says what it is; the filename is an opinion.** `content.py` trusts
the extension, which is tolerable there because Hugo serves the result as a
static file. Here *we* serve it back, so the type is read from the first bytes
and the name the browser sent is never used for anything but display. A file
called `gato.png` that is really HTML is refused, rather than stored and later
handed back with a content type that invites the browser to run it.
**The directory lives inside the volume that gets backed up.** `/data/uploads`
sits next to `board.db` in the same mount, so there is one thing to back up,
not two — and `scripts/backup-board.sh` covers both.
"""
from __future__ import annotations
import re
import secrets
from pathlib import Path
from flask import Blueprint, abort, current_app, g, send_from_directory
from .db import get_db
from .security import login_required
bp = Blueprint("uploads", __name__)
# How many pictures one post or comment may carry. Not a security limit —
# MAX_CONTENT_LENGTH is — just a bound on what one form submission can become.
MAX_FILES = 4
# extension -> content type. The extension is ours, derived from the bytes,
# never taken from the upload.
CONTENT_TYPES = {
"jpg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"avif": "image/avif",
}
# What content.py offers writers, kept here so there is one list rather than
# two that drift apart. `jpeg` appears only as an accepted spelling; anything
# stored is named `.jpg`.
IMAGE_EXTENSIONS = set(CONTENT_TYPES) | {"jpeg"}
# Stored names are generated by this module, so the pattern is a check on our
# own output — which is exactly why it is worth having. It is the last thing
# between a crafted request and send_from_directory.
STORED_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\.[a-z]{3,4}$")
class RejectedUpload(ValueError):
"""The message is written for the member who chose the file."""
def _detect(data: bytes) -> str | None:
"""The extension these bytes actually deserve, or None.
Magic numbers rather than a library: five formats, each identified by a
fixed prefix, is less code than a dependency and has no version to track.
"""
if data.startswith(b"\xff\xd8\xff"):
return "jpg"
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if data.startswith((b"GIF87a", b"GIF89a")):
return "gif"
# Both of these are container formats: the marker sits at a fixed offset
# rather than at the very start, so a prefix check would miss them.
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
if data[4:8] == b"ftyp" and data[8:12] in (b"avif", b"avis"):
return "avif"
return None
def slugify(text: str) -> str:
"""Only ever applied to a display name to build a readable stored name.
Falls back to "imagen" rather than the empty string: a name that is all
punctuation, or written in a script this strips entirely, must still
produce something that matches STORED_NAME.
"""
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug[:48] or "imagen"
def directory() -> Path:
path = Path(current_app.config["UPLOAD_DIR"])
path.mkdir(parents=True, exist_ok=True)
return path
def stage(files) -> list[dict]:
"""Read and check everything before anything is written or inserted.
Separate from `save` on purpose. A picture that is refused must not leave a
half-made thread behind, so nothing touches the database until every file
in the submission has passed.
"""
staged = []
real = [f for f in files if f and f.filename]
if len(real) > MAX_FILES:
raise RejectedUpload(f"Como máximo {MAX_FILES} imágenes por mensaje.")
for upload in real:
data = upload.read()
if not data:
continue
maximum = current_app.config["UPLOAD_MAX_BYTES"]
if len(data) > maximum:
raise RejectedUpload(
f"«{upload.filename}» pesa {len(data) // 1024}KB y el máximo "
f"es {maximum // 1024}KB."
)
extension = _detect(data)
if extension is None:
raise RejectedUpload(
f"«{upload.filename}» no parece una imagen. Se aceptan "
f"{', '.join(sorted(CONTENT_TYPES))}."
)
stem = slugify(upload.filename.rsplit(".", 1)[0])
staged.append({
"data": data,
"original_name": upload.filename[:200],
"content_type": CONTENT_TYPES[extension],
# A random suffix rather than a counter: two people uploading
# "foto.jpg" in the same second must not race for one path.
"stored_name": f"{stem}-{secrets.token_hex(6)}.{extension}",
})
return staged
def remove(stored_name: str) -> None:
"""Take a picture off disk. Missing is not an error.
Called after a row has already gone, so the file being absent means an
earlier attempt got this far — which is the state we wanted anyway.
"""
if not STORED_NAME.match(stored_name):
return
directory().joinpath(stored_name).unlink(missing_ok=True)
def save(staged: list[dict], member_id: int,
thread_id: int | None = None, comment_id: int | None = None,
message_id: int | None = None) -> None:
"""Write the files, then record them. In that order.
A row pointing at a file that does not exist renders as a broken image on
every future visit. A file with no row is invisible and gets swept up by
the next audit — so if one of the two has to happen first, it is the file.
"""
folder = directory()
for item in staged:
(folder / item["stored_name"]).write_bytes(item["data"])
get_db().execute(
"""INSERT INTO attachments
(thread_id, comment_id, message_id, stored_name,
original_name, content_type, bytes, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(thread_id, comment_id, message_id, item["stored_name"],
item["original_name"], item["content_type"], len(item["data"]),
member_id),
)
def for_threads(thread_ids: list[int]) -> dict[int, list]:
return _grouped("thread_id", thread_ids)
def for_comments(comment_ids: list[int]) -> dict[int, list]:
return _grouped("comment_id", comment_ids)
def for_messages(message_ids: list[int]) -> dict[int, list]:
return _grouped("message_id", message_ids)
def _grouped(column: str, ids: list[int]) -> dict[int, list]:
"""One query for a whole page rather than one per comment."""
if not ids:
return {}
marks = ",".join("?" * len(ids))
rows = get_db().execute(
f"""SELECT * FROM attachments WHERE {column} IN ({marks})
ORDER BY id""",
ids,
).fetchall()
grouped: dict[int, list] = {}
for row in rows:
grouped.setdefault(row[column], []).append(row)
return grouped
def _in_conversation(conversation_id: int) -> bool:
return get_db().execute(
"""SELECT 1 FROM conversation_members
WHERE conversation_id = ? AND member_id = ?""",
(conversation_id, g.member["id"]),
).fetchone() is not None
@bp.route("/media/<name>")
@login_required
def serve(name: str):
"""Behind the login, like the thread the picture belongs to.
The deleted check is the part that is easy to leave out: threads and
comments are *soft*-deleted, so without it, taking a post down would leave
its photo readable forever by anyone who noted the URL.
"""
if not STORED_NAME.match(name):
abort(404)
row = get_db().execute(
"""SELECT a.content_type, m.conversation_id
FROM attachments a
LEFT JOIN threads t ON t.id = a.thread_id
LEFT JOIN comments c ON c.id = a.comment_id
LEFT JOIN messages m ON m.id = a.message_id
WHERE a.stored_name = ?
AND COALESCE(t.deleted_at, c.deleted_at, m.deleted_at) IS NULL""",
(name,),
).fetchone()
if row is None:
abort(404)
# A picture on the board is for every member; one in a private message is
# for the two people in that conversation and nobody else. Being signed in
# is the whole check for the first and not nearly enough for the second.
if row["conversation_id"] is not None and not _in_conversation(row["conversation_id"]):
abort(404)
# mimetype from our own column, never guessed from the name on disk, and
# paired with the X-Content-Type-Options: nosniff set in app.py.
return send_from_directory(directory(), name, mimetype=row["content_type"])