This repository has been archived on 2026-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
wedding-app/infra/wedding_photo/apps/api/app/routes/uploads.py
Claude 5a19753013
feat: split into 3 docker-compose stacks (main / gitea / wedding_photo)
Repo passa a viver dentro de infra/, com três stacks isoladas que
compartilham uma network Docker externa ('infra-net'):

main/        infra base: postgres + redis + minio + minio-init + pgadmin
             + caddy. Postgres roda em modo multi-banco; o init script
             cria os DBs 'wedding' e 'gitea' com roles dedicadas. MinIO
             tem um bucket inicial criado pelo minio-init com anonymous
             download + CORS. Caddy é o único container expondo 80/443
             e roteia por hostname: gitea.{DOMAIN_BASE} / wedding.* /
             pgadmin.* / minio.* / media.* (rewrite de bucket).

gitea/       gitea + act_runner. Gitea liga no postgres compartilhado e
             usa redis pra cache+sessões. O runner ganha um Dockerfile
             pequeno que adiciona docker CLI por cima do
             gitea/act_runner pra workflows poderem chamar 'docker
             build'. Bootstrap do token de runner documentado no
             .env.example.

wedding_photo/  Só a aplicação: 'wedding_app' (FastAPI + SPA) +
             postgres-backup + media-backup. Os bancos e o MinIO vêm
             da stack main/. A app usa extra_hosts: host-gateway pra
             alcançar media.{DOMAIN_BASE} via Caddy mesmo em dev local
             — assim a assinatura S3 fecha com o host que o browser
             usa pra fazer PUT.

Orquestração:
- Makefile no root: 'make up' sobe tudo na ordem (main -> gitea ->
  wedding_photo). 'make up-{main,gitea,wedding}' pra controle
  granular. 'make logs-*', 'make down', 'make status', 'make pull-*'.
- network.sh cria a 'infra-net' antes de qualquer up; idempotente.
- Cada stack tem seu próprio .env.example. As creds compartilhadas
  (DOMAIN_BASE, MINIO_ROOT_*, WEDDING_DB_*) precisam casar entre
  main/.env e o consumidor (gitea/.env ou wedding_photo/.env).
- .gitignore ignora todas as pastas data/ dos volumes.
2026-06-08 23:34:14 +00:00

228 lines
7.2 KiB
Python

import asyncio
import time
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.base import get_db
from ..db.models import EventConfig, Upload
from ..lib.ids import hash_ip, upload_id
from ..lib.storage import storage
from ..lib.transcode import heic_to_jpeg, is_heic, swap_extension_to_jpg
from ..schemas.api import UploadConfirmIn, UploadInitIn
router = APIRouter()
SINGLE_PUT_MAX = 50 * 1024 * 1024
PART_SIZE = 10 * 1024 * 1024
KEY_PREFIX = "uploads"
IP_HASH_SALT = "wedding-uploads-v1"
Db = Annotated[AsyncSession, Depends(get_db)]
def _ext_from(filename: str, mime_type: str) -> str:
dot = filename.rfind(".")
if dot >= 0 and dot < len(filename) - 1:
ext = filename[dot + 1 :].lower()
if len(ext) <= 6 and ext.isalnum():
return ext
return {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"image/gif": "gif",
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/webm": "webm",
}.get(mime_type.lower(), "bin")
def _build_key(uid: str, filename: str, mime: str) -> str:
now = datetime.now(UTC)
ext = _ext_from(filename, mime)
return f"{KEY_PREFIX}/{now.year}/{now.month:02d}/{uid}.{ext}"
def _client_ip(request: Request) -> str:
h = request.headers
if cf := h.get("cf-connecting-ip"):
return cf
xff = h.get("x-forwarded-for")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
if real := h.get("x-real-ip"):
return real
return "unknown"
def _now_ms() -> int:
return int(time.time() * 1000)
@router.post("/init")
async def init_upload(body: UploadInitIn, request: Request, db: Db):
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if not cfg:
raise HTTPException(500, detail="not_configured")
is_video = body.mimeType.startswith("video/")
if is_video and not cfg.allow_video:
raise HTTPException(400, detail="video_not_allowed")
if body.sizeBytes > cfg.max_file_mb * 1024 * 1024:
raise HTTPException(
400, detail={"error": "file_too_large", "maxFileMb": cfg.max_file_mb}
)
if (
is_video
and body.durationSeconds is not None
and body.durationSeconds > cfg.max_video_seconds
):
raise HTTPException(
400,
detail={
"error": "video_too_long",
"maxVideoSeconds": cfg.max_video_seconds,
},
)
uid = upload_id()
key = _build_key(uid, body.filename, body.mimeType)
ip_h = hash_ip(_client_ip(request), IP_HASH_SALT)
if body.sizeBytes <= SINGLE_PUT_MAX:
presigned = storage.presign_put(key, body.mimeType, expires_in=900)
db.add(
Upload(
id=uid,
storage_key=key,
mime_type=body.mimeType,
size_bytes=body.sizeBytes,
duration_seconds=body.durationSeconds,
author_name=(body.authorName or "").strip() or None,
message=(body.message or "").strip() or None,
status="pending",
source="guest",
ip_hash=ip_h,
created_at=_now_ms(),
)
)
await db.commit()
return {
"uploadId": uid,
"storageKey": key,
"mode": "single",
"putUrl": presigned["url"],
"putHeaders": presigned["headers"],
}
part_count = (body.sizeBytes + PART_SIZE - 1) // PART_SIZE
multipart = await storage.init_multipart(
key, body.mimeType, part_count, expires_in=3600 * 6
)
db.add(
Upload(
id=uid,
storage_key=key,
mime_type=body.mimeType,
size_bytes=body.sizeBytes,
duration_seconds=body.durationSeconds,
author_name=(body.authorName or "").strip() or None,
message=(body.message or "").strip() or None,
status="pending",
source="guest",
ip_hash=ip_h,
provider_upload_id=multipart["upload_id"],
created_at=_now_ms(),
)
)
await db.commit()
return {
"uploadId": uid,
"storageKey": key,
"mode": "multipart",
"multipart": {
"providerUploadId": multipart["upload_id"],
"partSize": multipart["part_size"],
"partUrls": multipart["part_urls"],
},
}
@router.post("/{upload_id}/confirm")
async def confirm_upload(upload_id: str, body: UploadConfirmIn, db: Db):
row = (
await db.execute(select(Upload).where(Upload.id == upload_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, detail="not_found")
if row.status == "approved":
return {"ok": True, "alreadyApproved": True}
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if not cfg:
raise HTTPException(500, detail="not_configured")
if body.multipart:
if body.multipart.providerUploadId != row.provider_upload_id:
raise HTTPException(400, detail="multipart_mismatch")
await storage.complete_multipart(
row.storage_key,
body.multipart.providerUploadId,
[{"partNumber": p.partNumber, "etag": p.etag} for p in body.multipart.parts],
)
head = await storage.head(row.storage_key)
if head is None:
raise HTTPException(400, detail="not_uploaded")
# Transparently transcode HEIC to JPEG so the gallery renders in any
# browser. Falls back to the original on any failure so an upload is
# never lost; admin can re-upload manually if needed.
if is_heic(row.mime_type):
try:
src_bytes = await storage.get_bytes(row.storage_key)
jpeg_bytes = await asyncio.to_thread(heic_to_jpeg, src_bytes)
new_key = swap_extension_to_jpg(row.storage_key)
await storage.put_bytes(new_key, jpeg_bytes, "image/jpeg")
if new_key != row.storage_key:
await storage.delete(row.storage_key)
row.storage_key = new_key
row.mime_type = "image/jpeg"
row.size_bytes = len(jpeg_bytes)
except Exception as e: # noqa: BLE001
print(f"[transcode] HEIC->JPEG failed for {row.storage_key}: {e}")
final_status = "pending" if cfg.moderation == "pre" else "approved"
row.status = final_status
row.approved_at = _now_ms() if final_status == "approved" else None
await db.commit()
return {"ok": True, "status": final_status}
@router.post("/{upload_id}/abort")
async def abort_upload(upload_id: str, db: Db):
row = (
await db.execute(select(Upload).where(Upload.id == upload_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, detail="not_found")
if row.provider_upload_id:
await storage.abort_multipart(row.storage_key, row.provider_upload_id)
await db.delete(row)
await db.commit()
return {"ok": True}