Compare commits
No commits in common. "main" and "claude/wedding-qrcode-photos-C0PQt" have entirely different histories.
main
...
claude/wed
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
|
||||
@ -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
29
.gitignore
vendored
@ -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
409
CLAUDE.md
@ -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.
|
||||
94
Makefile
94
Makefile
@ -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
|
||||
7
apps/api/drizzle.config.ts
Normal file
7
apps/api/drizzle.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema.ts',
|
||||
out: './migrations',
|
||||
dialect: 'sqlite',
|
||||
} satisfies Config;
|
||||
@ -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
35
apps/api/package.json
Normal 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
28
apps/api/src/app.ts
Normal 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>;
|
||||
9
apps/api/src/db/client.ts
Normal file
9
apps/api/src/db/client.ts
Normal 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
62
apps/api/src/db/schema.ts
Normal 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
33
apps/api/src/env.ts
Normal 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
51
apps/api/src/lib/auth.ts
Normal 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
21
apps/api/src/lib/ids.ts
Normal 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);
|
||||
}
|
||||
80
apps/api/src/lib/qr-pdf.ts
Normal file
80
apps/api/src/lib/qr-pdf.ts
Normal 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();
|
||||
}
|
||||
22
apps/api/src/lib/qrcode.ts
Normal file
22
apps/api/src/lib/qrcode.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
15
apps/api/src/lib/storage-factory.ts
Normal file
15
apps/api/src/lib/storage-factory.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
164
apps/api/src/lib/storage-s3.ts
Normal file
164
apps/api/src/lib/storage-s3.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
49
apps/api/src/lib/storage.ts
Normal file
49
apps/api/src/lib/storage.ts
Normal 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>;
|
||||
}
|
||||
23
apps/api/src/lib/turnstile.ts
Normal file
23
apps/api/src/lib/turnstile.ts
Normal 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;
|
||||
}
|
||||
213
apps/api/src/routes/admin.ts
Normal file
213
apps/api/src/routes/admin.ts
Normal 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);
|
||||
});
|
||||
129
apps/api/src/routes/public.ts
Normal file
129
apps/api/src/routes/public.ts
Normal 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 });
|
||||
});
|
||||
209
apps/api/src/routes/uploads.ts
Normal file
209
apps/api/src/routes/uploads.ts
Normal 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
8
apps/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
15
apps/web/.dev.vars.example
Normal file
15
apps/web/.dev.vars.example
Normal 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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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.';
|
||||
}
|
||||
@ -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">Só fotos</option>
|
||||
<option value="video">Só 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
18
apps/web/src/worker.ts
Normal 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>;
|
||||
@ -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"]
|
||||
}
|
||||
@ -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
36
apps/web/wrangler.toml
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["*"],
|
||||
"AllowedMethods": ["PUT", "GET", "HEAD", "POST", "DELETE"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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)"
|
||||
@ -1,13 +0,0 @@
|
||||
{
|
||||
"Servers": {
|
||||
"1": {
|
||||
"Name": "Postgres (infra)",
|
||||
"Group": "Servers",
|
||||
"Host": "postgres",
|
||||
"Port": 5432,
|
||||
"MaintenanceDB": "postgres",
|
||||
"Username": "postgres",
|
||||
"SSLMode": "prefer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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})"
|
||||
@ -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
|
||||
@ -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=
|
||||
@ -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=*"]
|
||||
@ -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
|
||||
@ -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]
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
@ -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]
|
||||
@ -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()
|
||||
@ -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")
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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")
|
||||
@ -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")
|
||||
@ -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)}
|
||||
@ -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}
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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:-?})"
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
1980
infra/wedding_photo/pnpm-lock.yaml
generated
1980
infra/wedding_photo/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
||||
packages:
|
||||
- "apps/web"
|
||||
- "packages/shared"
|
||||
11
network.sh
11
network.sh
@ -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
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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>;
|
||||
@ -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
3552
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
Reference in New Issue
Block a user