Feat: Serve images directly from Cloudflare R2 CDN
API responses now include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these point to the CDN domain; when unset, they fall back to the existing API proxy paths so local dev requires no additional setup. UI updated to use response URL fields directly instead of constructing proxy URLs client-side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,16 @@ def _error(detail: str, code: str, status: int):
|
||||
raise HTTPException(status_code=status, detail={"detail": detail, "code": code})
|
||||
|
||||
|
||||
def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str, Any]:
|
||||
def _image_to_dict(
|
||||
image: Image, *, cdn_base: str | None = None, duplicate: bool | None = None
|
||||
) -> dict[str, Any]:
|
||||
_base = cdn_base.rstrip("/") if cdn_base else None
|
||||
file_url = f"{_base}/{image.storage_key}" if _base else f"/api/v1/images/{image.id}/file"
|
||||
thumbnail_url = (
|
||||
(f"{_base}/{image.thumbnail_key}" if _base else f"/api/v1/images/{image.id}/thumbnail")
|
||||
if image.thumbnail_key
|
||||
else None
|
||||
)
|
||||
data: dict[str, Any] = {
|
||||
"id": str(image.id),
|
||||
"hash": image.hash,
|
||||
@@ -38,6 +47,8 @@ def _image_to_dict(image: Image, *, duplicate: bool | None = None) -> dict[str,
|
||||
"height": image.height,
|
||||
"storage_key": image.storage_key,
|
||||
"thumbnail_key": image.thumbnail_key,
|
||||
"file_url": file_url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"created_at": image.created_at.isoformat(),
|
||||
"tags": image.tags,
|
||||
}
|
||||
@@ -133,10 +144,13 @@ async def upload_image(
|
||||
|
||||
hash_hex = compute_sha256(data)
|
||||
image_repo = ImageRepository(db)
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
existing = await image_repo.get_by_hash(hash_hex)
|
||||
if existing:
|
||||
return Response(
|
||||
content=__import__("json").dumps(_image_to_dict(existing, duplicate=True)),
|
||||
content=__import__("json").dumps(
|
||||
_image_to_dict(existing, cdn_base=_cdn_base, duplicate=True)
|
||||
),
|
||||
status_code=200,
|
||||
media_type="application/json",
|
||||
)
|
||||
@@ -183,7 +197,7 @@ async def upload_image(
|
||||
await tag_repo.attach_tags(image, tag_names)
|
||||
image = await image_repo.reload_with_tags(image.id)
|
||||
|
||||
return _image_to_dict(image, duplicate=False)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base, duplicate=False)
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
@@ -192,13 +206,15 @@ async def list_images(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
limit = min(limit, 100)
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
tag_names = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
||||
image_repo = ImageRepository(db)
|
||||
images, total = await image_repo.list_images(tag_names=tag_names, limit=limit, offset=offset)
|
||||
return {
|
||||
"items": [_image_to_dict(img) for img in images],
|
||||
"items": [_image_to_dict(img, cdn_base=_cdn_base) for img in images],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -209,7 +225,9 @@ async def list_images(
|
||||
async def get_image(
|
||||
image_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
if not image:
|
||||
@@ -217,7 +235,7 @@ async def get_image(
|
||||
status_code=404,
|
||||
detail={"detail": "Image not found", "code": "image_not_found"},
|
||||
)
|
||||
return _image_to_dict(image)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base)
|
||||
|
||||
|
||||
@router.get("/images/{image_id}/file")
|
||||
@@ -288,7 +306,9 @@ async def update_image_tags(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: Identity = Depends(require_auth),
|
||||
settings=Depends(get_settings),
|
||||
):
|
||||
_cdn_base = settings.s3_public_base_url
|
||||
image_repo = ImageRepository(db)
|
||||
image = await image_repo.get_by_id(image_id)
|
||||
if not image:
|
||||
@@ -309,7 +329,7 @@ async def update_image_tags(
|
||||
|
||||
await tag_repo.replace_tags_on_image(image, tag_names)
|
||||
image = await image_repo.reload_with_tags(image.id)
|
||||
return _image_to_dict(image)
|
||||
return _image_to_dict(image, cdn_base=_cdn_base)
|
||||
|
||||
|
||||
@router.delete("/images/{image_id}", status_code=204)
|
||||
|
||||
Reference in New Issue
Block a user