shivao-projeto/server/src/index.js
PontualTech / Karlão c46d30f7b9
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
feat(legal+ci): termos de uso + workflow Forgejo Actions pra build Android automático
LEGAL
- GET /termos: termos de uso completos (11 seções) com aviso CRÍTICO de limitação de responsabilidade pra navegação náutica (não substitui chartplotter/cartas/atenção do skipper)
- Reembolso CDC art. 49 (7 dias arrependimento) explicito
- Lei aplicável Brasil + foro SP

CI/CD
- .forgejo/workflows/build-android.yml: pipeline completo (checkout → JDK17 → Android SDK → npm install mobile/ → cap sync → gradle bundleRelease + assembleRelease → upload artifacts → release no manual)
- .forgejo/workflows/README.md: como configurar runner Forgejo no Coolify, secrets necessários (KEYSTORE_BASE64, KEYSTORE_PWD, FORGEJO_TOKEN), alternativas (Codemagic, GitHub Actions)
- Trigger automático em push em app/, mobile/, scripts/sync-html.mjs
- Trigger manual via botão Forgejo

VALIDADO
- Bundletool 1.17.2 instalado em ~/bundletool/
- AAB validado: arquivos OK
- APK por device gerado: 2.8-2.9 MB (vs 3.4 MB universal — Play Store entrega menor)
- ~/Downloads/Shivao-v1.2.0.apks (12MB, contém splits por arquitetura)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:57:01 -03:00

847 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, signupSchema, loginSchema, checkoutSchema } from './schemas/index.js';
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
import * as billing from './billing.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: aceita JWT (multi-tenant) OU BOAT_TOKEN legado (mapeia ao user 1)
function requireAuth(req, res, next) {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token) return res.status(401).json({ error: 'Missing token' });
// Fallback BOAT_TOKEN: backwards-compat com app legado do dono (Karlão), mapeia pro user default id=1
if (token === TOKEN) {
req.user = { id: 1, email: 'karlao@outlook.com', viaBoatToken: true };
return next();
}
// JWT
const payload = verifyToken(token);
if (!payload || payload.type !== 'access' || !payload.uid) {
return res.status(401).json({ error: 'Invalid token' });
}
// Carrega user fresh do DB pra confirmar que ainda existe
const user = db.findUserById(payload.uid);
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
next();
}
// ==== Public endpoints ====
app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
// ===== Auth endpoints (multi-tenant SaaS) =====
app.post('/api/auth/signup', validate(signupSchema), async (req, res) => {
const { email, password, name } = req.body;
if (db.findUserByEmail(email)) return res.status(409).json({ error: 'Email já cadastrado' });
try {
const hash = await hashPassword(password);
const id = db.createUser(email, hash, name);
db.audit(id, 'user_signup', 'user', String(id), { email }, req.ip);
const user = db.findUserById(id);
db.updateLastLogin(id);
res.json({
user,
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user),
});
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post('/api/auth/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = db.findUserByEmail(email);
if (!user) return res.status(401).json({ error: 'Credenciais inválidas' });
const ok = await verifyPassword(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Credenciais inválidas' });
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
res.json({
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
});
});
app.post('/api/auth/refresh', (req, res) => {
const { refreshToken } = req.body || {};
if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' });
const payload = verifyToken(refreshToken);
if (!payload || payload.type !== 'refresh') return res.status(401).json({ error: 'Invalid refresh' });
const user = db.findUserById(payload.uid);
if (!user) return res.status(401).json({ error: 'User not found' });
res.json({ accessToken: signAccessToken(user) });
});
app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Plans + license info
app.get('/api/license', requireAuth, (req, res) => {
const lic = db.getActiveLicense(req.user.id) || { plan: 'free', status: 'active', expires_at: null };
res.json({
plan: lic.plan,
status: lic.status,
expires_at: lic.expires_at,
features: planFeatures(lic.plan),
plans: PLANS,
billingEnabled: billing.isAsaasConfigured(),
});
});
// ===== Billing endpoints (Asaas) =====
app.get('/api/billing/status', (req, res) => {
res.json({ configured: billing.isAsaasConfigured() });
});
app.post('/api/billing/checkout', requireAuth, validate(checkoutSchema), async (req, res) => {
if (!billing.isAsaasConfigured()) return res.status(503).json({ error: 'Billing não configurado no servidor (ASAAS_API_KEY ausente)' });
if (req.user.viaBoatToken) return res.status(403).json({ error: 'Use uma conta com login (não BOAT_TOKEN) pra fazer upgrade' });
const { plan, cycle, billingType } = req.body;
try {
const value = billing.priceFor(plan, cycle);
let customerId = db.getUserAsaasCustomerId(req.user.id);
if (!customerId) {
customerId = await billing.getOrCreateCustomer(req.user);
db.setUserAsaasCustomerId(req.user.id, customerId);
}
const payment = await billing.createPayment({
customerId, plan, cycle, value, billingType,
description: `Shivao ${PLANS[plan].name} (${cycle === 'monthly' ? 'mensal' : 'anual'})`,
});
db.createPayment({
user_id: req.user.id,
asaas_payment_id: payment.id,
asaas_customer_id: customerId,
plan, cycle, value, billing_type: billingType,
status: payment.status,
invoice_url: payment.invoiceUrl,
due_date: new Date(payment.dueDate).getTime(),
});
db.audit(req.user.id, 'checkout_created', 'payment', payment.id, { plan, cycle, value, billingType }, req.ip);
// Pra PIX, busca QR code
let pix = null;
if (billingType === 'PIX') {
try { pix = await billing.getPixQrCode(payment.id); } catch (e) { console.warn('[pix qrcode]', e.message); }
}
res.json({
paymentId: payment.id,
invoiceUrl: payment.invoiceUrl,
bankSlipUrl: payment.bankSlipUrl,
status: payment.status,
value: payment.value,
dueDate: payment.dueDate,
pix: pix ? { qrCode: pix.encodedImage, payload: pix.payload, expiresAt: pix.expirationDate } : null,
});
} catch (e) {
console.error('[checkout]', e);
res.status(500).json({ error: e.message });
}
});
app.get('/api/billing/payment/:id', requireAuth, async (req, res) => {
const local = db.findPaymentByAsaasId(req.params.id);
if (!local || local.user_id !== req.user.id) return res.status(404).json({ error: 'not found' });
// Reconcilia com Asaas se ainda PENDING (pra caso webhook ter falhado)
if (local.status === 'PENDING' && billing.isAsaasConfigured()) {
try {
const fresh = await billing.getPaymentStatus(req.params.id);
if (fresh.status !== local.status) {
db.updatePaymentStatus(req.params.id, fresh.status, billing.isPaidStatus(fresh.status) ? Date.now() : null);
if (billing.isPaidStatus(fresh.status)) {
db.setLicense(local.user_id, local.plan, billing.computeExpiresAt(local.cycle), local.asaas_payment_id);
db.audit(local.user_id, 'license_activated', 'payment', req.params.id, { plan: local.plan, cycle: local.cycle, source: 'reconcile' }, req.ip);
}
}
return res.json({ ...local, status: fresh.status });
} catch (e) { /* fallthrough returns cached */ }
}
res.json(local);
});
app.get('/api/billing/payments', requireAuth, (req, res) => {
res.json(db.listUserPayments(req.user.id));
});
// Webhook Asaas: chama isso quando status muda. Precisa ser configurado no painel Asaas → Integrações
// URL: https://shivao.pontualtech.work/api/billing/asaas-webhook
// Header asaas-access-token: ASAAS_WEBHOOK_TOKEN (defina o mesmo no Coolify env)
app.post('/api/billing/asaas-webhook', (req, res) => {
const headerToken = req.headers['asaas-access-token'];
if (!billing.verifyWebhookToken(headerToken)) {
console.warn('[asaas-webhook] invalid token');
return res.status(401).json({ error: 'Invalid webhook token' });
}
const event = req.body;
if (!event || !event.event || !event.payment) {
return res.status(400).json({ error: 'Invalid payload' });
}
const p = event.payment;
const local = db.findPaymentByAsaasId(p.id);
if (!local) {
console.warn('[asaas-webhook] payment not found:', p.id);
// Aceitar 200 mesmo assim — Asaas não retentar
return res.json({ ok: true, ignored: true });
}
db.updatePaymentStatus(p.id, p.status, billing.isPaidStatus(p.status) ? Date.now() : null);
// Ativar licença se pago
if (billing.isPaidStatus(p.status)) {
db.setLicense(local.user_id, local.plan, billing.computeExpiresAt(local.cycle), p.id);
db.audit(local.user_id, 'license_activated', 'payment', p.id, { plan: local.plan, cycle: local.cycle, event: event.event }, req.ip);
console.log(`[asaas-webhook] license activated user=${local.user_id} plan=${local.plan}`);
} else if (billing.isFailedStatus(p.status)) {
// Pagamento estornado: revogar se a licença vinculada a esse payment
const lic = db.getActiveLicense(local.user_id);
if (lic && lic.asaas_subscription_id === p.id) {
db.setLicense(local.user_id, 'free', null, null);
db.audit(local.user_id, 'license_revoked', 'payment', p.id, { reason: p.status }, req.ip);
}
}
res.json({ ok: true });
});
// Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome
app.get('/.well-known/assetlinks.json', (req, res) => {
res.json([{
relation: ['delegate_permission/common.handle_all_urls'],
target: {
namespace: 'android_app',
package_name: 'br.com.pontualtech.shivao',
sha256_cert_fingerprints: [
'CA:BE:35:59:92:BA:3D:69:7C:38:0A:8A:E0:20:DE:2A:78:29:08:1C:93:F4:62:D5:6E:3F:04:E0:F5:26:23:09'
]
}
}]);
});
// Termos de Uso
app.get('/termos', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Termos de Uso · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}.warn{background:#8c343422;border-left:4px solid #8c3434;padding:12px 16px;margin:20px 0}a{color:#3f7768}small{opacity:.7}</style></head><body>
<h1>Termos de Uso — Shivao</h1>
<p><small>Última atualização: 27 de abril de 2026 · Versão 1.0</small></p>
<p>Ao usar o aplicativo <strong>Shivao</strong> (operado por <strong>PontualTech</strong>, CNPJ 32.772.178/0001-47), você concorda com estes termos. Leia com atenção.</p>
<h2>1. Aceitação</h2>
<p>O uso do app implica aceitação completa destes termos. Se não concorda, não use.</p>
<h2>2. Cadastro e conta</h2>
<ul>
<li>Você precisa ter ≥18 anos OU autorização do responsável legal.</li>
<li>Informações verdadeiras e atualizadas. Senha é responsabilidade sua.</li>
<li>1 conta por usuário. Compartilhamento de credenciais é proibido.</li>
<li>Podemos suspender contas com violação destes termos.</li>
</ul>
<h2>3. Planos, pagamentos e renovação</h2>
<ul>
<li><strong>Free:</strong> grátis, recursos limitados (vigia local + diário 10 últimas).</li>
<li><strong>Pro/Captain:</strong> assinatura mensal ou anual via Asaas (PIX, cartão, boleto).</li>
<li><strong>Renovação:</strong> ao fim do ciclo, você renova manualmente. Sem cobrança automática surpresa.</li>
<li><strong>Reembolso:</strong> 7 dias de arrependimento (CDC art. 49) — devolução integral via mesmo método. Após 7 dias: pro-rata do tempo restante.</li>
</ul>
<h2>4. Uso permitido</h2>
<ul>
<li>Uso pessoal ou profissional náutico (lazer, trabalho, charters).</li>
<li>1 usuário = 1 ou múltiplos barcos (no plano Captain).</li>
<li>Compartilhamento público de posição é OK pra tripulação/familia (links temporários).</li>
</ul>
<h2>5. Uso PROIBIDO</h2>
<ul>
<li>❌ Engenharia reversa do app ou backend (exceto pra interoperar legalmente).</li>
<li>❌ Revender o serviço como white-label sem licença comercial.</li>
<li>❌ Atacar a infraestrutura (DDoS, brute-force, exploit).</li>
<li>❌ Cadastrar bots ou contas falsas em massa.</li>
<li>❌ Usar pra atividade ilegal (pesca em área proibida, navegação clandestina, etc).</li>
</ul>
<h2>6. Limitação de responsabilidade — IMPORTANTE</h2>
<div class="warn"><strong>⚠️ AVISO CRÍTICO PARA NAVEGAÇÃO:</strong>
<p>O Shivao é uma <strong>FERRAMENTA AUXILIAR</strong> de navegação e segurança. <strong>NÃO substitui</strong>:</p>
<ul>
<li>Equipamentos náuticos certificados (chartplotter, AIS, VHF, balsas).</li>
<li>Cartas náuticas oficiais (Marinha do Brasil, NOAA).</li>
<li>Atenção do skipper.</li>
<li>Comunicação com a Capitania dos Portos.</li>
</ul>
<p>Não nos responsabilizamos por:</p>
<ul>
<li>Decisões tomadas com base no app (rota, fundeio, meteorologia).</li>
<li>Falha de GPS, internet, sensores ou notificações.</li>
<li>Danos materiais, pessoais ou ambientais decorrentes do uso.</li>
<li>Perda de dados (faça backups regulares).</li>
</ul>
<p><strong>O comandante da embarcação é o único responsável pela segurança a bordo.</strong></p></div>
<h2>7. Propriedade intelectual</h2>
<ul>
<li>Código, logo, nome "Shivao" pertencem à PontualTech.</li>
<li>Seus dados (viagens, mídia) pertencem a VOCÊ — exporte quando quiser, exclua a qualquer momento.</li>
<li>Bibliotecas open source: Leaflet (BSD-2), OpenStreetMap (ODbL), express-rate-limit (MIT), bcryptjs (MIT), jsonwebtoken (MIT).</li>
</ul>
<h2>8. Suspensão e cancelamento</h2>
<ul>
<li>Você pode cancelar a qualquer momento via app (Aba Conta → Sair → Excluir conta).</li>
<li>Podemos suspender se houver violação destes termos, com aviso por e-mail (exceto urgências de segurança).</li>
<li>Após cancelamento: 30 dias pra reativar, depois exclusão permanente.</li>
</ul>
<h2>9. Mudanças nos termos</h2>
<p>Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual em <a href="https://shivao.pontualtech.work/termos">shivao.pontualtech.work/termos</a>.</p>
<h2>10. Lei aplicável e foro</h2>
<p>Estes termos seguem a lei brasileira. Foro: comarca de São Paulo/SP. Disputas de consumo podem usar <a href="https://www.consumidor.gov.br">consumidor.gov.br</a> antes de judicializar.</p>
<h2>11. Contato</h2>
<p>Suporte: <a href="mailto:contato@pontualtech.com.br">contato@pontualtech.com.br</a><br>
Privacidade/LGPD: <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a></p>
<hr style="margin:40px 0;border:none;border-top:1px solid #a07832">
<p style="text-align:center"><small>Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}</small></p>
</body></html>`);
});
// Política de Privacidade (URL pública obrigatória pra Play Store + LGPD)
app.get('/politica', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Política de Privacidade · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}code{background:#fff;padding:2px 6px;border-radius:3px;font-size:.9em}a{color:#3f7768}small{opacity:.7}</style></head><body>
<h1>Política de Privacidade — Shivao</h1>
<p><small>Última atualização: 27 de abril de 2026 · Versão 1.0</small></p>
<p>O <strong>Shivao</strong> é um aplicativo de diário de bordo náutico operado por <strong>PontualTech</strong> (CNPJ 32.772.178/0001-47, São Paulo/SP, Brasil). Esta política descreve como coletamos, usamos e protegemos seus dados pessoais, em conformidade com a <strong>LGPD (Lei 13.709/2018)</strong> e o <strong>GDPR (Regulamento UE 2016/679)</strong>.</p>
<h2>1. Quais dados coletamos</h2>
<ul>
<li><strong>Conta:</strong> e-mail, senha (hash bcrypt), nome opcional.</li>
<li><strong>Dados de bordo:</strong> registros de viagens, manutenções, fotos/áudios/vídeos que VOCÊ adicionar, posições GPS quando você ativa rastreio ou vigia.</li>
<li><strong>Pagamentos:</strong> processados pela Asaas (parceiro PCI-DSS). Não armazenamos número de cartão.</li>
<li><strong>Logs técnicos:</strong> IP, timestamps, ações sensíveis (criar/revogar share, sync de estado) — guardados por 90 dias para auditoria de segurança.</li>
</ul>
<h2>2. O que NÃO coletamos</h2>
<ul>
<li>❌ Analytics de comportamento (Google Analytics, Facebook Pixel, etc).</li>
<li>❌ Tracking entre apps/sites.</li>
<li>❌ Anúncios de terceiros.</li>
<li>❌ Compartilhamento com brokers de dados.</li>
</ul>
<h2>3. Para que usamos seus dados</h2>
<ul>
<li>Operar o serviço (sync, vigia de fundeio, alarme remoto).</li>
<li>Processar pagamentos (apenas Asaas).</li>
<li>Enviar e-mails operacionais (recuperação de senha, confirmação de pagamento, alerta de fundeio).</li>
<li>Cumprir obrigações legais (notas fiscais, intimações judiciais quando aplicável).</li>
</ul>
<h2>4. Onde seus dados ficam</h2>
<p>Servidores próprios em data center na Alemanha (Hetzner Online GmbH, certificado ISO 27001), gerenciados pela PontualTech. Backups criptografados.</p>
<h2>5. Permissões do app Android</h2>
<ul>
<li><strong>Localização (incluindo background):</strong> imprescindível pra GPS de viagens, vigia de fundeio com alarme de drift e compartilhamento ao vivo.</li>
<li><strong>Câmera/Microfone/Galeria:</strong> apenas quando você anexar mídia a um registro.</li>
<li><strong>Notificações:</strong> alarme local de fundeio (toca som + vibra mesmo com tela apagada).</li>
<li><strong>Sensores (bússola, barômetro):</strong> usados localmente no celular, não transmitidos.</li>
</ul>
<h2>6. Compartilhamento de posição ao vivo</h2>
<p>Quando você cria um link público de compartilhamento, qualquer pessoa com o link vê a posição do barco em tempo real. Você controla a duração e pode revogar a qualquer momento. Os links usam tokens randômicos de 96 bits (impossíveis de adivinhar).</p>
<h2>7. Seus direitos (LGPD/GDPR)</h2>
<p>Você pode, a qualquer momento, solicitar:</p>
<ul>
<li>Acesso aos seus dados (exportar tudo via app).</li>
<li>Correção de dados incorretos.</li>
<li>Exclusão da conta e todos os dados (delete em até 30 dias).</li>
<li>Portabilidade (exportar GPX/CSV/JSON).</li>
<li>Revogar consentimento (cancelar assinatura).</li>
</ul>
<p>Solicitações por <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a>.</p>
<h2>8. Retenção de dados</h2>
<ul>
<li>Conta ativa: enquanto você usar.</li>
<li>Conta cancelada: 30 dias (período de arrependimento), depois exclusão permanente.</li>
<li>Logs de auditoria: 90 dias.</li>
<li>Notas fiscais: 5 anos (exigência legal).</li>
</ul>
<h2>9. Cookies</h2>
<p>O app usa apenas <code>localStorage</code> e <code>IndexedDB</code> locais (não são cookies HTTP). Sem cookies de tracking de terceiros.</p>
<h2>10. Crianças</h2>
<p>O Shivao não é destinado a menores de 13 anos. Não coletamos dados de menores intencionalmente.</p>
<h2>11. Mudanças nesta política</h2>
<p>Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual e histórico em <a href="https://shivao.pontualtech.work/politica">shivao.pontualtech.work/politica</a>.</p>
<h2>12. Contato e DPO</h2>
<p><strong>Encarregado de Dados (DPO):</strong> Karlão · <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a></p>
<p><strong>Suporte:</strong> <a href="mailto:contato@pontualtech.com.br">contato@pontualtech.com.br</a></p>
<p><strong>ANPD (autoridade brasileira):</strong> <a href="https://www.gov.br/anpd">gov.br/anpd</a></p>
<hr style="margin:40px 0;border:none;border-top:1px solid #a07832">
<p style="text-align:center"><small>Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}</small></p>
</body></html>`);
});
// PWA manifest (necessário pra "Add to Home Screen" + APK via PWABuilder)
app.get('/manifest.json', (req, res) => {
res.json({
name: 'Shivao · Diário de Bordo',
short_name: 'Shivao',
description: 'Diário de bordo do veleiro Shivao — viagens, manutenções, GPS, fundeio com alarme remoto',
start_url: '/',
display: 'standalone',
orientation: 'any',
background_color: '#0e2a3d',
theme_color: '#0e2a3d',
lang: 'pt-BR',
icons: [
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' }
],
categories: ['navigation', 'travel', 'productivity']
});
});
// ==== 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, per-user) ---
app.get('/api/data', requireAuth, (req, res) => {
const s = db.getState(req.user.id);
res.json(s);
});
app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
const { data } = req.body;
const ts = db.setState(req.user.id, data);
db.audit(req.user.id, '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 — escopo do user)
const ex = db.getMedia(req.user.id, id);
if (ex && ex.filename !== meta.filename) {
try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {}
db.deleteMedia(req.user.id, id);
} else if (ex) {
db.deleteMedia(req.user.id, id);
}
db.insertMedia(req.user.id, meta);
db.audit(req.user.id, '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(req.user.id).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.user.id, 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.user.id, 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.user.id, req.params.id);
db.audit(req.user.id, '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(req.user.id, {
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(req.user.id, lat, lng, distance || 0);
const a = db.getAnchor(req.user.id);
res.json({ ok: true, active: !!a?.active });
});
app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
const a = db.getAnchor(req.user.id);
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(req.user.id, true);
const result = await dispatchAlarm(payload);
db.logAlarm(req.user.id, 'drift', payload, result.sent, result.failed);
res.json(result);
});
app.post('/api/anchor/stop', requireAuth, (req, res) => {
db.clearAnchor(req.user.id);
res.json({ ok: true });
});
app.get('/api/anchor/status', requireAuth, (req, res) => {
res.json(db.getAnchor(req.user.id) || { active: 0 });
});
// --- Test endpoint ---
app.post('/api/test', requireAuth, async (req, res) => {
const result = await dispatchTest();
db.logAlarm(req.user.id, 'test', {}, result.sent, result.failed);
res.json(result);
});
app.get('/api/alarms', requireAuth, (req, res) => {
res.json(db.recentAlarms(req.user.id, 50));
});
// Auditoria (consulta autenticada — quem fez o quê e quando nos endpoints sensíveis do user)
app.get('/api/audit', requireAuth, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
res.json(db.recentAudit(req.user.id, 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(req.user.id, token, boatName || 'Shivao', expiresAt, zones);
db.audit(req.user.id, '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(req.user.id).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.user.id, req.params.token);
db.audit(req.user.id, '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.user.id, req.params.token, zones);
db.audit(req.user.id, '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 apenas para shares do user logado
const active = db.listActiveShares(req.user.id);
let posted = 0;
for (const s of active) {
if (boatName && s.boat_name && s.boat_name !== boatName) continue;
db.addSharePosition(s.token, lat, lng, speed);
posted++;
}
res.json({ ok: true, posted });
});
// ==== PUBLIC share endpoints (no auth) ====
app.get('/api/share/:token/info', publicShareLimiter, (req, res) => {
const s = db.getShare(req.params.token);
if (!s || s.revoked || s.expires_at < Date.now())
return res.status(404).json({ error: 'not found or expired' });
let zones = null;
if (s.zones) { try { zones = JSON.parse(s.zones); } catch (e) {} }
res.json({ boatName: s.boat_name, expiresAt: s.expires_at, zones });
});
app.get('/api/share/:token/positions', publicShareLimiter, (req, res) => {
const s = db.getShare(req.params.token);
if (!s || s.revoked || s.expires_at < Date.now())
return res.status(404).json({ error: 'not found or expired' });
const positions = db.getSharePositions(req.params.token, 500);
res.json(positions);
});
app.get('/share/:token', publicShareLimiter, (req, res) => {
const s = db.getShare(req.params.token);
if (!s || s.revoked) return res.status(404).type('html').send(sharePage(null, 'Link inválido ou revogado'));
if (s.expires_at < Date.now()) return res.status(410).type('html').send(sharePage(null, 'Link expirado'));
res.type('html').send(sharePage(s, null));
});
function sharePage(share, errorMsg) {
if (errorMsg) {
return `<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Indisponível</title><style>body{font-family:system-ui,sans-serif;background:#0e2a3d;color:#faf2dd;margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}.box{text-align:center;max-width:380px}.box h1{font-family:Georgia,serif;font-style:italic;font-size:32px;margin:0 0 8px;color:#c89f54}.box p{opacity:.85;line-height:1.6}</style></head><body><div class="box"><h1>Shivao</h1><p>${errorMsg}</p></div></body></html>`;
}
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0e2a3d">
<title>${escapeHtml(share.boat_name || 'Shivao')} ao vivo</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0e2a3d;color:#faf2dd}
.bar{background:#0e2a3d;color:#faf2dd;padding:14px 16px;border-bottom:1px solid #6f5217;box-shadow:0 1px 0 #a07832 inset;display:flex;justify-content:space-between;align-items:center;gap:10px}
.bar-left h1{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-weight:500;font-size:22px;color:#c89f54;line-height:1}
.bar-left .sub{font-family:'JetBrains Mono','Courier New',monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#a07832;margin-top:3px}
.bar-right{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.04em;text-align:right;color:#a07832}
.bar-right .live::before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:#ff4444;margin-right:5px;animation:p 1.4s infinite}
@keyframes p{50%{opacity:.3}}
#map{height:calc(100vh - 56px);width:100%;background:#1a2733}
.empty-msg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(14,42,61,.9);color:#c89f54;padding:18px 24px;font-family:Georgia,serif;font-style:italic;text-align:center;border:1px solid #a07832}
</style>
</head>
<body>
<div class="bar">
<div class="bar-left"><h1>${escapeHtml(share.boat_name || 'Shivao')}</h1><div class="sub">posição ao vivo</div></div>
<div class="bar-right"><div class="live">AO VIVO</div><div id="last-update">aguardando…</div></div>
</div>
<div id="map"></div>
<script>
const TOKEN=${JSON.stringify(req.params.token)};
const map=L.map('map',{zoomControl:true});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(map);
let trail=null,marker=null,fitDone=false;
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#c89f54" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
async function refresh(){
try{
// info (com zonas) na primeira vez
if(!window._zonesLoaded){
try{
const i=await fetch('/api/share/'+TOKEN+'/info');
if(i.ok){
const info=await i.json();
if(info.zones&&Array.isArray(info.zones)){
info.zones.forEach(z=>{
const color=z.type==='forbidden'?'#8c3434':'#b67025';
L.circle([z.center.lat,z.center.lng],{radius:z.radius,color,fillColor:color,fillOpacity:.15,weight:1.5,opacity:.6}).addTo(map).bindPopup(z.name+' · '+(z.type==='forbidden'?'Proibida':'Atenção'));
});
if(info.zones.length&&!fitDone){
const bounds=L.latLngBounds(info.zones.map(z=>[z.center.lat,z.center.lng]));
map.fitBounds(bounds,{padding:[40,40],maxZoom:13});
}
}
window._zonesLoaded=true;
}
}catch(e){}
}
const r=await fetch('/api/share/'+TOKEN+'/positions');
if(!r.ok){
if(r.status===404||r.status===410){
document.body.innerHTML='<div class="empty-msg">Link expirado ou revogado</div>';
return;
}
throw new Error('HTTP '+r.status);
}
const ps=await r.json();
if(!ps.length){
document.getElementById('last-update').textContent='sem posição';
if(!document.querySelector('.empty-msg')){
const e=document.createElement('div');e.className='empty-msg';e.textContent='Aguardando primeira posição…';document.body.appendChild(e);
}
return;
}
document.querySelectorAll('.empty-msg').forEach(e=>e.remove());
const last=ps[ps.length-1];
const ll=ps.map(p=>[p.lat,p.lng]);
if(trail)trail.remove();
if(ps.length>1)trail=L.polyline(ll,{color:'#a07832',weight:3,opacity:.6}).addTo(map);
if(!marker)marker=L.marker([last.lat,last.lng],{icon:boatIcon}).addTo(map);
else marker.setLatLng([last.lat,last.lng]);
if(!fitDone){fitDone=true;if(ps.length>1)map.fitBounds(ll,{padding:[40,40]});else map.setView([last.lat,last.lng],14)}
const ago=Math.round((Date.now()-last.ts)/1000);
const agoTxt=ago<60?ago+'s':ago<3600?Math.round(ago/60)+'min':Math.round(ago/3600)+'h';
const spd=last.speed?(last.speed*1.94384).toFixed(1)+' kn · ':'';
document.getElementById('last-update').textContent=spd+'há '+agoTxt;
}catch(e){console.error(e)}
}
refresh();
setInterval(refresh,15000);
</script>
</body>
</html>`;
}
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c])}
// cleanup expired shares once a day
setInterval(() => {
try {
const n = db.cleanupExpiredShares();
if (n) console.log(`[cleanup] removidos ${n} shares expirados`);
} catch (e) { console.warn(e); }
}, 24 * 3600 * 1000);
// ==== Dead-man switch background check (multi-user) ====
const lastDeadmanFire = new Map(); // user_id -> ts
async function checkDeadman() {
const now = Date.now();
const sessions = db.listActiveAnchors();
for (const a of sessions) {
if (!a.active) continue;
const since = now - (a.last_heartbeat || a.started_at);
if (since < HEARTBEAT_TIMEOUT) continue;
const last = lastDeadmanFire.get(a.user_id) || 0;
if (now - last < HEARTBEAT_TIMEOUT) continue;
lastDeadmanFire.set(a.user_id, now);
console.log(`[deadman] user=${a.user_id} no heartbeat in ${Math.round(since/1000)}s — firing 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)
};
try {
const result = await dispatchAlarm(payload);
db.logAlarm(a.user_id, 'heartbeat_lost', payload, result.sent, result.failed);
} catch (e) {
console.warn(`[deadman] dispatch failed user=${a.user_id}:`, e.message);
}
}
}
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));