vienalatina/apps/board/tests/test_uploads.py
Claude 9355121b7c
Let members post pictures, and back them up
Phase B. Images on threads and comments, stored in /data/uploads —
inside the volume that already holds board.db, so there is one directory
to back up rather than two and no second mount to remember.

Not in the site repository, which is the distinction that matters: the
content editor uploads by committing, and a private photo committed
there would go through the build pipeline and out onto vienalatina.com.

The type is decided by the first bytes, not the filename. content.py
trusts the extension, which is tolerable where Hugo serves the result;
here we serve it back, so an HTML file called gato.png would be a script
running on our own origin. Five magic-number checks, no new dependency.
The uploaded name is kept only as text to show a person; the name on
disk is generated. Staging is separate from saving so a refused picture
cannot leave a half-made thread behind.

Threads and comments are soft-deleted, so the serving route checks the
parent is still live. Without it, taking a post down leaves its photo
readable by anyone who noted the URL.

And the hole this phase existed to close: backup-board.sh archived
board.db and nothing else, so the first upload would have made the
nightly backup silently incomplete while still reporting success. It now
archives the uploads directory too, verifies the archive, and names the
directory when it is empty rather than passing over it in silence.
Tested by restoring: destroyed the directory, restored from the archive,
compared bytes.

IMAGE_EXTENSIONS moves to uploads.py and content.py imports it, so the
editor and the board cannot drift apart about what counts as a picture.

46 new tests, 176 in total.

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

217 lines
8.9 KiB
Python

"""Pictures on the board.
Two things are being defended here, and they are not the same thing.
One is the member: a photo they attach must survive, be visible to other
members, and stop being visible when the post comes down.
The other is the server: an upload is the one place where a member hands over
bytes that this app later serves back. Most of these tests are about the ways
that can be abused, and they are written against the bytes rather than the
filename, because the filename is the attacker's to choose.
"""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from apps.board import uploads
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 64
WEBP = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 64
HTML = b"<!DOCTYPE html><script>alert(1)</script>"
def picture(data=PNG, name="foto.png"):
return (io.BytesIO(data), name)
def post_thread(post, follow_redirects=False, **files):
"""follow_redirects is named explicitly so it cannot fall into **files and
be posted as a form field — which it silently was, leaving two tests
asserting against a bare 302 body."""
payload = {"title": "Con foto", "body": "Mirad esto"}
payload.update(files)
return post("/comunidad/nuevo", payload, content_type="multipart/form-data",
follow_redirects=follow_redirects)
# --- what counts as an image ---------------------------------------------
@pytest.mark.parametrize("data,expected", [
(PNG, "png"), (JPEG, "jpg"), (WEBP, "webp"),
(b"GIF89a" + b"\x00" * 32, "gif"),
(b"\x00\x00\x00\x20ftypavif" + b"\x00" * 32, "avif"),
(HTML, None),
(b"", None),
(b"\x89PNG", None), # truncated magic
])
def test_the_bytes_decide(data, expected):
assert uploads._detect(data) is expected
def test_a_script_named_like_a_picture_is_refused(app, client, db, post, make_member, sign_in):
"""The filename is the uploader's to choose, so it decides nothing. This is
the case that makes sniffing worth the code: stored and served back as
image/png, an HTML file is a script running on our own origin."""
sign_in(make_member("maria"))
response = post_thread(post, pictures=picture(HTML, "gato.png"), follow_redirects=True)
assert "no parece una imagen" in response.get_data(as_text=True)
assert db.execute("SELECT 1 FROM attachments").fetchone() is None
def test_a_misnamed_but_real_picture_is_kept(app, client, db, post, make_member, sign_in):
"""The mirror of the test above, and the reason the extension is ignored
rather than compared: a JPEG called .png is somebody's phone being untidy,
not an attack, and it is stored as what it actually is."""
sign_in(make_member("maria"))
post_thread(post, pictures=picture(JPEG, "foto.png"))
row = db.execute("SELECT * FROM attachments").fetchone()
assert row["content_type"] == "image/jpeg"
assert row["stored_name"].endswith(".jpg")
def test_an_oversized_picture_is_refused(app, client, db, post, make_member, sign_in):
app.config["UPLOAD_MAX_BYTES"] = 100
sign_in(make_member("maria"))
response = post_thread(post, pictures=picture(PNG + b"\x00" * 500), follow_redirects=True)
assert "máximo" in response.get_data(as_text=True)
assert db.execute("SELECT 1 FROM attachments").fetchone() is None
def test_too_many_at_once_is_refused(app, client, db, post, make_member, sign_in):
sign_in(make_member("maria"))
response = post("/comunidad/nuevo", {
"title": "Muchas", "body": "Texto",
"pictures": [picture(PNG, f"f{n}.png") for n in range(uploads.MAX_FILES + 1)],
}, content_type="multipart/form-data", follow_redirects=True)
assert "Como máximo" in response.get_data(as_text=True)
assert db.execute("SELECT 1 FROM attachments").fetchone() is None
# --- a refusal must not leave wreckage -----------------------------------
def test_a_refused_picture_leaves_no_thread_behind(app, client, db, post, make_member, sign_in):
"""The reason staging is separate from saving. Insert the thread first and
a rejected photo leaves its author looking at a post they did not finish
writing, with no way to tell what happened."""
sign_in(make_member("maria"))
post_thread(post, pictures=picture(HTML, "malo.png"))
assert db.execute("SELECT 1 FROM threads").fetchone() is None
# --- the name on disk -----------------------------------------------------
def test_the_uploaded_name_is_never_used_as_a_path(app, client, db, post, make_member, sign_in):
sign_in(make_member("maria"))
post_thread(post, pictures=picture(PNG, "../../../etc/passwd.png"))
stored = db.execute("SELECT * FROM attachments").fetchone()
assert "/" not in stored["stored_name"]
assert uploads.STORED_NAME.match(stored["stored_name"])
# The original is kept, but only ever as text to show a person.
assert stored["original_name"] == "../../../etc/passwd.png"
def test_a_name_we_did_not_generate_is_not_served(app, client, make_member, sign_in):
sign_in(make_member("maria"))
for name in ("../board.db", "..%2Fboard.db", "board.db", "foto.png"):
assert client.get(f"/comunidad/media/{name}").status_code == 404
# --- who can see them -----------------------------------------------------
def test_a_picture_is_served_to_a_member(app, client, db, post, make_member, sign_in):
sign_in(make_member("maria"))
post_thread(post, pictures=picture(PNG))
name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"]
response = client.get(f"/comunidad/media/{name}")
assert response.status_code == 200
assert response.mimetype == "image/png"
assert response.data == PNG
def test_a_picture_is_not_served_to_a_stranger(app, client, db, post, make_member, sign_in):
sign_in(make_member("maria"))
post_thread(post, pictures=picture(PNG))
name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"]
with client.session_transaction() as session:
session.clear()
response = client.get(f"/comunidad/media/{name}")
assert response.status_code == 302
assert "/comunidad/login" in response.headers["Location"]
def test_deleting_the_thread_takes_its_pictures_out_of_reach(
app, client, db, post, make_member, sign_in):
"""Threads are soft-deleted, so without this check the row stays, the file
stays, and the photo of a post somebody asked to have removed is still
readable by anyone who noted the URL."""
sign_in(make_member("maria"))
post_thread(post, pictures=picture(PNG))
name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"]
thread_id = db.execute("SELECT id FROM threads").fetchone()["id"]
assert client.get(f"/comunidad/media/{name}").status_code == 200
post(f"/comunidad/tema/{thread_id}/eliminar")
assert client.get(f"/comunidad/media/{name}").status_code == 404
def test_the_same_applies_to_a_deleted_comment(app, client, db, post, make_member, sign_in):
member = make_member("maria")
sign_in(member)
post_thread(post, pictures=picture(PNG))
thread_id = db.execute("SELECT id FROM threads").fetchone()["id"]
post(f"/comunidad/tema/{thread_id}/comentar",
{"body": "Yo también", "pictures": picture(JPEG, "mia.jpg")},
content_type="multipart/form-data")
name = db.execute(
"SELECT stored_name FROM attachments WHERE comment_id IS NOT NULL").fetchone()["stored_name"]
comment_id = db.execute("SELECT id FROM comments").fetchone()["id"]
assert client.get(f"/comunidad/media/{name}").status_code == 200
post(f"/comunidad/comentario/{comment_id}/eliminar")
assert client.get(f"/comunidad/media/{name}").status_code == 404
# --- and they show up -----------------------------------------------------
def test_the_picture_appears_on_the_thread(app, client, db, post, make_member, sign_in):
sign_in(make_member("maria"))
post_thread(post, pictures=picture(PNG))
thread_id = db.execute("SELECT id FROM threads").fetchone()["id"]
name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"]
body = client.get(f"/comunidad/tema/{thread_id}").get_data(as_text=True)
assert f"/comunidad/media/{name}" in body
def test_the_file_reaches_the_disk(app, client, db, post, make_member, sign_in):
sign_in(make_member("maria"))
post_thread(post, pictures=picture(PNG))
name = db.execute("SELECT stored_name FROM attachments").fetchone()["stored_name"]
assert (Path(app.config["UPLOAD_DIR"]) / name).read_bytes() == PNG
def test_nothing_is_written_when_no_picture_is_chosen(app, client, db, post, make_member, sign_in):
"""An empty file input still arrives in the request; it must not become a
zero-byte attachment."""
sign_in(make_member("maria"))
post_thread(post, pictures=(io.BytesIO(b""), ""))
assert db.execute("SELECT 1 FROM attachments").fetchone() is None
assert db.execute("SELECT 1 FROM threads").fetchone() is not None