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) <noreply@anthropic.com>
315 lines
15 KiB
Markdown
315 lines
15 KiB
Markdown
# 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 `<audio>`
|
|
- Sem latência de carregamento
|
|
- Funciona até com o telefone no modo silencioso (em alguns navegadores)
|
|
|
|
**Cons:**
|
|
- Requer interação do usuário primeiro (políticas de autoplay)
|
|
- iOS pode silenciar mesmo Web Audio quando em modo silencioso
|
|
|
|
### Notificações: por que servidor faz fan-out?
|
|
|
|
**Alternativa A**: cliente fala direto com cada API (Telegram, Twilio, etc).
|
|
- ❌ Credenciais Twilio expostas no cliente
|
|
- ❌ Cliente precisa estar online quando alarme dispara
|
|
- ✅ Já implementado para Telegram/Discord/genérico (bots e webhooks são "públicos")
|
|
|
|
**Alternativa B (escolhida)**: cliente chama servidor, servidor faz fan-out.
|
|
- ✅ Credenciais sensíveis (Twilio, SMTP) só no servidor
|
|
- ✅ Servidor pode disparar mesmo se cliente caiu (dead-man switch)
|
|
- ✅ Auditável centralmente (`alarm_log`)
|
|
|
|
Atualmente fazemos os DOIS: cliente envia direto para webhooks que conhece
|
|
+ chama servidor para fan-out completo. Redundância intencional.
|
|
|
|
### Dead-man switch
|
|
|
|
Por que não usar push notifications (FCM, etc)?
|
|
|
|
- FCM exige Service Worker + Google Play Services + setup mais complexo
|
|
- Push é "do servidor para o cliente" — o que precisamos é o INVERSO
|
|
(servidor detectar que cliente sumiu)
|
|
- Polling com heartbeat é mais simples e mais robusto para esse caso
|
|
|
|
### Compartilhamento público
|
|
|
|
Tokens randômicos de 16 bytes (base64url) — espaço de 2^128 possibilidades,
|
|
inviável de adivinhar.
|
|
|
|
URL completa NÃO está na busca do Google (não há robots.txt mas também
|
|
não há link público em lugar nenhum). Depende de "security through
|
|
obscurity" do token.
|
|
|
|
Para mais segurança: adicionar PIN opcional no momento de criar o share
|
|
e exigir antes de mostrar o mapa. Não implementado.
|
|
|
|
### Tipografia
|
|
|
|
3 famílias (todas Google Fonts):
|
|
- **Fraunces** (variável, com eixos `opsz` e `SOFT`) — display, itálico de
|
|
caderno editorial
|
|
- **Manrope** — corpo, sans serif geométrica e legível
|
|
- **JetBrains Mono** — números (instrumentos), labels mono caps
|
|
|
|
Decisão de design: aparência de "diário antigo de capitão" sem ser excessivamente
|
|
nostálgico — paleta marítima e tipografia editorial limpa.
|
|
|
|
### Cores
|
|
|
|
- Pergaminho (`#efe5cd`) — fundo
|
|
- Tinta marinha (`#0e2a3d`) — texto principal
|
|
- Latão envelhecido (`#a07832`) — acentos, GPS, brass-bright em fundos escuros
|
|
- Tempestade (`#8c3434`) — proibições, alarmes
|
|
- Sol (`#b67025`) — atenção
|
|
- Alga (`#3f7768`) — sucesso, em segurança
|
|
|
|
## Fluxos críticos
|
|
|
|
### 1. Vigia de fundeio com dead-man switch
|
|
|
|
```
|
|
[App] [Servidor]
|
|
│ │
|
|
│ startTracking() — usuário toca "Fundear" │
|
|
│ │
|
|
│ POST /api/anchor/start │
|
|
│ { anchor_lat, anchor_lng, radius } │
|
|
│ ────────────────→ │
|
|
│ │ setAnchor(active=1)
|
|
│ │ startedAt = now
|
|
│ ←──────────────── │
|
|
│ startGPS() + startHeartbeat() │
|
|
│ │
|
|
├─ a cada 30s ────────────────────────────→ │
|
|
│ POST /api/anchor/heartbeat │
|
|
│ { lat, lng, distance } │
|
|
│ │ updateHeartbeat(...)
|
|
│ │
|
|
│ GPS detecta deriva (d > radius) │
|
|
│ triggerAnchorAlarm() — local │
|
|
│ - playAlarmSound() │
|
|
│ - vibrate() │
|
|
│ - showAlarmScreen() │
|
|
│ POST /api/anchor/alarm │
|
|
│ ────────────────→ │
|
|
│ │ dispatchAlarm()
|
|
│ │ → Telegram
|
|
│ │ → ntfy
|
|
│ │ → SMTP
|
|
│ │ → Twilio
|
|
│ │ logAlarm()
|
|
│ ←──────────────── │
|
|
│ │
|
|
. .
|
|
. APP MORRE / PERDE SINAL / TELA TRAVA .
|
|
. .
|
|
. │
|
|
. │ setInterval (30s)
|
|
. │ checkDeadman():
|
|
. │ if (now - last_heartbeat
|
|
. │ > HEARTBEAT_TIMEOUT)
|
|
. │ dispatchAlarm({
|
|
. │ reason: "heartbeat_lost"
|
|
. │ })
|
|
. │ setAlarmFired(true)
|
|
```
|
|
|
|
### 2. Sync de dados
|
|
|
|
```
|
|
App startup
|
|
│
|
|
├─ loadState() ← localStorage
|
|
│
|
|
├─ if (cloud configured)
|
|
│ try cloudPullAll()
|
|
│ - GET /api/data → estado da nuvem
|
|
│ - GET /api/media/list → mídias remotas
|
|
│ - download das mídias faltantes para IndexedDB
|
|
│
|
|
├─ usuário usa app, edita coisas
|
|
│ - cada saveState() agenda um cloudPushDataOnly() em 5s (debounced)
|
|
│
|
|
├─ cloudPushDataOnly()
|
|
│ - POST /api/data com {data: {...estado...}}
|
|
│ - servidor sobrescreve registro único
|
|
```
|
|
|
|
### 3. Compartilhamento ao vivo
|
|
|
|
```
|
|
[App dono] [Servidor] [Página pública]
|
|
│ │ │
|
|
│ POST /api/share/create │ │
|
|
│ { duration: 360, zones } │ │
|
|
│ ─────────────→ │ │
|
|
│ │ token = randomBytes(12) │
|
|
│ │ insert into shares │
|
|
│ ←───────────── │ │
|
|
│ { url: ".../share/abc123" } │
|
|
│ │ │
|
|
│ Compartilha URL com tripulação │
|
|
│ │ ─────────────→ │
|
|
│ │ │ abre /share/abc123
|
|
│ │ ←───────────── │
|
|
│ │ HTML com Leaflet │
|
|
│ │ │
|
|
├─ a cada 30s ──────────────→ │ │
|
|
│ POST /api/share/position │ │
|
|
│ { lat, lng, speed } │ │
|
|
│ │ insert into share_positions
|
|
│ │ │
|
|
│ │ ←───────────── │
|
|
│ │ GET /api/share/abc123/positions
|
|
│ │ ─────────────→ │
|
|
│ │ │ desenha trilha + marcador
|
|
│ │ │
|
|
│ │ (auto-refresh a cada 15s) │
|
|
```
|
|
|
|
## Performance considerations
|
|
|
|
- **localStorage**: limite 5-10 MB, varia por navegador. Travessias com track de
|
|
GPS longas pesam: 1 ponto a cada 5s = 720 pts/h. Cada ponto ~70 bytes → 50 KB/h.
|
|
Uma viagem de 50h = 2.5 MB. Limite: ~100h de tracking total no app antes de
|
|
estourar. Se virar problema, mover tracks para IndexedDB.
|
|
|
|
- **IndexedDB**: limite muito maior (~50% do storage do dispositivo). Cada foto
|
|
3-5 MB, vídeo 30-100 MB. Em 32 GB de Android com bastante coisa, ainda dá pra
|
|
ter algumas centenas de fotos e dezenas de vídeos.
|
|
|
|
- **Mapas Leaflet**: cada modal cria nova instância. Importante chamar `.remove()`
|
|
ao fechar para evitar memory leaks. Fizemos isso em todos os modais de mapa.
|
|
|
|
- **Web Audio context**: criado on-demand quando alarme dispara. Não há vazamento.
|
|
|
|
- **Watch position**: throttling baseado em bateria reduz drasticamente o consumo.
|
|
Eco mode = ~30% menos uso de GPS comparado a normal.
|
|
|
|
## Segurança — checklist para produção
|
|
|
|
Ver **HANDOFF.md** para detalhes. Resumo:
|
|
|
|
- [ ] Rate limiting nos endpoints públicos
|
|
- [ ] Validação de schema nos endpoints autenticados
|
|
- [ ] Reduzir limite de upload de 200 MB
|
|
- [ ] Audit log
|
|
- [ ] Backup off-site automático do volume `/data`
|
|
- [ ] Monitoramento de uptime (UptimeRobot, Better Stack, etc)
|
|
- [ ] Alertas de erro 5xx (Sentry?)
|