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.
This commit is contained in:
Claude 2026-06-07 22:45:20 +00:00
parent 00baf9bd89
commit 2fa17e5feb
No known key found for this signature in database
47 changed files with 1509 additions and 2458 deletions

View File

@ -14,3 +14,11 @@ dist
.DS_Store
.vscode
.idea
__pycache__
**/__pycache__
*.pyc
.venv
**/.venv
.mypy_cache
.ruff_cache
.pytest_cache

13
.gitignore vendored
View File

@ -11,5 +11,18 @@ build/
.env.*.local
!.env.example
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
.python-version
.mypy_cache/
.pytest_cache/
.ruff_cache/
*.egg-info/
.vscode/
.idea/

View File

@ -1,35 +1,44 @@
FROM node:22-alpine AS base
# ---- Stage 1: build the React/Vite SPA with pnpm ----
FROM node:22-alpine AS web-build
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
FROM base AS deps
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/shared/package.json packages/shared/
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile
FROM deps AS build
WORKDIR /app
COPY tsconfig.base.json ./
COPY packages packages
COPY apps apps
COPY packages/shared packages/shared
COPY apps/web apps/web
RUN pnpm -F @leetete/web build
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV STATIC_DIR=/app/public
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml* ./
COPY --from=build /app/tsconfig.base.json ./
COPY --from=build /app/packages packages
COPY --from=build /app/apps/api apps/api
COPY --from=build /app/apps/web/dist public
# ---- Stage 2: Python runtime ----
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PORT=3000 \
STATIC_DIR=/app/public
# Install uv (faster than pip for resolution + install)
RUN pip install --no-cache-dir uv==0.5.4
WORKDIR /app/apps/api
# Install Python dependencies into the system interpreter
COPY apps/api/pyproject.toml ./
RUN uv pip install --system --no-cache .
# Copy app source
COPY apps/api/app ./app
# Copy built web static assets from stage 1
COPY --from=web-build /app/apps/web/dist /app/public
EXPOSE 3000
CMD ["pnpm", "--filter", "@leetete/api", "start"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000", "--proxy-headers", "--forwarded-allow-ips=*"]

32
Makefile Normal file
View File

@ -0,0 +1,32 @@
.PHONY: install dev-web dev-api migrate build docker-up docker-down docker-logs lint typecheck
install:
pnpm install
cd apps/api && uv sync
dev-web:
pnpm -F @leetete/web dev
dev-api:
cd apps/api && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 3000
migrate:
cd apps/api && uv run python -m app.db.migrate
build:
pnpm -F @leetete/web build
typecheck:
pnpm -F @leetete/web typecheck
lint:
cd apps/api && uv run ruff check app
docker-up:
docker compose up -d --build
docker-down:
docker compose down
docker-logs:
docker compose logs -f app

0
apps/api/app/__init__.py Normal file
View File

40
apps/api/app/config.py Normal file
View File

@ -0,0 +1,40 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
DATABASE_URL: str
S3_ENDPOINT: str
S3_BUCKET: str
S3_REGION: str = "us-east-1"
S3_ACCESS_KEY_ID: str
S3_SECRET_ACCESS_KEY: str
S3_PUBLIC_BASE_URL: str
S3_FORCE_PATH_STYLE: bool = True
COUPLE_NAMES: str = "Stefanie & Leandro"
EVENT_DATE: str | None = None
PUBLIC_BASE_URL: str | None = None
ADMIN_PASSWORD: str
SESSION_SECRET: str = Field(..., min_length=16)
ALLOWED_ADMIN_EMAILS: str
PORT: int = 3000
STATIC_DIR: str = "./public"
NODE_ENV: str = "production"
AUTO_MIGRATE: bool = True
@property
def admin_emails_list(self) -> list[str]:
return [e.strip().lower() for e in self.ALLOWED_ADMIN_EMAILS.split(",") if e.strip()]
@property
def is_production(self) -> bool:
return self.NODE_ENV == "production"
settings = Settings() # type: ignore[call-arg]

View File

33
apps/api/app/db/base.py Normal file
View File

@ -0,0 +1,33 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from ..config import settings
def _normalize_url(url: str) -> str:
"""Convert any postgres URL flavor to the asyncpg driver form."""
if url.startswith("postgres://"):
url = "postgresql://" + url[len("postgres://") :]
if url.startswith("postgresql://") and "+asyncpg" not in url:
url = "postgresql+asyncpg://" + url[len("postgresql://") :]
return url
engine = create_async_engine(
_normalize_url(settings.DATABASE_URL),
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_db() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session

View File

@ -0,0 +1,70 @@
import asyncio
import hashlib
from pathlib import Path
import asyncpg
def _to_pg_url(url: str) -> str:
if url.startswith("postgres://"):
url = "postgresql://" + url[len("postgres://") :]
if url.startswith("postgresql+asyncpg://"):
url = "postgresql://" + url[len("postgresql+asyncpg://") :]
return url
async def run_migrations(database_url: str) -> None:
conn = await asyncpg.connect(_to_pg_url(database_url))
try:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
migrations_dir = Path(__file__).parent.parent / "migrations"
files = sorted(f for f in migrations_dir.iterdir() if f.suffix == ".sql")
for f in files:
content = f.read_text(encoding="utf-8")
sha = hashlib.sha256(content.encode("utf-8")).hexdigest()
existing = await conn.fetchrow(
"SELECT sha256 FROM schema_migrations WHERE name = $1", f.name
)
if existing is not None:
if existing["sha256"] != sha:
raise RuntimeError(
f"Migration {f.name} has been modified after being applied. "
"Add a new migration file instead."
)
print(f"[migrate] skip {f.name}")
continue
print(f"[migrate] apply {f.name}")
async with conn.transaction():
await conn.execute(content)
await conn.execute(
"INSERT INTO schema_migrations (name, sha256) VALUES ($1, $2)",
f.name,
sha,
)
print("[migrate] done")
finally:
await conn.close()
def main() -> None:
from ..config import settings
asyncio.run(run_migrations(settings.DATABASE_URL))
if __name__ == "__main__":
main()

78
apps/api/app/db/models.py Normal file
View File

@ -0,0 +1,78 @@
from sqlalchemy import BigInteger, Boolean, Index, Integer, Text, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
_NOW_MS_DEFAULT = text("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT")
class EventConfig(Base):
__tablename__ = "event_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
couple_names: Mapped[str] = mapped_column(Text, nullable=False)
event_date: Mapped[str | None] = mapped_column(Text)
cover_key: Mapped[str | None] = mapped_column(Text)
gallery_visibility: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'public'")
)
moderation: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'post'")
)
max_file_mb: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("500")
)
allow_video: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("TRUE")
)
max_video_seconds: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("300")
)
welcome_message: Mapped[str | None] = mapped_column(Text)
updated_at: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
)
class Upload(Base):
__tablename__ = "uploads"
__table_args__ = (
Index("idx_uploads_status_created", "status", "created_at"),
Index("idx_uploads_source", "source"),
)
id: Mapped[str] = mapped_column(Text, primary_key=True)
storage_key: Mapped[str] = mapped_column(Text, nullable=False)
thumbnail_key: Mapped[str | None] = mapped_column(Text)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
duration_seconds: Mapped[int | None] = mapped_column(Integer)
author_name: Mapped[str | None] = mapped_column(Text)
message: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'approved'")
)
source: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'guest'")
)
ip_hash: Mapped[str | None] = mapped_column(Text)
provider_upload_id: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
)
approved_at: Mapped[int | None] = mapped_column(BigInteger)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(Text, primary_key=True)
action: Mapped[str] = mapped_column(Text, nullable=False)
actor: Mapped[str | None] = mapped_column(Text)
payload: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
)

View File

57
apps/api/app/lib/auth.py Normal file
View File

@ -0,0 +1,57 @@
import hmac
import time
from typing import Annotated
import jwt
from fastapi import Cookie, HTTPException, Response
from ..config import settings
COOKIE_NAME = "wedding_admin"
SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
def constant_time_eq(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
def _create_token(email: str) -> str:
payload = {
"email": email,
"exp": int(time.time()) + SESSION_TTL,
}
return jwt.encode(payload, settings.SESSION_SECRET, algorithm="HS256")
def set_session_cookie(response: Response, email: str) -> None:
token = _create_token(email)
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=SESSION_TTL,
httponly=True,
secure=settings.is_production,
samesite="lax",
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(COOKIE_NAME, path="/")
def get_admin_email(
wedding_admin: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
) -> str:
if not wedding_admin:
raise HTTPException(status_code=401, detail="unauthorized")
try:
payload = jwt.decode(
wedding_admin, settings.SESSION_SECRET, algorithms=["HS256"]
)
except jwt.PyJWTError as e:
raise HTTPException(status_code=401, detail="unauthorized") from e
email = payload.get("email")
if not email or not isinstance(email, str):
raise HTTPException(status_code=401, detail="unauthorized")
return email

22
apps/api/app/lib/ids.py Normal file
View File

@ -0,0 +1,22 @@
import hashlib
import secrets
_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
def _nanoid(size: int = 16) -> str:
n = len(_ALPHABET)
return "".join(_ALPHABET[secrets.randbelow(n)] for _ in range(size))
def upload_id() -> str:
return f"up_{_nanoid()}"
def audit_id() -> str:
return f"au_{_nanoid()}"
def hash_ip(ip: str, salt: str) -> str:
h = hashlib.sha256(f"{salt}:{ip}".encode("utf-8")).hexdigest()
return h[:32]

60
apps/api/app/lib/pdf.py Normal file
View File

@ -0,0 +1,60 @@
import io
from reportlab.lib.colors import Color
from reportlab.lib.pagesizes import A6
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas
from .qrcode_gen import generate_qr_png
def generate_qr_pdf(
url: str, couple_names: str, event_date: str | None = None
) -> bytes:
buf = io.BytesIO()
width, height = A6
c = Canvas(buf, pagesize=A6)
ink = Color(0.18, 0.16, 0.14)
muted = Color(0.45, 0.42, 0.38)
# Title
title_size = 28
c.setFillColor(ink)
c.setFont("Times-Italic", title_size)
title_y = height - 28 * mm
title_w = c.stringWidth(couple_names, "Times-Italic", title_size)
c.drawString((width - title_w) / 2, title_y, couple_names)
# Date
if event_date:
c.setFillColor(muted)
c.setFont("Helvetica", 11)
dw = c.stringWidth(event_date, "Helvetica", 11)
c.drawString((width - dw) / 2, title_y - 7 * mm, event_date)
# QR
qr_png = generate_qr_png(url, 800)
qr_img = ImageReader(io.BytesIO(qr_png))
qr_size = 70 * mm
qr_x = (width - qr_size) / 2
qr_y = (height - qr_size) / 2 - 8 * mm
c.drawImage(qr_img, qr_x, qr_y, qr_size, qr_size, preserveAspectRatio=True)
# CTA
cta = "Aponte a câmera do celular"
c.setFillColor(ink)
c.setFont("Helvetica-Bold", 12)
cw = c.stringWidth(cta, "Helvetica-Bold", 12)
c.drawString((width - cw) / 2, qr_y - 9 * mm, cta)
sub = "para enviar fotos e mensagem"
c.setFillColor(muted)
c.setFont("Helvetica", 10)
sw = c.stringWidth(sub, "Helvetica", 10)
c.drawString((width - sw) / 2, qr_y - 14 * mm, sub)
c.showPage()
c.save()
return buf.getvalue()

View File

@ -0,0 +1,37 @@
import io
import qrcode
from qrcode.constants import ERROR_CORRECT_M
from qrcode.image.svg import SvgPathImage
def _make(text: str, box_size: int = 10) -> qrcode.QRCode:
qr = qrcode.QRCode(
version=None,
error_correction=ERROR_CORRECT_M,
box_size=box_size,
border=2,
)
qr.add_data(text)
qr.make(fit=True)
return qr
def generate_qr_png(text: str, size: int = 800) -> bytes:
# box_size is per-module pixels; estimate to hit target output size
qr = _make(text, box_size=1)
modules = qr.modules_count + qr.border * 2
box_size = max(4, size // modules)
qr = _make(text, box_size=box_size)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def generate_qr_svg(text: str) -> str:
qr = _make(text, box_size=10)
img = qr.make_image(image_factory=SvgPathImage)
buf = io.BytesIO()
img.save(buf)
return buf.getvalue().decode("utf-8")

158
apps/api/app/lib/storage.py Normal file
View File

@ -0,0 +1,158 @@
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)
storage = S3Storage()

71
apps/api/app/main.py Normal file
View File

@ -0,0 +1,71 @@
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .config import settings
from .db.base import engine
from .db.migrate import run_migrations
from .routes import admin, public, uploads
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.AUTO_MIGRATE:
print("[startup] running migrations")
await run_migrations(settings.DATABASE_URL)
print(f"[startup] static dir: {settings.STATIC_DIR}")
yield
await engine.dispose()
app = FastAPI(
lifespan=lifespan,
title="Wedding Photos API",
openapi_url="/api/openapi.json",
docs_url="/api/docs",
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
async def health():
return {"ok": True, "ts": int(time.time() * 1000)}
app.include_router(public.router, prefix="/api", tags=["public"])
app.include_router(uploads.router, prefix="/api/uploads", tags=["uploads"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
# ----- SPA static asset serving -----
static_dir = Path(settings.STATIC_DIR)
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
if full_path.startswith("api/") or full_path == "api":
raise HTTPException(404, detail="not_found")
if full_path in {"favicon.ico", "robots.txt"}:
candidate = static_dir / full_path
if candidate.is_file():
return FileResponse(candidate)
index = static_dir / "index.html"
if index.is_file():
return FileResponse(index)
raise HTTPException(404, detail="static_not_found")

View File

View File

@ -0,0 +1,352 @@
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")

View File

@ -0,0 +1,143 @@
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)}

View File

@ -0,0 +1,208 @@
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 ..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")
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}

View File

View File

@ -0,0 +1,53 @@
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
class UploadInitIn(BaseModel):
filename: str = Field(min_length=1, max_length=255)
mimeType: str = Field(pattern=r"^(image|video)/[a-z0-9.+-]+$")
sizeBytes: int = Field(gt=0)
durationSeconds: int | None = Field(default=None, gt=0)
authorName: str | None = Field(default=None, max_length=80)
message: str | None = Field(default=None, max_length=2000)
class MultipartPart(BaseModel):
partNumber: int = Field(gt=0)
etag: str = Field(min_length=1)
class MultipartConfirm(BaseModel):
providerUploadId: str
parts: list[MultipartPart]
class UploadConfirmIn(BaseModel):
multipart: MultipartConfirm | None = None
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
class EventConfigUpdate(BaseModel):
coupleNames: str | None = Field(default=None, min_length=1, max_length=120)
eventDate: str | None = None
coverKey: str | None = None
galleryVisibility: Literal["public", "private"] | None = None
moderation: Literal["pre", "post"] | None = None
maxFileMb: int | None = Field(default=None, gt=0)
allowVideo: bool | None = None
maxVideoSeconds: int | None = Field(default=None, gt=0)
welcomeMessage: str | None = Field(default=None, max_length=2000)
class AdminUploadUpdate(BaseModel):
authorName: str | None = Field(default=None, max_length=80)
message: str | None = Field(default=None, max_length=2000)
class AdminBulk(BaseModel):
action: Literal["approve", "reject", "delete"]
ids: list[str] = Field(min_length=1, max_length=200)

View File

@ -1,7 +0,0 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'postgresql',
} satisfies Config;

View File

@ -1,40 +0,0 @@
{
"name": "@leetete/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/app.ts",
"types": "./src/app.ts",
"exports": {
".": "./src/app.ts",
"./env": "./src/env.ts",
"./db/schema": "./src/db/schema.ts"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"migrate": "tsx src/db/migrate.ts",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@leetete/shared": "workspace:*",
"aws4fetch": "^1.0.20",
"drizzle-orm": "^0.36.4",
"hono": "^4.6.14",
"nanoid": "^5.0.9",
"pdf-lib": "^1.17.1",
"postgres": "^3.4.5",
"qrcode": "^1.5.4",
"tsx": "^4.19.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/qrcode": "^1.5.5",
"drizzle-kit": "^0.28.1",
"typescript": "^5.6.3"
}
}

36
apps/api/pyproject.toml Normal file
View File

@ -0,0 +1,36 @@
[project]
name = "wedding-api"
version = "0.1.0"
description = "Backend for the wedding photos app"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]==0.115.5",
"uvicorn[standard]==0.32.1",
"sqlalchemy[asyncio]==2.0.36",
"asyncpg==0.30.0",
"boto3==1.35.66",
"qrcode[pil]==8.0",
"reportlab==4.2.5",
"pydantic-settings==2.6.1",
"pyjwt==2.10.0",
"email-validator==2.2.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.7.4",
"mypy==1.13.0",
"pytest==8.3.3",
"httpx==0.27.2",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]
ignore = ["E501"]
[tool.uv]
package = false

View File

@ -1,28 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Bindings } from './env.js';
import { adminRoutes } from './routes/admin.js';
import { publicRoutes } from './routes/public.js';
import { uploadsRoutes } from './routes/uploads.js';
export function createApp() {
const app = new Hono<Bindings>().basePath('/api');
app.use('*', cors({ origin: (origin) => origin ?? '*', credentials: true }));
app.get('/health', (c) => c.json({ ok: true, ts: Date.now() }));
app.route('/', publicRoutes);
app.route('/uploads', uploadsRoutes);
app.route('/admin', adminRoutes);
app.notFound((c) => c.json({ error: 'not_found' }, 404));
app.onError((err, c) => {
console.error('unhandled', err);
return c.json({ error: 'internal_error' }, 500);
});
return app;
}
export type App = ReturnType<typeof createApp>;

View File

@ -1,30 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
export type DB = ReturnType<typeof drizzle<typeof schema>>;
let dbInstance: DB | null = null;
let pgInstance: ReturnType<typeof postgres> | null = null;
export function initDb(databaseUrl: string): DB {
if (dbInstance) return dbInstance;
pgInstance = postgres(databaseUrl, { max: 10, idle_timeout: 30 });
dbInstance = drizzle(pgInstance, { schema });
return dbInstance;
}
export function getDb(): DB {
if (!dbInstance) {
throw new Error('DB not initialized. Call initDb() before getDb().');
}
return dbInstance;
}
export async function closeDb(): Promise<void> {
if (pgInstance) {
await pgInstance.end({ timeout: 5 });
pgInstance = null;
dbInstance = null;
}
}

View File

@ -1,71 +0,0 @@
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';
const __dirname = dirname(fileURLToPath(import.meta.url));
function sha256(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
export async function runMigrations(databaseUrl: string): Promise<void> {
const sql = postgres(databaseUrl, { max: 1 });
try {
await sql`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`;
const dir = join(__dirname, 'migrations');
const files = readdirSync(dir)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
const content = readFileSync(join(dir, file), 'utf-8');
const hash = sha256(content);
const existing = await sql<{ sha256: string }[]>`
SELECT sha256 FROM schema_migrations WHERE name = ${file}
`;
if (existing.length) {
if (existing[0]!.sha256 !== hash) {
throw new Error(
`Migration ${file} has been modified after being applied. ` +
`Add a new migration file instead.`,
);
}
console.log(`[migrate] skip ${file}`);
continue;
}
console.log(`[migrate] apply ${file}`);
await sql.unsafe(content);
await sql`
INSERT INTO schema_migrations (name, sha256) VALUES (${file}, ${hash})
`;
}
console.log('[migrate] done');
} finally {
await sql.end({ timeout: 5 });
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const url = process.env['DATABASE_URL'];
if (!url) {
console.error('DATABASE_URL is required');
process.exit(1);
}
runMigrations(url).catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@ -1,62 +0,0 @@
import { sql } from 'drizzle-orm';
import { bigint, boolean, index, integer, pgTable, text } from 'drizzle-orm/pg-core';
export const eventConfig = pgTable('event_config', {
id: integer('id').primaryKey().default(1),
coupleNames: text('couple_names').notNull(),
eventDate: text('event_date'),
coverKey: text('cover_key'),
galleryVisibility: text('gallery_visibility', { enum: ['public', 'private'] })
.notNull()
.default('public'),
moderation: text('moderation', { enum: ['pre', 'post'] }).notNull().default('post'),
maxFileMb: integer('max_file_mb').notNull().default(500),
allowVideo: boolean('allow_video').notNull().default(true),
maxVideoSeconds: integer('max_video_seconds').notNull().default(300),
welcomeMessage: text('welcome_message'),
updatedAt: bigint('updated_at', { mode: 'number' })
.notNull()
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
});
export const uploads = pgTable(
'uploads',
{
id: text('id').primaryKey(),
storageKey: text('storage_key').notNull(),
thumbnailKey: text('thumbnail_key'),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
durationSeconds: integer('duration_seconds'),
authorName: text('author_name'),
message: text('message'),
status: text('status', { enum: ['pending', 'approved', 'rejected'] })
.notNull()
.default('approved'),
source: text('source', { enum: ['guest', 'import'] }).notNull().default('guest'),
ipHash: text('ip_hash'),
providerUploadId: text('provider_upload_id'),
createdAt: bigint('created_at', { mode: 'number' })
.notNull()
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
approvedAt: bigint('approved_at', { mode: 'number' }),
},
(t) => ({
statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt),
sourceIdx: index('idx_uploads_source').on(t.source),
}),
);
export const auditLog = pgTable('audit_log', {
id: text('id').primaryKey(),
action: text('action').notNull(),
actor: text('actor'),
payload: text('payload'),
createdAt: bigint('created_at', { mode: 'number' })
.notNull()
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
});
export type EventConfigRow = typeof eventConfig.$inferSelect;
export type UploadRow = typeof uploads.$inferSelect;
export type NewUploadRow = typeof uploads.$inferInsert;

View File

@ -1,48 +0,0 @@
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
S3_ENDPOINT: z.string().min(1),
S3_BUCKET: z.string().min(1),
S3_REGION: z.string().default('us-east-1'),
S3_ACCESS_KEY_ID: z.string().min(1),
S3_SECRET_ACCESS_KEY: z.string().min(1),
S3_PUBLIC_BASE_URL: z.string().min(1),
S3_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
COUPLE_NAMES: z.string().default('Stefanie & Leandro'),
EVENT_DATE: z.string().optional(),
PUBLIC_BASE_URL: z.string().optional(),
ADMIN_PASSWORD: z.string().min(1),
SESSION_SECRET: z.string().min(16),
ALLOWED_ADMIN_EMAILS: z.string().min(1),
PORT: z.coerce.number().int().positive().default(3000),
STATIC_DIR: z.string().default('./public'),
NODE_ENV: z.enum(['development', 'production']).default('production'),
AUTO_MIGRATE: z.enum(['true', 'false']).default('true'),
TRUST_PROXY: z.enum(['true', 'false']).default('true'),
});
export type AppEnv = z.infer<typeof envSchema>;
export interface AppVariables {
adminEmail: string;
}
export type Bindings = {
Bindings: AppEnv;
Variables: AppVariables;
};
export function loadEnv(): AppEnv {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}

View File

@ -1,56 +0,0 @@
import type { Context, Next } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { sign, verify } from 'hono/jwt';
import type { Bindings } from '../env.js';
const COOKIE = 'wedding_admin';
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
interface SessionPayload {
email: string;
exp: number;
}
export async function requireAdmin(c: Context<Bindings>, next: Next) {
const token = getCookie(c, COOKIE);
if (!token) return c.json({ error: 'unauthorized' }, 401);
try {
const payload = (await verify(
token,
c.env.SESSION_SECRET,
'HS256',
)) as unknown as SessionPayload;
if (!payload.email) return c.json({ error: 'unauthorized' }, 401);
c.set('adminEmail', payload.email);
await next();
} catch {
return c.json({ error: 'unauthorized' }, 401);
}
}
export async function createSession(c: Context<Bindings>, email: string) {
const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
const token = await sign({ email, exp }, c.env.SESSION_SECRET, 'HS256');
const secure = c.env.NODE_ENV === 'production';
setCookie(c, COOKIE, token, {
httpOnly: true,
secure,
sameSite: 'Lax',
path: '/',
maxAge: SESSION_TTL_SECONDS,
});
}
export function destroySession(c: Context<Bindings>) {
deleteCookie(c, COOKIE, { path: '/' });
}
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
if (a.length !== b.length) return false;
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
let result = 0;
for (let i = 0; i < aBytes.length; i++) result |= aBytes[i]! ^ bBytes[i]!;
return result === 0;
}

View File

@ -1,21 +0,0 @@
import { customAlphabet } from 'nanoid';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const newId = customAlphabet(alphabet, 16);
export function uploadId(): string {
return `up_${newId()}`;
}
export function auditId(): string {
return `au_${newId()}`;
}
export async function hashIp(ip: string, salt: string): Promise<string> {
const data = new TextEncoder().encode(`${salt}:${ip}`);
const digest = await crypto.subtle.digest('SHA-256', data);
const bytes = new Uint8Array(digest);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex.slice(0, 32);
}

View File

@ -1,84 +0,0 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { generateQrPng } from './qrcode.js';
export interface QrPdfOptions {
url: string;
coupleNames: string;
eventDate?: string;
callToAction?: string;
}
const PT_PER_MM = 2.834645669;
const A6_WIDTH = 105 * PT_PER_MM;
const A6_HEIGHT = 148 * PT_PER_MM;
function centerX(
text: string,
fontSize: number,
font: import('pdf-lib').PDFFont,
): number {
const w = font.widthOfTextAtSize(text, fontSize);
return (A6_WIDTH - w) / 2;
}
export async function generateQrPdf(opts: QrPdfOptions): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([A6_WIDTH, A6_HEIGHT]);
const titleFont = await pdf.embedFont(StandardFonts.TimesRomanItalic);
const bodyFont = await pdf.embedFont(StandardFonts.Helvetica);
const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold);
const ink = rgb(0.18, 0.16, 0.14);
const muted = rgb(0.45, 0.42, 0.38);
const titleSize = 28;
const titleY = A6_HEIGHT - 28 * PT_PER_MM;
page.drawText(opts.coupleNames, {
x: centerX(opts.coupleNames, titleSize, titleFont),
y: titleY,
size: titleSize,
font: titleFont,
color: ink,
});
if (opts.eventDate) {
const dateSize = 11;
page.drawText(opts.eventDate, {
x: centerX(opts.eventDate, dateSize, bodyFont),
y: titleY - 7 * PT_PER_MM,
size: dateSize,
font: bodyFont,
color: muted,
});
}
const qrPng = await generateQrPng(opts.url, 600);
const qrImage = await pdf.embedPng(qrPng);
const qrSize = 70 * PT_PER_MM;
const qrX = (A6_WIDTH - qrSize) / 2;
const qrY = (A6_HEIGHT - qrSize) / 2 - 8 * PT_PER_MM;
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
const cta = opts.callToAction ?? 'Aponte a câmera do celular';
const ctaSize = 12;
page.drawText(cta, {
x: centerX(cta, ctaSize, boldFont),
y: qrY - 9 * PT_PER_MM,
size: ctaSize,
font: boldFont,
color: ink,
});
const sub = 'para enviar fotos e mensagem';
const subSize = 10;
page.drawText(sub, {
x: centerX(sub, subSize, bodyFont),
y: qrY - 14 * PT_PER_MM,
size: subSize,
font: bodyFont,
color: muted,
});
return await pdf.save();
}

View File

@ -1,19 +0,0 @@
import QRCode from 'qrcode';
export async function generateQrPng(text: string, size = 512): Promise<Uint8Array> {
const dataUrl = await QRCode.toDataURL(text, {
errorCorrectionLevel: 'M',
margin: 2,
width: size,
});
const base64 = dataUrl.split(',')[1] ?? '';
return Uint8Array.from(Buffer.from(base64, 'base64'));
}
export function generateQrSvg(text: string): Promise<string> {
return QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 2,
});
}

View File

@ -1,26 +0,0 @@
import type { AppEnv } from '../env.js';
import { S3Storage } from './storage-s3.js';
import type { StorageProvider } from './storage.js';
let storageInstance: StorageProvider | null = null;
export function initStorage(env: AppEnv): StorageProvider {
if (storageInstance) return storageInstance;
storageInstance = new S3Storage({
endpoint: env.S3_ENDPOINT,
bucket: env.S3_BUCKET,
region: env.S3_REGION,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
publicBaseUrl: env.S3_PUBLIC_BASE_URL,
forcePathStyle: env.S3_FORCE_PATH_STYLE === 'true',
});
return storageInstance;
}
export function getStorage(): StorageProvider {
if (!storageInstance) {
throw new Error('Storage not initialized. Call initStorage() before getStorage().');
}
return storageInstance;
}

View File

@ -1,164 +0,0 @@
import { AwsClient } from 'aws4fetch';
import type {
CompleteMultipartInput,
InitMultipartInput,
InitMultipartResult,
ObjectMeta,
PresignPutInput,
PresignPutResult,
StorageProvider,
} from './storage.js';
export interface S3StorageConfig {
endpoint: string;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
publicBaseUrl: string;
forcePathStyle?: boolean;
}
const DEFAULT_PART_SIZE = 10 * 1024 * 1024;
export class S3Storage implements StorageProvider {
private aws: AwsClient;
constructor(private config: S3StorageConfig) {
this.aws = new AwsClient({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
service: 's3',
region: config.region,
});
}
private objectUrl(key: string, query?: string): string {
const base = this.config.endpoint.replace(/\/$/, '');
const path = this.config.forcePathStyle
? `${base}/${this.config.bucket}/${encodeKey(key)}`
: `${base}/${encodeKey(key)}`;
return query ? `${path}?${query}` : path;
}
async presignPut(input: PresignPutInput): Promise<PresignPutResult> {
const expires = input.expiresInSec ?? 600;
const url = this.objectUrl(input.key, `X-Amz-Expires=${expires}`);
const signed = await this.aws.sign(
new Request(url, {
method: 'PUT',
headers: { 'content-type': input.contentType },
}),
{ aws: { signQuery: true } },
);
return {
url: signed.url,
method: 'PUT',
headers: { 'content-type': input.contentType },
};
}
async initMultipart(input: InitMultipartInput): Promise<InitMultipartResult> {
const initUrl = this.objectUrl(input.key, 'uploads=');
const init = await this.aws.fetch(initUrl, {
method: 'POST',
headers: { 'content-type': input.contentType },
});
if (!init.ok) {
throw new Error(`initMultipart failed: ${init.status} ${await init.text()}`);
}
const xml = await init.text();
const uploadId = matchXml(xml, 'UploadId');
if (!uploadId) throw new Error('multipart upload init: missing UploadId');
const expires = input.expiresInSec ?? 3600;
const partUrls: string[] = [];
for (let i = 1; i <= input.partCount; i++) {
const url = this.objectUrl(
input.key,
`partNumber=${i}&uploadId=${encodeURIComponent(uploadId)}&X-Amz-Expires=${expires}`,
);
const signed = await this.aws.sign(new Request(url, { method: 'PUT' }), {
aws: { signQuery: true },
});
partUrls.push(signed.url);
}
return { uploadId, partSize: DEFAULT_PART_SIZE, partUrls };
}
async completeMultipart(input: CompleteMultipartInput): Promise<void> {
const sorted = [...input.parts].sort((a, b) => a.partNumber - b.partNumber);
const body =
`<CompleteMultipartUpload>` +
sorted
.map(
(p) =>
`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`,
)
.join('') +
`</CompleteMultipartUpload>`;
const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`);
const res = await this.aws.fetch(url, { method: 'POST', body });
if (!res.ok) {
throw new Error(`completeMultipart failed: ${res.status} ${await res.text()}`);
}
}
async abortMultipart(input: { key: string; uploadId: string }): Promise<void> {
const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`);
await this.aws.fetch(url, { method: 'DELETE' });
}
publicUrl(key: string): string {
return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${encodeKey(key)}`;
}
async presignGet(key: string, expiresInSec = 3600): Promise<string> {
const url = this.objectUrl(key, `X-Amz-Expires=${expiresInSec}`);
const signed = await this.aws.sign(new Request(url, { method: 'GET' }), {
aws: { signQuery: true },
});
return signed.url;
}
async exists(key: string): Promise<boolean> {
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
return res.ok;
}
async delete(key: string): Promise<void> {
const res = await this.aws.fetch(this.objectUrl(key), { method: 'DELETE' });
if (!res.ok && res.status !== 404) {
throw new Error(`delete failed: ${res.status}`);
}
}
async head(key: string): Promise<ObjectMeta | null> {
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`head failed: ${res.status}`);
return {
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
contentLength: Number(res.headers.get('content-length') ?? 0),
etag: res.headers.get('etag') ?? '',
};
}
}
function encodeKey(key: string): string {
return key.split('/').map(encodeURIComponent).join('/');
}
function matchXml(xml: string, tag: string): string | null {
const m = xml.match(new RegExp(`<${tag}>([^<]+)</${tag}>`));
return m?.[1] ?? null;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -1,49 +0,0 @@
export interface PresignPutInput {
key: string;
contentType: string;
contentLength?: number;
expiresInSec?: number;
}
export interface PresignPutResult {
url: string;
method: 'PUT';
headers: Record<string, string>;
}
export interface InitMultipartInput {
key: string;
contentType: string;
partCount: number;
expiresInSec?: number;
}
export interface InitMultipartResult {
uploadId: string;
partSize: number;
partUrls: string[];
}
export interface CompleteMultipartInput {
key: string;
uploadId: string;
parts: { partNumber: number; etag: string }[];
}
export interface ObjectMeta {
contentType: string;
contentLength: number;
etag: string;
}
export interface StorageProvider {
presignPut(input: PresignPutInput): Promise<PresignPutResult>;
initMultipart(input: InitMultipartInput): Promise<InitMultipartResult>;
completeMultipart(input: CompleteMultipartInput): Promise<void>;
abortMultipart(input: { key: string; uploadId: string }): Promise<void>;
publicUrl(key: string): string;
presignGet(key: string, expiresInSec?: number): Promise<string>;
exists(key: string): Promise<boolean>;
delete(key: string): Promise<void>;
head(key: string): Promise<ObjectMeta | null>;
}

View File

@ -1,322 +0,0 @@
import { and, desc, eq, ilike, inArray, like, lt, or } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import {
adminBulkSchema,
adminUploadUpdateSchema,
eventConfigUpdateSchema,
type AdminStats,
type AdminUploadsResponse,
} from '@leetete/shared';
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import {
createSession,
destroySession,
requireAdmin,
timingSafeEqual,
} from '../lib/auth.js';
import { getStorage } from '../lib/storage-factory.js';
export const adminRoutes = new Hono<Bindings>();
const ADMIN_PAGE_SIZE = 30;
const loginSchema = z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(1),
});
adminRoutes.post('/login', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body' }, 400);
}
const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase());
const emailOk = allowed.includes(parsed.data.email);
const passwordOk = await timingSafeEqual(parsed.data.password, c.env.ADMIN_PASSWORD);
if (!emailOk || !passwordOk) {
return c.json({ error: 'unauthorized' }, 401);
}
await createSession(c, parsed.data.email);
return c.json({ ok: true, email: parsed.data.email });
});
adminRoutes.post('/logout', async (c) => {
destroySession(c);
return c.json({ ok: true });
});
adminRoutes.use('*', requireAdmin);
adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') }));
adminRoutes.get('/event', async (c) => {
const db = getDb();
const rows = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = rows[0];
if (!row) return c.json({ error: 'not_configured' }, 404);
const storage = getStorage();
return c.json({
coupleNames: row.coupleNames,
eventDate: row.eventDate,
coverKey: row.coverKey,
coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null,
galleryVisibility: row.galleryVisibility,
moderation: row.moderation,
maxFileMb: row.maxFileMb,
allowVideo: row.allowVideo,
maxVideoSeconds: row.maxVideoSeconds,
welcomeMessage: row.welcomeMessage,
updatedAt: row.updatedAt,
});
});
adminRoutes.patch('/event', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = eventConfigUpdateSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const db = getDb();
await db
.update(eventConfig)
.set({ ...parsed.data, updatedAt: Date.now() })
.where(eq(eventConfig.id, 1));
return c.json({ ok: true });
});
adminRoutes.get('/uploads', async (c) => {
const db = getDb();
const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined;
const kind = c.req.query('kind') as 'photo' | 'video' | undefined;
const q = c.req.query('q')?.trim();
const cursorParam = c.req.query('cursor');
const cursor = cursorParam ? Number(cursorParam) : null;
const limit = Math.min(Number(c.req.query('limit') ?? ADMIN_PAGE_SIZE), 100);
const filters = [];
if (status) filters.push(eq(uploads.status, status));
if (cursor !== null && Number.isFinite(cursor)) {
filters.push(lt(uploads.createdAt, cursor));
}
if (kind === 'photo') filters.push(like(uploads.mimeType, 'image/%'));
if (kind === 'video') filters.push(like(uploads.mimeType, 'video/%'));
if (q && q.length > 0) {
const pattern = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`;
const cond = or(
ilike(uploads.authorName, pattern),
ilike(uploads.message, pattern),
);
if (cond) filters.push(cond);
}
const where = filters.length ? and(...filters) : undefined;
const rows = await db
.select()
.from(uploads)
.where(where)
.orderBy(desc(uploads.createdAt))
.limit(limit + 1);
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = getStorage();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const coverKey = cfg[0]?.coverKey ?? null;
const items = slice.map((r) => ({
id: r.id,
url: storage.publicUrl(r.storageKey),
thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null,
storageKey: r.storageKey,
mimeType: r.mimeType,
isVideo: r.mimeType.startsWith('video/'),
sizeBytes: r.sizeBytes,
durationSeconds: r.durationSeconds,
authorName: r.authorName,
message: r.message,
status: r.status,
source: r.source,
createdAt: r.createdAt,
approvedAt: r.approvedAt,
isCover: coverKey === r.storageKey,
}));
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
return c.json({ items, nextCursor } satisfies AdminUploadsResponse);
});
adminRoutes.patch('/uploads/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => null);
const parsed = adminUploadUpdateSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const updates: Record<string, string | null> = {};
if (parsed.data.authorName !== undefined) {
updates['authorName'] = parsed.data.authorName?.trim() || null;
}
if (parsed.data.message !== undefined) {
updates['message'] = parsed.data.message?.trim() || null;
}
if (Object.keys(updates).length === 0) {
return c.json({ ok: true, noop: true });
}
const db = getDb();
const result = await db
.update(uploads)
.set(updates)
.where(eq(uploads.id, id))
.returning({ id: uploads.id });
if (!result.length) return c.json({ error: 'not_found' }, 404);
return c.json({ ok: true });
});
adminRoutes.post('/uploads/:id/approve', async (c) => {
const id = c.req.param('id');
const db = getDb();
const result = await db
.update(uploads)
.set({ status: 'approved', approvedAt: Date.now() })
.where(eq(uploads.id, id))
.returning({ id: uploads.id });
if (!result.length) return c.json({ error: 'not_found' }, 404);
return c.json({ ok: true });
});
adminRoutes.post('/uploads/:id/reject', async (c) => {
const id = c.req.param('id');
const db = getDb();
const result = await db
.update(uploads)
.set({ status: 'rejected', approvedAt: null })
.where(eq(uploads.id, id))
.returning({ id: uploads.id });
if (!result.length) return c.json({ error: 'not_found' }, 404);
return c.json({ ok: true });
});
adminRoutes.post('/uploads/:id/cover', async (c) => {
const id = c.req.param('id');
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const row = rows[0];
if (!row) return c.json({ error: 'not_found' }, 404);
await db
.update(eventConfig)
.set({ coverKey: row.storageKey, updatedAt: Date.now() })
.where(eq(eventConfig.id, 1));
return c.json({ ok: true });
});
adminRoutes.delete('/event/cover', async (c) => {
const db = getDb();
await db
.update(eventConfig)
.set({ coverKey: null, updatedAt: Date.now() })
.where(eq(eventConfig.id, 1));
return c.json({ ok: true });
});
adminRoutes.delete('/uploads/:id', async (c) => {
const id = c.req.param('id');
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const row = rows[0];
if (!row) return c.json({ error: 'not_found' }, 404);
const storage = getStorage();
await storage.delete(row.storageKey).catch((err) => {
console.warn('failed to delete from storage', row.storageKey, err);
});
if (row.thumbnailKey) {
await storage.delete(row.thumbnailKey).catch(() => {});
}
await db.delete(uploads).where(eq(uploads.id, id));
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
if (cfg[0]?.coverKey === row.storageKey) {
await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1));
}
return c.json({ ok: true });
});
adminRoutes.post('/uploads/bulk', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = adminBulkSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const db = getDb();
const storage = getStorage();
if (parsed.data.action === 'delete') {
const rows = await db
.select()
.from(uploads)
.where(inArray(uploads.id, parsed.data.ids));
await Promise.all(
rows.map((r) => storage.delete(r.storageKey).catch(() => {})),
);
await db.delete(uploads).where(inArray(uploads.id, parsed.data.ids));
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
if (cfg[0]?.coverKey && rows.some((r) => r.storageKey === cfg[0]!.coverKey)) {
await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1));
}
} else {
const status = parsed.data.action === 'approve' ? 'approved' : 'rejected';
const approvedAt = status === 'approved' ? Date.now() : null;
await db
.update(uploads)
.set({ status, approvedAt })
.where(inArray(uploads.id, parsed.data.ids));
}
return c.json({ ok: true, count: parsed.data.ids.length });
});
adminRoutes.get('/stats', async (c) => {
const db = getDb();
const all = await db
.select({
status: uploads.status,
mimeType: uploads.mimeType,
sizeBytes: uploads.sizeBytes,
})
.from(uploads);
const stats: AdminStats = {
total: all.length,
approved: 0,
pending: 0,
rejected: 0,
photos: 0,
videos: 0,
totalBytes: 0,
};
for (const r of all) {
stats[r.status]++;
if (r.mimeType.startsWith('image/')) stats.photos++;
else if (r.mimeType.startsWith('video/')) stats.videos++;
stats.totalBytes += r.sizeBytes;
}
return c.json(stats);
});
adminRoutes.get('/export.zip', async (c) => {
return c.json({ error: 'not_implemented_yet' }, 501);
});
adminRoutes.post('/imports', async (c) => {
return c.json({ error: 'not_implemented_yet' }, 501);
});

View File

@ -1,131 +0,0 @@
import { and, desc, eq, lt } from 'drizzle-orm';
import { Hono } from 'hono';
import type { GalleryResponse } from '@leetete/shared';
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import { generateQrPdf } from '../lib/qr-pdf.js';
import { generateQrPng, generateQrSvg } from '../lib/qrcode.js';
import { getStorage } from '../lib/storage-factory.js';
export const publicRoutes = new Hono<Bindings>();
const GALLERY_PAGE_SIZE = 24;
publicRoutes.get('/event', async (c) => {
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = cfg[0];
if (!row) return c.json({ error: 'not_configured' }, 404);
const storage = getStorage();
return c.json({
coupleNames: row.coupleNames,
eventDate: row.eventDate,
welcomeMessage: row.welcomeMessage,
galleryVisibility: row.galleryVisibility,
allowVideo: row.allowVideo,
maxFileMb: row.maxFileMb,
maxVideoSeconds: row.maxVideoSeconds,
coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null,
});
});
publicRoutes.get('/gallery', async (c) => {
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = cfg[0];
if (!row || row.galleryVisibility !== 'public') {
return c.json({ items: [], nextCursor: null } satisfies GalleryResponse);
}
const cursorParam = c.req.query('cursor');
const cursor = cursorParam ? Number(cursorParam) : null;
const limit = Math.min(Number(c.req.query('limit') ?? GALLERY_PAGE_SIZE), 60);
const baseCondition = eq(uploads.status, 'approved');
const where =
cursor !== null && Number.isFinite(cursor)
? and(baseCondition, lt(uploads.createdAt, cursor))
: baseCondition;
const rows = await db
.select()
.from(uploads)
.where(where)
.orderBy(desc(uploads.createdAt))
.limit(limit + 1);
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = getStorage();
const items = slice.map((r) => ({
id: r.id,
url: storage.publicUrl(r.storageKey),
thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null,
mimeType: r.mimeType,
isVideo: r.mimeType.startsWith('video/'),
authorName: r.authorName,
message: r.message,
createdAt: r.createdAt,
}));
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
return c.json({ items, nextCursor } satisfies GalleryResponse);
});
publicRoutes.get('/qrcode', async (c) => {
const format = (c.req.query('format') ?? 'png').toLowerCase();
const overrideUrl = c.req.query('url');
const reqUrl = new URL(c.req.url);
const base = overrideUrl ?? c.env.PUBLIC_BASE_URL ?? `${reqUrl.protocol}//${reqUrl.host}`;
const target = base.replace(/\/$/, '') + '/enviar';
if (format === 'svg') {
const svg = await generateQrSvg(target);
return new Response(svg, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=300',
},
});
}
if (format === 'pdf') {
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const row = cfg[0];
const pdf = await generateQrPdf({
url: target,
coupleNames: row?.coupleNames ?? c.env.COUPLE_NAMES,
eventDate: row?.eventDate ?? c.env.EVENT_DATE,
});
return new Response(pdf, {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'inline; filename="qr-mesa.pdf"',
'cache-control': 'no-store',
},
});
}
const png = await generateQrPng(target, 800);
return new Response(png, {
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=300',
},
});
});
publicRoutes.get('/stats', async (c) => {
const db = getDb();
const all = await db
.select({ id: uploads.id, mimeType: uploads.mimeType })
.from(uploads)
.where(eq(uploads.status, 'approved'));
const photos = all.filter((u) => u.mimeType.startsWith('image/')).length;
const videos = all.filter((u) => u.mimeType.startsWith('video/')).length;
return c.json({ photos, videos, total: all.length });
});

View File

@ -1,220 +0,0 @@
import { eq } from 'drizzle-orm';
import { Hono, type Context } from 'hono';
import {
uploadConfirmSchema,
uploadInitSchema,
type UploadInitResponse,
} from '@leetete/shared';
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import { hashIp, uploadId } from '../lib/ids.js';
import { getStorage } from '../lib/storage-factory.js';
export const uploadsRoutes = new Hono<Bindings>();
const SINGLE_PUT_MAX = 50 * 1024 * 1024;
const PART_SIZE = 10 * 1024 * 1024;
const KEY_PREFIX = 'uploads';
const IP_HASH_SALT = 'wedding-uploads-v1';
function extFromFilename(filename: string, mimeType: string): string {
const dot = filename.lastIndexOf('.');
if (dot >= 0 && dot < filename.length - 1) {
const ext = filename.slice(dot + 1).toLowerCase();
if (/^[a-z0-9]{1,6}$/.test(ext)) return ext;
}
const map: Record<string, string> = {
'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',
};
return map[mimeType.toLowerCase()] ?? 'bin';
}
function buildKey(id: string, filename: string, mimeType: string): string {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const ext = extFromFilename(filename, mimeType);
return `${KEY_PREFIX}/${yyyy}/${mm}/${id}.${ext}`;
}
function getClientIp(c: Context<Bindings>): string {
return (
c.req.header('cf-connecting-ip') ??
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
c.req.header('x-real-ip') ??
'unknown'
);
}
uploadsRoutes.post('/init', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = uploadInitSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const input = parsed.data;
const db = getDb();
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const event = cfg[0];
if (!event) return c.json({ error: 'not_configured' }, 500);
const isVideo = input.mimeType.startsWith('video/');
if (isVideo && !event.allowVideo) {
return c.json({ error: 'video_not_allowed' }, 400);
}
if (input.sizeBytes > event.maxFileMb * 1024 * 1024) {
return c.json({ error: 'file_too_large', maxFileMb: event.maxFileMb }, 400);
}
if (
isVideo &&
input.durationSeconds !== undefined &&
input.durationSeconds > event.maxVideoSeconds
) {
return c.json({ error: 'video_too_long', maxVideoSeconds: event.maxVideoSeconds }, 400);
}
const id = uploadId();
const storageKey = buildKey(id, input.filename, input.mimeType);
const storage = getStorage();
const ipHashValue = await hashIp(getClientIp(c), IP_HASH_SALT);
if (input.sizeBytes <= SINGLE_PUT_MAX) {
const presigned = await storage.presignPut({
key: storageKey,
contentType: input.mimeType,
contentLength: input.sizeBytes,
expiresInSec: 900,
});
await db.insert(uploads).values({
id,
storageKey,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
durationSeconds: input.durationSeconds ?? null,
authorName: input.authorName?.trim() || null,
message: input.message?.trim() || null,
status: 'pending',
source: 'guest',
ipHash: ipHashValue,
});
const response: UploadInitResponse = {
uploadId: id,
storageKey,
mode: 'single',
putUrl: presigned.url,
putHeaders: presigned.headers,
};
return c.json(response);
}
const partCount = Math.ceil(input.sizeBytes / PART_SIZE);
const multipart = await storage.initMultipart({
key: storageKey,
contentType: input.mimeType,
partCount,
expiresInSec: 3600 * 6,
});
await db.insert(uploads).values({
id,
storageKey,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
durationSeconds: input.durationSeconds ?? null,
authorName: input.authorName?.trim() || null,
message: input.message?.trim() || null,
status: 'pending',
source: 'guest',
ipHash: ipHashValue,
providerUploadId: multipart.uploadId,
});
const response: UploadInitResponse = {
uploadId: id,
storageKey,
mode: 'multipart',
multipart: {
providerUploadId: multipart.uploadId,
partSize: multipart.partSize,
partUrls: multipart.partUrls,
},
};
return c.json(response);
});
uploadsRoutes.post('/:id/confirm', async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const parsed = uploadConfirmSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
}
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const upload = rows[0];
if (!upload) return c.json({ error: 'not_found' }, 404);
if (upload.status === 'approved') return c.json({ ok: true, alreadyApproved: true });
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
const event = cfg[0];
if (!event) return c.json({ error: 'not_configured' }, 500);
const storage = getStorage();
if (parsed.data.multipart) {
if (parsed.data.multipart.providerUploadId !== upload.providerUploadId) {
return c.json({ error: 'multipart_mismatch' }, 400);
}
await storage.completeMultipart({
key: upload.storageKey,
uploadId: parsed.data.multipart.providerUploadId,
parts: parsed.data.multipart.parts,
});
}
const head = await storage.head(upload.storageKey);
if (!head) {
return c.json({ error: 'not_uploaded' }, 400);
}
const finalStatus = event.moderation === 'pre' ? 'pending' : 'approved';
await db
.update(uploads)
.set({
status: finalStatus,
approvedAt: finalStatus === 'approved' ? Date.now() : null,
})
.where(eq(uploads.id, id));
return c.json({ ok: true, status: finalStatus });
});
uploadsRoutes.post('/:id/abort', async (c) => {
const id = c.req.param('id');
const db = getDb();
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
const upload = rows[0];
if (!upload) return c.json({ error: 'not_found' }, 404);
if (upload.providerUploadId) {
const storage = getStorage();
await storage
.abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId })
.catch(() => {});
}
await db.delete(uploads).where(eq(uploads.id, id));
return c.json({ ok: true });
});

View File

@ -1,65 +0,0 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { createApp } from './app.js';
import { closeDb, initDb } from './db/client.js';
import { runMigrations } from './db/migrate.js';
import type { Bindings } from './env.js';
import { loadEnv } from './env.js';
import { initStorage } from './lib/storage-factory.js';
async function main() {
const env = loadEnv();
if (env.AUTO_MIGRATE === 'true') {
console.log('[startup] running migrations');
await runMigrations(env.DATABASE_URL);
}
initDb(env.DATABASE_URL);
initStorage(env);
const apiApp = createApp();
const app = new Hono<Bindings>();
app.route('/', apiApp);
app.use(
'/assets/*',
serveStatic({
root: env.STATIC_DIR,
rewriteRequestPath: (path) => path,
}),
);
for (const f of ['/favicon.ico', '/robots.txt']) {
app.get(f, serveStatic({ path: `${env.STATIC_DIR}${f}` }));
}
app.get('*', serveStatic({ path: `${env.STATIC_DIR}/index.html` }));
const server = serve(
{
fetch: (req) => app.fetch(req, env),
port: env.PORT,
hostname: '0.0.0.0',
},
(info) => {
console.log(`[startup] listening on http://0.0.0.0:${info.port}`);
},
);
const shutdown = async (signal: string) => {
console.log(`[shutdown] received ${signal}`);
server.close();
await closeDb();
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
}
main().catch((err) => {
console.error('[startup] failed', err);
process.exit(1);
});

View File

@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*"]
}

View File

@ -4,11 +4,9 @@
"version": "0.1.0",
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev:api": "pnpm -F @leetete/api dev",
"dev:web": "pnpm -F @leetete/web dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"migrate": "pnpm -F @leetete/api migrate"
"build": "pnpm -F @leetete/web build",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"typescript": "^5.6.3"

986
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,3 @@
packages:
- "apps/*"
- "packages/*"
- "apps/web"
- "packages/shared"