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