[Spec Kit] Implementation progress

Implements all 88 tasks for the Reaction Image Board (specs/001-reaction-image-board):

- docker-compose.yml: postgres, minio, minio-init, api, ui services with healthchecks
- api/: FastAPI app with SQLAlchemy 2.x async, Alembic migrations, S3/MinIO storage,
  full integration + unit test suite (pytest + pytest-asyncio)
- ui/: Angular 19 standalone app (Library, Upload, Detail, NotFound components)
- .env.example: all required environment variables
- .gitignore: Python, Node, Docker, IDE, .env patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:13:23 +00:00
parent 691f7570fe
commit 8bf6ef443a
74 changed files with 3005 additions and 88 deletions

View File

View File

@@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
class StorageBackend(ABC):
@abstractmethod
async def put(self, key: str, data: bytes, content_type: str) -> None:
"""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."""
@abstractmethod
async def delete(self, key: str) -> None:
"""Delete object at key."""

View File

@@ -0,0 +1,46 @@
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_presigned_url(self, key: str, expires_in_seconds: int = 3600) -> str:
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
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)