From 74e34d359b3de649faf4aec851adf9e4a6cffecb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 19 May 2026 11:16:01 -0400 Subject: [PATCH] feat(attachments): provenance payload attachments + download route Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 2 + backend/app/api/attachments.py | 27 +++++++++++++ backend/app/services/provenance_service.py | 35 +++++++++++++++- tests/test_api_attachments.py | 47 ++++++++++++++++++++++ tests/test_provenance_service.py | 23 +++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/attachments.py create mode 100644 tests/test_api_attachments.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 109a351..6f0085d 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -17,6 +17,7 @@ def all_blueprints() -> list[Blueprint]: from .aliases import aliases_bp from .allowlist import allowlist_bp from .artist import artist_bp + from .attachments import attachments_bp from .gallery import gallery_bp from .import_admin import import_admin_bp from .ml_admin import ml_admin_bp @@ -27,6 +28,7 @@ def all_blueprints() -> list[Blueprint]: from .tags import tags_bp return [ api_bp, + attachments_bp, gallery_bp, provenance_bp, tags_bp, diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py new file mode 100644 index 0000000..1fa8800 --- /dev/null +++ b/backend/app/api/attachments.py @@ -0,0 +1,27 @@ +"""Attachment download: streams a preserved non-art post file.""" + +from quart import Blueprint, jsonify, send_file + +from ..extensions import get_session +from ..models import PostAttachment + +attachments_bp = Blueprint( + "attachments", __name__, url_prefix="/api/attachments" +) + + +@attachments_bp.route("//download", methods=["GET"]) +async def download(attachment_id: int): + async with get_session() as session: + att = await session.get(PostAttachment, attachment_id) + if att is None: + return jsonify({"error": "not found"}), 404 + path = att.path + filename = att.original_filename + mimetype = att.mime or "application/octet-stream" + return await send_file( + path, + mimetype=mimetype, + as_attachment=True, + attachment_filename=filename, + ) diff --git a/backend/app/services/provenance_service.py b/backend/app/services/provenance_service.py index ac9d7da..091feef 100644 --- a/backend/app/services/provenance_service.py +++ b/backend/app/services/provenance_service.py @@ -9,7 +9,14 @@ mutates and never imports tag/ML modules. from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from ..models import Artist, ImageProvenance, ImageRecord, Post, Source +from ..models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + PostAttachment, + Source, +) from ..utils.html_sanitize import sanitize_post_html @@ -33,10 +40,32 @@ def _artist_dict(a: Artist) -> dict: return {"id": a.id, "name": a.name, "slug": a.slug} +def _attachment_dict(a: PostAttachment) -> dict: + return { + "id": a.id, + "original_filename": a.original_filename, + "size_bytes": a.size_bytes, + "ext": a.ext, + "download_url": f"/api/attachments/{a.id}/download", + } + + class ProvenanceService: def __init__(self, session: AsyncSession): self.session = session + async def _attachments_for_posts(self, post_ids: list[int]) -> list[dict]: + if not post_ids: + return [] + rows = ( + await self.session.execute( + select(PostAttachment) + .where(PostAttachment.post_id.in_(post_ids)) + .order_by(PostAttachment.id.asc()) + ) + ).scalars().all() + return [_attachment_dict(a) for a in rows] + async def for_image(self, image_id: int) -> dict | None: rec = await self.session.get(ImageRecord, image_id) if rec is None: @@ -51,6 +80,8 @@ class ProvenanceService: ImageProvenance.id.asc()) ) rows = (await self.session.execute(stmt)).all() + post_ids = [ip.post_id for ip, _p, _s, _a in rows] + attachments = await self._attachments_for_posts(post_ids) return { "image_id": image_id, "provenance": [ @@ -64,6 +95,7 @@ class ProvenanceService: } for ip, post, src, art in rows ], + "attachments": attachments, } async def for_post(self, post_id: int) -> dict | None: @@ -81,4 +113,5 @@ class ProvenanceService: "post": _post_dict(post), "source": _source_dict(src), "artist": _artist_dict(art), + "attachments": await self._attachments_for_posts([post.id]), } diff --git a/tests/test_api_attachments.py b/tests/test_api_attachments.py new file mode 100644 index 0000000..5516438 --- /dev/null +++ b/tests/test_api_attachments.py @@ -0,0 +1,47 @@ +import pytest + +from backend.app import create_app +from backend.app.models import Artist, PostAttachment + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_download_streams_with_disposition(client, db, tmp_path): + blob = tmp_path / "pack.zip" + blob.write_bytes(b"PK\x03\x04binarypayload") + a = Artist(name="Q", slug="q") + db.add(a) + await db.flush() + att = PostAttachment( + post_id=None, artist_id=a.id, sha256="q" + "0" * 63, + path=str(blob), original_filename="pack.zip", ext=".zip", + mime="application/zip", size_bytes=blob.stat().st_size, + ) + db.add(att) + await db.flush() + await db.commit() + + resp = await client.get(f"/api/attachments/{att.id}/download") + assert resp.status_code == 200 + disp = resp.headers.get("Content-Disposition", "") + assert "attachment" in disp + assert "pack.zip" in disp + assert (await resp.get_data()) == b"PK\x03\x04binarypayload" + + +@pytest.mark.asyncio +async def test_download_404(client): + resp = await client.get("/api/attachments/999999/download") + assert resp.status_code == 404 diff --git a/tests/test_provenance_service.py b/tests/test_provenance_service.py index 5670eb4..5cdbf60 100644 --- a/tests/test_provenance_service.py +++ b/tests/test_provenance_service.py @@ -7,6 +7,7 @@ from backend.app.models import ( ImageProvenance, ImageRecord, Post, + PostAttachment, Source, ) from backend.app.services.provenance_service import ProvenanceService @@ -153,3 +154,25 @@ async def test_for_post_returns_post_source_artist(db): "url": source.url} assert payload["artist"] == {"id": artist.id, "name": "Carol", "slug": "carol"} + + +@pytest.mark.asyncio +async def test_for_post_includes_attachments(db): + artist, source, post = await _seed_post( + db, artist_name="Att", slug="att", platform="patreon", + ext_id="55", + ) + db.add(PostAttachment( + post_id=post.id, artist_id=artist.id, sha256="t" + "0" * 63, + path="/images/attachments/t00/t.zip", original_filename="t.zip", + ext=".zip", mime="application/zip", size_bytes=9, + )) + await db.flush() + svc = ProvenanceService(db) + payload = await svc.for_post(post.id) + assert len(payload["attachments"]) == 1 + att = payload["attachments"][0] + assert att["original_filename"] == "t.zip" + assert att["download_url"].endswith( + f"/api/attachments/{att['id']}/download" + )