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.
228 lines
7.2 KiB
Python
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}
|