feat(products): app-served image route — storefront-authorized, immutable cache (SD-0002 §6.4, INV-16)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:34:04 -07:00
parent 5d5341b29b
commit 7f51f5242f
4 changed files with 124 additions and 1 deletions
+2 -1
View File
@@ -20,6 +20,7 @@ from .errors import (
RunNotFound,
)
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
from .repo import image_for_serving
from .service import (
confirm_draft,
discard_draft,
@@ -41,5 +42,5 @@ __all__ = [
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH",
"import_validate", "get_draft", "get_draft_records", "discard_draft",
"confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
"run_image_phase", "recover_incomplete_runs",
"run_image_phase", "recover_incomplete_runs", "image_for_serving",
]
+13
View File
@@ -453,6 +453,19 @@ def incomplete_runs(conn: psycopg.Connection) -> list[dict]:
return [{"id": r[0], "storefront_id": r[1]} for r in rows]
def image_for_serving(conn: psycopg.Connection, storefront_id: int, image_id: int) -> dict | None:
"""Storefront-scoped lookup for the serving route: status + rendition keys (INV-14)."""
row = conn.execute(
"SELECT i.status, i.key_original, i.key_thumb, i.key_card, i.key_detail"
" FROM product_image i JOIN product p ON p.id = i.product_id"
" WHERE i.id = %s AND p.storefront_id = %s",
(image_id, storefront_id),
).fetchone()
if row is None:
return None
return {"status": row[0], "original": row[1], "thumb": row[2], "card": row[3], "detail": row[4]}
def run_image_outcomes(conn: psycopg.Connection, run_id: int) -> list[dict]:
rows = conn.execute(
"SELECT p.handle, i.source_url, i.status, i.failure_reason,"
+31
View File
@@ -18,6 +18,7 @@ from typing import Any
import psycopg
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
from fastapi import Path as ApiPath
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -410,6 +411,36 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'},
)
_RENDITION_CT = {"thumb": "image/webp", "card": "image/webp",
"detail": "image/webp", "original": "application/octet-stream"}
@app.get("/api/products/images/{image_id}/{rendition}")
def serve_product_image(
image_id: int,
rendition: str = ApiPath(pattern="^(original|thumb|card|detail)$"),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Serve a hosted image rendition, storefront-authorized + immutable cache (§6.4, INV-16)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
rec = products.image_for_serving(conn, sf.id, image_id)
if rec is None:
return _error(404, "not_found", "No such image.")
if rec["status"] != "fetched":
return _error(409, "not_fetched", "This image has not been fetched yet.")
key = rec[rendition]
if not key:
return _error(404, "not_found", "No such rendition.")
try:
data = app.state.objectstore.get(key)
except objectstore_mod.ObjectNotFound:
return _error(404, "not_found", "No such image.")
return Response(content=data, media_type=_RENDITION_CT[rendition],
headers={"Cache-Control": "public, max-age=31536000, immutable"})
@app.get("/api/products/sample.csv")
def products_sample_csv():
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""