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>
43 lines
1.4 KiB
Python
43 lines
1.4 KiB
Python
from contextlib import asynccontextmanager
|
|
|
|
import aiobotocore.session
|
|
|
|
from app.config import get_settings
|
|
from app.storage.backend import StorageBackend
|
|
|
|
|
|
class S3StorageBackend(StorageBackend):
|
|
def __init__(self) -> None:
|
|
self._settings = get_settings()
|
|
self._session = aiobotocore.session.get_session()
|
|
|
|
@asynccontextmanager
|
|
async def _client(self):
|
|
s = self._settings
|
|
async with self._session.create_client(
|
|
"s3",
|
|
region_name=s.s3_region,
|
|
endpoint_url=s.s3_endpoint_url or None,
|
|
aws_access_key_id=s.s3_access_key_id,
|
|
aws_secret_access_key=s.s3_secret_access_key,
|
|
) as client:
|
|
yield client
|
|
|
|
async def put(self, key: str, data: bytes, content_type: str) -> None:
|
|
async with self._client() as client:
|
|
await client.put_object(
|
|
Bucket=self._settings.s3_bucket_name,
|
|
Key=key,
|
|
Body=data,
|
|
ContentType=content_type,
|
|
)
|
|
|
|
async def get(self, key: str) -> bytes:
|
|
async with self._client() as client:
|
|
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:
|
|
await client.delete_object(Bucket=self._settings.s3_bucket_name, Key=key)
|