Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
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>
847 lines
38 KiB
JavaScript
847 lines
38 KiB
JavaScript
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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));
|