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:
commit
5b02feae50
21 changed files with 11340 additions and 0 deletions
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal 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
331
API.md
Normal 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
315
ARCHITECTURE.md
Normal 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
168
BACKLOG.md
Normal 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
246
CONTRIBUTING.md
Normal 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
230
DEPLOY.md
Normal 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
211
HANDOFF.md
Normal 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
100
PROJECT_CONTEXT.md
Normal 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
105
README.md
Normal 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
3468
app/diario-bordo.html
Normal file
File diff suppressed because it is too large
Load diff
5
server/.gitignore
vendored
Normal file
5
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
31
server/Dockerfile
Normal file
31
server/Dockerfile
Normal 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
184
server/README.md
Normal 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
18
server/docker-compose.yml
Normal 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
1479
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
server/package.json
Normal file
23
server/package.json
Normal 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
3468
server/public/index.html
Normal file
File diff suppressed because it is too large
Load diff
213
server/src/db.js
Normal file
213
server/src/db.js
Normal 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
439
server/src/index.js
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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
219
server/src/notifications.js
Normal 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, '<')}</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;
|
||||||
|
}
|
||||||
52
server/src/schemas/index.js
Normal file
52
server/src/schemas/index.js
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue