feat(attachments): provenance payload attachments + download route
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user