feat(attachments): provenance payload attachments + download route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 11:16:01 -04:00
parent 89103c4570
commit 74e34d359b
5 changed files with 133 additions and 1 deletions
+2
View File
@@ -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,
+27
View File
@@ -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("/<int:attachment_id>/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,
)
+34 -1
View File
@@ -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]),
}
+47
View File
@@ -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
+23
View File
@@ -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"
)