commit 5b02feae502f199fd6aef102bcfecce1b3f96d16 Author: PontualTech / Karlão Date: Mon Apr 27 13:24:08 2026 -0300 chore: initial commit + security hardening (4 runs squad shivao-melhoria) 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) 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 `