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) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-27 13:24:08 -03:00
commit 5b02feae50
21 changed files with 11340 additions and 0 deletions

35
.gitignore vendored Normal file
View file

@ -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/

331
API.md Normal file
View file

@ -0,0 +1,331 @@
# API Reference
Servidor Express em Node.js. Todas as rotas autenticadas exigem header:
```
Authorization: Bearer <BOAT_TOKEN>
```
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

315
ARCHITECTURE.md Normal file
View file

@ -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 `<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?)

168
BACKLOG.md Normal file
View file

@ -0,0 +1,168 @@
# BACKLOG — Próximas melhorias
Ideias e melhorias sugeridas, ordenadas por impacto/esforço.
---
## 🔥 Prioridade alta
### Hardening de produção (ver HANDOFF.md, seção crítica)
- Rate limiting nos endpoints públicos
- Validação de schema (zod/ajv)
- Reduzir tamanho de upload de mídia
- Backup off-site automático
- Monitoring (UptimeRobot, Sentry)
- Audit log de operações sensíveis
### Service Worker / PWA real
- Permitir uso offline real (atualmente offline-first mas não offline-only)
- Background Sync API para retentar sync quando reconectar
- Push notifications via Web Push (servidor envia → celular recebe sem o app aberto)
- Cache de tiles do mapa para uso em alto-mar
### Testes automatizados
- Backend: vitest com fixtures de SQLite em memória
- Frontend: playwright para fluxos críticos (criar viagem, vigia, alarme)
- CI no GitHub Actions
### Reconexão robusta de GPS
- Atualmente, se o Android pausa o GPS quando tab fica em background, não
retoma automaticamente. Precisamos detectar isso e re-iniciar.
- Considerar Sensor APIs alternativas (Generic Sensor, DeviceOrientationEvent)
---
## 🟡 Prioridade média
### Geofencing avançado
- Suporte a polígonos (não só círculos)
- UI: tap pra adicionar vértices, double-tap pra fechar
- Detecção: ponto-em-polígono com ray-casting
- Zonas com horário (ex: "marina fecha às 22h")
- Zonas dependentes de maré
### Tracks como entidade separada
- Hoje cada track vai dentro do trip como JSON. Para tracks longas (multi-dia)
isso pesa no localStorage.
- Mover para IndexedDB com sua própria tabela
- Permitir "continuar" um track entre sessões do app
### Histórico de fundeios mais rico
- Hoje só guarda resumo. Salvar também os GPS points completos.
- Análise: "Em que ponto aquela vez que o vento mudou?" — replay visual
### Importar/exportar mais formatos
- KML (Google Earth)
- TCX (Garmin)
- CSV simples (lat, lng, ts)
- AIS (sistema de identificação automática) — importar tracks de outros barcos
### Multi-barco / multi-dispositivo
- Suportar mais de um barco no mesmo backend
- Cada barco com seu próprio token e dados isolados
- UI no app para "trocar de barco"
- Compartilhamento entre tripulação (perfis com permissões)
### Sincronização incremental
- Hoje sempre envia/baixa estado todo. Para usuários com muito histórico,
fica lento e pesa.
- Versionar por entidade (id + updated_at). Sync envia só o que mudou.
- CRDTs ou Last-Write-Wins-com-timestamp
### Refatoração do frontend
- HTML monolítico (~3500 linhas) — difícil de revisar
- Sugestão: migrar para Vue/Svelte/Lit + Vite com build single-file
- Manter mesma estética/UX
---
## 🟢 Prioridade baixa / nice-to-have
### Tiles náuticas
- OpenSeaMap overlay (boias, faróis, ports)
- Mapbox com nautical layer (pago, mas mais bonito)
- Tiles com batimetria
### Previsão de vento como overlay no mapa
- Windy Map Forecast API (precisa de chave separada, ECMWF Pro custa caro)
- Embed da Windy via iframe
- Visualização local de wind barbs nas próximas N horas
### Histórico de tempo
- Salvar previsão a cada fetch
- Comparar previsão vs realidade ("disseram 15kn, foi 22kn")
- Útil para aprender sobre microclimas locais
### AIS Receiver
- Se usuário tem receptor AIS no barco, integrar
- Mostrar outros barcos próximos no mapa em tempo real
- Alerta de proximidade / colisão
### Compass / heading
- Usar `DeviceOrientationEvent` para mostrar a proa do barco
- Útil em modo de "arrumar a posição da âncora"
### Logs de bordo automáticos
- Detectar transições (saiu da marina → começa viagem; entrou em outra
marina → encerra viagem)
- Sugerir nome de destino com base em geocoding reverso
### Manutenção preditiva
- Com base no horímetro + histórico, sugerir próxima manutenção
("troca de óleo a cada 100h, faltam 23h")
- Lembretes automáticos no app
- Custo médio histórico por categoria
### Modo de leme / autopilot
- Curva de respostas de leme em função do vento
- Salvar trim de velas que funcionaram bem em condições parecidas
### Compartilhamento de relatórios
- Gerar PDF bonito da viagem (dados + fotos + mapa)
- Postar diretamente em redes sociais
- Galeria pública de viagens (opt-in)
### App nativo via Capacitor
- Wrap do HTML em app Android/iOS nativo
- Acesso a APIs que browser não tem (background GPS contínuo)
- Notificações push reais
- Distribuir via Play Store / App Store
### Desktop
- Versão Electron para uso no chartplotter
- Sync com app de celular
### Integração com instrumentos via NMEA
- Receptor NMEA 0183 / 2000 via WebSerial
- Importar dados de velocidade da água, profundidade, temperatura
- Direção/intensidade do vento de anemômetro do mastro
### IA / análise
- Análise de viagens: "Você navegou X milhas em Y horas, vento médio Z"
- Sugestões de melhoria: "Sua média foi 4kn — o barco rende mais 5kn neste vento"
- Detecção de anomalias no consumo de combustível
---
## 🔮 Ideias exploratórias
### "Modo MOB" (Man Over Board)
- Botão grande de emergência
- Marca a posição imediatamente
- Dispara TODOS os canais com prioridade máxima
- Inclui Coast Guard / Marinha se configurado
### Previsão de chegada (ETA)
- Com base na velocidade média e rumo
- Atualiza em tempo real durante a viagem
- Compartilha automaticamente com tripulação em terra
### Diário social
- Compartilhar viagens com amigos (privado)
- Comentários, likes
- Encontros: "Aquele veleiro está perto, quer ancorar junto?"
### Comunidade do Shivao
- App específico do barco com fotos da tripulação
- Rituais e tradições do barco registradas
- Tipo um "livro do barco" digital

246
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,246 @@
# CONTRIBUTING — Guia para devs
## Setup inicial
### Requisitos
- Node.js 20+
- npm
- (Opcional) Docker para teste do build de produção
### Rodando o backend localmente
```bash
cd server
cp .env.example .env
# Edite .env com seus valores (BOAT_TOKEN obrigatório)
npm install
npm start
```
Acesse `http://localhost:3000`
Modo dev com auto-reload:
```bash
npm run dev
```
### Rodando o frontend localmente
O HTML é standalone — pode abrir direto no navegador (`file:///...`), mas
algumas APIs do navegador (Geolocation, Wake Lock, MediaRecorder) **só
funcionam em HTTPS ou localhost**.
Recomendado: deixar o backend servindo o frontend:
```bash
cd server
npm start
# Acesse http://localhost:3000 — o backend serve public/index.html
```
Para iterar em mudanças do frontend:
- Edite `app/diario-bordo.html`
- Copie para `server/public/index.html`
- Recarregue o navegador (sem necessidade de rebuild)
Script de copy útil (para macOS/Linux):
```bash
cp app/diario-bordo.html server/public/index.html
```
### Testando com Docker
```bash
cd server
docker compose up --build
```
Para testar o pipeline completo de produção (igual Coolify faria).
---
## Estrutura do código
### `app/diario-bordo.html`
Único arquivo HTML. Para navegar pelo código, use search:
| Comentário | Função |
|------------|--------|
| `============ STORAGE ============` | Persistência local |
| `============ GPS Math ============` | Haversine, conversões |
| `============ GPS Tracking ============` | Rastreio de viagem |
| `============ ANCHOR WATCH ============` | Vigia de fundeio |
| `============ ALARM ============` | Sistema de alarme |
| `============ CONTACTS ============` | Cadastro de contatos |
| `============ CLOUD SYNC ============` | Integração com servidor |
| `============ WEBHOOKS ============` | Telegram/Discord direto |
| `============ GEOFENCING / ZONES ============` | Zonas de proibição/atenção |
| `============ CHECKLISTS ============` | Listas de verificação |
| `============ GPX EXPORT ============` | Export GPX |
| `============ BATTERY SAVER ============` | Modo economia |
| `============ WEATHER ============` | Windy + Open-Meteo |
### `server/src/`
- `index.js` — entry point Express, todos os routes
- `db.js` — wrapper SQLite com queries prepared
- `notifications.js` — fan-out para todos os canais
---
## Convenções
### Estilo de código
- **Sem build step no frontend** — escrevemos JS direto que roda no navegador.
Cuidado com features muito recentes (target ≥ Chrome 100, Safari 16).
- **Zero dependências runtime no frontend** exceto Leaflet e Google Fonts.
- Variáveis curtas em código compacto (ex: `r` para response, `c` para content).
Trade-off de legibilidade x tamanho do arquivo.
### CSS
- Sistema de design baseado em variáveis CSS no `:root`
- Tipografia: 3 famílias (Fraunces, Manrope, JetBrains Mono)
- Paleta marítima editorial (ver ARCHITECTURE.md)
- Mobile-first, com `@media (min-width:560px)` para desktop
### Backend
- ESM modules (`"type": "module"`)
- `async/await` em vez de promises encadeadas
- Erros logados com `console.warn` ou `console.error`
- Status HTTP semânticos: 200 ok, 400 bad input, 401 unauth, 404 not found, 500 server
### Commits e branches
- `main` — sempre deployável
- Features em branches `feat/...`, fixes em `fix/...`
- PRs com descrição clara do que muda e por quê
---
## Como adicionar uma nova funcionalidade
### Exemplo: novo canal de notificação (Slack)
1. **Backend** — em `server/src/notifications.js`:
```js
async function sendSlack(text) {
const url = process.env.SLACK_WEBHOOK_URL;
if (!url) return null;
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
return { ok: r.ok };
} catch (e) {
return { ok: false, error: e.message };
}
}
```
2. Adicionar ao `dispatchAlarm()`:
```js
const tasks = [
// ...existentes
['slack', sendSlack(text)]
];
```
3. Adicionar a `listConfiguredChannels()`:
```js
if (env.SLACK_WEBHOOK_URL) channels.push('slack');
```
4. **Documentar** — adicionar `SLACK_WEBHOOK_URL=` no `.env.example`
5. **Testar** localmente:
```bash
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/test
```
### Exemplo: novo campo na viagem
1. **Frontend** — adicionar no modal de viagem (HTML)
2. Adicionar no `saveTrip()` — incluir no objeto data
3. Adicionar na renderização (`renderTrips`)
4. **Backup compatibility**: viagens antigas não terão o campo, lembre de
tratar `undefined` graciosamente
Não precisa mudar o backend — a API guarda JSON arbitrário em `state.data`.
---
## Debugging
### Frontend
Abrir DevTools no Chrome (F12 ou ⌥⌘I). Console mostra:
- `[zone]` ao entrar/sair de zona
- `[deadman]` no servidor (acessível via `docker logs`)
- Erros de fetch sem CORS são típicos quando se acessa `file://` em vez de `http://localhost`
Application tab:
- `Local Storage` — ver todo o estado
- `IndexedDB > diario_bordo_db > media` — ver mídias
### Backend
```bash
# Logs em produção
docker logs <container>
# Em dev, vê no terminal direto
# Testar endpoint manualmente
curl -H "Authorization: Bearer abc..." http://localhost:3000/api/info
```
SQLite — abrir o `.db` direto:
```bash
sqlite3 server/data/shivao.db
> .tables
> SELECT * FROM alarm_log ORDER BY ts DESC LIMIT 10;
```
---
## Releases
Atualmente não há versionamento formal. Sugestão para o time:
1. Adicionar `version` em `package.json`
2. Tags Git para releases (`v1.0.0`)
3. Changelog (`CHANGELOG.md`) com mudanças por versão
4. Coolify: configurar para deploy automático apenas em tags (não em todo push)
---
## Boas práticas para o app de barco
Coisas a manter em mente ao desenvolver:
1. **Conectividade incerta**: o app é usado em alto-mar. Falhas de rede são
normais. Tudo deve degradar graciosamente.
2. **Bateria é crítica**: cada feature que ativa GPS/CPU contínuo precisa
ser justificada. Sempre considerar o impacto em bateria.
3. **Tela ao sol**: contraste alto, fontes grandes. A paleta atual já é
bem legível ao sol.
4. **Mãos molhadas**: tap targets grandes (mínimo 44x44 px).
5. **Sob estresse**: alarmes, MOB, etc. devem ter UX MUITO simples — botões
óbvios, pouco texto, ações irreversíveis sempre com confirmação.
6. **Dados são preciosos**: nunca apagar sem dupla confirmação. Backup é fácil
(aba Arquivo).
7. **Single-handed sailing**: capitão pode estar sozinho no leme. Operação
com uma mão deve ser viável onde possível.

230
DEPLOY.md Normal file
View file

@ -0,0 +1,230 @@
# DEPLOY — Coolify (Hetzner VPS)
Guia passo-a-passo para colocar o **shivao-cloud** em produção no Coolify.
## Pré-requisitos
- VPS Hetzner com **Coolify** instalado e acessível
- Domínio (ou subdomínio) apontando para o IP do servidor
- Ex: `shivao.exemplo.com` → IP da VPS
- DNS propagado (verifique com `dig shivao.exemplo.com`)
## Passo 1 — Subir o código para Git
Recomendado: criar repo privado no GitHub/GitLab.
```bash
cd shivao-projeto/server
git init
git add .
git commit -m "Initial commit"
git remote add origin git@github.com:OWNER/shivao-cloud.git
git push -u origin main
```
> Alternativa: usar a opção "Deploy from Docker Image" do Coolify e fazer
> build local + push para um registry. Mas Git é mais simples.
## Passo 2 — No Coolify
### 2.1. Criar a aplicação
1. **+ New Resource** → **Application**
2. Selecione **Public/Private Repository (with GitHub App)** ou **Public/Private Repository**
3. Cole a URL do repo
4. Branch: `main`
5. **Build Pack**: `Dockerfile`
6. **Base Directory**: deixe em branco (o Dockerfile está na raiz)
7. **Dockerfile location**: `/Dockerfile`
8. **Port**: `3000`
9. Salvar
### 2.2. Configurar domínio
1. Aba **Domains**
2. Adicione: `https://shivao.exemplo.com`
3. **Generate Let's Encrypt** (automático)
4. Salvar
### 2.3. Variáveis de ambiente
Aba **Environment Variables** → adicionar:
#### Mínimo obrigatório
```
BOAT_TOKEN=<gere com: openssl rand -hex 32>
```
#### Pelo menos um canal de notificação
**Telegram** (recomendado, grátis):
```
TELEGRAM_BOT_TOKEN=<token do BotFather>
TELEGRAM_CHAT_IDS=<seus chat IDs separados por vírgula>
```
**E-mail** (Gmail com app password):
```
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=seu-email@gmail.com
SMTP_PASS=<senha de app de 16 chars>
SMTP_FROM=Shivao <seu-email@gmail.com>
SMTP_TO=destinatario@exemplo.com,outro@exemplo.com
```
**ntfy.sh** (push grátis sem cadastro):
```
NTFY_TOPIC=<algo aleatório, ex: shivao-alertas-x7k9p2>
```
Veja **server/.env.example** para todas as opções (Twilio, webhook, etc).
#### Opcionais
```
HEARTBEAT_TIMEOUT_SEC=300 # 5 min — tempo até dead-man disparar
NODE_ENV=production
```
### 2.4. Volume persistente
**Crítico** — sem isso, ao reiniciar o container você perde tudo.
1. Aba **Storages**
2. **+ Add**
3. Tipo: **Persistent Storage** ou **Volume**
4. Name: `shivao-data`
5. Mount Path: `/data`
6. Salvar
### 2.5. Deploy
1. Aba principal da aplicação
2. **Deploy** (canto superior direito)
3. Acompanhar logs do build (~2-3 minutos primeira vez)
Se tudo der certo, ao final:
```
Shivao Cloud rodando em :3000
Canais configurados: telegram, email, ...
Dead-man switch: 300s
```
## Passo 3 — Verificar
### 3.1. Health check público
```bash
curl https://shivao.exemplo.com/api/health
# {"ok":true,"ts":1730000000000}
```
### 3.2. Auth funcionando
```bash
curl -H "Authorization: Bearer SEU_TOKEN" https://shivao.exemplo.com/api/info
# {"channels":["telegram","email"],"heartbeatTimeoutSec":300,"version":"1.0"}
```
### 3.3. Acessar o app
Abra `https://shivao.exemplo.com/` no navegador. O frontend deve carregar.
### 3.4. Testar notificação
No app: **Arquivo** → preencha servidor + token → **Disparar mensagem de teste**
Você deve receber a mensagem em todos os canais configurados.
## Passo 4 — Configurar Telegram (5 minutos)
Se ainda não fez:
1. No Telegram, fale com [@BotFather](https://t.me/BotFather)
2. `/newbot` → escolha nome e username → ele dá um **token**
3. Copie o token (tipo `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
4. Inicie conversa com o bot recém-criado (envie qualquer coisa)
5. Acesse `https://api.telegram.org/bot<SEU_TOKEN>/getUpdates`
6. No JSON retornado, procure `"chat":{"id":123456789,...}` → esse é seu `CHAT_ID`
7. Cole `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_IDS` no Coolify
8. Redeploy
9. Teste no app
Para grupo: adicione o bot ao grupo, mande uma mensagem lá, repita o passo 5
(o `chat_id` do grupo é negativo).
## Passo 5 — Conectar o app ao servidor
No celular:
1. Abrir `https://shivao.exemplo.com/` no Chrome
2. Menu (⋮) → **Adicionar à tela inicial** (vira "app")
3. Abrir o app, ir em **Arquivo**
4. Seção **☁️ Sincronização na nuvem**:
- Servidor: `https://shivao.exemplo.com`
- Token: o `BOAT_TOKEN` definido no Coolify
5. **Testar conexão** → deve receber notificação de teste
6. **↑ Enviar tudo para nuvem** (primeira vez)
A partir daí, o app sincroniza automaticamente. Se trocar de celular,
basta colar a URL + token e tocar em **↓ Baixar da nuvem**.
## Atualizando o código
Sempre que houver mudanças:
```bash
git push origin main
```
No Coolify:
- **Deploy** → ele puxa, builda, e troca o container
- A migração de schema do SQLite é automática (CREATE TABLE IF NOT EXISTS)
## Backup
O volume `/data` contém:
- `shivao.db` (SQLite com todos os dados)
- `media/` (fotos, áudios, vídeos)
**Recomendado**: configurar backup periódico do volume no Coolify ou
sincronizar `/data` para um S3 com `restic` ou `borgbackup` via cronjob.
## Troubleshooting
### "BOAT_TOKEN não configurado"
Variável de ambiente faltando ou < 16 chars. Veja Passo 2.3.
### "Failed to fetch" no app
- DNS não propagou ainda
- Token errado
- Coolify não está expondo SSL (verificar Domains)
### "Webhooks falham mas Telegram funciona"
Discord/genérico podem ter rate limits. O servidor faz fan-out independente,
então um falhar não afeta os outros.
### "GPS não funciona no app"
Geolocation API só funciona em HTTPS. Coolify configura HTTPS
automaticamente — mas verifique se está acessando `https://...` e não `http://`.
### "Dead-man switch não dispara"
- Verificar logs do servidor: `docker logs <container>`
- Dead-man só dispara se houve `/api/anchor/start` antes
- Default é 5 min sem heartbeat — pode ajustar em `HEARTBEAT_TIMEOUT_SEC`
### Container reinicia em loop
Verificar logs. Causa comum: `BOAT_TOKEN` muito curto (mínimo 16 chars).
## Custos esperados
- Hetzner CX22 (2 vCPU, 4 GB RAM): ~6€/mês — sobra recurso
- Domínio: ~10€/ano
- Telegram, ntfy, Open-Meteo: grátis
- Twilio (se ativar SMS): ~$1/mês + ~$0.05 por SMS no Brasil
- Windy Point Forecast: o dono já tem premium pessoal
Total mínimo: ~7€/mês.

211
HANDOFF.md Normal file
View file

@ -0,0 +1,211 @@
# HANDOFF — Estado do Projeto
Documento para a equipe que vai continuar o desenvolvimento.
---
## ✅ O que está pronto e funcionando
### Frontend (app/diario-bordo.html)
Arquivo único HTML standalone (~200 KB). Pode ser:
- Aberto direto no navegador
- Servido pelo backend (já é o caso — está em `server/public/index.html`)
- "Instalado" no Android via "Adicionar à tela inicial"
**Funcionalidades verificadas em desenvolvimento:**
| Recurso | Status | Observações |
|---------|--------|-------------|
| Persistência local (localStorage + IndexedDB) | ✅ | Robusto |
| Travessias / Reparos / Pendências CRUD | ✅ | Pronto |
| GPS + rastreio + mapa | ✅ | Leaflet + OSM tiles |
| Vigia de fundeio + alarme | ✅ | Web Audio + Vibration + Wake Lock |
| Swing circle + auto-recentro | ✅ | Implementado mas pouco testado em campo |
| Geofencing | ✅ | Apenas círculos (polígonos = futuro) |
| Mídia (foto/áudio/vídeo) | ✅ | IndexedDB, com viewer e download |
| Importar GPX | ✅ | Trk e Rte points |
| Exportar GPX | ✅ | Por travessia |
| Sync com nuvem | ✅ | Pull, push, auto-sync |
| Webhooks diretos (Telegram, Discord) | ✅ | Sem servidor |
| Compartilhamento de posição ao vivo | ✅ | Requer servidor |
| Checklists customizáveis | ✅ | 5 templates pré-cadastrados |
| Windy Point Forecast API | ✅ | Premium key + fallback Open-Meteo |
| Modo economia de energia | ✅ | Battery API (Chrome only) |
### Backend (server/)
Express + SQLite + Docker, deployável em Coolify ou qualquer VPS.
| Recurso | Status |
|---------|--------|
| Auth via Bearer Token | ✅ |
| Endpoints de sync | ✅ |
| Upload/download de mídia | ✅ |
| Vigia + dead-man switch | ✅ |
| Notificações fan-out (6 canais) | ✅ |
| Compartilhamento público com mapa | ✅ |
| Migração de schema | ✅ Automática |
| Health check | ✅ |
| Cleanup de shares expirados | ✅ Diário |
---
## ✅ Pronto (recém-implementado)
- **Audit log de ações sensíveis** (`server/src/db.js` + 6 endpoints instrumentados + `GET /api/audit`)
- Nova tabela `audit_log (id, ts, action, entity, entity_id, summary, ip)` com índice em `ts DESC`
- Funções `db.audit(action, entity, entityId, summary, ip)` e `db.recentAudit(limit)`
- Eventos rastreados: `state_set`, `media_insert`, `media_delete`, `share_create`, `share_revoke`, `share_zones_update`
- Endpoint `GET /api/audit?limit=N` (autenticado, max 500) pra consulta
- Implementado pelo squad `shivao-melhoria` em 2026-04-27 (run 2026-04-27-131311 +audit step)
- **Validação Zod nos endpoints autenticados** (`server/src/schemas/index.js` + middleware nos endpoints)
- `POST /api/data`: schema permissivo `z.object({data: z.record(z.string(), z.unknown())})` — aceita qualquer estrutura de objeto, rejeita payload sem `data` ou não-objeto
- `POST /api/share/:token/zones`: schema com até 100 zonas, cada uma com lat/lng/radius validados
- 400 com top 5 issues do Zod em caso de falha
- Implementado pelo squad `shivao-melhoria` em 2026-04-27 (run 2026-04-27-131311 +zod step)
- **Tamanho de upload reduzido pra 50 MB** (`server/src/index.js` linha 83)
- Multer agora retorna 413 (Payload Too Large) acima de 50MB
- Implementado pelo squad `shivao-melhoria` em 2026-04-27 (run 2026-04-27-131311)
- **Rate limiting** nos 3 endpoints públicos de share (`/api/share/:token/info`, `/api/share/:token/positions`, `/share/:token`)
- 60 req/min/IP via `express-rate-limit` v8 — cobre auto-refresh 15s da tripulação com margem 15×
- Aplicado em `server/src/index.js` linhas 38 (limiter), 262, 271, 279 (middleware)
- Implementado pelo squad `shivao-melhoria` em 2026-04-27 (run 2026-04-27-124638)
## ⚠️ O que precisa ser feito antes de produção
### 🔴 Crítico (segurança)
1. **CORS** atualmente permissivo (`*`) — apropriado para single-tenant pessoal,
mas se for evoluir para multi-usuário, precisa rever
- **Status:** decisão consciente de manter `*` enquanto for single-tenant. Item documentado, não-acionável agora.
### 🟡 Importante (qualidade)
3. **Testes automatizados** — projeto não tem testes
- Backend: vitest ou jest para os endpoints
- Frontend: playwright para fluxos críticos (especialmente alarme e GPS)
4. **Tratamento de erros no frontend**
- Várias chamadas async fazem `.catch(()=>{})` silenciosamente
- Falhas de sync não são reportadas ao usuário
5. **Reconexão da vigia** após app dormir
- Wake Lock pode falhar; GPS pode pausar quando tab inativo
- Investigar Service Worker + Background Sync API
6. **Refatoração do frontend**
- HTML monolítico tem ~3500 linhas
- Considerar quebrar em módulos (mas mantendo single-file deployment)
- Avaliar migrar para Vite + Vue/Svelte mantendo build single-file
7. **TypeScript no backend**
- Hoje JavaScript puro com JSDoc
- Migração para TS deixaria mais robusto
### 🟢 Bom ter
8. **Service Worker / PWA real**
- Hoje usa apenas meta tags + "Adicionar à tela inicial"
- Service Worker permitiria offline real, push notifications
9. **Backup automático para S3/Backblaze**
- Hoje SQLite + filesystem em volume Coolify
- Schedule de backup off-site
10. **Multi-usuário / multi-barco**
- Hoje single-tenant (um BOAT_TOKEN)
- Para escalar: introduzir users + boats + permissions
11. **Histórico de tracks de cada vigia de fundeio**
- Hoje só salva resumo (max drift, alarms)
- Salvar também a poligonal de movimento
12. **i18n**
- Hoje hardcoded em português
- Estrutura permite extrair strings facilmente
---
## 🐛 Bugs/limitações conhecidas
1. **iOS:** Battery API não disponível → modo "auto" sempre cai em normal.
Não é problema crítico, só não economiza bateria automaticamente.
2. **iOS:** Wake Lock funciona em Safari 16.4+. Em versões anteriores,
a tela apaga e o GPS pode parar.
3. **iOS:** Modo silencioso pode silenciar o som do alarme. Vibração
continua funcionando.
4. **Android:** Quando o navegador é fechado completamente, o GPS pára
(limitação do browser). O dead-man switch do servidor cobre isso.
5. **Modal de tracking** abre como modal mas seria melhor full-screen real.
Atualmente usa CSS hack com height:100vh.
6. **Geofencing** só suporta círculos, não polígonos. Para áreas
irregulares (canal estreito, baía com formato complexo) é uma limitação.
7. **Map tiles** vêm de tile.openstreetmap.org sem cache. Em viagem com
dados móveis caros, pode consumir muito. Considerar cache ou tiles
próprios (MapTiler, Mapbox).
8. **Não há sincronização incremental** — sempre envia/baixa o estado todo.
Para usuários com muito histórico, fica lento. Sugerimos versionar
por entidade (CRDT ou last-write-wins por id+ts).
9. **A chave Windy fica em localStorage** — se sincronizada via cloud,
trafega entre dispositivos do dono (OK, mas vale documentar).
10. **Sem HTTPS local em desenvolvimento** — a API do Geolocation só
funciona em HTTPS ou localhost. Devs precisam usar localhost.
---
## 📋 Decisões técnicas relevantes
Detalhes em **ARCHITECTURE.md**, mas em resumo:
- **SQLite + filesystem** em vez de Postgres + S3 → simplicidade,
single-tenant, fácil backup
- **Single-file HTML** → instalável no celular sem app store, deploy simples
- **Bearer Token simples** em vez de OAuth → uso pessoal
- **Leaflet + OSM** em vez de Google Maps/Mapbox → grátis, sem chaves
- **Web Audio API** para alarme em vez de mp3 estático → mais alto e confiável
- **Open-Meteo como fallback** → app funciona sem nenhuma chave de API
- **Windy** como provedor premium → o dono já tem assinatura
---
## 🚀 Para começar
Para colocar em produção:
1. Ver **DEPLOY.md** — passo-a-passo do Coolify
2. Configurar canais de notificação (mínimo: Telegram via .env)
3. Criar bot do Telegram + obter chat_id (instruções em DEPLOY.md)
4. Definir `BOAT_TOKEN` (32 chars aleatórios)
5. Volume `/data` persistente
6. Deploy
Para desenvolver localmente:
1. Ver **CONTRIBUTING.md**
2. `cp .env.example .env` e preencher
3. `npm install`
4. `npm start`
5. Acessar `http://localhost:3000`
Para o time do dono passar a chave Windy:
- A chave premium é configurada **no app** (não no servidor) via
Aba Arquivo → Meteorologia · Windy Point Forecast
- Não cole no código nem em variáveis de ambiente
---
## 📞 Contato
Projeto pessoal do dono do veleiro Shivao. Equipe de devs deve coordenar
diretamente com ele para credenciais de produção, domínios e orçamento.

100
PROJECT_CONTEXT.md Normal file
View file

@ -0,0 +1,100 @@
# PROJECT_CONTEXT — Histórico e contexto
> Documento informal sobre como o projeto chegou ao estado atual. Útil para
> a equipe entender as decisões e a evolução.
## Origem
O projeto começou simples: o dono do veleiro **Shivao** queria um diário de
bordo digital para registrar viagens, manutenções, horímetro do motor e
passageiros, com possibilidade de exportar e compartilhar.
A primeira versão foi um HTML standalone com localStorage — funcional, mas
limitado. A partir daí, foi crescendo iterativamente:
## Linha do tempo
1. **v1**: HTML básico — viagens, manutenções, exportar JSON/CSV
2. **v2**: Adicionado uso no Android, fotos da câmera, áudio (MediaRecorder),
vídeo, lembretes de manutenção futura, lista de manutenções a fazer com
custos. Migrado armazenamento de mídia para IndexedDB.
3. **v3**: GPS — detecção de posição inicial, rastreio em tempo real,
cálculo de milhas náuticas e velocidade em nós, mapa Leaflet com OSM.
4. **v4 (design)**: Refatoração visual completa — adoção da estética
"marítimo editorial" com Fraunces (serif itálico), Manrope, JetBrains Mono,
paleta pergaminho/marinha/latão.
5. **v5**: Vigia de fundeio com alarme sonoro forte, vibração, botões para
disparar mensagens de WhatsApp/SMS/e-mail (limitação: browser não envia
automaticamente, requer 1 toque por contato).
6. **v6**: Servidor próprio (Node.js + SQLite + Docker para Coolify VPS)
com sincronização na nuvem, dead-man switch, e fan-out automático de
notificações para múltiplos canais (Telegram, ntfy, e-mail SMTP, Twilio,
webhook).
7. **v7**: Webhooks diretos do app (Telegram, Discord, genérico) sem
precisar do servidor; histórico de fundeios com mapa de cada sessão;
compensação de maré/corrente via "swing circle" (centro de giro
independente da posição da âncora) com auto-recentro.
8. **v8**: Geofencing com zonas de proibição/atenção; compartilhamento
público de posição em tempo real (link temporário com mapa Leaflet);
importação de tracks GPX.
9. **v9**: Exportação GPX por viagem; modo economia de energia (Battery API);
previsão meteorológica (Open-Meteo); checklists de bordo customizáveis.
10. **v10 (atual)**: Migração da meteorologia para Windy Point Forecast API
(premium key do dono) com cálculo de vento a partir de componentes
`wind_u`/`wind_v` e ondas em modelo `gfsWave` paralelo. Open-Meteo mantido
como fallback.
## Princípios mantidos durante a evolução
- **Single-tenant**: o app é pessoal, do dono. Sem necessidade de gestão de
usuários, login complexo, etc.
- **Offline-first**: o app funciona 100% sem o servidor. O servidor é uma
camada de robustez, não dependência.
- **Single-file frontend**: facilita distribuição, instalação no celular e
uso offline.
- **Single binary backend**: 1 container Docker, 1 banco SQLite, 1 volume.
Deploy trivial no Coolify.
- **Estética cuidada**: o app não é um "app de app store qualquer" — tem
identidade visual própria, faz sentido para um barco de verdade.
- **Sem dark patterns**: zero analytics, zero tracking, zero ads. Tudo do
dono fica no servidor do dono.
## O que NÃO foi feito por opção
- **Login/registro de usuários**: por ser single-tenant pessoal.
- **App nativo iOS/Android**: maior complexidade, sem ganho proporcional para
uso pessoal. PWA atende.
- **Service Worker**: ficou como TODO. App funciona offline-first mas não é
PWA full ainda.
- **Push notifications via FCM**: para o uso anchor watch, polling com
heartbeat é melhor. Para outros casos, ntfy resolve.
- **TypeScript**: por simplicidade. Equipe pode migrar se quiser.
- **Build step**: para manter o "1 arquivo HTML" como deliverável principal.
## Limitações conhecidas e aceitas
(coisas que conscientemente deixamos como estão)
- Frontend monolítico em um único arquivo HTML
- API do servidor sobrescreve estado (não merge — vence o último write)
- Mensagens automáticas SMS/WhatsApp dependem do servidor (Twilio pago) ou
do tap manual do usuário no celular
- iOS tem limitações em algumas APIs (Battery, alguns modos de áudio com
silencioso ativado)
## Para o time de devs
O dono está confortável em deixar vocês continuarem a evolução. Pontos
importantes:
1. **Não compliquem prematuramente**. O app funciona. Adicione complexidade
só quando ela se justifica.
2. **Mantenham a estética**. A identidade visual é parte da experiência.
3. **Test em condições reais**. Bateria fraca, sinal fraco, sol forte, mãos
molhadas — esses são os cenários reais.
4. **Coordenem com o dono** antes de mudanças grandes (refatorações, mudanças
de stack, novos custos recorrentes).
5. **Backups são sagrados**. Antes de qualquer mudança no schema, garantir
que os dados existentes do dono não se perdem.
Boa sorte! 🌊⛵

105
README.md Normal file
View file

@ -0,0 +1,105 @@
# Shivao · Diário de Bordo
Aplicativo web para registro de viagens, manutenções e segurança de um veleiro
(em uso pelo veleiro **Shivao**), com recursos de GPS, vigia de fundeio com
alarme remoto, geofencing, sincronização na nuvem e notificações automáticas.
## Estrutura do projeto
```
shivao-projeto/
├── README.md ← este arquivo (visão geral)
├── HANDOFF.md ← estado atual + pendências para o time
├── DEPLOY.md ← guia de deploy no Coolify (Hetzner VPS)
├── API.md ← referência completa da API REST
├── ARCHITECTURE.md ← decisões técnicas e fluxos
├── BACKLOG.md ← melhorias futuras sugeridas
├── CONTRIBUTING.md ← convenções, como rodar localmente
├── app/
│ └── diario-bordo.html ← FRONTEND completo (HTML standalone, ~200 KB)
└── server/
├── Dockerfile ← imagem Node 20 alpine
├── docker-compose.yml
├── package.json
├── .env.example ← template de variáveis de ambiente
├── README.md ← documentação focada no backend
├── public/
│ └── index.html ← cópia do frontend (servido pelo backend)
└── src/
├── index.js ← entrypoint Express
├── db.js ← SQLite (better-sqlite3)
└── notifications.js ← Telegram, ntfy, e-mail, Twilio, webhook
```
## Visão geral em 30 segundos
O **frontend** é um único arquivo HTML que funciona offline-first e pode ser
"instalado" no Android via "Adicionar à tela inicial" do Chrome. Armazena
tudo em **localStorage** (dados estruturados) e **IndexedDB** (mídias).
O **backend opcional** é um servidor Node.js com SQLite que provê:
- **Sincronização na nuvem** dos dados entre dispositivos
- **Dead-man switch**: se o app pára de mandar heartbeat enquanto fundeado,
o servidor dispara o alarme sozinho
- **Notificações fan-out**: uma chamada de alarme do app → e-mail, Telegram,
ntfy, SMS, WhatsApp para todos os contatos configurados
- **Compartilhamento público**: link temporário que mostra a posição do barco
ao vivo num mapa para a tripulação em terra
- **Mídia**: upload e armazenamento de fotos/áudios/vídeos do app
## Funcionalidades implementadas
### Navegação e segurança
- ⛵ Registro de **travessias** com tripulação, datas, horímetro, vento, distância, observações
- 🛰 **Rastreio GPS** em tempo real com mapa Leaflet, distância em milhas náuticas, velocidade em nós
- ⚓ **Vigia de fundeio** com âncora + centro de giro independente, raio editável, auto-recentro,
alarme sonoro (Web Audio) + vibração + tela vermelha em caso de deriva
- 🚧 **Geofencing** com zonas de proibição (alarme) e atenção (aviso), detecção em tempo real
- 📍 **Compartilhamento público** com URL temporária para a tripulação ver no mapa
### Manutenção
- 🔧 Registro de **reparos** com horímetro, custo, prestador, fotos, notas fiscais
- 📋 **Lista de pendências** com data prevista OU horímetro alvo, prioridades, custo estimado
- 🔔 **Alertas** automáticos quando manutenção está próxima ou atrasada
- 📋 **Checklists** customizáveis (segurança, motor, vela, fundeio, travessia longa)
### Mídia
- 📷 Foto da câmera (input com `capture`)
- 🎙 Áudio com gravador embutido (MediaRecorder)
- 🎥 Vídeo da câmera ou galeria
- Visualização em tela cheia, download, exclusão
### Tempo
- 🌬 **Windy Point Forecast API** (chave premium) para vento, ondas, temperatura
- 🌊 Fallback para Open-Meteo (grátis, sem chave)
- ⚡ **Modo economia de energia** que ajusta GPS conforme nível de bateria
### Cloud
- ☁️ Sync automático com servidor próprio
- 🔄 Webhooks diretos do app: Telegram, Discord, genérico
- 🚨 Dead-man switch via servidor
- 📤 Compartilhamento de posição em tempo real
### Import/export
- 📥 **Importar GPX** de chartplotter, Navionics, Garmin, etc.
- 📤 **Exportar GPX** de cada travessia
- 📦 Backup/restore JSON completo (com mídias em base64)
- 📊 Export CSV de viagens, manutenções, pendências
- 🖨 Imprimir/PDF
### UX
- Design **maritime editorial** (Fraunces serif itálico + Manrope + JetBrains Mono)
- Paleta pergaminho/marinha/latão envelhecido
- Mobile-first, instalável como PWA
- Suporte a viewport seguro (safe-area-inset)
## Próximo passo
Ver **HANDOFF.md** para o estado atual e o que falta para entrar em produção.
---
Projeto pessoal — uso interno do veleiro Shivao.

3468
app/diario-bordo.html Normal file

File diff suppressed because it is too large Load diff

5
server/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
.env
data/
*.log
.DS_Store

31
server/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM node:20-alpine
# python + build tools needed for better-sqlite3 native module
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Install deps first for cached layer
COPY package.json ./
RUN npm install --omit=dev
# App code
COPY src ./src
COPY public ./public
# Persistent data dir
RUN mkdir -p /data/media && chown -R node:node /data
VOLUME ["/data"]
USER node
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "src/index.js"]

184
server/README.md Normal file
View file

@ -0,0 +1,184 @@
# Shivao Cloud
Backend para o Diário de Bordo do **Shivao** com:
- ☁️ **Sincronização** dos dados na nuvem (multi-dispositivo)
- 📷 **Mídia** (fotos, áudios, vídeos) armazenada no servidor
- 🚨 **Notificações automáticas** (Telegram, e-mail, SMS, WhatsApp, ntfy, webhook) — sem precisar tocar em botão
- 🛡️ **Dead-man switch**: se o celular perde sinal/bateria enquanto fundeado, o **servidor mesmo dispara o alarme**
---
## Deploy no Coolify (passo a passo)
### 1. Subir o código para um repositório Git
Crie um repo no GitHub/GitLab e envie esta pasta. Ou use o "Public Repository" do Coolify direto.
### 2. No painel Coolify
1. **+ New Resource** → **Application** → **Public/Private Repository**
2. Cole a URL do seu repositório
3. **Build Pack**: `Dockerfile`
4. **Port**: `3000`
5. **Domain**: configure seu domínio (ex: `shivao.seu-dominio.com`) — Coolify gera SSL automático
### 3. Variáveis de ambiente
Em **Environment Variables** no Coolify, adicione (mínimo):
| Variável | Valor |
|----------|-------|
| `BOAT_TOKEN` | string aleatória longa (ex: `openssl rand -hex 32`) |
| `TELEGRAM_BOT_TOKEN` | seu token do BotFather |
| `TELEGRAM_CHAT_IDS` | seu chat ID |
Veja `.env.example` para todas as opções (e-mail, SMS, etc.). **Configure pelo menos um canal**.
### 4. Volume persistente
Em **Storages****Persistent Storage**:
- **Name**: `shivao-data`
- **Mount Path**: `/data`
Sem isso, ao reiniciar o container você perde o banco e as mídias.
### 5. Deploy
Clique em **Deploy**. Aguarde o build. Acesse `https://shivao.seu-dominio.com` — vai aparecer o app.
---
## Configurando o Telegram (5 minutos, recomendado)
1. No Telegram, fale com [@BotFather](https://t.me/BotFather) → `/newbot`
2. Escolha um nome e username → ele dá um **token**
3. Inicie uma conversa com o bot que você criou (mande qualquer mensagem)
4. Acesse `https://api.telegram.org/bot<SEU_TOKEN>/getUpdates` no navegador
5. No JSON, procure `"chat":{"id":123456789...}` — esse é seu `TELEGRAM_CHAT_IDS`
6. Cole no Coolify e redeploy
Para receber em **grupo**: adicione o bot ao grupo, mande uma mensagem, repita o passo 4 (chat ID será negativo).
---
## Configurando ntfy.sh (sem cadastro, push grátis)
1. Instale o app **ntfy** ([Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [iOS](https://apps.apple.com/us/app/ntfy/id1625396347))
2. No app, **Subscribe** → invente um tópico **único e secreto** (ex: `shivao-aljg29x71kqp`)
3. Coloque esse mesmo nome em `NTFY_TOPIC` no Coolify
⚠️ Como ntfy é público, qualquer pessoa que adivinhe o tópico vê suas notificações. Use algo aleatório e longo.
---
## Configurando E-mail SMTP
**Gmail (mais comum)**:
1. Ative **verificação em 2 etapas** na conta Google
2. Vá em [Senhas de app](https://myaccount.google.com/apppasswords)
3. Gere uma senha → use ela em `SMTP_PASS`
4. Configure:
```
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=seu-email@gmail.com
SMTP_PASS=senha-de-app-de-16-caracteres
SMTP_FROM=Shivao Alertas <seu-email@gmail.com>
SMTP_TO=destinatario@email.com,outro@email.com
```
**Resend, SendGrid, Mailgun**: similar, basta seguir o painel deles e usar SMTP.
---
## Configurando Twilio (SMS / WhatsApp — pago)
Apenas se quiser SMS ou WhatsApp realmente automático.
1. Crie conta em [twilio.com](https://www.twilio.com)
2. Para SMS: compre um número (Brasil custa ~$1/mês + $0.05 por SMS)
3. Para WhatsApp: ative o sandbox grátis OU peça aprovação Business
```
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxx
TWILIO_FROM_NUMBER=+15551234567
TWILIO_SMS_TO=+5521999998888
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
TWILIO_WHATSAPP_TO=whatsapp:+5521999998888
```
---
## Como funciona o Dead-Man Switch
1. Quando você toca em **"Fundear"** no app, ele avisa o servidor
2. Enquanto a vigia está ativa, o app envia um **heartbeat** a cada 30 segundos: "estou vivo, GPS aqui"
3. Se o servidor não receber heartbeat por 5 minutos (`HEARTBEAT_TIMEOUT_SEC` configurável), ele assume que algo deu errado (celular morreu, perdeu sinal, app fechou) e **dispara o alarme remoto** automaticamente para todos os contatos
4. Quando você levanta âncora normalmente, o app avisa o servidor para parar a vigia
Isso garante que mesmo se você dormir e o celular morrer, **alguém vai ser avisado**.
---
## Endpoints da API (referência)
Todas requerem header `Authorization: Bearer <BOAT_TOKEN>` (exceto `/api/health`).
| Método | Rota | Função |
|--------|------|--------|
| GET | `/api/health` | Status público |
| GET | `/api/info` | Canais configurados |
| GET | `/api/data` | Baixar todo o estado |
| POST | `/api/data` | Enviar todo o estado |
| POST | `/api/media` | Upload de arquivo (multipart) |
| GET | `/api/media/list` | Lista de mídias |
| GET | `/api/media/:id` | Baixar mídia |
| DELETE | `/api/media/:id` | Apagar mídia |
| POST | `/api/anchor/start` | Iniciar vigia no servidor |
| POST | `/api/anchor/heartbeat` | Manter vivo |
| POST | `/api/anchor/alarm` | Disparar alarme imediato |
| POST | `/api/anchor/stop` | Encerrar vigia |
| GET | `/api/anchor/status` | Estado atual |
| POST | `/api/test` | Disparar mensagem de teste |
| GET | `/api/alarms` | Histórico de alarmes |
| POST | `/api/share/create` | Criar link público temporário |
| GET | `/api/share/list` | Listar shares ativos |
| DELETE | `/api/share/:token` | Revogar share |
| POST | `/api/share/position` | Postar posição (boat → server) |
| GET | `/share/:token` | **Página pública do link** (sem auth) |
| GET | `/api/share/:token/info` | Info do share (público) |
| GET | `/api/share/:token/positions` | Posições do share (público) |
---
## Rodando localmente (dev)
```bash
cp .env.example .env
# edite .env
npm install
npm start
```
Ou com Docker:
```bash
docker compose up --build
```
Acesse `http://localhost:3000`
---
## Conectando o app
No app (Diário de Bordo), vá em **Arquivo → Configurações da Nuvem**, preencha:
- **Servidor**: `https://shivao.seu-dominio.com`
- **Token**: o mesmo `BOAT_TOKEN` que você definiu
Toque em **Testar conexão** → deve receber a mensagem de teste em todos os canais configurados.
A partir daí o app sincroniza automaticamente.

18
server/docker-compose.yml Normal file
View file

@ -0,0 +1,18 @@
services:
shivao-cloud:
build: .
container_name: shivao-cloud
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- shivao_data:/data
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=3000
- DATA_DIR=/data
volumes:
shivao_data:

1479
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
server/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "shivao-cloud",
"version": "1.0.0",
"description": "Backend de nuvem para o Diário de Bordo do Shivao",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0",
"express-rate-limit": "^8.4.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15",
"zod": "^4.3.6"
},
"license": "MIT"
}

3468
server/public/index.html Normal file

File diff suppressed because it is too large Load diff

213
server/src/db.js Normal file
View file

@ -0,0 +1,213 @@
import Database from 'better-sqlite3';
import path from 'node:path';
import fs from 'node:fs';
const DATA_DIR = process.env.DATA_DIR || './data';
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(path.join(DATA_DIR, 'media'), { recursive: true });
const db = new Database(path.join(DATA_DIR, 'shivao.db'));
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.exec(`
CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
parent_id TEXT,
parent_type TEXT,
kind TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
filename TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_media_parent ON media(parent_id);
CREATE TABLE IF NOT EXISTS anchor_session (
id INTEGER PRIMARY KEY CHECK (id = 1),
active INTEGER NOT NULL DEFAULT 0,
boat_name TEXT,
anchor_lat REAL,
anchor_lng REAL,
radius INTEGER,
started_at INTEGER,
last_heartbeat INTEGER,
last_lat REAL,
last_lng REAL,
last_distance REAL,
alarm_fired INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS alarm_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT,
sent TEXT,
failed TEXT
);
CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY,
boat_name TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
revoked INTEGER DEFAULT 0,
zones TEXT
);
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
CREATE TABLE IF NOT EXISTS share_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
speed REAL DEFAULT 0,
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sp_token_ts ON share_positions(token, ts DESC);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
action TEXT NOT NULL,
entity TEXT,
entity_id TEXT,
summary TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC);
`);
// migration: add zones column if missing
try {
const cols = db.prepare("PRAGMA table_info(shares)").all();
if (!cols.some(c => c.name === 'zones')) {
db.exec('ALTER TABLE shares ADD COLUMN zones TEXT');
}
} catch (e) { /* ignore */ }
// ---- State (whole app data as a single JSON blob) ----
export function getState() {
const row = db.prepare('SELECT data, updated_at FROM state WHERE id = 1').get();
if (!row) return { data: null, updated_at: 0 };
return { data: JSON.parse(row.data), updated_at: row.updated_at };
}
export function setState(data) {
const json = JSON.stringify(data);
const now = Date.now();
db.prepare(`
INSERT INTO state (id, data, updated_at) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(json, now);
return now;
}
// ---- Media metadata ----
export function insertMedia(m) {
db.prepare(`
INSERT INTO media (id, parent_id, parent_type, kind, mime, size, filename, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(m.id, m.parent_id || null, m.parent_type || null, m.kind, m.mime, m.size, m.filename, m.created_at || Date.now());
}
export function listMedia() {
return db.prepare('SELECT * FROM media ORDER BY created_at DESC').all();
}
export function getMedia(id) {
return db.prepare('SELECT * FROM media WHERE id = ?').get(id);
}
export function deleteMedia(id) {
return db.prepare('DELETE FROM media WHERE id = ?').run(id);
}
// ---- Anchor session ----
export function getAnchor() {
return db.prepare('SELECT * FROM anchor_session WHERE id = 1').get();
}
export function setAnchor(a) {
const cur = getAnchor();
if (cur) {
db.prepare(`UPDATE anchor_session SET active=?, boat_name=?, anchor_lat=?, anchor_lng=?, radius=?, started_at=?, last_heartbeat=?, last_lat=?, last_lng=?, last_distance=?, alarm_fired=? WHERE id=1`)
.run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0);
} else {
db.prepare(`INSERT INTO anchor_session (id, active, boat_name, anchor_lat, anchor_lng, radius, started_at, last_heartbeat, last_lat, last_lng, last_distance, alarm_fired) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0);
}
}
export function clearAnchor() {
db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE id=1').run();
}
export function updateHeartbeat(lat, lng, dist) {
db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE id=1')
.run(Date.now(), lat, lng, dist);
}
export function setAlarmFired(fired) {
db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE id=1').run(fired ? 1 : 0);
}
// ---- Alarm log ----
export function logAlarm(type, payload, sent, failed) {
db.prepare('INSERT INTO alarm_log (ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?)')
.run(Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || []));
}
export function recentAlarms(limit = 50) {
return db.prepare('SELECT * FROM alarm_log ORDER BY ts DESC LIMIT ?').all(limit);
}
// ---- Shares ----
export function createShare(token, boatName, expiresAt, zones) {
db.prepare('INSERT INTO shares (token, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?)')
.run(token, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null);
}
export function updateShareZones(token, zones) {
db.prepare('UPDATE shares SET zones = ? WHERE token = ?').run(zones ? JSON.stringify(zones) : null, token);
}
export function getShare(token) {
return db.prepare('SELECT * FROM shares WHERE token = ?').get(token);
}
export function listActiveShares() {
const now = Date.now();
return db.prepare('SELECT * FROM shares WHERE revoked = 0 AND expires_at > ? ORDER BY created_at DESC').all(now);
}
export function revokeShare(token) {
return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ?').run(token);
}
export function addSharePosition(token, lat, lng, speed) {
db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)')
.run(token, lat, lng, speed || 0, Date.now());
// mantém apenas últimas 500 posições por share
db.prepare(`DELETE FROM share_positions WHERE token = ? AND id NOT IN (SELECT id FROM share_positions WHERE token = ? ORDER BY ts DESC LIMIT 500)`).run(token, token);
}
export function getSharePositions(token, limit = 500) {
return db.prepare('SELECT lat, lng, speed, ts FROM share_positions WHERE token = ? ORDER BY ts ASC LIMIT ?').all(token, limit);
}
export function cleanupExpiredShares() {
const now = Date.now();
// delete positions of shares that expired more than 7 days ago
const cutoff = now - 7 * 24 * 3600 * 1000;
const toDelete = db.prepare('SELECT token FROM shares WHERE expires_at < ? OR revoked = 1').all(cutoff).map(r => r.token);
for (const t of toDelete) {
db.prepare('DELETE FROM share_positions WHERE token = ?').run(t);
db.prepare('DELETE FROM shares WHERE token = ?').run(t);
}
return toDelete.length;
}
// ---- Audit log (ações sensíveis: who/what/when para investigação de incidentes) ----
export function audit(action, entity, entityId, summary, ip) {
db.prepare('INSERT INTO audit_log (ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?)')
.run(Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null);
}
export function recentAudit(limit = 100) {
return db.prepare('SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?').all(limit);
}
export const dataDir = DATA_DIR;
export default db;

439
server/src/index.js Normal file
View file

@ -0,0 +1,439 @@
import express from 'express';
import multer from 'multer';
import rateLimit from 'express-rate-limit';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as db from './db.js';
import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js';
import { validate, setStateSchema, updateZonesSchema } from './schemas/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000');
const TOKEN = process.env.BOAT_TOKEN;
const HEARTBEAT_TIMEOUT = parseInt(process.env.HEARTBEAT_TIMEOUT_SEC || '300') * 1000;
if (!TOKEN || TOKEN.length < 16) {
console.error('ERRO: BOAT_TOKEN não configurado ou muito curto. Defina no .env (mínimo 16 chars).');
process.exit(1);
}
const app = express();
app.use(express.json({ limit: '10mb' }));
app.disable('x-powered-by');
app.set('trust proxy', 1);
// CORS — single owner app, allow all origins so PWA on any device works
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS,PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Rate limit para endpoints PÚBLICOS de share.
// 60 req/min/IP cobre auto-refresh do frontend público (4/min/usuário) com margem ~15× pra
// tripulação compartilhar IP NAT (família/marina). Atacante real precisaria 1000+ IPs distintos.
// Confiamos no `app.set('trust proxy', 1)` acima pra extrair o IP real atrás do Coolify/nginx.
const publicShareLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, slow down.' },
});
// Auth middleware
function requireAuth(req, res, next) {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (token !== TOKEN) return res.status(401).json({ error: 'Unauthorized' });
next();
}
// ==== Public endpoints ====
app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
// ==== Static frontend ====
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
// ==== Authenticated API ====
// Server info (channels configured, version)
app.get('/api/info', requireAuth, (req, res) => {
res.json({
channels: listConfiguredChannels(),
heartbeatTimeoutSec: HEARTBEAT_TIMEOUT / 1000,
version: '1.0'
});
});
// --- State sync (whole JSON blob) ---
app.get('/api/data', requireAuth, (req, res) => {
const s = db.getState();
res.json(s);
});
app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
const { data } = req.body;
const ts = db.setState(data);
db.audit('state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
res.json({ ok: true, updated_at: ts });
});
// --- Media ---
const mediaDir = path.join(db.dataDir, 'media');
const upload = multer({
storage: multer.diskStorage({
destination: mediaDir,
filename: (req, file, cb) => {
const id = req.body.id || ('m_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7));
const ext = (file.mimetype.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '');
cb(null, `${id}.${ext}`);
}
}),
limits: { fileSize: 50 * 1024 * 1024 }
});
app.post('/api/media', requireAuth, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
const id = req.body.id || path.parse(req.file.filename).name;
const meta = {
id,
parent_id: req.body.parent_id || null,
parent_type: req.body.parent_type || null,
kind: req.body.kind || 'photo',
mime: req.file.mimetype,
size: req.file.size,
filename: req.file.filename,
created_at: parseInt(req.body.created_at) || Date.now()
};
// remove existing if any (overwrite)
const ex = db.getMedia(id);
if (ex && ex.filename !== meta.filename) {
try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {}
db.deleteMedia(id);
} else if (ex) {
db.deleteMedia(id);
}
db.insertMedia(meta);
db.audit('media_insert', 'media', id, { kind: meta.kind, mime: meta.mime, size: meta.size }, req.ip);
res.json({ ok: true, id, url: `/api/media/${id}` });
});
app.get('/api/media/list', requireAuth, (req, res) => {
res.json(db.listMedia().map(m => ({
id: m.id, parent_id: m.parent_id, parent_type: m.parent_type,
kind: m.kind, mime: m.mime, size: m.size, created_at: m.created_at
})));
});
app.get('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id);
if (!m) return res.status(404).json({ error: 'not found' });
const filepath = path.join(mediaDir, m.filename);
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'file missing' });
res.setHeader('Content-Type', m.mime);
res.setHeader('Cache-Control', 'private, max-age=31536000');
fs.createReadStream(filepath).pipe(res);
});
app.delete('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id);
if (!m) return res.status(404).json({ error: 'not found' });
try { fs.unlinkSync(path.join(mediaDir, m.filename)); } catch (e) {}
db.deleteMedia(req.params.id);
db.audit('media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip);
res.json({ ok: true });
});
// --- Anchor watch (with dead-man-switch) ---
app.post('/api/anchor/start', requireAuth, (req, res) => {
const { boat_name, anchor_lat, anchor_lng, radius } = req.body;
if (typeof anchor_lat !== 'number' || typeof anchor_lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' });
db.setAnchor({
active: true,
boat_name: boat_name || 'Veleiro',
anchor_lat, anchor_lng,
radius: radius || 50,
started_at: Date.now(),
last_heartbeat: Date.now(),
last_lat: anchor_lat,
last_lng: anchor_lng,
last_distance: 0,
alarm_fired: false
});
res.json({ ok: true });
});
app.post('/api/anchor/heartbeat', requireAuth, (req, res) => {
const { lat, lng, distance } = req.body;
db.updateHeartbeat(lat, lng, distance || 0);
const a = db.getAnchor();
res.json({ ok: true, active: !!a?.active });
});
app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
const a = db.getAnchor();
const payload = {
boat: req.body.boat_name || a?.boat_name || 'Veleiro',
lat: req.body.lat ?? a?.last_lat,
lng: req.body.lng ?? a?.last_lng,
distance: req.body.distance ?? a?.last_distance,
radius: req.body.radius ?? a?.radius,
reason: req.body.reason || 'drift',
ts: Date.now()
};
db.setAlarmFired(true);
const result = await dispatchAlarm(payload);
db.logAlarm('drift', payload, result.sent, result.failed);
res.json(result);
});
app.post('/api/anchor/stop', requireAuth, (req, res) => {
db.clearAnchor();
res.json({ ok: true });
});
app.get('/api/anchor/status', requireAuth, (req, res) => {
res.json(db.getAnchor() || { active: 0 });
});
// --- Test endpoint ---
app.post('/api/test', requireAuth, async (req, res) => {
const result = await dispatchTest();
db.logAlarm('test', {}, result.sent, result.failed);
res.json(result);
});
app.get('/api/alarms', requireAuth, (req, res) => {
res.json(db.recentAlarms(50));
});
// Auditoria (consulta autenticada — quem fez o quê e quando nos endpoints sensíveis)
app.get('/api/audit', requireAuth, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
res.json(db.recentAudit(limit));
});
// ==== LIVE SHARE ====
import crypto from 'node:crypto';
app.post('/api/share/create', requireAuth, (req, res) => {
const { durationMinutes, boatName, zones } = req.body;
if (!durationMinutes || durationMinutes < 1 || durationMinutes > 30 * 24 * 60)
return res.status(400).json({ error: 'invalid duration' });
const token = crypto.randomBytes(12).toString('base64url');
const expiresAt = Date.now() + durationMinutes * 60 * 1000;
db.createShare(token, boatName || 'Shivao', expiresAt, zones);
db.audit('share_create', 'share', token, { boatName: boatName || 'Shivao', expiresAt, zonesCount: Array.isArray(zones) ? zones.length : 0 }, req.ip);
const proto = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.headers['x-forwarded-host'] || req.headers.host;
const url = `${proto}://${host}/share/${token}`;
res.json({ token, expiresAt, url });
});
app.get('/api/share/list', requireAuth, (req, res) => {
res.json(db.listActiveShares().map(s => ({
token: s.token, boatName: s.boat_name, expiresAt: s.expires_at, createdAt: s.created_at
})));
});
app.delete('/api/share/:token', requireAuth, (req, res) => {
db.revokeShare(req.params.token);
db.audit('share_revoke', 'share', req.params.token, {}, req.ip);
res.json({ ok: true });
});
app.post('/api/share/:token/zones', requireAuth, validate(updateZonesSchema), (req, res) => {
const { zones } = req.body;
db.updateShareZones(req.params.token, zones);
db.audit('share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip);
res.json({ ok: true });
});
app.post('/api/share/position', requireAuth, (req, res) => {
const { lat, lng, speed, boatName } = req.body;
if (typeof lat !== 'number' || typeof lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' });
// posta para todos os shares ativos do barco
const active = db.listActiveShares();
let posted = 0;
for (const s of active) {
if (boatName && s.boat_name && s.boat_name !== boatName) continue;
db.addSharePosition(s.token, lat, lng, speed);
posted++;
}
res.json({ ok: true, posted });
});
// ==== PUBLIC share endpoints (no auth) ====
app.get('/api/share/:token/info', publicShareLimiter, (req, res) => {
const s = db.getShare(req.params.token);
if (!s || s.revoked || s.expires_at < Date.now())
return res.status(404).json({ error: 'not found or expired' });
let zones = null;
if (s.zones) { try { zones = JSON.parse(s.zones); } catch (e) {} }
res.json({ boatName: s.boat_name, expiresAt: s.expires_at, zones });
});
app.get('/api/share/:token/positions', publicShareLimiter, (req, res) => {
const s = db.getShare(req.params.token);
if (!s || s.revoked || s.expires_at < Date.now())
return res.status(404).json({ error: 'not found or expired' });
const positions = db.getSharePositions(req.params.token, 500);
res.json(positions);
});
app.get('/share/:token', publicShareLimiter, (req, res) => {
const s = db.getShare(req.params.token);
if (!s || s.revoked) return res.status(404).type('html').send(sharePage(null, 'Link inválido ou revogado'));
if (s.expires_at < Date.now()) return res.status(410).type('html').send(sharePage(null, 'Link expirado'));
res.type('html').send(sharePage(s, null));
});
function sharePage(share, errorMsg) {
if (errorMsg) {
return `<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Indisponível</title><style>body{font-family:system-ui,sans-serif;background:#0e2a3d;color:#faf2dd;margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}.box{text-align:center;max-width:380px}.box h1{font-family:Georgia,serif;font-style:italic;font-size:32px;margin:0 0 8px;color:#c89f54}.box p{opacity:.85;line-height:1.6}</style></head><body><div class="box"><h1>Shivao</h1><p>${errorMsg}</p></div></body></html>`;
}
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0e2a3d">
<title>${escapeHtml(share.boat_name || 'Shivao')} ao vivo</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0e2a3d;color:#faf2dd}
.bar{background:#0e2a3d;color:#faf2dd;padding:14px 16px;border-bottom:1px solid #6f5217;box-shadow:0 1px 0 #a07832 inset;display:flex;justify-content:space-between;align-items:center;gap:10px}
.bar-left h1{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-weight:500;font-size:22px;color:#c89f54;line-height:1}
.bar-left .sub{font-family:'JetBrains Mono','Courier New',monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#a07832;margin-top:3px}
.bar-right{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.04em;text-align:right;color:#a07832}
.bar-right .live::before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:#ff4444;margin-right:5px;animation:p 1.4s infinite}
@keyframes p{50%{opacity:.3}}
#map{height:calc(100vh - 56px);width:100%;background:#1a2733}
.empty-msg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(14,42,61,.9);color:#c89f54;padding:18px 24px;font-family:Georgia,serif;font-style:italic;text-align:center;border:1px solid #a07832}
</style>
</head>
<body>
<div class="bar">
<div class="bar-left"><h1>${escapeHtml(share.boat_name || 'Shivao')}</h1><div class="sub">posição ao vivo</div></div>
<div class="bar-right"><div class="live">AO VIVO</div><div id="last-update">aguardando</div></div>
</div>
<div id="map"></div>
<script>
const TOKEN=${JSON.stringify(req.params.token)};
const map=L.map('map',{zoomControl:true});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(map);
let trail=null,marker=null,fitDone=false;
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#c89f54" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
async function refresh(){
try{
// info (com zonas) na primeira vez
if(!window._zonesLoaded){
try{
const i=await fetch('/api/share/'+TOKEN+'/info');
if(i.ok){
const info=await i.json();
if(info.zones&&Array.isArray(info.zones)){
info.zones.forEach(z=>{
const color=z.type==='forbidden'?'#8c3434':'#b67025';
L.circle([z.center.lat,z.center.lng],{radius:z.radius,color,fillColor:color,fillOpacity:.15,weight:1.5,opacity:.6}).addTo(map).bindPopup(z.name+' · '+(z.type==='forbidden'?'Proibida':'Atenção'));
});
if(info.zones.length&&!fitDone){
const bounds=L.latLngBounds(info.zones.map(z=>[z.center.lat,z.center.lng]));
map.fitBounds(bounds,{padding:[40,40],maxZoom:13});
}
}
window._zonesLoaded=true;
}
}catch(e){}
}
const r=await fetch('/api/share/'+TOKEN+'/positions');
if(!r.ok){
if(r.status===404||r.status===410){
document.body.innerHTML='<div class="empty-msg">Link expirado ou revogado</div>';
return;
}
throw new Error('HTTP '+r.status);
}
const ps=await r.json();
if(!ps.length){
document.getElementById('last-update').textContent='sem posição';
if(!document.querySelector('.empty-msg')){
const e=document.createElement('div');e.className='empty-msg';e.textContent='Aguardando primeira posição…';document.body.appendChild(e);
}
return;
}
document.querySelectorAll('.empty-msg').forEach(e=>e.remove());
const last=ps[ps.length-1];
const ll=ps.map(p=>[p.lat,p.lng]);
if(trail)trail.remove();
if(ps.length>1)trail=L.polyline(ll,{color:'#a07832',weight:3,opacity:.6}).addTo(map);
if(!marker)marker=L.marker([last.lat,last.lng],{icon:boatIcon}).addTo(map);
else marker.setLatLng([last.lat,last.lng]);
if(!fitDone){fitDone=true;if(ps.length>1)map.fitBounds(ll,{padding:[40,40]});else map.setView([last.lat,last.lng],14)}
const ago=Math.round((Date.now()-last.ts)/1000);
const agoTxt=ago<60?ago+'s':ago<3600?Math.round(ago/60)+'min':Math.round(ago/3600)+'h';
const spd=last.speed?(last.speed*1.94384).toFixed(1)+' kn · ':'';
document.getElementById('last-update').textContent=spd+'há '+agoTxt;
}catch(e){console.error(e)}
}
refresh();
setInterval(refresh,15000);
</script>
</body>
</html>`;
}
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c])}
// cleanup expired shares once a day
setInterval(() => {
try {
const n = db.cleanupExpiredShares();
if (n) console.log(`[cleanup] removidos ${n} shares expirados`);
} catch (e) { console.warn(e); }
}, 24 * 3600 * 1000);
// ==== Dead-man switch background check ====
let lastDeadmanFire = 0;
async function checkDeadman() {
const a = db.getAnchor();
if (!a || !a.active) return;
const now = Date.now();
const since = now - (a.last_heartbeat || a.started_at);
if (since < HEARTBEAT_TIMEOUT) return;
// already fired recently? avoid spam
if (now - lastDeadmanFire < HEARTBEAT_TIMEOUT) return;
lastDeadmanFire = now;
console.log(`[deadman] No heartbeat in ${Math.round(since/1000)}s — firing remote alarm`);
const payload = {
boat: a.boat_name || 'Veleiro',
lat: a.last_lat, lng: a.last_lng,
distance: a.last_distance,
radius: a.radius,
reason: 'heartbeat_lost',
ts: now,
minutes_lost: Math.round(since / 60000)
};
const result = await dispatchAlarm(payload);
db.logAlarm('heartbeat_lost', payload, result.sent, result.failed);
}
setInterval(checkDeadman, 30000); // check every 30s
// ==== Start ====
app.listen(PORT, () => {
console.log(`Shivao Cloud rodando em :${PORT}`);
console.log(`Canais configurados: ${listConfiguredChannels().join(', ') || '(nenhum!)'}`);
console.log(`Dead-man switch: ${HEARTBEAT_TIMEOUT/1000}s`);
});
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));

219
server/src/notifications.js Normal file
View file

@ -0,0 +1,219 @@
import nodemailer from 'nodemailer';
const env = process.env;
// ---- Telegram ----
async function sendTelegram(text) {
const token = env.TELEGRAM_BOT_TOKEN;
const chats = (env.TELEGRAM_CHAT_IDS || '').split(',').map(s => s.trim()).filter(Boolean);
if (!token || chats.length === 0) return null;
const results = [];
for (const chatId of chats) {
try {
const r = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML', disable_notification: false })
});
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
results.push({ chat: chatId, ok: true });
} catch (e) {
results.push({ chat: chatId, ok: false, error: e.message });
}
}
return results;
}
// ---- ntfy.sh push ----
async function sendNtfy(text, title, priority) {
const topic = env.NTFY_TOPIC;
if (!topic) return null;
const server = env.NTFY_SERVER || 'https://ntfy.sh';
try {
const r = await fetch(`${server}/${topic}`, {
method: 'POST',
headers: {
'Title': title || 'Alerta Shivao',
'Priority': priority || 'urgent',
'Tags': 'rotating_light,anchor,warning',
'Click': text.match(/https:\/\/maps[^\s]+/)?.[0] || ''
},
body: text
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
// ---- Email ----
let transporter = null;
function getEmailTransporter() {
if (transporter) return transporter;
if (!env.SMTP_HOST || !env.SMTP_USER) return null;
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT || '587'),
secure: env.SMTP_SECURE === 'true',
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS }
});
return transporter;
}
async function sendEmail(text, subject) {
const t = getEmailTransporter();
const recipients = (env.SMTP_TO || '').split(',').map(s => s.trim()).filter(Boolean);
if (!t || recipients.length === 0) return null;
try {
await t.sendMail({
from: env.SMTP_FROM || env.SMTP_USER,
to: recipients.join(','),
subject: subject || 'Alerta Shivao',
text,
html: `<pre style="font-family:system-ui;font-size:14px">${text.replace(/</g, '&lt;')}</pre>`
});
return { ok: true, recipients: recipients.length };
} catch (e) {
return { ok: false, error: e.message };
}
}
// ---- Twilio SMS / WhatsApp ----
async function twilioRequest(messages) {
const sid = env.TWILIO_ACCOUNT_SID;
const token = env.TWILIO_AUTH_TOKEN;
if (!sid || !token) return null;
const auth = 'Basic ' + Buffer.from(`${sid}:${token}`).toString('base64');
const url = `https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`;
const results = [];
for (const msg of messages) {
try {
const body = new URLSearchParams({ From: msg.from, To: msg.to, Body: msg.body });
const r = await fetch(url, { method: 'POST', headers: { 'Authorization': auth, 'Content-Type': 'application/x-www-form-urlencoded' }, body });
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
results.push({ to: msg.to, ok: true });
} catch (e) {
results.push({ to: msg.to, ok: false, error: e.message });
}
}
return results;
}
async function sendSMS(text) {
const from = env.TWILIO_FROM_NUMBER;
const tos = (env.TWILIO_SMS_TO || '').split(',').map(s => s.trim()).filter(Boolean);
if (!from || tos.length === 0) return null;
const messages = tos.map(to => ({ from, to, body: text }));
return twilioRequest(messages);
}
async function sendWhatsApp(text) {
const from = env.TWILIO_WHATSAPP_FROM;
const tos = (env.TWILIO_WHATSAPP_TO || '').split(',').map(s => s.trim()).filter(Boolean);
if (!from || tos.length === 0) return null;
const messages = tos.map(to => ({
from: from.startsWith('whatsapp:') ? from : `whatsapp:${from}`,
to: to.startsWith('whatsapp:') ? to : `whatsapp:${to}`,
body: text
}));
return twilioRequest(messages);
}
// ---- Generic webhook ----
async function sendWebhook(payload) {
const url = env.WEBHOOK_URL;
if (!url) return null;
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
// ---- Build message ----
export function buildAlarmMessage(p) {
const lat = p.lat?.toFixed(6) ?? '?';
const lng = p.lng?.toFixed(6) ?? '?';
const mapsUrl = (p.lat && p.lng) ? `https://maps.google.com/?q=${lat},${lng}` : '';
const dist = Math.round(p.distance || 0);
const time = new Date(p.ts || Date.now()).toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
const boat = p.boat || 'Veleiro';
const reason = p.reason === 'heartbeat_lost' ?
'Servidor perdeu contato com o celular do barco' :
`Saiu ${dist}m da posição de fundeio (raio ${p.radius || '?'}m)`;
return `🚨 ALERTA — ${boat}
${reason}.
📍 Posição: ${lat}, ${lng}
${mapsUrl ? `🗺️ ${mapsUrl}\n` : ''}🕐 ${time}
Esta é uma mensagem automática do sistema de vigia de fundeio.`;
}
// ---- Main dispatcher ----
export async function dispatchAlarm(payload) {
const text = buildAlarmMessage(payload);
const subject = `🚨 ${payload.boat || 'Veleiro'} — ALERTA de fundeio`;
const tasks = [
['telegram', sendTelegram(text)],
['ntfy', sendNtfy(text, subject, 'urgent')],
['email', sendEmail(text, subject)],
['sms', sendSMS(text)],
['whatsapp', sendWhatsApp(text)],
['webhook', sendWebhook({ ...payload, message: text })]
];
const sent = [];
const failed = [];
for (const [name, promise] of tasks) {
const result = await promise;
if (result === null) continue; // not configured
if (Array.isArray(result)) {
for (const r of result) {
if (r.ok) sent.push(name);
else failed.push({ channel: name, ...r });
}
} else {
if (result.ok) sent.push(name);
else failed.push({ channel: name, ...result });
}
}
return { sent, failed };
}
// Quick test ping (no urgency)
export async function dispatchTest() {
const text = `✅ Teste — Sistema Shivao Cloud operacional.\nHora: ${new Date().toLocaleString('pt-BR')}`;
const sent = [];
const failed = [];
const r1 = await sendTelegram(text);
if (r1) r1.forEach(r => r.ok ? sent.push('telegram') : failed.push({ channel: 'telegram', ...r }));
const r2 = await sendNtfy(text, 'Teste Shivao', 'default');
if (r2) r2.ok ? sent.push('ntfy') : failed.push({ channel: 'ntfy', ...r2 });
const r3 = await sendEmail(text, 'Teste Shivao');
if (r3) r3.ok ? sent.push('email') : failed.push({ channel: 'email', ...r3 });
return { sent, failed };
}
export function listConfiguredChannels() {
const channels = [];
if (env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_IDS) channels.push('telegram');
if (env.NTFY_TOPIC) channels.push('ntfy');
if (env.SMTP_HOST && env.SMTP_TO) channels.push('email');
if (env.TWILIO_ACCOUNT_SID && env.TWILIO_SMS_TO) channels.push('sms');
if (env.TWILIO_ACCOUNT_SID && env.TWILIO_WHATSAPP_TO) channels.push('whatsapp');
if (env.WEBHOOK_URL) channels.push('webhook');
return channels;
}

View file

@ -0,0 +1,52 @@
// Schemas de validação Zod para endpoints autenticados.
// Filosofia: permissivos no formato (não rejeitar requests do app legacy)
// mas estritos em tamanhos/limites (defesa contra payloads gigantes).
import { z } from 'zod';
// ===== /api/data (sync de estado completo) =====
// O cliente envia { data: {...estado todo do app...} }.
// db.setState recebe `data` e salva como JSON serializado.
// Permissivo: data tem que ser objeto, conteúdo livre.
// Tamanho do payload já é limitado por express.json({ limit: '10mb' }).
export const setStateSchema = z.object({
data: z.record(z.string(), z.unknown()),
});
// ===== /api/share/:token/zones (atualizar zonas de geofencing do share) =====
// Cada zona: círculo geográfico (centro + raio) com tipo (proibida/atenção).
// Limite 100 zonas por share — suficiente pra qualquer caso real, bloqueia ataques de payload gigante.
export const zoneSchema = z.object({
id: z.string().max(100).optional(),
name: z.string().max(200).optional(),
type: z.enum(['forbidden', 'warning']).optional(),
center: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
}),
radius: z.number().positive().max(100000), // metros, max 100km
});
export const updateZonesSchema = z.object({
zones: z.array(zoneSchema).max(100),
});
// ===== Middleware genérico =====
// Uso: app.post('/x', requireAuth, validate(mySchema), handler)
// Em caso de falha: 400 com até 5 issues do Zod (path + message).
// Em caso de sucesso: substitui req.body pelo resultado parseado (já tipado/coercido).
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Invalid payload',
issues: result.error.issues.slice(0, 5).map(i => ({
path: i.path.join('.'),
message: i.message,
})),
});
}
req.body = result.data;
next();
};
}