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:
@@ -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")
|
||||
|
||||
@@ -7,8 +7,8 @@ class StorageBackend(ABC):
|
||||
"""Store object at key with given content type."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str:
|
||||
"""Return a pre-signed URL valid for expires_in_seconds."""
|
||||
async def get(self, key: str) -> bytes:
|
||||
"""Return object content as bytes."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> None:
|
||||
|
||||
@@ -32,14 +32,10 @@ class S3StorageBackend(StorageBackend):
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
async def get_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str:
|
||||
async def get(self, key: str) -> bytes:
|
||||
async with self._client() as client:
|
||||
url = await client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self._settings.s3_bucket_name, "Key": key},
|
||||
ExpiresIn=expires_in_seconds,
|
||||
)
|
||||
return url
|
||||
response = await client.get_object(Bucket=self._settings.s3_bucket_name, Key=key)
|
||||
return await response["Body"].read()
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
async with self._client() as client:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
T055 — GET /api/v1/images/{id}/file → 302 with Location header
|
||||
T055 — GET /api/v1/images/{id}/file → 200 with binary content, ETag, Cache-Control
|
||||
T056 — /file for unknown ID → 404 image_not_found
|
||||
T057 — /file response exposes no storage-specific details
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -17,20 +19,23 @@ def _minimal_webp() -> bytes:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_redirect_returns_302(client):
|
||||
async def test_file_returns_200_with_content(client):
|
||||
data = _minimal_webp()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
upload_body = upload.json()
|
||||
image_id = upload_body["id"]
|
||||
image_hash = upload_body["hash"]
|
||||
|
||||
# Don't follow redirects
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert "Location" in response.headers
|
||||
assert response.headers["Location"] # must not be empty
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("image/")
|
||||
assert response.headers["etag"] == f'"{image_hash}"'
|
||||
assert "immutable" in response.headers["cache-control"]
|
||||
assert len(response.content) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -39,3 +44,21 @@ async def test_file_unknown_id_returns_404(client):
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == "image_not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_response_exposes_no_storage_details(client):
|
||||
data = _minimal_webp()
|
||||
upload = await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": ("img.webp", io.BytesIO(data), "image/webp")},
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
|
||||
response = await client.get(f"/api/v1/images/{image_id}/file")
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert "minio" not in response.text.lower()
|
||||
assert "s3://" not in response.text.lower()
|
||||
assert "amazonaws.com" not in response.text.lower()
|
||||
|
||||
Reference in New Issue
Block a user