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(`
Ú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.
O uso do app implica aceitação completa destes termos. Se não concorda, não use.
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.
Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual em shivao.pontualtech.work/termos.
Estes termos seguem a lei brasileira. Foro: comarca de São Paulo/SP. Disputas de consumo podem usar consumidor.gov.br antes de judicializar.
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(`Ú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).
Servidores próprios em data center na Alemanha (Hetzner Online GmbH, certificado ISO 27001), gerenciados pela PontualTech. Backups criptografados.
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).
Você pode, a qualquer momento, solicitar:
Solicitações por dpo@pontualtech.com.br.
O app usa apenas localStorage e IndexedDB locais (não são cookies HTTP). Sem cookies de tracking de terceiros.
O Shivao não é destinado a menores de 13 anos. Não coletamos dados de menores intencionalmente.
Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual e histórico em shivao.pontualtech.work/politica.
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 `${errorMsg}