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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
out/
|
||||||
.cache/
|
.cache/
|
||||||
.turbo/
|
.turbo/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
worker-configuration.d.ts
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
!.env.example
|
!.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/
|
.vscode/
|
||||||
.idea/
|
.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',
|
gallery_visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
moderation TEXT NOT NULL DEFAULT 'post',
|
moderation TEXT NOT NULL DEFAULT 'post',
|
||||||
max_file_mb INTEGER NOT NULL DEFAULT 500,
|
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,
|
max_video_seconds INTEGER NOT NULL DEFAULT 300,
|
||||||
welcome_message TEXT,
|
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)
|
INSERT INTO event_config (id, couple_names, max_file_mb, max_video_seconds, allow_video, moderation, gallery_visibility)
|
||||||
VALUES (1, 'Stefanie & Leandro')
|
VALUES (1, 'Stefanie & Leandro', 500, 300, 1, 'post', 'public');
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE uploads (
|
CREATE TABLE uploads (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
storage_key TEXT NOT NULL,
|
storage_key TEXT NOT NULL,
|
||||||
thumbnail_key TEXT,
|
thumbnail_key TEXT,
|
||||||
mime_type TEXT NOT NULL,
|
mime_type TEXT NOT NULL,
|
||||||
size_bytes BIGINT NOT NULL,
|
size_bytes INTEGER NOT NULL,
|
||||||
duration_seconds INTEGER,
|
duration_seconds INTEGER,
|
||||||
author_name TEXT,
|
author_name TEXT,
|
||||||
message TEXT,
|
message TEXT,
|
||||||
@ -29,18 +28,17 @@ CREATE TABLE uploads (
|
|||||||
source TEXT NOT NULL DEFAULT 'guest',
|
source TEXT NOT NULL DEFAULT 'guest',
|
||||||
ip_hash TEXT,
|
ip_hash TEXT,
|
||||||
provider_upload_id TEXT,
|
provider_upload_id TEXT,
|
||||||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||||
approved_at BIGINT
|
approved_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC);
|
CREATE INDEX idx_uploads_status_created ON uploads (status, created_at DESC);
|
||||||
CREATE INDEX idx_uploads_source ON uploads (source);
|
CREATE INDEX idx_uploads_source ON uploads (source);
|
||||||
CREATE INDEX idx_uploads_author_lower ON uploads (LOWER(author_name));
|
|
||||||
|
|
||||||
CREATE TABLE audit_log (
|
CREATE TABLE audit_log (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
actor TEXT,
|
actor TEXT,
|
||||||
payload 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",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"pages:dev": "wrangler dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@leetete/api": "workspace:*",
|
||||||
"@leetete/shared": "workspace:*",
|
"@leetete/shared": "workspace:*",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@ -17,6 +20,7 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20241218.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@ -24,6 +28,7 @@
|
|||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11",
|
||||||
|
"wrangler": "^3.95.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,10 +1,7 @@
|
|||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(public status: number, public body: string) {
|
||||||
public status: number,
|
|
||||||
public body: string,
|
|
||||||
) {
|
|
||||||
super(`API ${status}: ${body}`);
|
super(`API ${status}: ${body}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6,7 +6,6 @@ interface EventInfo {
|
|||||||
coupleNames: string;
|
coupleNames: string;
|
||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
welcomeMessage: string | null;
|
welcomeMessage: string | null;
|
||||||
coverUrl: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@ -20,15 +19,6 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-full flex flex-col items-center justify-center px-6 py-12 text-center">
|
<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">
|
<h1 className="font-display text-5xl text-stone-800 mb-3">
|
||||||
{event?.coupleNames ?? 'Stefanie & Leandro'}
|
{event?.coupleNames ?? 'Stefanie & Leandro'}
|
||||||
</h1>
|
</h1>
|
||||||
@ -210,11 +210,7 @@ export default function Upload() {
|
|||||||
<div
|
<div
|
||||||
className="h-full bg-stone-700 transition-all"
|
className="h-full bg-stone-700 transition-all"
|
||||||
style={{
|
style={{
|
||||||
width: `${
|
width: `${progress.total ? Math.min(100, (progress.loaded / progress.total) * 100) : 0}%`,
|
||||||
progress.total
|
|
||||||
? Math.min(100, (progress.loaded / progress.total) * 100)
|
|
||||||
: 0
|
|
||||||
}%`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -256,7 +252,7 @@ function humanError(err: unknown): string {
|
|||||||
if (msg.includes('file_too_large')) return 'Arquivo grande demais.';
|
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_not_allowed')) return 'Vídeos não são permitidos.';
|
||||||
if (msg.includes('video_too_long')) return 'Vídeo muito longo.';
|
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.';
|
if (msg.includes('network')) return 'Falha de rede. Tenta de novo.';
|
||||||
return 'Erro ao enviar. Tenta de novo em alguns segundos.';
|
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 { Link, useNavigate } from 'react-router-dom';
|
||||||
import type {
|
import type {
|
||||||
AdminStats,
|
AdminStats,
|
||||||
@ -9,8 +9,6 @@ import type {
|
|||||||
interface AdminEvent {
|
interface AdminEvent {
|
||||||
coupleNames: string;
|
coupleNames: string;
|
||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
coverKey: string | null;
|
|
||||||
coverUrl: string | null;
|
|
||||||
galleryVisibility: 'public' | 'private';
|
galleryVisibility: 'public' | 'private';
|
||||||
moderation: 'pre' | 'post';
|
moderation: 'pre' | 'post';
|
||||||
maxFileMb: number;
|
maxFileMb: number;
|
||||||
@ -19,9 +17,6 @@ interface AdminEvent {
|
|||||||
welcomeMessage: string | null;
|
welcomeMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'pending' | 'approved' | 'rejected';
|
|
||||||
type KindFilter = 'all' | 'photo' | 'video';
|
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
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() {
|
export default function AdminDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState<string | null>(null);
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
const [event, setEvent] = useState<AdminEvent | null>(null);
|
const [event, setEvent] = useState<AdminEvent | null>(null);
|
||||||
const [uploads, setUploads] = useState<AdminUpload[]>([]);
|
const [uploads, setUploads] = useState<AdminUpload[]>([]);
|
||||||
const [status, setStatus] = useState<StatusFilter>('all');
|
const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>(
|
||||||
const [kind, setKind] = useState<KindFilter>('all');
|
'all',
|
||||||
const [search, setSearch] = useState('');
|
);
|
||||||
const debouncedSearch = useDebounced(search, 350);
|
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [savingEvent, setSavingEvent] = useState(false);
|
const [savingEvent, setSavingEvent] = useState(false);
|
||||||
const [eventDirty, setEventDirty] = useState(false);
|
const [eventDirty, setEventDirty] = useState(false);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [editing, setEditing] = useState<AdminUpload | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/me', { credentials: 'include' })
|
fetch('/api/admin/me', { credentials: 'include' })
|
||||||
@ -82,31 +65,22 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
refreshStats();
|
fetch('/api/admin/stats', { credentials: 'include' })
|
||||||
refreshEvent();
|
.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]);
|
}, [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) {
|
async function loadUploads(reset: boolean) {
|
||||||
if (!email || loading) return;
|
if (!email || loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const url = new URL('/api/admin/uploads', window.location.origin);
|
const url = new URL('/api/admin/uploads', window.location.origin);
|
||||||
if (status !== 'all') url.searchParams.set('status', status);
|
if (filter !== 'all') url.searchParams.set('status', filter);
|
||||||
if (kind !== 'all') url.searchParams.set('kind', kind);
|
|
||||||
if (debouncedSearch.trim()) url.searchParams.set('q', debouncedSearch.trim());
|
|
||||||
if (!reset && cursor) url.searchParams.set('cursor', cursor);
|
if (!reset && cursor) url.searchParams.set('cursor', cursor);
|
||||||
const r = await fetch(url, { credentials: 'include' });
|
const r = await fetch(url, { credentials: 'include' });
|
||||||
if (!r.ok) return;
|
|
||||||
const data = (await r.json()) as AdminUploadsResponse;
|
const data = (await r.json()) as AdminUploadsResponse;
|
||||||
setUploads((prev) => (reset ? data.items : [...prev, ...data.items]));
|
setUploads((prev) => (reset ? data.items : [...prev, ...data.items]));
|
||||||
setCursor(data.nextCursor);
|
setCursor(data.nextCursor);
|
||||||
@ -121,10 +95,14 @@ export default function AdminDashboard() {
|
|||||||
setUploads([]);
|
setUploads([]);
|
||||||
setCursor(null);
|
setCursor(null);
|
||||||
setDone(false);
|
setDone(false);
|
||||||
setSelected(new Set());
|
|
||||||
loadUploads(true);
|
loadUploads(true);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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) {
|
async function approve(id: string) {
|
||||||
await fetch(`/api/admin/uploads/${id}/approve`, {
|
await fetch(`/api/admin/uploads/${id}/approve`, {
|
||||||
@ -157,55 +135,7 @@ export default function AdminDashboard() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
setUploads((u) => u.filter((x) => x.id !== id));
|
setUploads((u) => u.filter((x) => x.id !== id));
|
||||||
setSelected((s) => {
|
|
||||||
const next = new Set(s);
|
|
||||||
next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
refreshStats();
|
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) {
|
async function saveEvent(e: React.FormEvent) {
|
||||||
@ -213,21 +143,11 @@ export default function AdminDashboard() {
|
|||||||
if (!event) return;
|
if (!event) return;
|
||||||
setSavingEvent(true);
|
setSavingEvent(true);
|
||||||
try {
|
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', {
|
await fetch('/api/admin/event', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(event),
|
||||||
});
|
});
|
||||||
setEventDirty(false);
|
setEventDirty(false);
|
||||||
} finally {
|
} finally {
|
||||||
@ -240,54 +160,6 @@ export default function AdminDashboard() {
|
|||||||
setEventDirty(true);
|
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) {
|
if (!email) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-full flex items-center justify-center p-6 text-stone-500">
|
<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 (
|
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">
|
<header className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-3xl text-stone-800">Painel dos noivos</h1>
|
<h1 className="font-display text-3xl text-stone-800">Painel dos noivos</h1>
|
||||||
@ -315,11 +192,7 @@ export default function AdminDashboard() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
||||||
<StatCard label="Aprovadas" value={stats.approved} />
|
<StatCard label="Aprovadas" value={stats.approved} />
|
||||||
<StatCard
|
<StatCard label="Pendentes" value={stats.pending} highlight={stats.pending > 0} />
|
||||||
label="Pendentes"
|
|
||||||
value={stats.pending}
|
|
||||||
highlight={stats.pending > 0}
|
|
||||||
/>
|
|
||||||
<StatCard label="Fotos / Vídeos" value={`${stats.photos} / ${stats.videos}`} />
|
<StatCard label="Fotos / Vídeos" value={`${stats.photos} / ${stats.videos}`} />
|
||||||
<StatCard label="Storage" value={formatBytes(stats.totalBytes)} />
|
<StatCard label="Storage" value={formatBytes(stats.totalBytes)} />
|
||||||
</section>
|
</section>
|
||||||
@ -328,25 +201,6 @@ export default function AdminDashboard() {
|
|||||||
{event && (
|
{event && (
|
||||||
<section className="bg-white rounded-lg border border-stone-200 p-5 mb-8">
|
<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>
|
<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">
|
<form onSubmit={saveEvent} className="space-y-4">
|
||||||
<Field label="Nome do casal">
|
<Field label="Nome do casal">
|
||||||
<input
|
<input
|
||||||
@ -447,86 +301,22 @@ export default function AdminDashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="bg-white rounded-lg border border-stone-200 p-5">
|
<section className="bg-white rounded-lg border border-stone-200 p-5">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="font-display text-xl text-stone-800 mr-auto">Uploads</h2>
|
<h2 className="font-display text-xl text-stone-800">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"
|
|
||||||
/>
|
|
||||||
<select
|
<select
|
||||||
value={status}
|
value={filter}
|
||||||
onChange={(e) => setStatus(e.target.value as StatusFilter)}
|
onChange={(e) =>
|
||||||
|
setFilter(e.target.value as 'all' | 'pending' | 'approved' | 'rejected')
|
||||||
|
}
|
||||||
className="text-sm rounded border border-stone-300 px-2 py-1"
|
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="pending">Pendentes</option>
|
||||||
<option value="approved">Aprovados</option>
|
<option value="approved">Aprovados</option>
|
||||||
<option value="rejected">Rejeitados</option>
|
<option value="rejected">Rejeitados</option>
|
||||||
</select>
|
</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>
|
</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 && (
|
{uploads.length === 0 && !loading && (
|
||||||
<p className="text-stone-500 text-sm py-8 text-center">Nenhum upload.</p>
|
<p className="text-stone-500 text-sm py-8 text-center">Nenhum upload.</p>
|
||||||
)}
|
)}
|
||||||
@ -536,13 +326,9 @@ export default function AdminDashboard() {
|
|||||||
<UploadCard
|
<UploadCard
|
||||||
key={u.id}
|
key={u.id}
|
||||||
upload={u}
|
upload={u}
|
||||||
selected={selected.has(u.id)}
|
|
||||||
onToggle={() => toggleSelect(u.id)}
|
|
||||||
onApprove={() => approve(u.id)}
|
onApprove={() => approve(u.id)}
|
||||||
onReject={() => reject(u.id)}
|
onReject={() => reject(u.id)}
|
||||||
onDelete={() => remove(u.id)}
|
onDelete={() => remove(u.id)}
|
||||||
onEdit={() => setEditing(u)}
|
|
||||||
onSetCover={() => setCover(u)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -574,14 +360,6 @@ export default function AdminDashboard() {
|
|||||||
Baixar QR Code (PDF A6)
|
Baixar QR Code (PDF A6)
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{editing && (
|
|
||||||
<EditModal
|
|
||||||
upload={editing}
|
|
||||||
onClose={() => setEditing(null)}
|
|
||||||
onSave={saveEdit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -618,22 +396,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
|
|
||||||
function UploadCard({
|
function UploadCard({
|
||||||
upload,
|
upload,
|
||||||
selected,
|
|
||||||
onToggle,
|
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
|
||||||
onSetCover,
|
|
||||||
}: {
|
}: {
|
||||||
upload: AdminUpload;
|
upload: AdminUpload;
|
||||||
selected: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit: () => void;
|
|
||||||
onSetCover: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const statusColor =
|
const statusColor =
|
||||||
upload.status === 'approved'
|
upload.status === 'approved'
|
||||||
@ -643,20 +413,11 @@ function UploadCard({
|
|||||||
: 'bg-rose-100 text-rose-800';
|
: 'bg-rose-100 text-rose-800';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="rounded-md border border-stone-200 overflow-hidden bg-white">
|
||||||
className={`rounded-md border overflow-hidden bg-white ${
|
|
||||||
selected ? 'border-stone-700 ring-2 ring-stone-700' : 'border-stone-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="aspect-square bg-stone-100 relative">
|
<div className="aspect-square bg-stone-100 relative">
|
||||||
{upload.isVideo ? (
|
{upload.isVideo ? (
|
||||||
<>
|
<>
|
||||||
<video
|
<video src={upload.url} preload="metadata" className="w-full h-full object-cover" muted />
|
||||||
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 className="absolute inset-0 flex items-center justify-center bg-black/30 text-white text-2xl">
|
||||||
▶
|
▶
|
||||||
</span>
|
</span>
|
||||||
@ -669,42 +430,27 @@ function UploadCard({
|
|||||||
className="w-full h-full object-cover"
|
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
|
<span
|
||||||
className={`absolute top-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded ${statusColor}`}
|
className={`absolute top-2 left-2 text-[10px] uppercase tracking-wide px-2 py-0.5 rounded ${statusColor}`}
|
||||||
>
|
>
|
||||||
{upload.status}
|
{upload.status}
|
||||||
</span>
|
</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>
|
||||||
<div className="p-2 text-xs text-stone-700 space-y-1">
|
<div className="p-2 text-xs text-stone-700 space-y-1">
|
||||||
<div className="flex justify-between text-stone-500">
|
<div className="flex justify-between text-stone-500">
|
||||||
<span>{formatDate(upload.createdAt)}</span>
|
<span>{formatDate(upload.createdAt)}</span>
|
||||||
<span>{formatBytes(upload.sizeBytes)}</span>
|
<span>{formatBytes(upload.sizeBytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium truncate">
|
{upload.authorName && <div className="font-medium">{upload.authorName}</div>}
|
||||||
{upload.authorName || <span className="text-stone-400 italic">sem nome</span>}
|
|
||||||
</div>
|
|
||||||
{upload.message && (
|
{upload.message && (
|
||||||
<p className="text-stone-600 line-clamp-2">{upload.message}</p>
|
<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' && (
|
{upload.status !== 'approved' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onApprove}
|
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
|
Aprovar
|
||||||
</button>
|
</button>
|
||||||
@ -713,31 +459,15 @@ function UploadCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onReject}
|
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
|
Ocultar
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
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
|
Apagar
|
||||||
</button>
|
</button>
|
||||||
@ -746,89 +476,3 @@ function UploadCard({
|
|||||||
</div>
|
</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"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"noEmit": true,
|
"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,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3000',
|
target: 'http://127.0.0.1:8788',
|
||||||
changeOrigin: true,
|
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 const eventConfigUpdateSchema = eventConfigSchema.partial();
|
||||||
export type EventConfigUpdate = z.infer<typeof eventConfigUpdateSchema>;
|
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({
|
export const uploadInitSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
mimeType: z.string().regex(/^(image|video)\/[a-z0-9.+-]+$/i),
|
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(),
|
durationSeconds: z.number().int().positive().optional(),
|
||||||
authorName: z.string().max(80).optional(),
|
authorName: z.string().max(80).optional(),
|
||||||
message: z.string().max(2000).optional(),
|
message: z.string().max(2000).optional(),
|
||||||
|
turnstileToken: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UploadInit = z.infer<typeof uploadInitSchema>;
|
export type UploadInit = z.infer<typeof uploadInitSchema>;
|
||||||
@ -80,56 +118,3 @@ export const galleryResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type GalleryResponse = z.infer<typeof galleryResponseSchema>;
|
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 UploadSource = 'guest' | 'import';
|
||||||
export type GalleryVisibility = 'public' | 'private';
|
export type GalleryVisibility = 'public' | 'private';
|
||||||
export type ModerationMode = 'pre' | 'post';
|
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