From c1f8b12d3ca7ea46f025f1e07ca966c6bcdff1b8 Mon Sep 17 00:00:00 2001 From: Leandro Ronetto Date: Tue, 9 Jun 2026 06:39:53 -0300 Subject: [PATCH] chore: initial commit (lronetto-main split do monorepo wedding-app) Co-Authored-By: Claude Opus 4.8 --- .env.example | 41 +++ .gitignore | 17 ++ CLAUDE.md | 422 +++++++++++++++++++++++++++ Makefile | 90 ++++++ caddy/Caddyfile | 50 ++++ docker-compose.yml | 121 ++++++++ minio/cors.json | 11 + minio/init.sh | 13 + network.sh | 11 + pgadmin/servers.json | 13 + postgres/init/01-create-databases.sh | 17 ++ 11 files changed, 806 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 caddy/Caddyfile create mode 100644 docker-compose.yml create mode 100644 minio/cors.json create mode 100644 minio/init.sh create mode 100644 network.sh create mode 100644 pgadmin/servers.json create mode 100644 postgres/init/01-create-databases.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a1fc90 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# ============================================================================= +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..519063d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.env +.env.local +.env.*.local +!.env.example + +*.log +.DS_Store +.vscode/ +.idea/ + +# Bind mounts dos volumes Docker +postgres/data/ +redis/data/ +minio/data/ +pgadmin/data/ +caddy/data/ +caddy/config/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..95bdd47 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,422 @@ +# 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 repositórios / 3 stacks Docker Compose + +Cada stack é um repositório git separado, com seu próprio `docker-compose.yml` isolado. Os 3 compartilham uma **network Docker externa** chamada `infra-net`. + +``` +lronetto-main/ Postgres + Redis + MinIO + pgAdmin + Caddy (+ orquestrador) +lronetto-gitea/ Gitea + Actions runner +lronetto-wedding/ App FastAPI + sidecars de backup (+ deploy CI) +``` + +No disco os 3 ficam como **diretórios irmãos** (ex.: `C:\Users\lrone\code\lronetto-*`). O `Makefile` orquestrador (neste repo, `lronetto-main`) referencia os outros dois por caminho relativo (`../lronetto-gitea`, `../lronetto-wedding`) — sobrescrevível via `GITEA_DIR=` / `WEDDING_DIR=`. + +**Por que repos separados**: cada stack tem ciclo de vida, histórico e CI próprios. Subir/derrubar ou versionar uma não afeta as outras. Gitea pode ser desativado sem tocar no app. + +**`lronetto-main` é o "platform layer"** que os consumidores usam. `gitea` e `wedding` conectam ao Postgres e MinIO de lá pela `infra-net`. + +> Histórico: nasceu como monorepo `wedding-app` (branch `main`) com tudo em `infra/{main,gitea,wedding_photo}/`. Foi dividido em 3 repos (fresh start, sem histórico herdado — o monorepo original fica arquivado como backup). + +--- + +## 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 + +Três repos irmãos no mesmo diretório pai: + +``` +lronetto-main/ # PLATFORM LAYER + ORQUESTRADOR (este repo) +├── .gitignore +├── Makefile # Orquestra as 3 stacks (refs ../lronetto-{gitea,wedding}) +├── network.sh # Cria infra-net (idempotente) +├── CLAUDE.md # Este arquivo +├── 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 + +lronetto-gitea/ # GIT + CI +├── .gitignore +├── docker-compose.yml +├── .env.example +└── runner/Dockerfile # act_runner + docker-cli + +lronetto-wedding/ # APLICAÇÃO +├── .gitignore +├── docker-compose.yml # app + postgres-backup + media-backup +├── .env.example +├── Dockerfile # multi-stage Node(build web) + Python(runtime) +├── Makefile # dev local (uv, pnpm) + ops da stack + deploy +├── .gitea/workflows/deploy.yml # CI: push na main -> SSH no host -> make deploy +├── 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 + +### Orquestrador (`lronetto-main/Makefile`) +Sobe/derruba as 3 stacks de uma vez. Precisa que `lronetto-gitea` e +`lronetto-wedding` estejam como diretórios irmãos (ou ajuste `GITEA_DIR=` / +`WEDDING_DIR=`). +```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 (este repo) +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} +``` + +### App wedding (`lronetto-wedding/Makefile`) +Repo self-contained — roda standalone, sem o orquestrador. +```bash +# Dev local (fora do Docker) +make install # pnpm install + uv sync +make dev-web # vite na 5173 +make dev-api # uvicorn --reload na 3000 +make migrate # roda migrations +make build # build do front +make typecheck / lint + +# Stack Docker (requer infra-net + platform layer de pé) +make up # up -d --build +make down / rebuild / logs / ps +make deploy # git reset --hard + up (chamado pelo CI) +``` + +--- + +## 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 no `.env` do repo `lronetto-gitea`: `GITEA_RUNNER_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) +Vive no repo **`lronetto-wedding`** (`.gitea/workflows/deploy.yml`). A cada push +na `main` — ou disparo manual (`workflow_dispatch`) — o runner conecta por +**SSH no host** e roda `make deploy` (git reset --hard + `up` com build no repo +wedding), seguido de health check em `/api/health`. + +**Por que SSH e não docker direto no runner**: o `.env` (segredos do app) fica +só no host, fora do git. O host já tem o repo clonado; o deploy só atualiza o +código e sobe. + +Setup (1ª vez): +1. No host, clone `lronetto-wedding` (ex.: `/opt/lronetto-wedding`) com um usuário + SSH que rode docker e tenha acesso ao repo. (Stack `main` precisa estar de pé.) +2. Gere um par de chaves SSH e adicione a **pública** no `~/.ssh/authorized_keys` + desse usuário. +3. No repo `lronetto-wedding` no Gitea → **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/lronetto-wedding`) +4. (Opcional) **Variables**: `DEPLOY_PORT` se o SSH não for 22. + +`make deploy` (no repo wedding) aceita `BRANCH=` pra sobrescrever a branch. + +--- + +## 9. Gotchas conhecidos + +### Postgres init script só roda na 1ª vez +`postgres/init/01-create-databases.sh` (neste repo, `lronetto-main`) é executado pelo entrypoint do postgres **apenas quando `PGDATA` está vazio**. Pra "rerodar": +```bash +make down-main +sudo rm -rf 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 < /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,gitea,wedding_photo}` no monorepo `wedding-app`, compartilhando `infra-net`. Caddy concentrado em `main/`. +6. **Deploy CI + rename main**: workflow Gitea Actions de deploy via SSH; branch renomeada pra `main`. +7. **Split em 3 repos**: `lronetto-main` + `lronetto-gitea` + `lronetto-wedding` (fresh start). Orquestrador no main, deploy CI no wedding. Monorepo `wedding-app` arquivado. + +Repos / branches: +- `lronetto-main`, `lronetto-gitea`, `lronetto-wedding` (atuais; default branch `main`) +- Arquivados no monorepo `wedding-app`: `claude/wedding-qrcode-photos-C0PQt` (Cloudflare original), `claude/docker-vps-migration` (1ª migração Docker Node) + +--- + +## 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 | `lronetto-main`: `minio/init.sh` + variável no `.env.example` | +| Outro DB no postgres | `lronetto-main`: `postgres/init/01-create-databases.sh` + role nova | +| Novo subdomínio Caddy | `lronetto-main`: `caddy/Caddyfile` + container_name correspondente | +| Nova app na rede | novo repo com `docker-compose.yml`, declarar `infra-net` como external, conectar a `postgres`/`redis`/`minio` por nome, e referenciar no Makefile orquestrador | +| 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 alvo (sintaxe GitHub Actions) — runner já registrado. Ex.: deploy do app em `lronetto-wedding` | + +--- + +## 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c8e05e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,90 @@ +.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 + +# Orquestrador das 3 stacks. `lronetto-main` é o "platform layer" +# (postgres + redis + minio + pgadmin + caddy). Os repos lronetto-gitea e +# lronetto-wedding são clonados como diretórios IRMÃOS e referenciados aqui. +# Ajuste os caminhos abaixo se usar outro layout no disco. + +NET := infra-net +WEDDING_DIR ?= ../lronetto-wedding +GITEA_DIR ?= ../lronetto-gitea + +DC_MAIN := docker compose -f docker-compose.yml --env-file .env +DC_GITEA := docker compose -f $(GITEA_DIR)/docker-compose.yml --env-file $(GITEA_DIR)/.env +DC_WEDDING := docker compose -f $(WEDDING_DIR)/docker-compose.yml --env-file $(WEDDING_DIR)/.env + +help: + @echo "Stacks: main (este repo) | gitea ($(GITEA_DIR)) | wedding ($(WEDDING_DIR))" + @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 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 + +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 diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..34ed6c5 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,50 @@ +{ + 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} + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba028e2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,121 @@ +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 diff --git a/minio/cors.json b/minio/cors.json new file mode 100644 index 0000000..3af25e2 --- /dev/null +++ b/minio/cors.json @@ -0,0 +1,11 @@ +{ + "CORSRules": [ + { + "AllowedOrigins": ["*"], + "AllowedMethods": ["PUT", "GET", "HEAD", "POST", "DELETE"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } + ] +} diff --git a/minio/init.sh b/minio/init.sh new file mode 100644 index 0000000..a62532d --- /dev/null +++ b/minio/init.sh @@ -0,0 +1,13 @@ +#!/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)" diff --git a/network.sh b/network.sh new file mode 100644 index 0000000..bcb9d36 --- /dev/null +++ b/network.sh @@ -0,0 +1,11 @@ +#!/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 diff --git a/pgadmin/servers.json b/pgadmin/servers.json new file mode 100644 index 0000000..6a4fb8e --- /dev/null +++ b/pgadmin/servers.json @@ -0,0 +1,13 @@ +{ + "Servers": { + "1": { + "Name": "Postgres (infra)", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "postgres", + "Username": "postgres", + "SSLMode": "prefer" + } + } +} diff --git a/postgres/init/01-create-databases.sh b/postgres/init/01-create-databases.sh new file mode 100644 index 0000000..b5a5b71 --- /dev/null +++ b/postgres/init/01-create-databases.sh @@ -0,0 +1,17 @@ +#!/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})"