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
246 lines
9.1 KiB
Python
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"])
|