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]),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user