From 5b02feae502f199fd6aef102bcfecce1b3f96d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PontualTech=20/=20Karl=C3=A3o?= Date: Mon, 27 Apr 2026 13:24:08 -0300 Subject: [PATCH] chore: initial commit + security hardening (4 runs squad shivao-melhoria) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importação inicial do projeto Shivão (Diário de Bordo do veleiro) em estado pronto pra produção, já incluindo as 5 mudanças de hardening implementadas pela squad shivao-melhoria em 2026-04-27. Mudanças de hardening (HANDOFF.md seção "✅ Pronto"): 1. **Rate limiting nos 3 endpoints públicos de share** - express-rate-limit ^8.4.1, 60 req/min/IP - server/src/index.js linhas 38, 262, 271, 279 2. **Tamanho de upload reduzido pra 50MB** (era 200MB) - multer linha 84 3. **Validação Zod nos endpoints autenticados** (POST /api/data e /zones) - novo arquivo server/src/schemas/index.js - middleware validate() retorna 400 com top 5 issues 4. **Audit log de ações sensíveis** - nova tabela audit_log + funções db.audit() e db.recentAudit() - 6 endpoints instrumentados (state_set, media_insert/delete, share_create/revoke/zones_update) - novo endpoint GET /api/audit (autenticado) 5. **Catch silencioso de webhook em zona PROIBIDA tratado** - app/diario-bordo.html + server/public/index.html linha 3280 - agora loga erro + exibe toast ao usuário Status final do HANDOFF: - 🔴 Críticos restantes: 1 (CORS — decisão consciente single-tenant, não-acionável) - 🟡 Importantes: 4 itens (testes, vigia reconnect, refator frontend, demais catches) - 🟢 Bom-ter: 5 itens Stack: Node 20 ESM + Express + better-sqlite3 + Docker (Coolify) Single-tenant pessoal · single-file frontend HTML · offline-first Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 35 + API.md | 331 ++++ ARCHITECTURE.md | 315 ++++ BACKLOG.md | 168 ++ CONTRIBUTING.md | 246 +++ DEPLOY.md | 230 +++ HANDOFF.md | 211 +++ PROJECT_CONTEXT.md | 100 + README.md | 105 ++ app/diario-bordo.html | 3468 +++++++++++++++++++++++++++++++++++ server/.gitignore | 5 + server/Dockerfile | 31 + server/README.md | 184 ++ server/docker-compose.yml | 18 + server/package-lock.json | 1479 +++++++++++++++ server/package.json | 23 + server/public/index.html | 3468 +++++++++++++++++++++++++++++++++++ server/src/db.js | 213 +++ server/src/index.js | 439 +++++ server/src/notifications.js | 219 +++ server/src/schemas/index.js | 52 + 21 files changed, 11340 insertions(+) create mode 100644 .gitignore create mode 100644 API.md create mode 100644 ARCHITECTURE.md create mode 100644 BACKLOG.md create mode 100644 CONTRIBUTING.md create mode 100644 DEPLOY.md create mode 100644 HANDOFF.md create mode 100644 PROJECT_CONTEXT.md create mode 100644 README.md create mode 100644 app/diario-bordo.html create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/docker-compose.yml create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/public/index.html create mode 100644 server/src/db.js create mode 100644 server/src/index.js create mode 100644 server/src/notifications.js create mode 100644 server/src/schemas/index.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c55521 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependências +node_modules/ +**/node_modules/ + +# Dados do dono (NUNCA commitar — ficam no volume Coolify) +data/ +server/data/ +**/shivao.db +**/shivao.db-shm +**/shivao.db-wal + +# Segredos +.env +.env.* +!.env.example +**/.env +**/.env.* + +# OS / IDE +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo + +# Logs locais +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build artifacts (não há build step hoje, mas reserva) +dist/ +build/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..0d4d15e --- /dev/null +++ b/API.md @@ -0,0 +1,331 @@ +# API Reference + +Servidor Express em Node.js. Todas as rotas autenticadas exigem header: + +``` +Authorization: Bearer +``` + +Salvo onde indicado como **público** (sem auth). + +## Convenções + +- Content-Type: `application/json` (exceto `/api/media` que é multipart) +- Timestamps: Unix ms (números inteiros) +- Coordenadas: `lat`, `lng` (decimais) +- Velocidade: m/s no servidor, knots no frontend (conversão x1.94384) +- Distância: metros no servidor, milhas náuticas no frontend (m / 1852) + +--- + +## Endpoints públicos + +### `GET /api/health` + +Health check. Útil para monitoramento. + +**Response:** +```json +{ "ok": true, "ts": 1730000000000 } +``` + +### `GET /share/:token` + +Página HTML pública com mapa em tempo real do barco. + +- Renderiza Leaflet com marcador do barco e trilha das últimas posições +- Inclui zonas de geofencing (proibidas/atenção) se foram salvas no share +- Auto-refresh a cada 15 segundos +- Retorna 404 se token inválido, 410 se expirado + +### `GET /api/share/:token/info` + +Metadata do share (sem expor `BOAT_TOKEN`). + +**Response:** +```json +{ + "boatName": "Shivao", + "expiresAt": 1730086400000, + "zones": [ + { "id": "...", "name": "Pedras", "type": "forbidden", + "center": { "lat": -23.0, "lng": -43.0 }, "radius": 100 } + ] +} +``` + +### `GET /api/share/:token/positions` + +Posições históricas do share (até 500 últimas). + +**Response:** +```json +[ + { "lat": -23.0, "lng": -43.0, "speed": 3.2, "ts": 1730000000000 }, + ... +] +``` + +--- + +## Server info + +### `GET /api/info` + +Quais canais de notificação estão configurados. + +**Response:** +```json +{ + "channels": ["telegram", "email", "ntfy"], + "heartbeatTimeoutSec": 300, + "version": "1.0" +} +``` + +--- + +## Sync de dados + +### `GET /api/data` + +Retorna todo o estado armazenado. + +**Response:** +```json +{ + "data": { ... estado completo ... }, + "updated_at": 1730000000000 +} +``` + +### `POST /api/data` + +Substitui todo o estado. + +**Body:** +```json +{ "data": { ... estado completo ... } } +``` + +> **Nota**: o servidor não merge nem versiona — substitui inteiro. +> Para multi-dispositivo, é responsabilidade do cliente puxar antes de modificar +> ou implementar uma estratégia de merge. + +--- + +## Mídia + +### `POST /api/media` + +Upload de arquivo. **Multipart form-data**: +- `file`: arquivo binário +- `id`: ID único (cliente gera) +- `parent_id`: ID da viagem/manutenção pai (opcional) +- `parent_type`: `trip`, `maint`, ou `pending` +- `kind`: `photo`, `video`, `audio` +- `created_at`: timestamp ms + +Limite: 200 MB por arquivo (revisar antes de produção — ver HANDOFF.md). + +**Response:** +```json +{ "ok": true, "id": "abc123", "url": "/api/media/abc123" } +``` + +### `GET /api/media/list` + +Lista todas as mídias (metadata, sem o conteúdo). + +**Response:** +```json +[ + { "id": "abc", "parent_id": "trip_1", "kind": "photo", + "mime": "image/jpeg", "size": 123456, "created_at": 1730000000000 } +] +``` + +### `GET /api/media/:id` + +Stream do arquivo. Retorna o blob com `Content-Type` original. + +### `DELETE /api/media/:id` + +Remove a mídia (DB + filesystem). + +--- + +## Vigia de fundeio + dead-man switch + +### `POST /api/anchor/start` + +Registra início de uma vigia no servidor. + +**Body:** +```json +{ + "boat_name": "Shivao", + "anchor_lat": -23.0, "anchor_lng": -43.0, + "radius": 50 +} +``` + +### `POST /api/anchor/heartbeat` + +App envia a cada 30s enquanto vigia ativa. Reseta o timer do dead-man. + +**Body:** +```json +{ "lat": -23.001, "lng": -43.001, "distance": 12.5 } +``` + +### `POST /api/anchor/alarm` + +Disparar alarme manualmente. Aciona TODOS os canais configurados em paralelo. + +**Body:** +```json +{ + "boat_name": "Shivao", + "lat": -23.005, "lng": -43.005, + "distance": 87.3, + "radius": 50, + "reason": "drift" +} +``` + +**Response:** +```json +{ + "sent": ["telegram", "email", "ntfy"], + "failed": [{ "channel": "sms", "error": "Twilio rate limit" }] +} +``` + +### `POST /api/anchor/stop` + +Encerra vigia. Limpa estado no servidor. + +### `GET /api/anchor/status` + +Estado atual da vigia. + +**Response:** +```json +{ + "active": 1, + "boat_name": "Shivao", + "anchor_lat": -23.0, "anchor_lng": -43.0, + "radius": 50, + "started_at": 1730000000000, + "last_heartbeat": 1730000300000, + "last_lat": -23.001, "last_lng": -43.001, + "last_distance": 12.5, + "alarm_fired": 0 +} +``` + +### Dead-man switch + +Roda em background a cada 30s. Se `last_heartbeat` está há mais de +`HEARTBEAT_TIMEOUT_SEC` (padrão 5 min) e `active=1`, dispara alarme com +`reason: "heartbeat_lost"`. + +Não envia spam — só dispara uma vez por episódio (até receber novo heartbeat +ou stop). + +--- + +## Compartilhamento ao vivo + +### `POST /api/share/create` + +Cria link público temporário. + +**Body:** +```json +{ + "durationMinutes": 360, + "boatName": "Shivao", + "zones": [ ... opcional, snapshot das zonas atuais ... ] +} +``` + +**Response:** +```json +{ + "token": "Aljg29x71kqp...", + "expiresAt": 1730086400000, + "url": "https://shivao.exemplo.com/share/Aljg29x71kqp..." +} +``` + +### `GET /api/share/list` + +Lista shares ativos (não expirados, não revogados). + +### `DELETE /api/share/:token` + +Revoga share (marca como inativo). Página pública passa a retornar 404. + +### `POST /api/share/:token/zones` + +Atualiza zonas do share (caso o dono adicione/remova zonas após criar o share). + +**Body:** +```json +{ "zones": [ ... ] } +``` + +### `POST /api/share/position` + +Posta posição atual. App chama a cada 30s enquanto há share ativo. +A posição vai para **todos** os shares ativos do barco (filtrados por `boat_name`). + +**Body:** +```json +{ "lat": -23.0, "lng": -43.0, "speed": 3.2, "boatName": "Shivao" } +``` + +**Response:** +```json +{ "ok": true, "posted": 2 } +``` + +--- + +## Outros + +### `POST /api/test` + +Dispara mensagem de teste em todos os canais configurados (sem urgência). + +**Response:** +```json +{ "sent": ["telegram"], "failed": [] } +``` + +### `GET /api/alarms` + +Histórico dos últimos 50 alarmes (incluindo testes). + +**Response:** +```json +[ + { + "id": 1, "ts": 1730000000000, "type": "drift", + "payload": "{...JSON...}", + "sent": "[\"telegram\",\"ntfy\"]", + "failed": "[]" + } +] +``` + +--- + +## Cleanup automático + +Job rodando 1x/dia: + +- Remove `share_positions` de shares cujo `expires_at` passou há mais de 7 dias +- Remove os próprios `shares` expirados diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fe97e6d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,315 @@ +# ARCHITECTURE + +## Visão geral + +``` +┌─────────────────────────┐ ┌──────────────────────────────────┐ +│ APP (celular/web) │ │ SHIVAO CLOUD (VPS Coolify) │ +│ │ │ │ +│ - HTML standalone │ ─HTTPS─→│ Express + SQLite │ +│ - localStorage (state) │ Bearer │ - /api/data (sync) │ +│ - IndexedDB (mídia) │ Token │ - /api/media (upload/serve) │ +│ - Leaflet + OSM │ │ - /api/anchor/* (vigia) │ +│ - Web Audio (alarme) │ │ - /share/:token (público) │ +│ - Geolocation API │ │ │ +│ │←HTTPS───│ Notificações (fan-out): │ +│ │ updates │ - Telegram │ +└─────────────────────────┘ │ - ntfy.sh │ + │ - SMTP │ + ↓ webhooks diretos │ - Twilio (SMS/WhatsApp) │ + (do app sem servidor) │ - Webhook genérico │ + ↓ │ │ +┌──────────────────┐ │ Dead-man switch: │ +│ Telegram bot │ │ - heartbeat a cada 30s │ +│ Discord webhook │ │ - timeout 5min → alarme │ +│ Webhook genérico │ │ │ +└──────────────────┘ │ Compartilhamento público: │ + │ - tokens aleatórios │ + ↓ Windy API │ - posições rotativas (500 max) │ + (do app, com chave do dono) │ - cleanup diário │ + ↓ └──────────────────────────────────┘ +┌──────────────────┐ ↓ +│ api.windy.com │ ┌──────────────────────────────────┐ +│ Point Forecast │ │ Volume persistente /data │ +│ - GFS │ │ - shivao.db (SQLite) │ +│ - GFS Wave │ │ - media/ (uploads) │ +└──────────────────┘ └──────────────────────────────────┘ +``` + +## Decisões técnicas + +### Frontend: por que single-file HTML? + +**Prós:** +- Distribuição trivial (compartilhar por e-mail, drive, etc) +- Funciona offline (após primeiro carregamento) +- Pode ser "instalado" no celular sem app store (apenas Chrome → Adicionar à tela inicial) +- Zero build step para customizações simples + +**Contras:** +- Arquivo monolítico (~3500 linhas, 200 KB) +- Difícil de revisar em PRs +- Sem code splitting + +**Alternativas consideradas:** +- React/Vue PWA: maior overhead, build step, mas componentização melhor +- Capacitor/Cordova: app nativo "real", mas complexidade de publicação + +### Frontend: armazenamento + +**Estado estruturado (`localStorage`)**: travessias, manutenções, pendências, +configurações. Vantagem: sincronização trivial (1 JSON blob). Desvantagem: +limite de ~5MB total — se viagens viram milhares com track points longos, +pode estourar. + +**Mídias (`IndexedDB`)**: blobs de fotos, áudios, vídeos. Cada arquivo guardado +como Blob, separado do estado. URLs criadas com `URL.createObjectURL()` sob +demanda. Limites do navegador: ~50% do disco no Android, varia no iOS. + +### Backend: por que SQLite + filesystem? + +Para single-tenant pessoal: +- **Simplicidade**: zero configuração, sem servidor de DB separado +- **Backup trivial**: copiar o arquivo `.db` + pasta `media/` +- **Performance**: WAL mode + synchronous=NORMAL é mais que rápido o suficiente +- **Coolify-friendly**: 1 container, 1 volume + +Se evoluir para multi-tenant: Postgres + S3 (ou MinIO) seria a escolha óbvia. + +### Backend: ESM modules + +Node 20+ com `"type": "module"`. Imports nativos `import x from 'y'`. Decisão +para ficar moderno e por preferência. Trade-off: algumas libs antigas precisam +de gambiarras de import. + +### Auth: Bearer Token simples + +Um único `BOAT_TOKEN` configurado via env var. Vai em todo request. + +**Por quê não OAuth/JWT/cookies?** +- Single-tenant pessoal — não há "usuários" +- Token simples roda em qualquer dispositivo do dono +- Sem expiração — para revogar, troca o env var + +**Limitações:** +- Se o token vazar, intruso tem acesso completo +- Sem audit trail granular (não tem quem fez o quê) + +Mitigação: token longo aleatório (32 bytes hex). HTTPS obrigatório +(garantido pelo Coolify). + +### Mapas: Leaflet + OSM + +**Leaflet**: biblioteca leve (~40 KB), API estável, plugin ecosystem. + +**OSM tiles** (`tile.openstreetmap.org`): gratuitos, sem chave, qualidade +boa para uso recreativo. Política de uso permite apps pessoais. + +**Limitações:** +- Tiles náuticas específicas não disponíveis (sem batimetria, sem profundidade) +- Para ECDIS-grade, considerar Mapbox + nautical layer ou OpenSeaMap overlay + +### Áudio do alarme: Web Audio API + +Em vez de tocar mp3 estático: + +**Pros:** +- Volume real-time controlável +- Padrão de tons alternados (klaxon 900↔1200 Hz) impossível de gerar com `