Feat: Proxy image content through the API instead of redirecting to MinIO

Replace the presigned-URL redirect (302) in GET /api/v1/images/{id}/file
with a direct proxy that fetches bytes from S3 server-side and returns them
to the client. The browser never contacts the storage backend, eliminating
the /etc/hosts workaround needed in local development.

- StorageBackend: swap get_presigned_url for get(key) -> bytes
- S3StorageBackend: implement get() via aiobotocore get_object
- serve_image_file: return Response with ETag + Cache-Control: immutable
- test_serving: assert 200 + content-type + ETag; add no-storage-details test
- Spec Kit artifacts for feature 002-api-image-proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 16:36:43 +00:00
parent 1cee6adc68
commit cd89ba5dea
13 changed files with 688 additions and 25 deletions

View File

@@ -1,11 +1,8 @@
import io
import struct
import uuid
import zlib
from typing import Annotated, Any
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.provider import AuthProvider
@@ -219,8 +216,21 @@ async def serve_image_file(
status_code=404,
detail={"detail": "Image not found", "code": "image_not_found"},
)
url = await storage.get_presigned_url(image.storage_key, expires_in_seconds=3600)
return RedirectResponse(url=url, status_code=302)
try:
data = await storage.get(image.storage_key)
except Exception:
raise HTTPException(
status_code=500,
detail={"detail": "Failed to retrieve image content", "code": "storage_error"},
) from None
return Response(
content=data,
media_type=image.mime_type,
headers={
"ETag": f'"{image.hash}"',
"Cache-Control": "public, max-age=31536000, immutable",
},
)
@router.patch("/images/{image_id}/tags")