Compare commits

...

No commits in common. "main" and "claude/wedding-qrcode-photos-C0PQt" have entirely different histories.

95 changed files with 4919 additions and 5241 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@ -1,69 +0,0 @@
# Deploy automático do app wedding.
#
# A cada push na branch principal (ou disparo manual), conecta por SSH no host
# de produção e roda `make deploy`, que faz git reset --hard na branch + rebuild
# do app wedding (docker compose up -d --build).
#
# Por que SSH (e não rodar docker direto no runner):
# os arquivos .env (segredos do app) ficam só no host, fora do git. O host já
# tem o repo configurado, então o deploy é só atualizar o código e subir.
#
# Segredos necessários (Gitea: repo > Settings > Actions > Secrets):
# DEPLOY_HOST IP ou hostname do host de produção
# DEPLOY_USER usuário SSH (precisa rodar docker + ter o repo clonado)
# DEPLOY_SSH_KEY chave PRIVADA SSH (conteúdo completo, incl. cabeçalhos)
# DEPLOY_PATH caminho absoluto do repo no host (ex.: /opt/wedding-app)
# Variáveis opcionais (Gitea: ... > Variables):
# DEPLOY_PORT porta SSH (default 22)
name: Deploy
on:
push:
branches:
- main
paths:
# Só dispara quando algo que afeta o app/infra muda. Edição de docs
# (CLAUDE.md, README) não redeploya.
- "infra/wedding_photo/**"
- "Makefile"
- ".gitea/workflows/deploy.yml"
workflow_dispatch: {}
# Cancela um deploy em andamento se um novo push chegar.
concurrency:
group: deploy-wedding
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Configura chave SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
PORT="${{ vars.DEPLOY_PORT }}"
ssh-keyscan -p "${PORT:-22}" -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via SSH
run: |
PORT="${{ vars.DEPLOY_PORT }}"
ssh -i ~/.ssh/deploy_key -p "${PORT:-22}" \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"set -e; cd '${{ secrets.DEPLOY_PATH }}'; make deploy BRANCH=main"
- name: Health check
run: |
PORT="${{ vars.DEPLOY_PORT }}"
# Espera o container ficar saudável e confere /health internamente.
ssh -i ~/.ssh/deploy_key -p "${PORT:-22}" \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"for i in \$(seq 1 15); do \
if docker exec wedding_app sh -c 'wget -qO- http://localhost:3000/api/health' >/dev/null 2>&1; then \
echo 'app OK'; exit 0; \
fi; \
echo \"aguardando app... (\$i/15)\"; sleep 4; \
done; \
echo 'app NAO respondeu'; docker logs --tail 50 wedding_app; exit 1"

29
.gitignore vendored
View File

@ -1,39 +1,20 @@
node_modules/
dist/
build/
out/
.cache/
.turbo/
*.log
.DS_Store
.wrangler/
.dev.vars
worker-configuration.d.ts
.env
.env.local
.env.*.local
!.env.example
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
.python-version
.mypy_cache/
.pytest_cache/
.ruff_cache/
*.egg-info/
# Diretórios de dados (bind mounts dos volumes Docker)
infra/main/postgres/data/
infra/main/redis/data/
infra/main/minio/data/
infra/main/pgadmin/data/
infra/main/caddy/data/
infra/main/caddy/config/
infra/gitea/gitea/data/
infra/gitea/runner/data/
infra/wedding_photo/backups/
.vscode/
.idea/

409
CLAUDE.md
View File

@ -1,409 +0,0 @@
# CLAUDE.md
Notas de projeto pro Claude Code (e qualquer dev que entre depois). Resume todas as decisões tomadas ao longo das conversas. Linguagem do projeto: **PT-BR**.
---
## 1. Visão geral
App web pro casamento de **Stefanie & Leandro**. Convidados escaneiam um QR Code na mesa, abrem o site, mandam fotos/vídeos + uma mensagem. Os noivos administram tudo num painel `/admin`.
**Escopo**: single-tenant (um casamento). Multi-tenant (SaaS) foi avaliado — fica pra um pivot futuro se houver demanda real (3+ pedidos).
---
## 2. Arquitetura: 3 stacks Docker Compose
Cada stack é um `docker-compose.yml` isolado. Os 3 compartilham uma **network Docker externa** chamada `infra-net`.
```
infra/
├── main/ Postgres + Redis + MinIO + pgAdmin + Caddy
├── gitea/ Gitea + Actions runner
└── wedding_photo/ App FastAPI + sidecars de backup
```
**Por que separados**: cada stack tem seu próprio ciclo de vida (`make up-main`, `make up-gitea`, `make up-wedding`). Subir/derrubar uma não afeta as outras. Gitea pode ser desativado sem tocar no app.
**`main/` é o "platform layer"** que os consumidores usam. `gitea/` e `wedding_photo/` conectam ao Postgres e MinIO de lá.
---
## 3. Stack técnica
| Camada | Escolha | Por quê |
|---|---|---|
| HTTP framework | **FastAPI 0.115** | Async-first, OpenAPI auto, Pydantic v2 |
| ASGI server | **uvicorn** | Padrão de fato |
| ORM | **SQLAlchemy 2.0 async + asyncpg** | Tipos modernos, drivers async maduros |
| Validação | **Pydantic v2** | Built-in no FastAPI; camelCase no wire |
| Env loader | **pydantic-settings** | Falha rápido em var faltando |
| Storage SDK | **boto3** wrapped em `asyncio.to_thread` | S3 compatível, lida com MinIO + R2 + B2 idem |
| Auth | **pyjwt** + cookie HttpOnly | 7 dias, HS256, constant-time compare |
| QR codes | **qrcode** + **reportlab** | PNG/SVG/PDF A6 imprimível |
| HEIC | **pillow-heif** | Decode iPhone HEIC → JPEG no `/confirm` |
| Frontend | **Vite + React 18 + Tailwind + react-router** | Stack comum, rápida |
| DB | **Postgres 16** | Multi-banco em uma instância (wedding + gitea) |
| Cache | **Redis 7** | Gitea usa hoje (cache + sessões); wedding pode usar depois |
| Storage | **MinIO** | S3 compat self-hosted; código portável pra R2/B2 |
| Reverse proxy | **Caddy 2** | TLS auto (Let's Encrypt em prod, interno em `*.localhost` dev) |
| Git hosting | **Gitea 1.22** + Actions runner | Self-hosted, leve, compatível com GitHub Actions |
| Backup | `prodrigestivill/postgres-backup-local` + `alpine + mc` | Sidecars com cron, rotação dias/semanas/meses |
| Package mgmt | **uv** (Python), **pnpm** (Node) | Mais rápidos que pip/npm |
---
## 4. Estrutura de diretórios completa
```
.
├── .gitignore
├── Makefile # Orquestra as 3 stacks
├── network.sh # Cria infra-net (idempotente)
├── CLAUDE.md # Este arquivo
└── infra/
├── main/ # PLATFORM LAYER
│ ├── docker-compose.yml
│ ├── .env.example
│ ├── caddy/Caddyfile # hostname-based routing
│ ├── postgres/init/01-create-databases.sh # cria DBs wedding + gitea
│ ├── minio/{init.sh, cors.json} # cria bucket wedding-media
│ └── pgadmin/servers.json # postgres pré-conectado
├── gitea/ # GIT + CI
│ ├── docker-compose.yml
│ ├── .env.example
│ └── runner/Dockerfile # act_runner + docker-cli
└── wedding_photo/ # APLICAÇÃO
├── docker-compose.yml # app + postgres-backup + media-backup
├── .env.example
├── Dockerfile # multi-stage Node(build web) + Python(runtime)
├── Makefile # dev local (uv, pnpm) — não duplica root
├── pyproject.toml, package.json, pnpm-workspace.yaml, ...
├── apps/
│ ├── api/ # FastAPI Python
│ │ ├── pyproject.toml
│ │ └── app/
│ │ ├── main.py # FastAPI app, lifespan, SPA fallback
│ │ ├── config.py # pydantic-settings
│ │ ├── db/{base,models,migrate}.py
│ │ ├── migrations/ # SQL puro, runner idempotente
│ │ ├── lib/ # auth, storage, qrcode, pdf, ids, transcode
│ │ ├── schemas/api.py # Pydantic v2 wire models
│ │ └── routes/{public,uploads,admin}.py
│ └── web/ # Vite + React + Tailwind SPA
│ ├── index.html
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ └── src/
│ ├── main.tsx, App.tsx
│ ├── routes/{Home,Upload,Gallery}.tsx + admin/{Login,Dashboard}.tsx
│ └── lib/{api,upload}.ts
├── packages/shared/ # Zod schemas TS (usado pelo web)
├── infra/backup/ # entrypoint + script do media-backup
└── backups/ # destino dos sidecars (gitignored)
├── postgres/
└── media/
```
---
## 5. Network e roteamento
### Network
`infra-net` é **external**. Criada pelo `network.sh` (chamada por `make network`). Containers de stacks diferentes se enxergam por nome via DNS interno do Docker.
### Container names fixos
- `postgres`, `redis`, `minio`, `pgadmin`, `caddy` (stack main)
- `gitea`, `gitea_runner` (stack gitea)
- `wedding_app`, `wedding_pg_backup`, `wedding_media_backup` (stack wedding_photo)
### URLs (com `DOMAIN_BASE`)
| Hostname | Serve |
|---|---|
| `https://wedding.{DOMAIN_BASE}` | Site dos noivos (uploads + galeria + admin) |
| `https://gitea.{DOMAIN_BASE}` | Git hosting + Actions |
| `https://pgadmin.{DOMAIN_BASE}` | Web UI dos bancos |
| `https://minio.{DOMAIN_BASE}` | Console admin do MinIO |
| `https://media.{DOMAIN_BASE}` | S3 API pública do MinIO (uploads/downloads) |
| `ssh://git@gitea.{DOMAIN_BASE}:2222` | Git via SSH |
### `DOMAIN_BASE`
- **Dev local**: `localhost` → Caddy emite cert interno automático pra `*.localhost`
- **Produção**: domínio real (ex.: `lronetto.com`) → Let's Encrypt automático
- Mudar `DOMAIN_BASE` requer **down + up** das 3 stacks (envs lidos no boot)
### Hairpin / `extra_hosts`
`wedding_app` tem `extra_hosts: media.{DOMAIN_BASE}:host-gateway` e `wedding.{DOMAIN_BASE}:host-gateway` pra que, mesmo dentro do container, ele resolva esses hostnames pro Docker host gateway → Caddy. Assim assinaturas S3 fecham (signing host == host que o browser usa pra PUT).
---
## 6. Comandos
### Root `Makefile`
```bash
make help # lista tudo
make up # network + main + gitea + wedding (na ordem)
make down # inverso
make restart # down + up
make status # ps das 3 stacks
make up-main # só infra base
make up-gitea
make up-wedding # rebuilda imagem do app
make rebuild-wedding # build --no-cache + up
make down-{main,gitea,wedding}
make logs-{main,gitea,wedding}
make pull-{main,gitea,wedding}
```
### Wedding `Makefile` (dev local, fora do Docker)
```bash
make install # pnpm install + uv sync
make dev-web # vite na 5173
make dev-api # uvicorn --reload na 3000
make migrate # roda migrations do D1
make build # build do front
make typecheck
make lint
```
---
## 7. Convenções
### Variáveis de ambiente
- **SCREAMING_SNAKE_CASE**
- Cada stack tem seu `.env` (cópia do `.env.example` ao lado)
- Variáveis compartilhadas entre stacks (`DOMAIN_BASE`, `MINIO_ROOT_*`, `WEDDING_DB_*`) precisam casar manualmente
- Senhas defaults nos `.env.example` são placeholders `troque-essa-senha-forte` — sempre trocar
- `SESSION_SECRET` mínimo 16 chars (sugestão: UUID + sufixo)
### IDs
- Nanoid 16 chars + prefixo: `up_xxxx...` (upload), `au_xxxx...` (audit)
- Implementação em `apps/api/app/lib/ids.py`
### API wire format
- **camelCase** (`coupleNames`, `createdAt`, `maxFileMb`, etc.)
- Mesmo schema entre Python (Pydantic) e TS (Zod) — campos batem nome-a-nome
- Erros: `{"error": "code", "details": ...}` com HTTP status apropriado
### Timestamps
- `bigint` em ms desde epoch (não `timestamptz`)
- Razões: lida fácil com JS `Date.now()`, sort sem timezone, não precisa de cast
- Default no DB: `(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT`
### Migrations
- SQL puro em `apps/api/app/migrations/NNNN_descricao.sql`
- Runner em `app/db/migrate.py`: idempotente, valida SHA256 (impede editar migration aplicada)
- Roda automaticamente no boot do `wedding_app` (`AUTO_MIGRATE=true`)
- Schema sempre em **Postgres puro** (não SQLite syntax)
### Storage keys
- Formato: `uploads/{YYYY}/{MM}/{id}.{ext}`
- Ex.: `uploads/2026/06/up_abc123def456.jpg`
- Extensão vem do filename, fallback no MIME type
---
## 8. Fluxos importantes
### Upload (convidado)
1. `POST /api/uploads/init` → backend valida (tamanho, vídeo permitido, duração), cria row `pending`, retorna URL pré-assinada
2. Browser faz **`PUT` direto no MinIO** (não passa pelo backend) com a URL pré-assinada
3. Decisão **single vs multipart**:
- `<= 50 MB`: single PUT
- `> 50 MB`: multipart 10 MB chunks
4. `POST /api/uploads/:id/confirm` → backend faz HEAD pra verificar o objeto, completa multipart se aplicável, **se for HEIC**: transcoda pra JPEG e substitui o storage_key, marca `approved` (ou `pending` se moderation=`pre`)
5. Galeria pública lista só `status=approved`
### HEIC transcoding
- Detecção: `mime_type in {"image/heic", "image/heif"}`
- Em `/confirm` após HEAD: baixa, decoda com pillow-heif, aplica EXIF rotation, salva JPEG quality 88 progressive, escreve com `.jpg`, deleta HEIC
- **Falha não bloqueia o upload**: log + mantém HEIC original (gallery mostra placeholder)
- Razão: browsers (especialmente Android) não renderizam HEIC nativamente
### Multipart upload (cliente)
- Em `apps/web/src/lib/upload.ts`
- Sequential (não paralelo pra MVP) com `XMLHttpRequest` (precisa de progress event)
- ETag de cada chunk via header `etag` na resposta — exige CORS `ExposeHeaders: ["ETag"]` no bucket
### Admin login
- `POST /api/admin/login` body `{email, password}`
- Valida email contra `ALLOWED_ADMIN_EMAILS` (separados por vírgula no env)
- Compara senha com `ADMIN_PASSWORD` via `hmac.compare_digest` (constant-time)
- Issue cookie HttpOnly JWT HS256 com email + exp 7 dias
- `GET /api/admin/*` decora com `Depends(get_admin_email)` que verifica o cookie
### Admin: gestão de uploads
- `GET /api/admin/uploads?status=...&kind=photo|video&q=...&cursor=...` — paginação por timestamp DESC
- `PATCH /api/admin/uploads/:id` — edita `authorName` + `message`
- `POST /api/admin/uploads/:id/{approve,reject,cover}` — actions individuais
- `DELETE /api/admin/uploads/:id` — apaga do banco + storage (incl. thumbnail)
- `POST /api/admin/uploads/bulk``{action: approve|reject|delete, ids: [...]}` até 200 IDs
- `DELETE /api/admin/event/cover` — remove a foto de capa
### Backup
- **Postgres**: `prodrigestivill/postgres-backup-local` daily, retenção dias/semanas/meses, escreve em `./backups/postgres/` (bind mount do host)
- **MinIO**: alpine + mc cron-driven, `mc mirror` (incremental) pra `./backups/media/`
- **Backup remoto opcional**: configurar `BACKUP_REMOTE_*` no `.env` da wedding → espelha pra outro endpoint S3 (R2/B2/etc.)
### Gitea bootstrap (1ª vez)
1. `make up-main` (precisa estar de pé pro postgres + redis)
2. `make up-gitea` (runner falha porque ainda não tem token, ok)
3. Cria user admin via CLI (nome **NÃO pode ser `admin`** — é reservado):
```bash
read -s PW
docker exec -u git -it gitea gitea admin user create \
--username lronetto --password "$PW" --email lronetto@gmail.com --admin
```
4. Abre `https://gitea.{DOMAIN_BASE}` → loga
5. Avatar → **Site Administration****Actions****Runners****Create new Runner** → copia token
6. Cola em `infra/gitea/.env`: `GITEA_RUNNER_TOKEN=<token>`
7. `make up-gitea` (runner agora se registra)
### Trocar senha do Gitea
```bash
read -s NEWPW
docker exec -u git -it gitea gitea admin user change-password \
--username lronetto --password "$NEWPW"
```
### Deploy automático (CI/CD)
Workflow `.gitea/workflows/deploy.yml`. A cada push na branch principal
(`main`) que toque `infra/wedding_photo/**`, `Makefile` ou o
próprio workflow — ou disparo manual (`workflow_dispatch`) — o runner conecta
por **SSH no host** e roda `make deploy` (git reset --hard + `up-wedding` com
build), seguido de um health check em `/api/health`.
**Por que SSH e não docker direto no runner**: os `.env` (segredos do app)
ficam só no host, fora do git. O host já tem o repo configurado; o deploy só
atualiza o código e sobe.
Setup (1ª vez):
1. No host, garanta o repo clonado (ex.: `/opt/wedding-app`) e um usuário SSH
que rode docker e tenha acesso ao repo.
2. Gere um par de chaves SSH e adicione a **pública** no `~/.ssh/authorized_keys`
desse usuário.
3. Em Gitea → repo → **Settings → Actions → Secrets**, crie:
- `DEPLOY_HOST` — IP/hostname do host
- `DEPLOY_USER` — usuário SSH
- `DEPLOY_SSH_KEY` — a chave **privada** (conteúdo completo)
- `DEPLOY_PATH` — caminho do repo no host (ex.: `/opt/wedding-app`)
4. (Opcional) **Variables**: `DEPLOY_PORT` se o SSH não for 22.
`make deploy` aceita `BRANCH=` pra sobrescrever a branch deployada.
---
## 9. Gotchas conhecidos
### Postgres init script só roda na 1ª vez
`infra/main/postgres/init/01-create-databases.sh` é executado pelo entrypoint do postgres **apenas quando `PGDATA` está vazio**. Pra "rerodar":
```bash
make down-main
sudo rm -rf infra/main/postgres/data
make up-main
```
Alternativa: criar manualmente via `docker exec -i postgres psql`.
### `docker exec -it` com heredoc
`-t` aloca TTY e conflita com stdin redirecionado. Usar **`-i` só**:
```bash
docker exec -i postgres psql -U postgres <<EOF
CREATE ROLE gitea WITH LOGIN PASSWORD 'xxx';
EOF
```
### Gitea reservou nomes
`admin`, `api`, `user`, `org`, `explore`, `install`, `repo` etc. são reservados. Use outro nome (ex.: `lronetto`, `gitadmin`). O role admin vem do flag `--admin`, não do username.
### Gitea: imagem regular vs rootless
Volumes diferentes:
- `gitea/gitea:1.22` (regular): `/data`
- `gitea/gitea:1.22-rootless`: `/var/lib/gitea` + `/etc/gitea`
Usamos a **regular** com `INSTALL_LOCK=true` pra pular o wizard `/install` no primeiro boot.
### `docker exec` por padrão entra como root
Pra Gitea, isso quebra (`Gitea is not supposed to be run as root`). Sempre `docker exec -u git ...` quando for chamar binário do gitea.
### CORS no MinIO
Bucket precisa de `ExposeHeaders: ["ETag"]` pra multipart funcionar (cliente lê ETag de cada PUT). Aplicado pelo `infra/main/minio/init.sh` via `mc cors set`.
### Postgres path-style URLs
`S3_FORCE_PATH_STYLE=true` é necessário pra MinIO. Sem isso, boto3 monta URL virtual-hosted (`bucket.endpoint/key`) que MinIO single-instance não suporta.
### Mudança em `.env` exige restart
Compose só lê env vars no boot do container. `make down-X && make up-X` após editar.
### TLS local em `*.localhost`
Caddy emite cert auto-assinado pelo root local. Browser pede pra aceitar (1x por subdomínio). Pra evitar prompts:
```bash
docker exec caddy cat /data/caddy/pki/authorities/local/root.crt > /tmp/caddy-root.crt
# Importa no trust store do OS/browser
```
### Configurar firewall em produção
- VPS firewall (ufw/iptables): liberar 80 e 443
- **Cloud firewall** (security group AWS/DO/Vultr): também liberar 80 e 443 — esse é separado e quase sempre é o esquecido
- Caddy ACME challenge precisa de **80 acessível externamente**, senão Let's Encrypt falha
### DNS precisa apontar antes do `up`
Pra produção, registros A pros subdomínios (`wedding`, `gitea`, `media`, `pgadmin`, `minio`) precisam estar propagados antes do Caddy tentar emitir cert. Senão ele entra em backoff e demora.
### `ACME_EMAIL` obrigatório em prod
Let's Encrypt requer email pra contato de renovação. Em dev pode ficar vazio (Caddy usa internal CA).
### Hairpin DNS no `wedding_app`
O `extra_hosts: host-gateway` pra `media.{DOMAIN_BASE}` faz o container resolver o subdomínio pro host. Sem isso, requests server-side (HEAD/DELETE/multipart complete) vão pra IP público → roteador → host → Caddy (lentidão). Com host-gateway: container → host → Caddy (rápido).
---
## 10. Histórico de iterações (sem detalhe — referência rápida)
1. **MVP Cloudflare**: Workers + D1 + R2 + Pages + Access. Funcionou mas Access não rola em `*.workers.dev`.
2. **Migração 1**: full Docker (Node/Hono + Postgres + MinIO + Caddy). Single compose, deploy num VPS.
3. **Migração 2**: backend reescrito em Python (FastAPI + SQLAlchemy + boto3 + pyjwt). Mesmo contrato de API. Frontend não mexeu.
4. **HEIC + backup**: pillow-heif no `/confirm`, 2 sidecars de backup (Postgres + MinIO mirror).
5. **Reorganização em 3 stacks**: `infra/main` + `infra/gitea` + `infra/wedding_photo` compartilhando `infra-net`. Caddy concentrado em `main/`.
Branches relevantes:
- `claude/wedding-qrcode-photos-C0PQt` (Cloudflare original)
- `claude/docker-vps-migration` (1ª migração Docker Node)
- `main` (atual; Python + restructure 3 stacks — renomeada de `claude/python-backend`)
---
## 11. Onde olhar pra estender
| Quero adicionar... | Olha em |
|---|---|
| Nova rota pública | `apps/api/app/routes/public.py` |
| Nova rota admin | `apps/api/app/routes/admin.py` |
| Novo campo no upload | `apps/api/app/db/models.py` + nova migration + `apps/api/app/schemas/api.py` + atualizar `routes/uploads.py` |
| Novo bucket no MinIO | `infra/main/minio/init.sh` + variável no `.env.example` |
| Outro DB no postgres | `infra/main/postgres/init/01-create-databases.sh` + role nova |
| Novo subdomínio Caddy | `infra/main/caddy/Caddyfile` + container_name correspondente |
| Nova app na rede | criar `infra/<app>/docker-compose.yml`, declarar `infra-net` como external, conectar a `postgres`/`redis`/`minio` por nome |
| Nova tela no front | `apps/web/src/routes/` + rota em `App.tsx` |
| Schema compartilhado front-back | duplica: Zod em `packages/shared/src/schemas.ts` (TS) + Pydantic em `apps/api/app/schemas/api.py` (Python). **Camelo nos dois** |
| Workflow CI no Gitea | `.gitea/workflows/*.yml` no repo (sintaxe GitHub Actions) — runner já registrado |
---
## 12. Decisões deferidas (a fazer se necessário)
- **Thumbnails server-side**: galeria carrega imagens full-size. Pra otimizar: `sharp` no upload `/confirm` (ou um job async), salvar `thumbnail_key`. ~2h.
- **Export ZIP do admin**: streamed ZIP de todos os uploads. ~1h.
- **Rate limit em `/uploads/init`**: hoje sem limite. Vale colocar Redis-based se houver suspeita de abuso. ~1h.
- **Email aos noivos quando upload chegar**: Resend ou SMTP. ~2h.
- **Slideshow pra projetar na recepção**: tela `/slideshow` com auto-advance. ~30 min.
- **Custom domain do bucket público** (em vez de `media.X`): mais "branded". DNS + CNAME pro MinIO. ~15 min.
- **Pivot multi-tenant SaaS**: ver seção 1 — 2-4 semanas se valer a pena.

View File

@ -1,94 +0,0 @@
.PHONY: help network up down restart status \
up-main up-gitea up-wedding \
down-main down-gitea down-wedding \
logs-main logs-gitea logs-wedding \
pull-main pull-gitea pull-wedding \
rebuild-wedding deploy
NET := infra-net
BRANCH ?= main
DC_MAIN := docker compose -f infra/main/docker-compose.yml --env-file infra/main/.env
DC_GITEA := docker compose -f infra/gitea/docker-compose.yml --env-file infra/gitea/.env
DC_WEDDING := docker compose -f infra/wedding_photo/docker-compose.yml --env-file infra/wedding_photo/.env
help:
@echo "Stacks: main (postgres+redis+minio+pgadmin+caddy) | gitea | wedding_photo"
@echo ""
@echo " make up Sobe tudo na ordem (main -> gitea -> wedding)"
@echo " make down Desce tudo na ordem inversa"
@echo " make restart down + up"
@echo " make status ps de todas as stacks"
@echo ""
@echo " make up-main Sobe só a stack main"
@echo " make up-gitea Sobe só o gitea"
@echo " make up-wedding Build + up do app wedding"
@echo " make rebuild-wedding Force rebuild do app wedding"
@echo " make deploy git reset --hard + up-wedding (usado pelo CI)"
@echo " make down-{main,gitea,wedding}"
@echo " make logs-{main,gitea,wedding}"
@echo " make pull-{main,gitea,wedding}"
@echo " make network Cria a network external '$(NET)' (idempotente)"
network:
@./network.sh
up: network up-main up-gitea up-wedding
down: down-wedding down-gitea down-main
restart: down up
status:
@$(DC_MAIN) ps || true
@$(DC_GITEA) ps || true
@$(DC_WEDDING) ps || true
up-main: network
$(DC_MAIN) up -d
up-gitea: network
$(DC_GITEA) up -d
up-wedding: network
$(DC_WEDDING) up -d --build
rebuild-wedding: network
$(DC_WEDDING) build --no-cache
$(DC_WEDDING) up -d
# Deploy automático: atualiza o código e sobe só o app wedding com build.
# Chamado pelo workflow .gitea/workflows/deploy.yml via SSH no host.
# BRANCH pode ser sobrescrito: `make deploy BRANCH=main`
deploy: network
git fetch --all --prune
git checkout $(BRANCH)
git reset --hard origin/$(BRANCH)
$(DC_WEDDING) up -d --build
$(DC_WEDDING) ps
down-main:
$(DC_MAIN) down
down-gitea:
$(DC_GITEA) down
down-wedding:
$(DC_WEDDING) down
logs-main:
$(DC_MAIN) logs -f
logs-gitea:
$(DC_GITEA) logs -f
logs-wedding:
$(DC_WEDDING) logs -f
pull-main:
$(DC_MAIN) pull
pull-gitea:
$(DC_GITEA) pull
pull-wedding:
$(DC_WEDDING) pull

View File

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

View File

@ -6,22 +6,21 @@ CREATE TABLE event_config (
gallery_visibility TEXT NOT NULL DEFAULT 'public',
moderation TEXT NOT NULL DEFAULT 'post',
max_file_mb INTEGER NOT NULL DEFAULT 500,
allow_video BOOLEAN NOT NULL DEFAULT TRUE,
allow_video INTEGER NOT NULL DEFAULT 1,
max_video_seconds INTEGER NOT NULL DEFAULT 300,
welcome_message TEXT,
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
INSERT INTO event_config (id, couple_names)
VALUES (1, 'Stefanie & Leandro')
ON CONFLICT (id) DO NOTHING;
INSERT INTO event_config (id, couple_names, max_file_mb, max_video_seconds, allow_video, moderation, gallery_visibility)
VALUES (1, 'Stefanie & Leandro', 500, 300, 1, 'post', 'public');
CREATE TABLE uploads (
id TEXT PRIMARY KEY,
storage_key TEXT NOT NULL,
thumbnail_key TEXT,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
size_bytes INTEGER NOT NULL,
duration_seconds INTEGER,
author_name TEXT,
message TEXT,
@ -29,18 +28,17 @@ CREATE TABLE uploads (
source TEXT NOT NULL DEFAULT 'guest',
ip_hash TEXT,
provider_upload_id TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
approved_at BIGINT
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
approved_at INTEGER
);
CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC);
CREATE INDEX idx_uploads_source ON uploads (source);
CREATE INDEX idx_uploads_author_lower ON uploads (LOWER(author_name));
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
actor TEXT,
payload TEXT,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);

35
apps/api/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"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": {
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@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",
"qrcode": "^1.5.4",
"zod": "^3.23.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@types/qrcode": "^1.5.5",
"drizzle-kit": "^0.28.1",
"typescript": "^5.6.3"
}
}

28
apps/api/src/app.ts Normal file
View File

@ -0,0 +1,28 @@
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

@ -0,0 +1,9 @@
import type { D1Database } from '@cloudflare/workers-types';
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema.js';
export type DB = ReturnType<typeof getDb>;
export function getDb(d1: D1Database) {
return drizzle(d1, { schema });
}

62
apps/api/src/db/schema.ts Normal file
View File

@ -0,0 +1,62 @@
import { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const eventConfig = sqliteTable('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: integer('allow_video', { mode: 'boolean' }).notNull().default(true),
maxVideoSeconds: integer('max_video_seconds').notNull().default(300),
welcomeMessage: text('welcome_message'),
updatedAt: integer('updated_at')
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
export const uploads = sqliteTable(
'uploads',
{
id: text('id').primaryKey(),
storageKey: text('storage_key').notNull(),
thumbnailKey: text('thumbnail_key'),
mimeType: text('mime_type').notNull(),
sizeBytes: integer('size_bytes').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: integer('created_at')
.notNull()
.default(sql`(unixepoch() * 1000)`),
approvedAt: integer('approved_at'),
},
(t) => ({
statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt),
sourceIdx: index('idx_uploads_source').on(t.source),
}),
);
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
action: text('action').notNull(),
actor: text('actor'),
payload: text('payload'),
createdAt: integer('created_at')
.notNull()
.default(sql`(unixepoch() * 1000)`),
});
export type EventConfigRow = typeof eventConfig.$inferSelect;
export type UploadRow = typeof uploads.$inferSelect;
export type NewUploadRow = typeof uploads.$inferInsert;

33
apps/api/src/env.ts Normal file
View File

@ -0,0 +1,33 @@
import type { D1Database, R2Bucket } from '@cloudflare/workers-types';
export interface AppEnv {
DB: D1Database;
MEDIA: R2Bucket;
S3_ENDPOINT: string;
S3_BUCKET: string;
S3_REGION: string;
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
S3_PUBLIC_BASE_URL: string;
S3_FORCE_PATH_STYLE?: string;
COUPLE_NAMES: string;
EVENT_DATE?: string;
PUBLIC_BASE_URL: string;
TURNSTILE_SECRET?: string;
ADMIN_PASSWORD: string;
SESSION_SECRET: string;
ALLOWED_ADMIN_EMAILS: string;
}
export interface AppVariables {
adminEmail: string;
}
export type Bindings = {
Bindings: AppEnv;
Variables: AppVariables;
};

51
apps/api/src/lib/auth.ts Normal file
View File

@ -0,0 +1,51 @@
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');
setCookie(c, COOKIE, token, {
httpOnly: true,
secure: true,
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;
}

21
apps/api/src/lib/ids.ts Normal file
View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,80 @@
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

@ -0,0 +1,22 @@
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] ?? '';
const bin = atob(base64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export function generateQrSvg(text: string): Promise<string> {
return QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 2,
});
}

View File

@ -0,0 +1,15 @@
import type { AppEnv } from '../env.js';
import { S3Storage } from './storage-s3.js';
import type { StorageProvider } from './storage.js';
export function createStorage(env: AppEnv): StorageProvider {
return 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',
});
}

View File

@ -0,0 +1,164 @@
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

@ -0,0 +1,49 @@
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

@ -0,0 +1,23 @@
interface TurnstileVerifyResponse {
success: boolean;
'error-codes'?: string[];
}
export async function verifyTurnstile(
token: string,
secret: string,
remoteIp?: string,
): Promise<boolean> {
const body = new FormData();
body.append('secret', secret);
body.append('response', token);
if (remoteIp) body.append('remoteip', remoteIp);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body,
});
if (!res.ok) return false;
const data = (await res.json()) as TurnstileVerifyResponse;
return data.success === true;
}

View File

@ -0,0 +1,213 @@
import { and, desc, eq, lt } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import {
type AdminStats,
type AdminUploadsResponse,
eventConfigUpdateSchema,
} 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 { createStorage } 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(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 404);
return c.json({
coupleNames: cfg.coupleNames,
eventDate: cfg.eventDate,
coverKey: cfg.coverKey,
galleryVisibility: cfg.galleryVisibility,
moderation: cfg.moderation,
maxFileMb: cfg.maxFileMb,
allowVideo: cfg.allowVideo,
maxVideoSeconds: cfg.maxVideoSeconds,
welcomeMessage: cfg.welcomeMessage,
updatedAt: cfg.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(c.env.DB);
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(c.env.DB);
const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined;
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));
}
const where = filters.length ? and(...filters) : undefined;
const rows = await db
.select()
.from(uploads)
.where(where)
.orderBy(desc(uploads.createdAt))
.limit(limit + 1)
.all();
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = createStorage(c.env);
const items = slice.map((row) => ({
id: row.id,
url: storage.publicUrl(row.storageKey),
thumbnailUrl: row.thumbnailKey ? storage.publicUrl(row.thumbnailKey) : null,
mimeType: row.mimeType,
isVideo: row.mimeType.startsWith('video/'),
sizeBytes: row.sizeBytes,
durationSeconds: row.durationSeconds,
authorName: row.authorName,
message: row.message,
status: row.status,
source: row.source,
createdAt: row.createdAt,
approvedAt: row.approvedAt,
}));
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
return c.json({ items, nextCursor } satisfies AdminUploadsResponse);
});
adminRoutes.post('/uploads/:id/approve', async (c) => {
const id = c.req.param('id');
const db = getDb(c.env.DB);
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(c.env.DB);
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.delete('/uploads/:id', async (c) => {
const id = c.req.param('id');
const db = getDb(c.env.DB);
const row = await db.select().from(uploads).where(eq(uploads.id, id)).get();
if (!row) return c.json({ error: 'not_found' }, 404);
const storage = createStorage(c.env);
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));
return c.json({ ok: true });
});
adminRoutes.get('/stats', async (c) => {
const db = getDb(c.env.DB);
const all = await db
.select({
status: uploads.status,
mimeType: uploads.mimeType,
sizeBytes: uploads.sizeBytes,
})
.from(uploads)
.all();
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

@ -0,0 +1,129 @@
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 { generateQrPng, generateQrSvg } from '../lib/qrcode.js';
import { generateQrPdf } from '../lib/qr-pdf.js';
import { createStorage } from '../lib/storage-factory.js';
export const publicRoutes = new Hono<Bindings>();
const GALLERY_PAGE_SIZE = 24;
publicRoutes.get('/event', async (c) => {
const db = getDb(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 404);
return c.json({
coupleNames: cfg.coupleNames,
eventDate: cfg.eventDate,
welcomeMessage: cfg.welcomeMessage,
galleryVisibility: cfg.galleryVisibility,
allowVideo: cfg.allowVideo,
maxFileMb: cfg.maxFileMb,
maxVideoSeconds: cfg.maxVideoSeconds,
});
});
publicRoutes.get('/gallery', async (c) => {
const db = getDb(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ items: [], nextCursor: null } satisfies GalleryResponse);
if (cfg.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)
.all();
const hasMore = rows.length > limit;
const slice = hasMore ? rows.slice(0, limit) : rows;
const storage = createStorage(c.env);
const items = slice.map((row) => ({
id: row.id,
url: storage.publicUrl(row.storageKey),
thumbnailUrl: row.thumbnailKey ? storage.publicUrl(row.thumbnailKey) : null,
mimeType: row.mimeType,
isVideo: row.mimeType.startsWith('video/'),
authorName: row.authorName,
message: row.message,
createdAt: row.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 ?? `${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(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
const pdf = await generateQrPdf({
url: target,
coupleNames: cfg?.coupleNames ?? c.env.COUPLE_NAMES,
eventDate: cfg?.eventDate ?? c.env.EVENT_DATE,
});
return new Response(pdf as BodyInit, {
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 as BodyInit, {
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=300',
},
});
});
publicRoutes.get('/stats', async (c) => {
const db = getDb(c.env.DB);
const all = await db
.select({ id: uploads.id, mimeType: uploads.mimeType })
.from(uploads)
.where(eq(uploads.status, 'approved'))
.all();
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

@ -0,0 +1,209 @@
import { eq } from 'drizzle-orm';
import { Hono } 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 { createStorage } 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}`;
}
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(c.env.DB);
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 500);
const isVideo = input.mimeType.startsWith('video/');
if (isVideo && !cfg.allowVideo) {
return c.json({ error: 'video_not_allowed' }, 400);
}
if (input.sizeBytes > cfg.maxFileMb * 1024 * 1024) {
return c.json({ error: 'file_too_large', maxFileMb: cfg.maxFileMb }, 400);
}
if (
isVideo &&
input.durationSeconds !== undefined &&
input.durationSeconds > cfg.maxVideoSeconds
) {
return c.json({ error: 'video_too_long', maxVideoSeconds: cfg.maxVideoSeconds }, 400);
}
const id = uploadId();
const storageKey = buildKey(id, input.filename, input.mimeType);
const storage = createStorage(c.env);
const ip =
c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown';
const ipHashValue = await hashIp(ip, 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(c.env.DB);
const upload = await db.select().from(uploads).where(eq(uploads.id, id)).get();
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)).get();
if (!cfg) return c.json({ error: 'not_configured' }, 500);
const storage = createStorage(c.env);
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 = cfg.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(c.env.DB);
const upload = await db.select().from(uploads).where(eq(uploads.id, id)).get();
if (!upload) return c.json({ error: 'not_found' }, 404);
if (upload.providerUploadId) {
const storage = createStorage(c.env);
await storage
.abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId })
.catch(() => {});
}
await db.delete(uploads).where(eq(uploads.id, id));
return c.json({ ok: true });
});

8
apps/api/tsconfig.json Normal file
View File

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

View File

@ -0,0 +1,15 @@
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_BUCKET=wedding-media
S3_REGION=auto
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_BASE_URL=https://media.stefanieeleandro.pages.dev
S3_FORCE_PATH_STYLE=false
COUPLE_NAMES=Stefanie & Leandro
PUBLIC_BASE_URL=http://localhost:5173
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
CF_ACCESS_TEAM=yourteam.cloudflareaccess.com
CF_ACCESS_AUD=
ALLOWED_ADMIN_EMAILS=stefanie@example.com,leandro@example.com

View File

@ -7,9 +7,12 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"deploy": "wrangler deploy",
"pages:dev": "wrangler dev"
},
"dependencies": {
"@leetete/api": "workspace:*",
"@leetete/shared": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -17,6 +20,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241218.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
@ -24,6 +28,7 @@
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^5.4.11",
"wrangler": "^3.95.0"
}
}

View File

@ -1,10 +1,7 @@
const API_BASE = '/api';
export class ApiError extends Error {
constructor(
public status: number,
public body: string,
) {
constructor(public status: number, public body: string) {
super(`API ${status}: ${body}`);
}
}

View File

@ -6,7 +6,6 @@ interface EventInfo {
coupleNames: string;
eventDate: string | null;
welcomeMessage: string | null;
coverUrl: string | null;
}
export default function Home() {
@ -20,15 +19,6 @@ export default function Home() {
return (
<main className="min-h-full flex flex-col items-center justify-center px-6 py-12 text-center">
{event?.coverUrl && (
<div className="w-full max-w-md mb-8 overflow-hidden rounded-xl shadow-sm">
<img
src={event.coverUrl}
alt=""
className="w-full aspect-[4/3] object-cover"
/>
</div>
)}
<h1 className="font-display text-5xl text-stone-800 mb-3">
{event?.coupleNames ?? 'Stefanie & Leandro'}
</h1>

View File

@ -210,11 +210,7 @@ export default function Upload() {
<div
className="h-full bg-stone-700 transition-all"
style={{
width: `${
progress.total
? Math.min(100, (progress.loaded / progress.total) * 100)
: 0
}%`,
width: `${progress.total ? Math.min(100, (progress.loaded / progress.total) * 100) : 0}%`,
}}
/>
</div>
@ -256,7 +252,7 @@ function humanError(err: unknown): string {
if (msg.includes('file_too_large')) return 'Arquivo grande demais.';
if (msg.includes('video_not_allowed')) return 'Vídeos não são permitidos.';
if (msg.includes('video_too_long')) return 'Vídeo muito longo.';
if (msg.toLowerCase().includes('cors')) return 'Erro de CORS no storage — me avisa.';
if (msg.toLowerCase().includes('cors')) return 'Erro de CORS no R2 — me avisa.';
if (msg.includes('network')) return 'Falha de rede. Tenta de novo.';
return 'Erro ao enviar. Tenta de novo em alguns segundos.';
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type {
AdminStats,
@ -9,8 +9,6 @@ import type {
interface AdminEvent {
coupleNames: string;
eventDate: string | null;
coverKey: string | null;
coverUrl: string | null;
galleryVisibility: 'public' | 'private';
moderation: 'pre' | 'post';
maxFileMb: number;
@ -19,9 +17,6 @@ interface AdminEvent {
welcomeMessage: string | null;
}
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected';
type KindFilter = 'all' | 'photo' | 'video';
const MB = 1024 * 1024;
function formatBytes(n: number): string {
@ -40,32 +35,20 @@ function formatDate(ts: number): string {
});
}
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return v;
}
export default function AdminDashboard() {
const navigate = useNavigate();
const [email, setEmail] = useState<string | null>(null);
const [stats, setStats] = useState<AdminStats | null>(null);
const [event, setEvent] = useState<AdminEvent | null>(null);
const [uploads, setUploads] = useState<AdminUpload[]>([]);
const [status, setStatus] = useState<StatusFilter>('all');
const [kind, setKind] = useState<KindFilter>('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebounced(search, 350);
const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>(
'all',
);
const [cursor, setCursor] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [loading, setLoading] = useState(false);
const [savingEvent, setSavingEvent] = useState(false);
const [eventDirty, setEventDirty] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<AdminUpload | null>(null);
useEffect(() => {
fetch('/api/admin/me', { credentials: 'include' })
@ -82,31 +65,22 @@ export default function AdminDashboard() {
useEffect(() => {
if (!email) return;
refreshStats();
refreshEvent();
fetch('/api/admin/stats', { credentials: 'include' })
.then((r) => r.json() as Promise<AdminStats>)
.then(setStats);
fetch('/api/admin/event', { credentials: 'include' })
.then((r) => r.json() as Promise<AdminEvent>)
.then(setEvent);
}, [email]);
async function refreshStats() {
const r = await fetch('/api/admin/stats', { credentials: 'include' });
if (r.ok) setStats((await r.json()) as AdminStats);
}
async function refreshEvent() {
const r = await fetch('/api/admin/event', { credentials: 'include' });
if (r.ok) setEvent((await r.json()) as AdminEvent);
}
async function loadUploads(reset: boolean) {
if (!email || loading) return;
setLoading(true);
try {
const url = new URL('/api/admin/uploads', window.location.origin);
if (status !== 'all') url.searchParams.set('status', status);
if (kind !== 'all') url.searchParams.set('kind', kind);
if (debouncedSearch.trim()) url.searchParams.set('q', debouncedSearch.trim());
if (filter !== 'all') url.searchParams.set('status', filter);
if (!reset && cursor) url.searchParams.set('cursor', cursor);
const r = await fetch(url, { credentials: 'include' });
if (!r.ok) return;
const data = (await r.json()) as AdminUploadsResponse;
setUploads((prev) => (reset ? data.items : [...prev, ...data.items]));
setCursor(data.nextCursor);
@ -121,10 +95,14 @@ export default function AdminDashboard() {
setUploads([]);
setCursor(null);
setDone(false);
setSelected(new Set());
loadUploads(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, status, kind, debouncedSearch]);
}, [email, filter]);
async function refreshStats() {
const r = await fetch('/api/admin/stats', { credentials: 'include' });
setStats((await r.json()) as AdminStats);
}
async function approve(id: string) {
await fetch(`/api/admin/uploads/${id}/approve`, {
@ -157,55 +135,7 @@ export default function AdminDashboard() {
credentials: 'include',
});
setUploads((u) => u.filter((x) => x.id !== id));
setSelected((s) => {
const next = new Set(s);
next.delete(id);
return next;
});
refreshStats();
refreshEvent();
}
async function setCover(item: AdminUpload) {
await fetch(`/api/admin/uploads/${item.id}/cover`, {
method: 'POST',
credentials: 'include',
});
setUploads((u) => u.map((x) => ({ ...x, isCover: x.id === item.id })));
refreshEvent();
}
async function clearCover() {
await fetch('/api/admin/event/cover', {
method: 'DELETE',
credentials: 'include',
});
setUploads((u) => u.map((x) => ({ ...x, isCover: false })));
refreshEvent();
}
async function bulk(action: 'approve' | 'reject' | 'delete') {
if (selected.size === 0) return;
if (action === 'delete' && !confirm(`Apagar ${selected.size} arquivos? Não dá pra desfazer.`))
return;
const ids = Array.from(selected);
await fetch('/api/admin/uploads/bulk', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action, ids }),
});
if (action === 'delete') {
setUploads((u) => u.filter((x) => !selected.has(x.id)));
} else {
const newStatus = action === 'approve' ? 'approved' : 'rejected';
setUploads((u) =>
u.map((x) => (selected.has(x.id) ? { ...x, status: newStatus } : x)),
);
}
setSelected(new Set());
refreshStats();
refreshEvent();
}
async function saveEvent(e: React.FormEvent) {
@ -213,21 +143,11 @@ export default function AdminDashboard() {
if (!event) return;
setSavingEvent(true);
try {
const body = {
coupleNames: event.coupleNames,
eventDate: event.eventDate,
galleryVisibility: event.galleryVisibility,
moderation: event.moderation,
maxFileMb: event.maxFileMb,
allowVideo: event.allowVideo,
maxVideoSeconds: event.maxVideoSeconds,
welcomeMessage: event.welcomeMessage,
};
await fetch('/api/admin/event', {
method: 'PATCH',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify(event),
});
setEventDirty(false);
} finally {
@ -240,54 +160,6 @@ export default function AdminDashboard() {
setEventDirty(true);
}
async function saveEdit(authorName: string, message: string) {
if (!editing) return;
await fetch(`/api/admin/uploads/${editing.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
authorName: authorName.trim() || null,
message: message.trim() || null,
}),
});
setUploads((u) =>
u.map((x) =>
x.id === editing.id
? {
...x,
authorName: authorName.trim() || null,
message: message.trim() || null,
}
: x,
),
);
setEditing(null);
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectAllOnPage() {
setSelected(new Set(uploads.map((u) => u.id)));
}
async function logout() {
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' });
navigate('/admin/login', { replace: true });
}
const allOnPageSelected = useMemo(
() => uploads.length > 0 && uploads.every((u) => selected.has(u.id)),
[uploads, selected],
);
if (!email) {
return (
<main className="min-h-full flex items-center justify-center p-6 text-stone-500">
@ -296,8 +168,13 @@ export default function AdminDashboard() {
);
}
async function logout() {
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' });
navigate('/admin/login', { replace: true });
}
return (
<main className="min-h-full max-w-5xl mx-auto px-4 py-8">
<main className="min-h-full max-w-4xl mx-auto px-4 py-8">
<header className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display text-3xl text-stone-800">Painel dos noivos</h1>
@ -315,11 +192,7 @@ export default function AdminDashboard() {
{stats && (
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<StatCard label="Aprovadas" value={stats.approved} />
<StatCard
label="Pendentes"
value={stats.pending}
highlight={stats.pending > 0}
/>
<StatCard label="Pendentes" value={stats.pending} highlight={stats.pending > 0} />
<StatCard label="Fotos / Vídeos" value={`${stats.photos} / ${stats.videos}`} />
<StatCard label="Storage" value={formatBytes(stats.totalBytes)} />
</section>
@ -328,25 +201,6 @@ export default function AdminDashboard() {
{event && (
<section className="bg-white rounded-lg border border-stone-200 p-5 mb-8">
<h2 className="font-display text-xl text-stone-800 mb-4">Configuração</h2>
{event.coverUrl && (
<div className="mb-4 flex items-center gap-3">
<img
src={event.coverUrl}
alt="capa"
className="w-24 h-24 object-cover rounded border border-stone-200"
/>
<div className="text-sm">
<p className="text-stone-700">Foto de capa definida.</p>
<button
type="button"
onClick={clearCover}
className="text-xs text-rose-600 hover:underline"
>
Remover capa
</button>
</div>
</div>
)}
<form onSubmit={saveEvent} className="space-y-4">
<Field label="Nome do casal">
<input
@ -447,86 +301,22 @@ export default function AdminDashboard() {
)}
<section className="bg-white rounded-lg border border-stone-200 p-5">
<div className="flex flex-wrap items-center gap-2 mb-4">
<h2 className="font-display text-xl text-stone-800 mr-auto">Uploads</h2>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar autor ou mensagem"
className="text-sm rounded border border-stone-300 px-2 py-1 w-full sm:w-64"
/>
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-xl text-stone-800">Uploads</h2>
<select
value={status}
onChange={(e) => setStatus(e.target.value as StatusFilter)}
value={filter}
onChange={(e) =>
setFilter(e.target.value as 'all' | 'pending' | 'approved' | 'rejected')
}
className="text-sm rounded border border-stone-300 px-2 py-1"
>
<option value="all">Todos status</option>
<option value="all">Todos</option>
<option value="pending">Pendentes</option>
<option value="approved">Aprovados</option>
<option value="rejected">Rejeitados</option>
</select>
<select
value={kind}
onChange={(e) => setKind(e.target.value as KindFilter)}
className="text-sm rounded border border-stone-300 px-2 py-1"
>
<option value="all">Foto+Vídeo</option>
<option value="photo"> fotos</option>
<option value="video"> vídeos</option>
</select>
</div>
{selected.size > 0 && (
<div className="bg-stone-100 border border-stone-200 rounded p-2 mb-3 flex items-center gap-2 text-sm">
<span className="text-stone-700">
{selected.size} selecionad{selected.size === 1 ? 'o' : 'os'}
</span>
<button
type="button"
onClick={() => bulk('approve')}
className="bg-emerald-600 text-white rounded py-1 px-3 text-xs hover:bg-emerald-700"
>
Aprovar
</button>
<button
type="button"
onClick={() => bulk('reject')}
className="bg-amber-600 text-white rounded py-1 px-3 text-xs hover:bg-amber-700"
>
Rejeitar
</button>
<button
type="button"
onClick={() => bulk('delete')}
className="bg-stone-800 text-white rounded py-1 px-3 text-xs hover:bg-stone-900"
>
Apagar
</button>
<button
type="button"
onClick={() => setSelected(new Set())}
className="ml-auto text-stone-500 text-xs hover:underline"
>
Limpar seleção
</button>
</div>
)}
{uploads.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 mb-2">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={() => {
if (allOnPageSelected) setSelected(new Set());
else selectAllOnPage();
}}
/>
Selecionar todos desta página
</label>
)}
{uploads.length === 0 && !loading && (
<p className="text-stone-500 text-sm py-8 text-center">Nenhum upload.</p>
)}
@ -536,13 +326,9 @@ export default function AdminDashboard() {
<UploadCard
key={u.id}
upload={u}
selected={selected.has(u.id)}
onToggle={() => toggleSelect(u.id)}
onApprove={() => approve(u.id)}
onReject={() => reject(u.id)}
onDelete={() => remove(u.id)}
onEdit={() => setEditing(u)}
onSetCover={() => setCover(u)}
/>
))}
</div>
@ -574,14 +360,6 @@ export default function AdminDashboard() {
Baixar QR Code (PDF A6)
</a>
</footer>
{editing && (
<EditModal
upload={editing}
onClose={() => setEditing(null)}
onSave={saveEdit}
/>
)}
</main>
);
}
@ -618,22 +396,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
function UploadCard({
upload,
selected,
onToggle,
onApprove,
onReject,
onDelete,
onEdit,
onSetCover,
}: {
upload: AdminUpload;
selected: boolean;
onToggle: () => void;
onApprove: () => void;
onReject: () => void;
onDelete: () => void;
onEdit: () => void;
onSetCover: () => void;
}) {
const statusColor =
upload.status === 'approved'
@ -643,20 +413,11 @@ function UploadCard({
: 'bg-rose-100 text-rose-800';
return (
<div
className={`rounded-md border overflow-hidden bg-white ${
selected ? 'border-stone-700 ring-2 ring-stone-700' : 'border-stone-200'
}`}
>
<div className="rounded-md border border-stone-200 overflow-hidden bg-white">
<div className="aspect-square bg-stone-100 relative">
{upload.isVideo ? (
<>
<video
src={upload.url}
preload="metadata"
className="w-full h-full object-cover"
muted
/>
<video src={upload.url} preload="metadata" className="w-full h-full object-cover" muted />
<span className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-2xl">
</span>
@ -669,42 +430,27 @@ function UploadCard({
className="w-full h-full object-cover"
/>
)}
<label className="absolute top-2 right-2 bg-white/90 rounded p-1 cursor-pointer">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
className="cursor-pointer"
/>
</label>
<span
className={`absolute top-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded ${statusColor}`}
>
{upload.status}
</span>
{upload.isCover && (
<span className="absolute bottom-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded bg-stone-800 text-cream">
capa
</span>
)}
</div>
<div className="p-2 text-xs text-stone-700 space-y-1">
<div className="flex justify-between text-stone-500">
<span>{formatDate(upload.createdAt)}</span>
<span>{formatBytes(upload.sizeBytes)}</span>
</div>
<div className="font-medium truncate">
{upload.authorName || <span className="text-stone-400 italic">sem nome</span>}
</div>
{upload.authorName && <div className="font-medium">{upload.authorName}</div>}
{upload.message && (
<p className="text-stone-600 line-clamp-2">{upload.message}</p>
)}
<div className="flex flex-wrap gap-1 pt-1">
<div className="flex gap-1 pt-1">
{upload.status !== 'approved' && (
<button
type="button"
onClick={onApprove}
className="text-[11px] bg-emerald-600 text-white rounded py-1 px-2 hover:bg-emerald-700"
className="flex-1 text-[11px] bg-emerald-600 text-white rounded py-1 hover:bg-emerald-700"
>
Aprovar
</button>
@ -713,31 +459,15 @@ function UploadCard({
<button
type="button"
onClick={onReject}
className="text-[11px] bg-amber-600 text-white rounded py-1 px-2 hover:bg-amber-700"
className="flex-1 text-[11px] bg-amber-600 text-white rounded py-1 hover:bg-amber-700"
>
Ocultar
</button>
)}
<button
type="button"
onClick={onEdit}
className="text-[11px] bg-stone-200 text-stone-800 rounded py-1 px-2 hover:bg-stone-300"
>
Editar
</button>
{!upload.isVideo && !upload.isCover && (
<button
type="button"
onClick={onSetCover}
className="text-[11px] bg-stone-200 text-stone-800 rounded py-1 px-2 hover:bg-stone-300"
>
Capa
</button>
)}
<button
type="button"
onClick={onDelete}
className="ml-auto text-[11px] bg-stone-700 text-white rounded py-1 px-2 hover:bg-stone-800"
className="flex-1 text-[11px] bg-stone-700 text-white rounded py-1 hover:bg-stone-800"
>
Apagar
</button>
@ -746,89 +476,3 @@ function UploadCard({
</div>
);
}
function EditModal({
upload,
onClose,
onSave,
}: {
upload: AdminUpload;
onClose: () => void;
onSave: (authorName: string, message: string) => Promise<void>;
}) {
const [authorName, setAuthorName] = useState(upload.authorName ?? '');
const [message, setMessage] = useState(upload.message ?? '');
const [saving, setSaving] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
await onSave(authorName, message);
} finally {
setSaving(false);
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
onClick={onClose}
>
<form
onSubmit={submit}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-lg p-5 w-full max-w-md space-y-4"
>
<h3 className="font-display text-xl text-stone-800">Editar mensagem</h3>
<div className="aspect-video bg-stone-100 rounded overflow-hidden">
{upload.isVideo ? (
<video
src={upload.url}
controls
className="w-full h-full object-contain"
/>
) : (
<img src={upload.url} alt="" className="w-full h-full object-contain" />
)}
</div>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Autor</span>
<input
type="text"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
maxLength={80}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Mensagem</span>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
maxLength={2000}
rows={4}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="flex-1 border border-stone-400 text-stone-700 rounded-full py-2 hover:bg-stone-50"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="flex-1 bg-stone-800 text-cream rounded-full py-2 disabled:bg-stone-400 hover:bg-stone-700"
>
{saving ? 'Salvando…' : 'Salvar'}
</button>
</div>
</form>
</div>
);
}

18
apps/web/src/worker.ts Normal file
View File

@ -0,0 +1,18 @@
import { createApp } from '@leetete/api';
import type { AppEnv } from '@leetete/api/env';
const app = createApp();
export interface Env extends AppEnv {
ASSETS: Fetcher;
}
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url);
if (url.pathname.startsWith('/api/')) {
return app.fetch(req, env, ctx);
}
return env.ASSETS.fetch(req);
},
} satisfies ExportedHandler<Env>;

View File

@ -4,7 +4,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
"types": ["vite/client", "@cloudflare/workers-types"]
},
"include": ["src/**/*", "vite.config.ts"]
"include": ["src/**/*", "functions/**/*", "vite.config.ts"]
}

View File

@ -7,7 +7,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
target: 'http://127.0.0.1:8788',
changeOrigin: true,
},
},

36
apps/web/wrangler.toml Normal file
View File

@ -0,0 +1,36 @@
name = "stefanieeleandro"
main = "src/worker.ts"
compatibility_date = "2026-04-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = true
[[d1_databases]]
binding = "DB"
database_name = "wedding-db"
database_id = "81fac13f-00b2-49ec-b7df-bde036849e70"
migrations_dir = "../api/migrations"
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "wedding-media"
[vars]
COUPLE_NAMES = "Stefanie & Leandro"
PUBLIC_BASE_URL = "https://stefanieeleandro.lronetto.workers.dev"
S3_REGION = "auto"
S3_BUCKET = "wedding-media"
S3_FORCE_PATH_STYLE = "true"
S3_ENDPOINT = "https://04cc83318332b325076a71c8db5e6c42.r2.cloudflarestorage.com"
S3_PUBLIC_BASE_URL = "https://pub-e29e475661ee460c99ba6ea01cf26a7c.r2.dev"
# CF_ACCESS_TEAM, CF_ACCESS_AUD are set after the Cloudflare Access app is created.
#
# Secrets (set via dashboard or `wrangler secret put`):
# S3_ACCESS_KEY_ID
# S3_SECRET_ACCESS_KEY
# TURNSTILE_SECRET
# ALLOWED_ADMIN_EMAILS

View File

@ -1,32 +0,0 @@
# =============================================================================
# infra/gitea/.env — copie pra .env e edite
# =============================================================================
# Casa com infra/main/.env
DOMAIN_BASE=localhost
TZ=America/Sao_Paulo
# Banco no postgres compartilhado (mesmas creds do main/.env)
GITEA_DB_NAME=gitea
GITEA_DB_USER=gitea
GITEA_DB_PASSWORD=troque-essa-senha-forte
# Porta SSH no host (pra git@gitea.localhost:user/repo.git)
GITEA_SSH_PORT=2222
# Cadastro: true = só admin convida; false = qualquer um cria conta
GITEA_DISABLE_REGISTRATION=true
GITEA_REQUIRE_SIGNIN=false
# ----- Gitea Actions Runner -----
# Como pegar o token (bootstrap):
# 1. Suba só o gitea: make up-gitea (runner vai falhar a 1ª vez, ok)
# 2. Acesse https://gitea.localhost e crie o usuário admin (no terminal:
# docker exec -it gitea gitea admin user create --username admin
# --password ... --email ... --admin)
# 3. UI: Site Administration → Actions → Runners → Create new runner
# 4. Copie o token aqui e: make up-gitea (runner registra e fica de pé)
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=local-runner
# Labels exemplos. Adicione mais se quiser ambientes específicos.
GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-22.04,node:docker://node:22-alpine,python:docker://python:3.12-slim

View File

@ -1,68 +0,0 @@
services:
gitea:
image: gitea/gitea:1.22
container_name: gitea
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
TZ: ${TZ:-America/Sao_Paulo}
# Banco em postgres compartilhado (stack main/)
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: postgres:5432
GITEA__database__NAME: ${GITEA_DB_NAME:-gitea}
GITEA__database__USER: ${GITEA_DB_USER:-gitea}
GITEA__database__PASSWD: ${GITEA_DB_PASSWORD}
# Servidor
GITEA__server__DOMAIN: gitea.${DOMAIN_BASE:-localhost}
GITEA__server__ROOT_URL: https://gitea.${DOMAIN_BASE:-localhost}/
GITEA__server__SSH_DOMAIN: gitea.${DOMAIN_BASE:-localhost}
GITEA__server__SSH_LISTEN_PORT: "22"
GITEA__server__SSH_PORT: "${GITEA_SSH_PORT:-2222}"
GITEA__server__HTTP_PORT: "3000"
# Cache (Redis compartilhado)
GITEA__cache__ENABLED: "true"
GITEA__cache__ADAPTER: redis
GITEA__cache__HOST: redis://redis:6379/0
# Sessões em Redis também
GITEA__session__PROVIDER: redis
GITEA__session__PROVIDER_CONFIG: redis://redis:6379/1
# Service
GITEA__service__DISABLE_REGISTRATION: ${GITEA_DISABLE_REGISTRATION:-true}
GITEA__service__REQUIRE_SIGNIN_VIEW: ${GITEA_REQUIRE_SIGNIN:-false}
# Pula a tela /install no primeiro boot (toda config vem via env).
GITEA__security__INSTALL_LOCK: "true"
# Actions
GITEA__actions__ENABLED: "true"
GITEA__actions__DEFAULT_ACTIONS_URL: github
ports:
# SSH pra git push/pull. UI passa pelo Caddy (porta 443).
- "${GITEA_SSH_PORT:-2222}:22"
volumes:
- ./gitea/data:/data
networks:
- infra-net
runner:
build:
context: ./runner
container_name: gitea_runner
restart: unless-stopped
depends_on:
- gitea
environment:
TZ: ${TZ:-America/Sao_Paulo}
GITEA_INSTANCE_URL: http://gitea:3000
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-local-runner}
GITEA_RUNNER_LABELS: ${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://catthehacker/ubuntu:act-22.04,node:docker://node:22-alpine,python:docker://python:3.12-slim}
volumes:
- ./runner/data:/data
- /var/run/docker.sock:/var/run/docker.sock
networks:
- infra-net
networks:
infra-net:
external: true
name: infra-net

View File

@ -1,7 +0,0 @@
FROM gitea/act_runner:latest
# Adiciona docker CLI pro runner conseguir buildar/pushar imagens dentro
# dos workflows (Action `docker/build-push-action` etc.). O socket do host
# é montado via volume no docker-compose.yml.
USER root
RUN apk add --no-cache docker-cli docker-cli-buildx

View File

@ -1,41 +0,0 @@
# =============================================================================
# infra/main/.env — copie pra .env e edite
# =============================================================================
# ----- Domínio + TLS -----
# Dev local: deixa "localhost". Caddy emite cert interno automático pra *.localhost
# Prod: usa teu domínio (ex.: lronetto.com.br). Apontar DNS:
# wedding.SEU.DOMINIO → IP do VPS
# gitea.SEU.DOMINIO → IP do VPS
# media.SEU.DOMINIO → IP do VPS
# pgadmin.SEU.DOMINIO → IP do VPS
# minio.SEU.DOMINIO → IP do VPS
DOMAIN_BASE=localhost
# Em produção: email pra Let's Encrypt renovar certs. Em dev pode ficar vazio.
ACME_EMAIL=
# Timezone (aplica em postgres-backup, cron de media-backup, etc.)
TZ=America/Sao_Paulo
# ----- Postgres: admin + bancos por app -----
POSTGRES_ADMIN_PASSWORD=troque-essa-senha-forte
WEDDING_DB_USER=wedding
WEDDING_DB_PASSWORD=troque-essa-senha-forte
WEDDING_DB_NAME=wedding
GITEA_DB_USER=gitea
GITEA_DB_PASSWORD=troque-essa-senha-forte
GITEA_DB_NAME=gitea
# ----- MinIO (root creds) -----
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-essa-senha-forte
# Bucket criado já no boot pra ser consumido pela stack wedding_photo
WEDDING_BUCKET=wedding-media
# ----- pgAdmin (login web) -----
PGADMIN_EMAIL=admin@localhost
PGADMIN_PASSWORD=troque-essa-senha-forte

View File

@ -1,50 +0,0 @@
{
email {$ACME_EMAIL}
# Em dev local (*.localhost) Caddy emite cert interno automaticamente.
# Em produção, Let's Encrypt entra pelos hostnames reais.
}
# ----- Gitea (UI + git over HTTPS) -----
gitea.{$DOMAIN_BASE} {
encode zstd gzip
reverse_proxy gitea:3000 {
transport http {
response_header_timeout 10m
dial_timeout 30s
}
}
}
# ----- Wedding (app dos noivos) -----
wedding.{$DOMAIN_BASE} {
encode zstd gzip
request_body {
max_size 600MB
}
reverse_proxy wedding_app:3000 {
transport http {
response_header_timeout 10m
dial_timeout 30s
}
}
}
# ----- pgAdmin -----
pgadmin.{$DOMAIN_BASE} {
encode gzip
reverse_proxy pgadmin:80
}
# ----- MinIO console (admin web) -----
minio.{$DOMAIN_BASE} {
encode gzip
reverse_proxy minio:9001
}
# ----- MinIO S3 API público (uploads/downloads do bucket) -----
media.{$DOMAIN_BASE} {
encode gzip
reverse_proxy minio:9000 {
header_up Host {upstream_hostport}
}
}

View File

@ -1,121 +0,0 @@
services:
postgres:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
POSTGRES_DB: postgres
TZ: ${TZ:-America/Sao_Paulo}
WEDDING_DB_USER: ${WEDDING_DB_USER:-wedding}
WEDDING_DB_PASSWORD: ${WEDDING_DB_PASSWORD}
WEDDING_DB_NAME: ${WEDDING_DB_NAME:-wedding}
GITEA_DB_USER: ${GITEA_DB_USER:-gitea}
GITEA_DB_PASSWORD: ${GITEA_DB_PASSWORD}
GITEA_DB_NAME: ${GITEA_DB_NAME:-gitea}
volumes:
- ./postgres/data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 10
networks:
- infra-net
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
command: ["redis-server", "--save", "60", "1", "--appendonly", "yes"]
volumes:
- ./redis/data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- infra-net
minio:
image: minio/minio:latest
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- ./minio/data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 15s
timeout: 10s
retries: 10
networks:
- infra-net
minio-init:
image: minio/mc:latest
container_name: minio-init
restart: "no"
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
WEDDING_BUCKET: ${WEDDING_BUCKET:-wedding-media}
volumes:
- ./minio/init.sh:/init.sh:ro
- ./minio/cors.json:/cors.json:ro
entrypoint: ["/bin/sh", "/init.sh"]
networks:
- infra-net
pgadmin:
image: dpage/pgadmin4:latest
container_name: pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
PGADMIN_DISABLE_POSTFIX: "true"
PGADMIN_CONFIG_SERVER_MODE: "True"
PGADMIN_CONFIG_PROXY_X_FOR_COUNT: "1"
PGADMIN_CONFIG_PROXY_X_PROTO_COUNT: "1"
volumes:
- ./pgadmin/data:/var/lib/pgadmin
- ./pgadmin/servers.json:/pgadmin4/servers.json:ro
depends_on:
postgres:
condition: service_healthy
networks:
- infra-net
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN_BASE: ${DOMAIN_BASE:-localhost}
ACME_EMAIL: ${ACME_EMAIL:-}
WEDDING_BUCKET: ${WEDDING_BUCKET:-wedding-media}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
networks:
- infra-net
networks:
infra-net:
external: true
name: infra-net

View File

@ -1,11 +0,0 @@
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["PUT", "GET", "HEAD", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}

View File

@ -1,13 +0,0 @@
#!/bin/sh
# Inicializa o bucket usado pela stack wedding_photo. Roda como container
# one-shot depois que o MinIO ficou healthy.
set -e
mc alias set --quiet local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc mb --ignore-existing "local/$WEDDING_BUCKET"
mc anonymous set download "local/$WEDDING_BUCKET"
mc cors set "local/$WEDDING_BUCKET" /cors.json 2>/dev/null || \
echo "[minio-init] cors set ignorado (versão antiga do mc?)"
echo "[minio-init] bucket '$WEDDING_BUCKET' pronto (anonymous download + CORS)"

View File

@ -1,13 +0,0 @@
{
"Servers": {
"1": {
"Name": "Postgres (infra)",
"Group": "Servers",
"Host": "postgres",
"Port": 5432,
"MaintenanceDB": "postgres",
"Username": "postgres",
"SSLMode": "prefer"
}
}
}

View File

@ -1,17 +0,0 @@
#!/bin/bash
# Cria os bancos e roles dedicados a cada app. Rodado pelo postgres entrypoint
# uma única vez, no primeiro boot. Subsequente: ignorado (init scripts só
# rodam quando PGDATA está vazio).
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE ROLE "${WEDDING_DB_USER}" WITH LOGIN PASSWORD '${WEDDING_DB_PASSWORD}';
CREATE DATABASE "${WEDDING_DB_NAME}" OWNER "${WEDDING_DB_USER}";
GRANT ALL PRIVILEGES ON DATABASE "${WEDDING_DB_NAME}" TO "${WEDDING_DB_USER}";
CREATE ROLE "${GITEA_DB_USER}" WITH LOGIN PASSWORD '${GITEA_DB_PASSWORD}';
CREATE DATABASE "${GITEA_DB_NAME}" OWNER "${GITEA_DB_USER}";
GRANT ALL PRIVILEGES ON DATABASE "${GITEA_DB_NAME}" TO "${GITEA_DB_USER}";
EOSQL
echo "[postgres-init] roles + databases criados para wedding (${WEDDING_DB_NAME}) e gitea (${GITEA_DB_NAME})"

View File

@ -1,24 +0,0 @@
node_modules
**/node_modules
.git
.gitignore
.dockerignore
.env
.env.local
.env.*.local
dist
**/dist
.cache
.turbo
*.log
.DS_Store
.vscode
.idea
__pycache__
**/__pycache__
*.pyc
.venv
**/.venv
.mypy_cache
.ruff_cache
.pytest_cache

View File

@ -1,53 +0,0 @@
# =============================================================================
# infra/wedding_photo/.env — copie pra .env e edite
# As variáveis com prefixo compartilhado (MINIO_*, WEDDING_DB_*) precisam ter
# os MESMOS valores aqui e em infra/main/.env (mesmas creds do banco e do MinIO).
# =============================================================================
# Compartilhadas com main/
DOMAIN_BASE=localhost
TZ=America/Sao_Paulo
# DB (mesmo do main/.env)
WEDDING_DB_USER=wedding
WEDDING_DB_PASSWORD=troque-essa-senha-forte
WEDDING_DB_NAME=wedding
# MinIO (mesmo do main/.env)
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=troque-essa-senha-forte
WEDDING_BUCKET=wedding-media
S3_REGION=us-east-1
# Opcional: sobreescrevem o default se quiser endpoints customizados.
# Default monta a partir de DOMAIN_BASE + WEDDING_BUCKET:
# S3_ENDPOINT=https://media.${DOMAIN_BASE}
# S3_PUBLIC_BASE_URL=https://media.${DOMAIN_BASE}/${WEDDING_BUCKET}
# S3_ENDPOINT=
# S3_PUBLIC_BASE_URL=
# ----- App (específico desta stack) -----
COUPLE_NAMES=Stefanie & Leandro
EVENT_DATE=07 de junho de 2026
ADMIN_PASSWORD=senha-forte-compartilhada-entre-os-dois
SESSION_SECRET=gere-um-uuid-longo-aqui-min-32-chars
ALLOWED_ADMIN_EMAILS=voce@exemplo.com,outro@exemplo.com
# ----- Backups (escrevem em ./backups/ no host) -----
BACKUP_SCHEDULE_DB=@daily
BACKUP_SCHEDULE_MEDIA=0 3 * * *
BACKUP_KEEP_DAYS=14
BACKUP_KEEP_WEEKS=4
BACKUP_KEEP_MONTHS=6
# Backup remoto OPCIONAL (R2, B2, outro MinIO). Vazio = só backup local.
# Exemplo com Cloudflare R2:
# BACKUP_REMOTE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# BACKUP_REMOTE_ACCESS_KEY=...
# BACKUP_REMOTE_SECRET_KEY=...
# BACKUP_REMOTE_BUCKET=wedding-media-backup
BACKUP_REMOTE_ENDPOINT=
BACKUP_REMOTE_ACCESS_KEY=
BACKUP_REMOTE_SECRET_KEY=
BACKUP_REMOTE_BUCKET=

View File

@ -1,44 +0,0 @@
# ---- 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
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/shared/package.json packages/shared/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile
COPY tsconfig.base.json ./
COPY packages/shared packages/shared
COPY apps/web apps/web
RUN pnpm -F @leetete/web build
# ---- 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 ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000", "--proxy-headers", "--forwarded-allow-ips=*"]

View File

@ -1,26 +0,0 @@
.PHONY: install dev-web dev-api migrate build typecheck lint
# Comandos de DESENVOLVIMENTO LOCAL (rodam fora do Docker).
# Pra subir/derrubar a stack, use o Makefile do root: `make up-wedding` etc.
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

View File

@ -1,40 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,70 +0,0 @@
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()

View File

@ -1,78 +0,0 @@
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

@ -1,57 +0,0 @@
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

View File

@ -1,22 +0,0 @@
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]

View File

@ -1,60 +0,0 @@
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

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

View File

@ -1,176 +0,0 @@
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)
async def get_bytes(self, key: str) -> bytes:
def _get() -> bytes:
res = self._client.get_object(Bucket=self._bucket, Key=key)
return res["Body"].read()
return await asyncio.to_thread(_get)
async def put_bytes(self, key: str, data: bytes, content_type: str) -> None:
def _put() -> None:
self._client.put_object(
Bucket=self._bucket,
Key=key,
Body=data,
ContentType=content_type,
)
await asyncio.to_thread(_put)
storage = S3Storage()

View File

@ -1,39 +0,0 @@
import io
import logging
import pillow_heif
from PIL import Image, ImageOps
pillow_heif.register_heif_opener()
logger = logging.getLogger(__name__)
HEIC_MIMES = {"image/heic", "image/heif"}
def is_heic(mime_type: str) -> bool:
return mime_type.lower() in HEIC_MIMES
def heic_to_jpeg(heic_bytes: bytes, quality: int = 88) -> bytes:
"""Decode HEIC, apply EXIF rotation, re-encode as JPEG.
Raises on any decode/encode failure so the caller can decide whether
to keep the HEIC fallback.
"""
img = Image.open(io.BytesIO(heic_bytes))
img = ImageOps.exif_transpose(img)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
out = io.BytesIO()
img.save(out, format="JPEG", quality=quality, optimize=True, progressive=True)
return out.getvalue()
def swap_extension_to_jpg(key: str) -> str:
lower = key.lower()
if lower.endswith(".heic"):
return key[: -len(".heic")] + ".jpg"
if lower.endswith(".heif"):
return key[: -len(".heif")] + ".jpg"
return key + ".jpg"

View File

@ -1,71 +0,0 @@
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

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

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

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

View File

@ -1,53 +0,0 @@
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,37 +0,0 @@
[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",
"pillow-heif==0.21.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,81 +0,0 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: wedding_app
restart: unless-stopped
environment:
DATABASE_URL: postgres://${WEDDING_DB_USER:-wedding}:${WEDDING_DB_PASSWORD}@postgres:5432/${WEDDING_DB_NAME:-wedding}
# Endpoint pra assinar/servir uploads pelo browser. Em prod usa
# https://media.{DOMAIN_BASE}. Em dev local também — o app acessa via
# extra_hosts -> host gateway -> Caddy -> minio.
S3_ENDPOINT: ${S3_ENDPOINT:-https://media.${DOMAIN_BASE:-localhost}}
S3_BUCKET: ${WEDDING_BUCKET:-wedding-media}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-https://media.${DOMAIN_BASE:-localhost}/${WEDDING_BUCKET:-wedding-media}}
S3_FORCE_PATH_STYLE: "true"
COUPLE_NAMES: ${COUPLE_NAMES:-Stefanie & Leandro}
EVENT_DATE: ${EVENT_DATE:-}
PUBLIC_BASE_URL: https://wedding.${DOMAIN_BASE:-localhost}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SESSION_SECRET: ${SESSION_SECRET}
ALLOWED_ADMIN_EMAILS: ${ALLOWED_ADMIN_EMAILS}
AUTO_MIGRATE: "true"
NODE_ENV: production
PORT: "3000"
# Em dev local, faz `media.localhost` (e o domínio do site) apontarem pro
# host gateway, pra o app dentro do container alcançar o Caddy do main/.
extra_hosts:
- "media.${DOMAIN_BASE:-localhost}:host-gateway"
- "wedding.${DOMAIN_BASE:-localhost}:host-gateway"
networks:
- infra-net
postgres-backup:
image: prodrigestivill/postgres-backup-local:16-alpine
container_name: wedding_pg_backup
restart: unless-stopped
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: ${WEDDING_DB_NAME:-wedding}
POSTGRES_USER: ${WEDDING_DB_USER:-wedding}
POSTGRES_PASSWORD: ${WEDDING_DB_PASSWORD}
POSTGRES_EXTRA_OPTS: "--clean --if-exists"
SCHEDULE: ${BACKUP_SCHEDULE_DB:-@daily}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
BACKUP_KEEP_WEEKS: ${BACKUP_KEEP_WEEKS:-4}
BACKUP_KEEP_MONTHS: ${BACKUP_KEEP_MONTHS:-6}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/postgres:/backups
networks:
- infra-net
media-backup:
image: alpine:3.20
container_name: wedding_media_backup
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${WEDDING_BUCKET:-wedding-media}
BACKUP_SCHEDULE_MEDIA: ${BACKUP_SCHEDULE_MEDIA:-0 3 * * *}
BACKUP_REMOTE_ENDPOINT: ${BACKUP_REMOTE_ENDPOINT:-}
BACKUP_REMOTE_ACCESS_KEY: ${BACKUP_REMOTE_ACCESS_KEY:-}
BACKUP_REMOTE_SECRET_KEY: ${BACKUP_REMOTE_SECRET_KEY:-}
BACKUP_REMOTE_BUCKET: ${BACKUP_REMOTE_BUCKET:-}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/media:/backups
- ./infra/backup:/scripts:ro
entrypoint: ["/bin/sh", "/scripts/media-backup-entrypoint.sh"]
networks:
- infra-net
networks:
infra-net:
external: true
name: infra-net

View File

@ -1,36 +0,0 @@
#!/bin/sh
set -e
echo "[backup] installing mc + ca-certs + tzdata"
apk add --no-cache --quiet curl ca-certificates tzdata >/dev/null
curl -sSLo /usr/local/bin/mc https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x /usr/local/bin/mc
if [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then
cp "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
fi
echo "[backup] configuring mc aliases"
mc alias set --quiet local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
if [ -n "$BACKUP_REMOTE_ENDPOINT" ] && [ -n "$BACKUP_REMOTE_ACCESS_KEY" ]; then
echo "[backup] remote target configured: $BACKUP_REMOTE_ENDPOINT"
mc alias set --quiet remote "$BACKUP_REMOTE_ENDPOINT" "$BACKUP_REMOTE_ACCESS_KEY" "$BACKUP_REMOTE_SECRET_KEY"
else
echo "[backup] no remote target — local-only mirror"
fi
mkdir -p /backups /var/log
SCHEDULE="${BACKUP_SCHEDULE_MEDIA:-0 3 * * *}"
echo "[backup] schedule: $SCHEDULE"
mkdir -p /etc/crontabs
echo "$SCHEDULE /scripts/media-backup-run.sh >> /var/log/backup.log 2>&1" > /etc/crontabs/root
echo "" >> /etc/crontabs/root
echo "[backup] running initial mirror"
/scripts/media-backup-run.sh || echo "[backup] initial mirror failed — will retry on schedule"
echo "[backup] starting crond"
exec crond -f -l 6

View File

@ -1,16 +0,0 @@
#!/bin/sh
set -e
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "[$TS] media-backup: start"
mc mirror --overwrite --remove --quiet "local/$S3_BUCKET" "/backups/$S3_BUCKET" \
|| echo "[$TS] media-backup: local mirror reported errors"
if [ -n "$BACKUP_REMOTE_ENDPOINT" ] && [ -n "$BACKUP_REMOTE_BUCKET" ]; then
mc mirror --overwrite --quiet "local/$S3_BUCKET" "remote/$BACKUP_REMOTE_BUCKET/$S3_BUCKET" \
|| echo "[$TS] media-backup: remote mirror reported errors"
fi
LOCAL_SIZE="$(du -sh "/backups/$S3_BUCKET" 2>/dev/null | awk '{print $1}')"
echo "[$TS] media-backup: done (local: ${LOCAL_SIZE:-?})"

View File

@ -1,18 +0,0 @@
{
"name": "stefanieeleandro",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev:web": "pnpm -F @leetete/web dev",
"build": "pnpm -F @leetete/web build",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"typescript": "^5.6.3"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
NET="${INFRA_NETWORK:-infra-net}"
if docker network inspect "$NET" >/dev/null 2>&1; then
echo "[network] '$NET' já existe"
else
docker network create --driver bridge "$NET" >/dev/null
echo "[network] '$NET' criada"
fi

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "stefanieeleandro",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "pnpm -F @leetete/web dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"deploy": "pnpm -F @leetete/web deploy",
"db:generate": "pnpm -F @leetete/api db:generate",
"db:migrate:local": "pnpm -F @leetete/web exec wrangler d1 migrations apply wedding-db --local",
"db:migrate:remote": "pnpm -F @leetete/web exec wrangler d1 migrations apply wedding-db --remote"
},
"devDependencies": {
"typescript": "^5.6.3"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
}
}

View File

@ -17,6 +17,43 @@ export type EventConfig = z.infer<typeof eventConfigSchema>;
export const eventConfigUpdateSchema = eventConfigSchema.partial();
export type EventConfigUpdate = z.infer<typeof eventConfigUpdateSchema>;
export const adminUploadSchema = z.object({
id: z.string(),
url: z.string().url(),
thumbnailUrl: z.string().url().nullable(),
mimeType: z.string(),
isVideo: z.boolean(),
sizeBytes: z.number().int(),
durationSeconds: z.number().int().nullable(),
authorName: z.string().nullable(),
message: z.string().nullable(),
status: z.enum(['pending', 'approved', 'rejected']),
source: z.enum(['guest', 'import']),
createdAt: z.number(),
approvedAt: z.number().nullable(),
});
export type AdminUpload = z.infer<typeof adminUploadSchema>;
export const adminUploadsResponseSchema = z.object({
items: z.array(adminUploadSchema),
nextCursor: z.string().nullable(),
});
export type AdminUploadsResponse = z.infer<typeof adminUploadsResponseSchema>;
export const adminStatsSchema = z.object({
total: z.number().int(),
approved: z.number().int(),
pending: z.number().int(),
rejected: z.number().int(),
photos: z.number().int(),
videos: z.number().int(),
totalBytes: z.number().int(),
});
export type AdminStats = z.infer<typeof adminStatsSchema>;
export const uploadInitSchema = z.object({
filename: z.string().min(1).max(255),
mimeType: z.string().regex(/^(image|video)\/[a-z0-9.+-]+$/i),
@ -24,6 +61,7 @@ export const uploadInitSchema = z.object({
durationSeconds: z.number().int().positive().optional(),
authorName: z.string().max(80).optional(),
message: z.string().max(2000).optional(),
turnstileToken: z.string().min(1).optional(),
});
export type UploadInit = z.infer<typeof uploadInitSchema>;
@ -80,56 +118,3 @@ export const galleryResponseSchema = z.object({
});
export type GalleryResponse = z.infer<typeof galleryResponseSchema>;
export const adminUploadSchema = z.object({
id: z.string(),
url: z.string().url(),
thumbnailUrl: z.string().url().nullable(),
storageKey: z.string(),
mimeType: z.string(),
isVideo: z.boolean(),
sizeBytes: z.number().int(),
durationSeconds: z.number().int().nullable(),
authorName: z.string().nullable(),
message: z.string().nullable(),
status: z.enum(['pending', 'approved', 'rejected']),
source: z.enum(['guest', 'import']),
createdAt: z.number(),
approvedAt: z.number().nullable(),
isCover: z.boolean(),
});
export type AdminUpload = z.infer<typeof adminUploadSchema>;
export const adminUploadsResponseSchema = z.object({
items: z.array(adminUploadSchema),
nextCursor: z.string().nullable(),
});
export type AdminUploadsResponse = z.infer<typeof adminUploadsResponseSchema>;
export const adminUploadUpdateSchema = z.object({
authorName: z.string().max(80).nullable().optional(),
message: z.string().max(2000).nullable().optional(),
});
export type AdminUploadUpdate = z.infer<typeof adminUploadUpdateSchema>;
export const adminBulkSchema = z.object({
action: z.enum(['approve', 'reject', 'delete']),
ids: z.array(z.string().min(1)).min(1).max(200),
});
export type AdminBulk = z.infer<typeof adminBulkSchema>;
export const adminStatsSchema = z.object({
total: z.number().int(),
approved: z.number().int(),
pending: z.number().int(),
rejected: z.number().int(),
photos: z.number().int(),
videos: z.number().int(),
totalBytes: z.number().int(),
});
export type AdminStats = z.infer<typeof adminStatsSchema>;

View File

@ -2,5 +2,3 @@ export type UploadStatus = 'pending' | 'approved' | 'rejected';
export type UploadSource = 'guest' | 'import';
export type GalleryVisibility = 'public' | 'private';
export type ModerationMode = 'pre' | 'post';
export type BulkAction = 'approve' | 'reject' | 'delete';
export type UploadKind = 'photo' | 'video';

3552
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

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