feat: rewrite backend in Python (FastAPI + SQLAlchemy + boto3)
Same routes, same Docker topology, same env vars. Frontend untouched.
Backend swap:
- Hono on Node -> FastAPI on Python 3.12, served by uvicorn behind Caddy.
- Drizzle on Node -> SQLAlchemy 2.0 async (asyncpg driver) with declarative
models that mirror the previous schema 1:1. Migrations are still plain
SQL files (apps/api/app/migrations/) run idempotently at startup by
app/db/migrate.py via asyncpg, tracking each file's sha256 in a
schema_migrations table.
- aws4fetch -> boto3 wrapped in asyncio.to_thread so the async routes
do not block on MinIO/S3 calls. The presign_put / init_multipart /
complete_multipart / head / delete contract is unchanged so the React
client sees the same /api/uploads/* payloads.
- Cookie+JWT admin auth -> pyjwt + FastAPI Cookie() dependency.
constant-time password compare. Same 7-day HttpOnly cookie shape.
- QR code: qrcode lib + reportlab. /api/qrcode keeps format=png|svg|pdf;
the PDF is still A6 with the couple's names + 'Aponte a câmera' CTA.
- Pydantic-settings replaces the zod env loader and still fails fast
on missing required vars.
Routes ported with identical paths and JSON shapes:
- public: /api/event, /api/gallery, /api/qrcode, /api/stats
- uploads: /api/uploads/init, /{id}/confirm, /{id}/abort
- admin: /login, /logout, /me, /event (GET+PATCH), /uploads (GET with
?status, ?kind=photo|video, ?q= for search), /uploads/{id} (PATCH
for author/message edit), /uploads/{id}/{approve,reject,cover},
/uploads/{id} (DELETE), /event/cover (DELETE), /uploads/bulk, /stats.
Build pipeline:
- Dockerfile: stage 1 builds the React/Vite SPA with pnpm; stage 2 is
python:3.12-slim that installs deps via uv (fast, reproducible),
copies the app/, and copies the SPA dist from stage 1 into /app/public
so the FastAPI process serves both /api/* and the SPA assets.
- docker-compose.yml unchanged: postgres + minio + minio-init + app +
caddy. Same env vars (DATABASE_URL, S3_*, ADMIN_PASSWORD, SESSION_SECRET,
ALLOWED_ADMIN_EMAILS, AUTO_MIGRATE).
- Removed apps/api from the pnpm workspace; root package.json now only
scripts the web. Makefile centralizes dev/build/docker commands so
the Python side has the same DX as the Node side did.
2026-06-07 19:45:20 -03:00
|
|
|
import asyncio
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import boto3
|
|
|
|
|
from botocore.client import Config
|
|
|
|
|
|
|
|
|
|
from ..config import settings
|
|
|
|
|
|
|
|
|
|
DEFAULT_PART_SIZE = 10 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class S3Storage:
|
|
|
|
|
"""Thin wrapper around boto3 that fits the same shape used by routes.
|
|
|
|
|
|
|
|
|
|
All network methods are async-wrapped via asyncio.to_thread so they
|
|
|
|
|
can be awaited from FastAPI handlers without blocking the event loop.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self._client = boto3.client(
|
|
|
|
|
"s3",
|
|
|
|
|
endpoint_url=settings.S3_ENDPOINT,
|
|
|
|
|
region_name=settings.S3_REGION,
|
|
|
|
|
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
|
|
|
|
|
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
|
|
|
|
|
config=Config(
|
|
|
|
|
signature_version="s3v4",
|
|
|
|
|
s3={
|
|
|
|
|
"addressing_style": "path"
|
|
|
|
|
if settings.S3_FORCE_PATH_STYLE
|
|
|
|
|
else "virtual",
|
|
|
|
|
},
|
|
|
|
|
retries={"max_attempts": 3, "mode": "standard"},
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
self._bucket = settings.S3_BUCKET
|
|
|
|
|
self._public_base = settings.S3_PUBLIC_BASE_URL.rstrip("/")
|
|
|
|
|
|
|
|
|
|
def presign_put(
|
|
|
|
|
self, key: str, content_type: str, expires_in: int = 600
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
url = self._client.generate_presigned_url(
|
|
|
|
|
"put_object",
|
|
|
|
|
Params={
|
|
|
|
|
"Bucket": self._bucket,
|
|
|
|
|
"Key": key,
|
|
|
|
|
"ContentType": content_type,
|
|
|
|
|
},
|
|
|
|
|
ExpiresIn=expires_in,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"url": url,
|
|
|
|
|
"method": "PUT",
|
|
|
|
|
"headers": {"content-type": content_type},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def init_multipart(
|
|
|
|
|
self,
|
|
|
|
|
key: str,
|
|
|
|
|
content_type: str,
|
|
|
|
|
part_count: int,
|
|
|
|
|
expires_in: int = 3600,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
def _init() -> dict[str, Any]:
|
|
|
|
|
res = self._client.create_multipart_upload(
|
|
|
|
|
Bucket=self._bucket, Key=key, ContentType=content_type
|
|
|
|
|
)
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
res = await asyncio.to_thread(_init)
|
|
|
|
|
upload_id: str = res["UploadId"]
|
|
|
|
|
|
|
|
|
|
def _sign_parts() -> list[str]:
|
|
|
|
|
urls: list[str] = []
|
|
|
|
|
for i in range(1, part_count + 1):
|
|
|
|
|
u = self._client.generate_presigned_url(
|
|
|
|
|
"upload_part",
|
|
|
|
|
Params={
|
|
|
|
|
"Bucket": self._bucket,
|
|
|
|
|
"Key": key,
|
|
|
|
|
"PartNumber": i,
|
|
|
|
|
"UploadId": upload_id,
|
|
|
|
|
},
|
|
|
|
|
ExpiresIn=expires_in,
|
|
|
|
|
)
|
|
|
|
|
urls.append(u)
|
|
|
|
|
return urls
|
|
|
|
|
|
|
|
|
|
part_urls = await asyncio.to_thread(_sign_parts)
|
|
|
|
|
return {
|
|
|
|
|
"upload_id": upload_id,
|
|
|
|
|
"part_size": DEFAULT_PART_SIZE,
|
|
|
|
|
"part_urls": part_urls,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def complete_multipart(
|
|
|
|
|
self,
|
|
|
|
|
key: str,
|
|
|
|
|
upload_id: str,
|
|
|
|
|
parts: list[dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
sorted_parts = sorted(parts, key=lambda p: p["partNumber"])
|
|
|
|
|
boto_parts = [
|
|
|
|
|
{"PartNumber": p["partNumber"], "ETag": p["etag"]} for p in sorted_parts
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def _complete() -> None:
|
|
|
|
|
self._client.complete_multipart_upload(
|
|
|
|
|
Bucket=self._bucket,
|
|
|
|
|
Key=key,
|
|
|
|
|
UploadId=upload_id,
|
|
|
|
|
MultipartUpload={"Parts": boto_parts},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await asyncio.to_thread(_complete)
|
|
|
|
|
|
|
|
|
|
async def abort_multipart(self, key: str, upload_id: str) -> None:
|
|
|
|
|
def _abort() -> None:
|
|
|
|
|
try:
|
|
|
|
|
self._client.abort_multipart_upload(
|
|
|
|
|
Bucket=self._bucket, Key=key, UploadId=upload_id
|
|
|
|
|
)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
await asyncio.to_thread(_abort)
|
|
|
|
|
|
|
|
|
|
def public_url(self, key: str) -> str:
|
|
|
|
|
return f"{self._public_base}/{key}"
|
|
|
|
|
|
|
|
|
|
async def head(self, key: str) -> dict[str, Any] | None:
|
|
|
|
|
def _head() -> dict[str, Any] | None:
|
|
|
|
|
try:
|
|
|
|
|
res = self._client.head_object(Bucket=self._bucket, Key=key)
|
|
|
|
|
return {
|
|
|
|
|
"contentType": res.get("ContentType", "application/octet-stream"),
|
|
|
|
|
"contentLength": int(res.get("ContentLength", 0)),
|
|
|
|
|
"etag": (res.get("ETag", "") or "").strip('"'),
|
|
|
|
|
}
|
|
|
|
|
except self._client.exceptions.ClientError as e:
|
|
|
|
|
code = e.response.get("Error", {}).get("Code", "")
|
|
|
|
|
if code in {"404", "NoSuchKey", "NotFound"}:
|
|
|
|
|
return None
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
return await asyncio.to_thread(_head)
|
|
|
|
|
|
|
|
|
|
async def delete(self, key: str) -> None:
|
|
|
|
|
def _delete() -> None:
|
|
|
|
|
try:
|
|
|
|
|
self._client.delete_object(Bucket=self._bucket, Key=key)
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
await asyncio.to_thread(_delete)
|
|
|
|
|
|
feat: HEIC->JPEG transcoding and Postgres + MinIO backup sidecars
HEIC handling
- pillow-heif joins the deps; app/lib/transcode.py registers the
HEIF opener and exposes heic_to_jpeg(bytes, quality=88) with EXIF
rotation applied so portrait iPhone photos do not show sideways.
- Storage gains get_bytes / put_bytes so the server can read the
uploaded HEIC out of MinIO, decode it, and put a JPEG back.
- /api/uploads/{id}/confirm now runs the transcode after the HEAD
check passes when mime_type is image/heic or image/heif: writes
the JPEG under a sibling key, deletes the HEIC, and updates the
upload row's storage_key / mime_type / size_bytes. Failures fall
back to keeping the HEIC so an upload is never lost in transit;
the admin can re-upload if a particular file is unrenderable.
Backups (two sidecars in docker-compose.yml)
- postgres-backup uses prodrigestivill/postgres-backup-local:16-alpine
with BACKUP_SCHEDULE_DB (default @daily) and layered retention
(days/weeks/months) writing to ./backups/postgres on the host.
- media-backup is an alpine container that pulls mc on boot, sets up
an alias for the in-cluster MinIO, optionally adds a remote alias
(R2/B2/S3 via BACKUP_REMOTE_*), and runs mc mirror on a configurable
cron schedule. Local mirror always; remote mirror only when
BACKUP_REMOTE_* are set.
- Both write to ./backups/ on the host (bind mount), so the operator
can rsync the directory off-box without touching containers.
- .env.example documents every new variable, including a R2 example
for the remote target, and TZ for cron alignment.
Local backups directory is .gitignore'd so accidental commits do not
ship someone else's wedding photos to GitHub.
2026-06-07 19:50:29 -03:00
|
|
|
async def get_bytes(self, key: str) -> bytes:
|
|
|
|
|
def _get() -> bytes:
|
|
|
|
|
res = self._client.get_object(Bucket=self._bucket, Key=key)
|
|
|
|
|
return res["Body"].read()
|
|
|
|
|
|
|
|
|
|
return await asyncio.to_thread(_get)
|
|
|
|
|
|
|
|
|
|
async def put_bytes(self, key: str, data: bytes, content_type: str) -> None:
|
|
|
|
|
def _put() -> None:
|
|
|
|
|
self._client.put_object(
|
|
|
|
|
Bucket=self._bucket,
|
|
|
|
|
Key=key,
|
|
|
|
|
Body=data,
|
|
|
|
|
ContentType=content_type,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await asyncio.to_thread(_put)
|
|
|
|
|
|
feat: rewrite backend in Python (FastAPI + SQLAlchemy + boto3)
Same routes, same Docker topology, same env vars. Frontend untouched.
Backend swap:
- Hono on Node -> FastAPI on Python 3.12, served by uvicorn behind Caddy.
- Drizzle on Node -> SQLAlchemy 2.0 async (asyncpg driver) with declarative
models that mirror the previous schema 1:1. Migrations are still plain
SQL files (apps/api/app/migrations/) run idempotently at startup by
app/db/migrate.py via asyncpg, tracking each file's sha256 in a
schema_migrations table.
- aws4fetch -> boto3 wrapped in asyncio.to_thread so the async routes
do not block on MinIO/S3 calls. The presign_put / init_multipart /
complete_multipart / head / delete contract is unchanged so the React
client sees the same /api/uploads/* payloads.
- Cookie+JWT admin auth -> pyjwt + FastAPI Cookie() dependency.
constant-time password compare. Same 7-day HttpOnly cookie shape.
- QR code: qrcode lib + reportlab. /api/qrcode keeps format=png|svg|pdf;
the PDF is still A6 with the couple's names + 'Aponte a câmera' CTA.
- Pydantic-settings replaces the zod env loader and still fails fast
on missing required vars.
Routes ported with identical paths and JSON shapes:
- public: /api/event, /api/gallery, /api/qrcode, /api/stats
- uploads: /api/uploads/init, /{id}/confirm, /{id}/abort
- admin: /login, /logout, /me, /event (GET+PATCH), /uploads (GET with
?status, ?kind=photo|video, ?q= for search), /uploads/{id} (PATCH
for author/message edit), /uploads/{id}/{approve,reject,cover},
/uploads/{id} (DELETE), /event/cover (DELETE), /uploads/bulk, /stats.
Build pipeline:
- Dockerfile: stage 1 builds the React/Vite SPA with pnpm; stage 2 is
python:3.12-slim that installs deps via uv (fast, reproducible),
copies the app/, and copies the SPA dist from stage 1 into /app/public
so the FastAPI process serves both /api/* and the SPA assets.
- docker-compose.yml unchanged: postgres + minio + minio-init + app +
caddy. Same env vars (DATABASE_URL, S3_*, ADMIN_PASSWORD, SESSION_SECRET,
ALLOWED_ADMIN_EMAILS, AUTO_MIGRATE).
- Removed apps/api from the pnpm workspace; root package.json now only
scripts the web. Makefile centralizes dev/build/docker commands so
the Python side has the same DX as the Node side did.
2026-06-07 19:45:20 -03:00
|
|
|
|
|
|
|
|
storage = S3Storage()
|