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(`Termos de Uso · Shivao

Termos de Uso — Shivao

Última atualização: 27 de abril de 2026 · Versão 1.0

Ao usar o aplicativo Shivao (operado por PontualTech, CNPJ 32.772.178/0001-47), você concorda com estes termos. Leia com atenção.

1. Aceitação

O uso do app implica aceitação completa destes termos. Se não concorda, não use.

2. Cadastro e conta

3. Planos, pagamentos e renovação

4. Uso permitido

5. Uso PROIBIDO

6. Limitação de responsabilidade — IMPORTANTE

⚠️ AVISO CRÍTICO PARA NAVEGAÇÃO:

O Shivao é uma FERRAMENTA AUXILIAR de navegação e segurança. NÃO substitui:

Não nos responsabilizamos por:

O comandante da embarcação é o único responsável pela segurança a bordo.

7. Propriedade intelectual

8. Suspensão e cancelamento

9. Mudanças nos termos

Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual em shivao.pontualtech.work/termos.

10. Lei aplicável e foro

Estes termos seguem a lei brasileira. Foro: comarca de São Paulo/SP. Disputas de consumo podem usar consumidor.gov.br antes de judicializar.

11. Contato

Suporte: contato@pontualtech.com.br
Privacidade/LGPD: dpo@pontualtech.com.br


Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}

`); }); // Política de Privacidade (URL pública obrigatória pra Play Store + LGPD) app.get('/politica', (req, res) => { res.type('html').send(`Política de Privacidade · Shivao

Política de Privacidade — Shivao

Última atualização: 27 de abril de 2026 · Versão 1.0

O Shivao é um aplicativo de diário de bordo náutico operado por PontualTech (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 LGPD (Lei 13.709/2018) e o GDPR (Regulamento UE 2016/679).

1. Quais dados coletamos

2. O que NÃO coletamos

3. Para que usamos seus dados

4. Onde seus dados ficam

Servidores próprios em data center na Alemanha (Hetzner Online GmbH, certificado ISO 27001), gerenciados pela PontualTech. Backups criptografados.

5. Permissões do app Android

6. Compartilhamento de posição ao vivo

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).

7. Seus direitos (LGPD/GDPR)

Você pode, a qualquer momento, solicitar:

Solicitações por dpo@pontualtech.com.br.

8. Retenção de dados

9. Cookies

O app usa apenas localStorage e IndexedDB locais (não são cookies HTTP). Sem cookies de tracking de terceiros.

10. Crianças

O Shivao não é destinado a menores de 13 anos. Não coletamos dados de menores intencionalmente.

11. Mudanças nesta política

Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual e histórico em shivao.pontualtech.work/politica.

12. Contato e DPO

Encarregado de Dados (DPO): Karlão · dpo@pontualtech.com.br

Suporte: contato@pontualtech.com.br

ANPD (autoridade brasileira): gov.br/anpd


Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}

`); }); // 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 `Indisponível

Shivao

${errorMsg}

`; } return ` ${escapeHtml(share.boat_name || 'Shivao')} ao vivo

${escapeHtml(share.boat_name || 'Shivao')}

posição ao vivo
AO VIVO
aguardando…
`; } 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));