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.
353 lines
10 KiB
Python
353 lines
10 KiB
Python
import time
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
from sqlalchemy import delete, or_, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..config import settings
|
|
from ..db.base import get_db
|
|
from ..db.models import EventConfig, Upload
|
|
from ..lib.auth import (
|
|
clear_session_cookie,
|
|
constant_time_eq,
|
|
get_admin_email,
|
|
set_session_cookie,
|
|
)
|
|
from ..lib.storage import storage
|
|
from ..schemas.api import (
|
|
AdminBulk,
|
|
AdminUploadUpdate,
|
|
EventConfigUpdate,
|
|
LoginIn,
|
|
)
|
|
|
|
router = APIRouter()
|
|
ADMIN_PAGE_SIZE = 30
|
|
|
|
Db = Annotated[AsyncSession, Depends(get_db)]
|
|
Admin = Annotated[str, Depends(get_admin_email)]
|
|
|
|
|
|
def _now_ms() -> int:
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
# ---------- Auth (unauthenticated) ----------
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(body: LoginIn, response: Response):
|
|
email = body.email.lower()
|
|
if email not in settings.admin_emails_list:
|
|
raise HTTPException(401, detail="unauthorized")
|
|
if not constant_time_eq(body.password, settings.ADMIN_PASSWORD):
|
|
raise HTTPException(401, detail="unauthorized")
|
|
set_session_cookie(response, email)
|
|
return {"ok": True, "email": email}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(response: Response):
|
|
clear_session_cookie(response)
|
|
return {"ok": True}
|
|
|
|
|
|
# ---------- Authenticated ----------
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(email: Admin):
|
|
return {"email": email}
|
|
|
|
|
|
@router.get("/event")
|
|
async def get_event(_: Admin, 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,
|
|
"coverKey": cfg.cover_key,
|
|
"coverUrl": storage.public_url(cfg.cover_key) if cfg.cover_key else None,
|
|
"galleryVisibility": cfg.gallery_visibility,
|
|
"moderation": cfg.moderation,
|
|
"maxFileMb": cfg.max_file_mb,
|
|
"allowVideo": cfg.allow_video,
|
|
"maxVideoSeconds": cfg.max_video_seconds,
|
|
"welcomeMessage": cfg.welcome_message,
|
|
"updatedAt": cfg.updated_at,
|
|
}
|
|
|
|
|
|
_EVENT_COL_MAP = {
|
|
"coupleNames": "couple_names",
|
|
"eventDate": "event_date",
|
|
"coverKey": "cover_key",
|
|
"galleryVisibility": "gallery_visibility",
|
|
"moderation": "moderation",
|
|
"maxFileMb": "max_file_mb",
|
|
"allowVideo": "allow_video",
|
|
"maxVideoSeconds": "max_video_seconds",
|
|
"welcomeMessage": "welcome_message",
|
|
}
|
|
|
|
|
|
@router.patch("/event")
|
|
async def patch_event(body: EventConfigUpdate, _: Admin, db: Db):
|
|
incoming = body.model_dump(exclude_unset=True)
|
|
updates: dict[str, object] = {
|
|
_EVENT_COL_MAP[k]: v for k, v in incoming.items() if k in _EVENT_COL_MAP
|
|
}
|
|
if not updates:
|
|
return {"ok": True, "noop": True}
|
|
updates["updated_at"] = _now_ms()
|
|
await db.execute(
|
|
update(EventConfig).where(EventConfig.id == 1).values(**updates)
|
|
)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/uploads")
|
|
async def list_uploads(
|
|
_: Admin,
|
|
db: Db,
|
|
status: str | None = None,
|
|
kind: str | None = None,
|
|
q: str | None = None,
|
|
cursor: str | None = None,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = ADMIN_PAGE_SIZE,
|
|
):
|
|
stmt = select(Upload)
|
|
if status in {"pending", "approved", "rejected"}:
|
|
stmt = stmt.where(Upload.status == status)
|
|
if cursor and cursor.isdigit():
|
|
stmt = stmt.where(Upload.created_at < int(cursor))
|
|
if kind == "photo":
|
|
stmt = stmt.where(Upload.mime_type.like("image/%"))
|
|
elif kind == "video":
|
|
stmt = stmt.where(Upload.mime_type.like("video/%"))
|
|
if q and q.strip():
|
|
pattern = f"%{q.strip()}%"
|
|
stmt = stmt.where(
|
|
or_(
|
|
Upload.author_name.ilike(pattern),
|
|
Upload.message.ilike(pattern),
|
|
)
|
|
)
|
|
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
|
|
|
|
cfg = (
|
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
|
).scalar_one_or_none()
|
|
cover_key = cfg.cover_key if cfg else None
|
|
|
|
items = [
|
|
{
|
|
"id": r.id,
|
|
"url": storage.public_url(r.storage_key),
|
|
"thumbnailUrl": storage.public_url(r.thumbnail_key)
|
|
if r.thumbnail_key
|
|
else None,
|
|
"storageKey": r.storage_key,
|
|
"mimeType": r.mime_type,
|
|
"isVideo": r.mime_type.startswith("video/"),
|
|
"sizeBytes": r.size_bytes,
|
|
"durationSeconds": r.duration_seconds,
|
|
"authorName": r.author_name,
|
|
"message": r.message,
|
|
"status": r.status,
|
|
"source": r.source,
|
|
"createdAt": r.created_at,
|
|
"approvedAt": r.approved_at,
|
|
"isCover": cover_key == r.storage_key,
|
|
}
|
|
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.patch("/uploads/{upload_id}")
|
|
async def patch_upload(
|
|
upload_id: str, body: AdminUploadUpdate, _: Admin, db: Db
|
|
):
|
|
incoming = body.model_dump(exclude_unset=True)
|
|
if not incoming:
|
|
return {"ok": True, "noop": True}
|
|
snake = {"authorName": "author_name", "message": "message"}
|
|
updates: dict[str, object | None] = {}
|
|
for k, v in incoming.items():
|
|
if k in snake:
|
|
updates[snake[k]] = v.strip() or None if isinstance(v, str) else v
|
|
if not updates:
|
|
return {"ok": True, "noop": True}
|
|
res = await db.execute(
|
|
update(Upload)
|
|
.where(Upload.id == upload_id)
|
|
.values(**updates)
|
|
.returning(Upload.id)
|
|
)
|
|
await db.commit()
|
|
if res.scalar_one_or_none() is None:
|
|
raise HTTPException(404, detail="not_found")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/uploads/{upload_id}/approve")
|
|
async def approve(upload_id: str, _: Admin, db: Db):
|
|
res = await db.execute(
|
|
update(Upload)
|
|
.where(Upload.id == upload_id)
|
|
.values(status="approved", approved_at=_now_ms())
|
|
.returning(Upload.id)
|
|
)
|
|
await db.commit()
|
|
if res.scalar_one_or_none() is None:
|
|
raise HTTPException(404, detail="not_found")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/uploads/{upload_id}/reject")
|
|
async def reject(upload_id: str, _: Admin, db: Db):
|
|
res = await db.execute(
|
|
update(Upload)
|
|
.where(Upload.id == upload_id)
|
|
.values(status="rejected", approved_at=None)
|
|
.returning(Upload.id)
|
|
)
|
|
await db.commit()
|
|
if res.scalar_one_or_none() is None:
|
|
raise HTTPException(404, detail="not_found")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/uploads/{upload_id}/cover")
|
|
async def set_cover(upload_id: str, _: Admin, 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")
|
|
await db.execute(
|
|
update(EventConfig)
|
|
.where(EventConfig.id == 1)
|
|
.values(cover_key=row.storage_key, updated_at=_now_ms())
|
|
)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/event/cover")
|
|
async def clear_cover(_: Admin, db: Db):
|
|
await db.execute(
|
|
update(EventConfig)
|
|
.where(EventConfig.id == 1)
|
|
.values(cover_key=None, updated_at=_now_ms())
|
|
)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/uploads/{upload_id}")
|
|
async def delete_upload(upload_id: str, _: Admin, 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")
|
|
|
|
storage_key = row.storage_key
|
|
thumb_key = row.thumbnail_key
|
|
|
|
await storage.delete(storage_key)
|
|
if thumb_key:
|
|
await storage.delete(thumb_key)
|
|
await db.delete(row)
|
|
|
|
cfg = (
|
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
|
).scalar_one_or_none()
|
|
if cfg and cfg.cover_key == storage_key:
|
|
await db.execute(
|
|
update(EventConfig).where(EventConfig.id == 1).values(cover_key=None)
|
|
)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/uploads/bulk")
|
|
async def bulk(body: AdminBulk, _: Admin, db: Db):
|
|
if body.action == "delete":
|
|
rows = list(
|
|
(await db.execute(select(Upload).where(Upload.id.in_(body.ids))))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
for r in rows:
|
|
await storage.delete(r.storage_key)
|
|
await db.execute(delete(Upload).where(Upload.id.in_(body.ids)))
|
|
|
|
cfg = (
|
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
|
).scalar_one_or_none()
|
|
if cfg and any(r.storage_key == cfg.cover_key for r in rows):
|
|
await db.execute(
|
|
update(EventConfig).where(EventConfig.id == 1).values(cover_key=None)
|
|
)
|
|
else:
|
|
new_status = "approved" if body.action == "approve" else "rejected"
|
|
approved_at = _now_ms() if new_status == "approved" else None
|
|
await db.execute(
|
|
update(Upload)
|
|
.where(Upload.id.in_(body.ids))
|
|
.values(status=new_status, approved_at=approved_at)
|
|
)
|
|
|
|
await db.commit()
|
|
return {"ok": True, "count": len(body.ids)}
|
|
|
|
|
|
@router.get("/stats")
|
|
async def stats(_: Admin, db: Db):
|
|
rows = (
|
|
await db.execute(
|
|
select(Upload.status, Upload.mime_type, Upload.size_bytes)
|
|
)
|
|
).all()
|
|
counts = {
|
|
"total": len(rows),
|
|
"approved": 0,
|
|
"pending": 0,
|
|
"rejected": 0,
|
|
"photos": 0,
|
|
"videos": 0,
|
|
"totalBytes": 0,
|
|
}
|
|
for r in rows:
|
|
counts[r.status] = counts.get(r.status, 0) + 1
|
|
if r.mime_type.startswith("image/"):
|
|
counts["photos"] += 1
|
|
elif r.mime_type.startswith("video/"):
|
|
counts["videos"] += 1
|
|
counts["totalBytes"] += r.size_bytes
|
|
return counts
|
|
|
|
|
|
@router.get("/export.zip")
|
|
async def export_zip(_: Admin):
|
|
raise HTTPException(501, detail="not_implemented_yet")
|
|
|
|
|
|
@router.post("/imports")
|
|
async def imports(_: Admin):
|
|
raise HTTPException(501, detail="not_implemented_yet")
|