fix: add authenticated product image contract

This commit is contained in:
2026-09-12 16:19:41 +08:00
parent fe62a7f721
commit f900be30ba
4 changed files with 152 additions and 16 deletions
+80 -14
View File
@@ -46,10 +46,11 @@ from .schemas import (
Project, Project,
ProjectSummary, ProjectSummary,
ProductDetailResponse, ProductDetailResponse,
ProductImageRecord, ProductImageResponse,
ProductInput, ProductInput,
ProductRecord, ProductRecord,
ProductVersionArchiveResponse, ProductVersionArchiveResponse,
ProductVersionDetailResponse,
ProductWordcloudArchiveResponse, ProductWordcloudArchiveResponse,
Template, Template,
WordLocation, WordLocation,
@@ -1148,6 +1149,19 @@ def _product_archive_service() -> ProductArchiveService:
) )
def _product_image_response(row: sqlite3.Row) -> ProductImageResponse:
return ProductImageResponse(
image_id=row["image_id"],
product_id=row["product_id"],
version_id=row["version_id"],
image_path=row["image_path"],
image_type=row["image_type"],
is_cover=bool(row["is_cover"]),
created_at=datetime.fromisoformat(row["created_at"]),
image_url=f"/api/products/{row['product_id']}/images/{row['image_id']}",
)
def _version_archive_response(version_id: str) -> ProductVersionArchiveResponse: def _version_archive_response(version_id: str) -> ProductVersionArchiveResponse:
row = product_archive_store._fetchone( row = product_archive_store._fetchone(
"SELECT * FROM product_versions WHERE version_id = ?", (version_id,) "SELECT * FROM product_versions WHERE version_id = ?", (version_id,)
@@ -1172,32 +1186,50 @@ def _version_archive_response(version_id: str) -> ProductVersionArchiveResponse:
(version_id,), (version_id,),
) )
] ]
preview_image = product_archive_store._fetchone(
"""SELECT * FROM product_images
WHERE product_id = ? AND version_id = ? AND image_type = 'design_preview'
ORDER BY created_at DESC, image_id DESC LIMIT 1""",
(row["product_id"], version_id),
)
design_preview_path = (
str(preview_image["image_path"])
if preview_image and preview_image["image_path"]
else str(PRODUCT_ARCHIVES_DIR / row["product_id"] / version_id / "design-preview.png")
)
return ProductVersionArchiveResponse( return ProductVersionArchiveResponse(
version_id=row["version_id"], version_id=row["version_id"],
product_id=row["product_id"], product_id=row["product_id"],
version=row["version"], version=row["version"],
metadata=json.loads(row["metadata"] or "{}"), metadata=json.loads(row["metadata"] or "{}"),
created_at=datetime.fromisoformat(row["created_at"]), created_at=datetime.fromisoformat(row["created_at"]),
design_preview_path=str( design_preview_path=design_preview_path,
PRODUCT_ARCHIVES_DIR / row["product_id"] / row["version_id"] / "design-preview.png"
),
wordcloud_count=len(archives), wordcloud_count=len(archives),
wordcloud_archives=archives, wordcloud_archives=archives,
) )
def _version_detail_response(version_id: str) -> ProductVersionDetailResponse:
response = _version_archive_response(version_id)
row = product_archive_store._fetchone(
"""SELECT * FROM product_images
WHERE product_id = ? AND version_id = ? AND image_type = 'design_preview'
ORDER BY created_at DESC, image_id DESC LIMIT 1""",
(response.product_id, version_id),
)
return ProductVersionDetailResponse(
**response.model_dump(),
design_preview_image_id=row["image_id"] if row else None,
design_preview_url=(
f"/api/products/{response.product_id}/images/{row['image_id']}" if row else ""
),
)
def _product_detail_response(product_id: str) -> ProductDetailResponse: def _product_detail_response(product_id: str) -> ProductDetailResponse:
product = _get_product_or_404(product_id) product = _get_product_or_404(product_id)
images = [ images = [
ProductImageRecord( _product_image_response(row)
image_id=row["image_id"],
product_id=row["product_id"],
version_id=row["version_id"],
image_path=row["image_path"],
image_type=row["image_type"],
is_cover=bool(row["is_cover"]),
created_at=datetime.fromisoformat(row["created_at"]),
)
for row in product_archive_store._fetchall( for row in product_archive_store._fetchall(
"""SELECT * FROM product_images """SELECT * FROM product_images
WHERE product_id = ? ORDER BY created_at, image_id""", WHERE product_id = ? ORDER BY created_at, image_id""",
@@ -1205,7 +1237,7 @@ def _product_detail_response(product_id: str) -> ProductDetailResponse:
) )
] ]
versions = [ versions = [
_version_archive_response(row["version_id"]) _version_detail_response(row["version_id"])
for row in product_archive_store._fetchall( for row in product_archive_store._fetchall(
"""SELECT version_id FROM product_versions """SELECT version_id FROM product_versions
WHERE product_id = ? ORDER BY created_at, version_id""", WHERE product_id = ? ORDER BY created_at, version_id""",
@@ -1215,6 +1247,40 @@ def _product_detail_response(product_id: str) -> ProductDetailResponse:
return ProductDetailResponse(**product.model_dump(), images=images, versions=versions) return ProductDetailResponse(**product.model_dump(), images=images, versions=versions)
@app.get(
"/api/products/{product_id}/images/{image_id}",
response_class=FileResponse,
)
def get_product_image(
request: Request,
product_id: str,
image_id: str,
) -> FileResponse:
_require_orders_auth(request)
_get_product_or_404(product_id)
row = product_archive_store._fetchone(
"SELECT * FROM product_images WHERE product_id = ? AND image_id = ?",
(product_id, image_id),
)
if row is None or not row["image_path"]:
raise HTTPException(status_code=404, detail="product image not found")
image_path = Path(row["image_path"]).resolve()
archive_root = PRODUCT_ARCHIVES_DIR.resolve()
if not image_path.is_relative_to(archive_root) or not image_path.is_file():
raise HTTPException(status_code=404, detail="product image not found")
media_types = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
media_type = media_types.get(image_path.suffix.lower(), "application/octet-stream")
return FileResponse(image_path, media_type=media_type, filename=image_path.name)
@app.post("/api/products", response_model=ProductRecord, status_code=201) @app.post("/api/products", response_model=ProductRecord, status_code=201)
def create_product(request: Request, product: ProductInput) -> ProductRecord: def create_product(request: Request, product: ProductInput) -> ProductRecord:
_require_orders_auth(request) _require_orders_auth(request)
+14 -2
View File
@@ -89,9 +89,21 @@ class ProductVersionArchiveResponse(ProductVersionRecord):
wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list) wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list)
class ProductImageResponse(ProductImageRecord):
image_url: str = ""
class ProductVersionDetailResponse(ProductVersionRecord):
design_preview_image_id: str | None = None
design_preview_url: str = ""
design_preview_path: str = ""
wordcloud_count: int = 0
wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list)
class ProductDetailResponse(ProductRecord): class ProductDetailResponse(ProductRecord):
images: list[ProductImageRecord] = Field(default_factory=list) images: list[ProductImageResponse] = Field(default_factory=list)
versions: list[ProductVersionArchiveResponse] = Field(default_factory=list) versions: list[ProductVersionDetailResponse] = Field(default_factory=list)
class JobCreateResponse(BaseModel): class JobCreateResponse(BaseModel):
+55
View File
@@ -332,12 +332,67 @@ def test_product_detail_contract_includes_images_and_archive_versions(
assert detail["images"][0]["is_cover"] is True assert detail["images"][0]["is_cover"] is True
assert len(detail["versions"]) == 1 assert len(detail["versions"]) == 1
version = detail["versions"][0] version = detail["versions"][0]
preview_image = detail["images"][0]
expected_image_url = f"/api/products/{product['product_id']}/images/{preview_image['image_id']}"
assert version["version_id"] == archived.json()["version_id"] assert version["version_id"] == archived.json()["version_id"]
assert version["design_preview_image_id"] == preview_image["image_id"]
assert version["design_preview_url"] == expected_image_url
assert version["wordcloud_count"] == 1 assert version["wordcloud_count"] == 1
assert version["wordcloud_archives"][0]["source_job_id"] == "job-success" assert version["wordcloud_archives"][0]["source_job_id"] == "job-success"
assert Path(version["design_preview_path"]).name == "design-preview.png" assert Path(version["design_preview_path"]).name == "design-preview.png"
def test_product_detail_image_contract_is_authenticated_and_path_safe(
product_archive_client, prepared_wordcloud_job, tmp_path
):
product = create_product(product_archive_client)
archive_product_version(
product_archive_client, product["product_id"], prepared_wordcloud_job.document
)
detail = product_archive_client.get(
f"/api/products/{product['product_id']}", headers=orders_auth_header()
).json()
image = detail["images"][0]
assert image["image_url"] == f"/api/products/{product['product_id']}/images/{image['image_id']}"
served = product_archive_client.get(
f"/api/products/{product['product_id']}/images/{image['image_id']}",
headers=orders_auth_header(),
)
assert served.status_code == 200
assert served.headers["content-type"] == "image/png"
assert served.content == product_archive_client.archive_root.joinpath(
product["product_id"], image["version_id"], "design-preview.png"
).read_bytes()
outside_path = tmp_path / "outside.png"
outside_path.write_bytes(PNG_BYTES)
product_archive_client.product_store._execute(
"UPDATE product_images SET image_path = ? WHERE image_id = ?",
(str(outside_path), image["image_id"]),
)
unsafe = product_archive_client.get(
f"/api/products/{product['product_id']}/images/{image['image_id']}",
headers=orders_auth_header(),
)
assert unsafe.status_code == 404
unknown = product_archive_client.get(
f"/api/products/{product['product_id']}/images/img_unknown",
headers=orders_auth_header(),
)
assert unknown.status_code == 404
unauthenticated_detail = product_archive_client.get(
f"/api/products/{product['product_id']}"
)
unauthenticated_image = product_archive_client.get(
f"/api/products/{product['product_id']}/images/{image['image_id']}"
)
assert unauthenticated_detail.status_code == unauthenticated_image.status_code == 403
def test_product_routes_require_orders_auth(product_archive_client): def test_product_routes_require_orders_auth(product_archive_client):
product = create_product(product_archive_client) product = create_product(product_archive_client)
requests = [ requests = [
+3
View File
@@ -288,6 +288,7 @@ export interface ProductImage {
image_type: string; image_type: string;
is_cover: boolean; is_cover: boolean;
created_at: string; created_at: string;
image_url?: string;
} }
export interface ProductWordcloudArchive { export interface ProductWordcloudArchive {
@@ -309,6 +310,8 @@ export interface ProductVersion {
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
created_at: string; created_at: string;
design_preview_path: string; design_preview_path: string;
design_preview_image_id?: string | null;
design_preview_url?: string;
wordcloud_count: number; wordcloud_count: number;
wordcloud_archives: ProductWordcloudArchive[]; wordcloud_archives: ProductWordcloudArchive[];
} }