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.
144 lines
4.3 KiB
Python
144 lines
4.3 KiB
Python
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..config import settings
|
|
from ..db.base import get_db
|
|
from ..db.models import EventConfig, Upload
|
|
from ..lib.pdf import generate_qr_pdf
|
|
from ..lib.qrcode_gen import generate_qr_png, generate_qr_svg
|
|
from ..lib.storage import storage
|
|
|
|
router = APIRouter()
|
|
GALLERY_PAGE_SIZE = 24
|
|
|
|
Db = Annotated[AsyncSession, Depends(get_db)]
|
|
|
|
|
|
@router.get("/event")
|
|
async def get_event(db: Db):
|
|
cfg = (
|
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
|
).scalar_one_or_none()
|
|
if not cfg:
|
|
raise HTTPException(404, detail="not_configured")
|
|
return {
|
|
"coupleNames": cfg.couple_names,
|
|
"eventDate": cfg.event_date,
|
|
"welcomeMessage": cfg.welcome_message,
|
|
"galleryVisibility": cfg.gallery_visibility,
|
|
"allowVideo": cfg.allow_video,
|
|
"maxFileMb": cfg.max_file_mb,
|
|
"maxVideoSeconds": cfg.max_video_seconds,
|
|
"coverUrl": storage.public_url(cfg.cover_key) if cfg.cover_key else None,
|
|
}
|
|
|
|
|
|
@router.get("/gallery")
|
|
async def get_gallery(
|
|
db: Db,
|
|
cursor: str | None = None,
|
|
limit: Annotated[int, Query(le=60, ge=1)] = GALLERY_PAGE_SIZE,
|
|
):
|
|
cfg = (
|
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
|
).scalar_one_or_none()
|
|
if not cfg or cfg.gallery_visibility != "public":
|
|
return {"items": [], "nextCursor": None}
|
|
|
|
cursor_val: int | None = None
|
|
if cursor and cursor.isdigit():
|
|
cursor_val = int(cursor)
|
|
|
|
stmt = select(Upload).where(Upload.status == "approved")
|
|
if cursor_val is not None:
|
|
stmt = stmt.where(Upload.created_at < cursor_val)
|
|
stmt = stmt.order_by(Upload.created_at.desc()).limit(limit + 1)
|
|
|
|
rows = list((await db.execute(stmt)).scalars().all())
|
|
has_more = len(rows) > limit
|
|
slice_rows = rows[:limit] if has_more else rows
|
|
|
|
items = [
|
|
{
|
|
"id": r.id,
|
|
"url": storage.public_url(r.storage_key),
|
|
"thumbnailUrl": storage.public_url(r.thumbnail_key)
|
|
if r.thumbnail_key
|
|
else None,
|
|
"mimeType": r.mime_type,
|
|
"isVideo": r.mime_type.startswith("video/"),
|
|
"authorName": r.author_name,
|
|
"message": r.message,
|
|
"createdAt": r.created_at,
|
|
}
|
|
for r in slice_rows
|
|
]
|
|
next_cursor = str(slice_rows[-1].created_at) if has_more and slice_rows else None
|
|
return {"items": items, "nextCursor": next_cursor}
|
|
|
|
|
|
@router.get("/qrcode")
|
|
async def get_qrcode(
|
|
request: Request,
|
|
db: Db,
|
|
format: str = "png",
|
|
url: str | None = None,
|
|
):
|
|
fmt = format.lower()
|
|
if url:
|
|
base = url
|
|
elif settings.PUBLIC_BASE_URL:
|
|
base = settings.PUBLIC_BASE_URL
|
|
else:
|
|
base = str(request.base_url).rstrip("/")
|
|
target = base.rstrip("/") + "/enviar"
|
|
|
|
if fmt == "svg":
|
|
svg = generate_qr_svg(target)
|
|
return Response(
|
|
content=svg,
|
|
media_type="image/svg+xml; charset=utf-8",
|
|
headers={"cache-control": "public, max-age=300"},
|
|
)
|
|
|
|
if fmt == "pdf":
|
|
cfg = (
|
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
|
).scalar_one_or_none()
|
|
pdf_bytes = generate_qr_pdf(
|
|
target,
|
|
cfg.couple_names if cfg else settings.COUPLE_NAMES,
|
|
cfg.event_date if cfg else settings.EVENT_DATE,
|
|
)
|
|
return Response(
|
|
content=pdf_bytes,
|
|
media_type="application/pdf",
|
|
headers={
|
|
"content-disposition": 'inline; filename="qr-mesa.pdf"',
|
|
"cache-control": "no-store",
|
|
},
|
|
)
|
|
|
|
png = generate_qr_png(target, 800)
|
|
return Response(
|
|
content=png,
|
|
media_type="image/png",
|
|
headers={"cache-control": "public, max-age=300"},
|
|
)
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_stats(db: Db):
|
|
rows = (
|
|
await db.execute(
|
|
select(Upload.id, Upload.mime_type).where(Upload.status == "approved")
|
|
)
|
|
).all()
|
|
photos = sum(1 for r in rows if r.mime_type.startswith("image/"))
|
|
videos = sum(1 for r in rows if r.mime_type.startswith("video/"))
|
|
return {"photos": photos, "videos": videos, "total": len(rows)}
|