import express from 'express'; import http from 'node:http'; 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'; import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js'; import * as gcal from './google-calendar.js'; import * as tuya from './tuya.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); }); // Diagnostic log endpoint — recebe log do BLE pra debugar app.post('/api/bms/diag-log', requireAuth, (req, res) => { const { log } = req.body || {}; if (!log || typeof log !== 'string') return res.status(400).json({ error: 'log string required' }); const dir = path.join(db.dataDir, 'diag-logs'); try { fs.mkdirSync(dir, { recursive: true }); } catch {} const ts = new Date().toISOString().replace(/[:.]/g, '-'); const file = path.join(dir, `${req.user.id}-${ts}.txt`); try { fs.writeFileSync(file, log.slice(0, 50000)); db.audit(req.user.id, 'bms_diag_log', 'bluetooth', null, { bytes: log.length, file: path.basename(file) }, req.ip); res.json({ ok: true, file: path.basename(file) }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ADMIN: lista TODOS os logs (BOAT_TOKEN apenas) app.get('/api/bms/diag-log/_all', requireAuth, (req, res) => { if (!req.user.viaBoatToken) return res.status(403).json({ error: 'admin only' }); const dir = path.join(db.dataDir, 'diag-logs'); try { if (!fs.existsSync(dir)) return res.json({ files: [] }); const files = fs.readdirSync(dir).map(f => { const stat = fs.statSync(path.join(dir, f)); return { name: f, size: stat.size, mtime: stat.mtime }; }).sort((a, b) => b.mtime - a.mtime); res.json({ files }); } catch (e) { res.status(500).json({ error: e.message }) } }); // Lista logs disponíveis (debug) app.get('/api/bms/diag-log', requireAuth, (req, res) => { const dir = path.join(db.dataDir, 'diag-logs'); try { if (!fs.existsSync(dir)) return res.json({ files: [] }); const files = fs.readdirSync(dir) .filter(f => f.startsWith(`${req.user.id}-`)) .map(f => { const stat = fs.statSync(path.join(dir, f)); return { name: f, size: stat.size, mtime: stat.mtime }; }) .sort((a, b) => b.mtime - a.mtime); res.json({ files }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Lê conteúdo de um log específico app.get('/api/bms/diag-log/:file', requireAuth, (req, res) => { const file = req.params.file.replace(/[^a-zA-Z0-9._-]/g, ''); // Admin (BOAT_TOKEN) lê qualquer; user normal só os próprios if (!req.user.viaBoatToken && !file.startsWith(`${req.user.id}-`)) { return res.status(403).json({ error: 'forbidden' }); } const fullPath = path.join(db.dataDir, 'diag-logs', file); try { if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'not found' }); const content = fs.readFileSync(fullPath, 'utf8'); res.type('text/plain').send(content); } catch (e) { res.status(500).json({ error: e.message }); } }); // ===== IoT (Smart Life / Tuya) ===== // Proxy assinado pra Tuya Cloud API. Access Secret nunca vai pro client. app.get('/api/iot/devices', requireAuth, async (req, res) => { if (!tuya.isEnabled()) return tuya.disabledResponse(res); try { const r = await tuya.listDevices(); if (r.error) return res.status(502).json({ error: r.error, code: r.code }); // Enriquece com label humano const devices = r.devices.map(d => ({ ...d, category_label: tuya.categoryLabel(d.category) })); res.json({ devices }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/iot/status/:deviceId', requireAuth, async (req, res) => { if (!tuya.isEnabled()) return tuya.disabledResponse(res); const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, ''); if (!deviceId) return res.status(400).json({ error: 'deviceId required' }); try { const r = await tuya.getDeviceStatus(deviceId); if (r.error) return res.status(502).json({ error: r.error, code: r.code }); res.json({ status: r.status }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/iot/command/:deviceId', requireAuth, async (req, res) => { if (!tuya.isEnabled()) return tuya.disabledResponse(res); const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, ''); const { commands } = req.body || {}; if (!deviceId) return res.status(400).json({ error: 'deviceId required' }); if (!Array.isArray(commands) || commands.length === 0) { return res.status(400).json({ error: 'commands array required' }); } // Validação básica: cada item precisa ter code:string + value for (const c of commands) { if (!c || typeof c.code !== 'string') { return res.status(400).json({ error: 'each command needs {code:string, value:any}' }); } } try { const r = await tuya.sendCommand(deviceId, commands); if (!r.ok) return res.status(502).json({ error: r.error, code: r.code }); db.audit(req.user.id, 'iot_command', 'tuya', deviceId, { commands }, req.ip); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ===== Google Login via OAuth redirect (pra apps Capacitor onde GSI popup não funciona) ===== // In-memory store: session_id → { tokens, createdAt }. Auto-expira em 10min. const pendingGoogleSessions = new Map(); setInterval(() => { const now = Date.now(); for (const [k, v] of pendingGoogleSessions.entries()) { if (now - v.createdAt > 10 * 60 * 1000) pendingGoogleSessions.delete(k); } }, 60000); // App chama isso pra obter URL pro browser externo app.get('/api/auth/google/start', (req, res) => { if (!gcal.isEnabled()) return gcal.disabledResponse(res); const sessionId = req.query.session || ('s_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)); // Reusa buildAuthUrl mas com flow=login e session encoded em state // Truque: passa userId=0 (não-autenticado) + flow no returnTo const stateRaw = Buffer.from(JSON.stringify({ uid: 0, flow: 'login', session: sessionId, n: Math.random().toString(36).slice(2) })).toString('base64url'); const u = new URL('https://accounts.google.com/o/oauth2/v2/auth'); u.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID); u.searchParams.set('redirect_uri', process.env.GOOGLE_REDIRECT_URI); u.searchParams.set('response_type', 'code'); u.searchParams.set('scope', 'openid email profile'); u.searchParams.set('access_type', 'online'); u.searchParams.set('prompt', 'select_account'); u.searchParams.set('state', stateRaw); res.json({ url: u.toString(), session: sessionId }); }); // App polling: retorna 204 se ainda esperando, 200 com tokens se Google completou app.get('/api/auth/google/poll', (req, res) => { const session = req.query.session; if (!session) return res.status(400).json({ error: 'session required' }); const data = pendingGoogleSessions.get(session); if (!data) return res.status(204).send(); pendingGoogleSessions.delete(session); // one-shot res.json(data.tokens); }); // Login com Google (Sign-In via popup do GSI no web) — recebe ID token, valida no Google, cria/loga user app.post('/api/auth/google', async (req, res) => { const { credential } = req.body || {}; if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' }); try { // Valida o ID token via tokeninfo endpoint do Google (sem dependência adicional) const r = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`); if (!r.ok) return res.status(401).json({ error: 'invalid_google_token' }); const info = await r.json(); // Confere que aud = nosso CLIENT_ID const expectedAud = process.env.GOOGLE_CLIENT_ID || ''; if (expectedAud && info.aud !== expectedAud) { return res.status(401).json({ error: 'token_audience_mismatch' }); } if (!info.email_verified || info.email_verified === 'false') { return res.status(403).json({ error: 'email_not_verified' }); } const email = info.email; const name = info.name || email.split('@')[0]; let user = db.findUserByEmail(email); if (!user) { // Auto-cria com senha aleatória inutilizável (login só via Google daqui pra frente) const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); const hash = await hashPassword(randomPwd); const id = db.createUser(email, hash, name); db.audit(id, 'user_signup_google', 'user', String(id), { email }, req.ip); user = db.findUserById(id); } db.updateLastLogin(user.id); db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip); const safe = db.findUserById(user.id); res.json({ user: safe, accessToken: signAccessToken(safe), refreshToken: signRefreshToken(safe), }); } catch (e) { console.warn('[auth/google]', e.message); res.status(500).json({ error: e.message }); } }); // 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' ] } }]); }); // Atalho: /apk redireciona pra última APK release no Forgejo const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.12.0/Shivao-v1.12.0.apk'; app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL)); // Página A4 imprimível com QR Code + instruções (cola no barco/marina) app.get('/imprimir', (req, res) => { const url = `https://${req.headers.host || 'shivao.pontualtech.work'}/apk`; const qrApi = `https://api.qrserver.com/v1/create-qr-code/?size=480x480&data=${encodeURIComponent(url)}&color=0e2a3d&bgcolor=ffffff&qzone=2&format=png`; res.type('html').send(`Imprimir · QR Code Shivao

Shivao

Diário de Bordo · Aplicativo Náutico
GPS · Vigia de Fundeio · Meteorologia
QR Code
aponte a câmera do celular Android aqui ↑
PASSO 1
Abra a câmera do seu Android e aponte pro QR Code acima
PASSO 2
Toque na notificação que aparece pra abrir o link no Chrome
PASSO 3
O Chrome baixa o APK (3,4 MB). Toque no download → Instalar
PASSO 4
Aceite "fontes desconhecidas" se pedir. Pronto — abra o ícone Shivao
Não tem leitor de QR? Digite no Chrome:
shivao.pontualtech.work/apk
Shivao · PontualTech · CNPJ 32.772.178/0001-47
shivao.pontualtech.work/politica · /termos
`); }); // QR Code da URL /apk pra facilitar instalação no celular app.get('/qr', (req, res) => { const url = `https://${req.headers.host || 'shivao.pontualtech.work'}/apk`; const qrApi = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(url)}&color=0e2a3d&bgcolor=efe5cd&qzone=2`; res.type('html').send(`Baixar app · Shivao

Shivao · Diário de Bordo

Aponte a câmera do celular pro QR Code:

QR Code APK Shivao
${url}
Baixar APK direto Android: o Chrome vai baixar o APK. Toque na notificação de download e siga as instruções pra instalar. `); }); // 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, googleCalendar: gcal.isEnabled(), version: '1.0' }); }); // ===== Google Calendar OAuth + sync ===== app.get('/api/google/status', requireAuth, (req, res) => { if (!gcal.isEnabled()) return res.json({ enabled: false }); const conn = db.getGoogleConnection(req.user.id); res.json({ enabled: true, connected: !!conn, email: conn?.email || null, last_sync_at: conn?.last_sync_at || null, }); }); app.get('/api/google/auth-url', requireAuth, (req, res) => { if (!gcal.isEnabled()) return gcal.disabledResponse(res); const returnTo = (req.query.return_to || '/').toString().slice(0, 200); const url = gcal.buildAuthUrl(req.user.id, returnTo); res.json({ url }); }); app.get('/api/google/callback', async (req, res) => { if (!gcal.isEnabled()) return gcal.disabledResponse(res); const { code, state, error } = req.query; if (error) return res.status(400).send(`Erro do Google: ${error}`); if (!code || !state) return res.status(400).send('Faltam parâmetros code/state.'); let parsed; try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); } catch (e) { return res.status(400).send('state inválido'); } try { const tokens = await gcal.exchangeCodeForTokens(code); const userInfo = await gcal.getUserInfo(tokens.access_token); // === Flow LOGIN (do app via /api/auth/google/start) === if (parsed.flow === 'login' && parsed.session) { let user = db.findUserByEmail(userInfo.email); if (!user) { const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); const hash = await hashPassword(randomPwd); const id = db.createUser(userInfo.email, hash, userInfo.name || userInfo.email.split('@')[0]); db.audit(id, 'user_signup_google', 'user', String(id), { email: userInfo.email }, req.ip); user = db.findUserById(id); } db.updateLastLogin(user.id); db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip); const safe = db.findUserById(user.id); // Salva tokens em memória pra app coletar via /poll pendingGoogleSessions.set(parsed.session, { tokens: { user: safe, accessToken: signAccessToken(safe), refreshToken: signRefreshToken(safe), }, createdAt: Date.now(), }); return res.send(`Login OK

Logado com sucesso

Você está conectado como ${userInfo.email}

Volte pro app — ele vai detectar o login automaticamente em alguns segundos.

`); } // === Flow CALENDAR (conectar Google Calendar pra user já logado) === const userId = parsed.uid; if (!userId) return res.status(400).send('state sem uid'); db.saveGoogleConnection(userId, { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + (tokens.expires_in * 1000), email: userInfo.email, }); db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip); const returnTo = parsed.rt || '/'; res.send(`Google conectado

✓ Google Agenda conectado

Conectado como ${userInfo.email}

Pode fechar esta janela e voltar pro app.

`); } catch (e) { console.warn('[google] callback failed', e.message); res.status(500).send('Erro: ' + e.message); } }); app.post('/api/google/disconnect', requireAuth, (req, res) => { if (!gcal.isEnabled()) return gcal.disabledResponse(res); db.deleteGoogleConnection(req.user.id); db.audit(req.user.id, 'google_disconnected', 'google_calendar', null, null, req.ip); res.json({ ok: true }); }); // Push: envia/atualiza um evento no Google a partir de uma pendência local app.post('/api/google/sync-pending', requireAuth, async (req, res) => { if (!gcal.isEnabled()) return gcal.disabledResponse(res); const { pending } = req.body || {}; if (!pending || !pending.id) return res.status(400).json({ error: 'pending.id required' }); try { if (pending.deleted) { if (pending.googleEventId) await gcal.deleteEvent(req.user.id, pending.googleEventId); return res.json({ ok: true, deleted: true }); } const ev = await gcal.upsertEventForPending(req.user.id, pending); res.json({ ok: true, event: { id: ev.id, htmlLink: ev.htmlLink, updated: ev.updated } }); } catch (e) { if (String(e.message).includes('not_connected')) { return res.status(409).json({ error: 'not_connected' }); } res.status(500).json({ error: e.message }); } }); // Pull: lista eventos modificados no Google (cliente faz merge) app.get('/api/google/pull', requireAuth, async (req, res) => { if (!gcal.isEnabled()) return gcal.disabledResponse(res); try { const conn = db.getGoogleConnection(req.user.id); if (!conn) return res.status(409).json({ error: 'not_connected' }); const result = await gcal.listChangedEvents(req.user.id, conn.sync_token); if (result.nextSyncToken) { db.setGoogleSyncToken(req.user.id, result.nextSyncToken, Date.now()); } const items = (result.items || []).map(gcal.eventToPending); res.json({ items }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- 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); // Notifica outros devices do mesmo user em tempo real (não bloqueia resposta) const originDeviceId = req.headers['x-device-id'] || req.query.device || null; broadcastStateChange(req.user.id, { kind: 'state', updated_at: ts, originDeviceId }); res.json({ ok: true, updated_at: ts, online_devices: getOnlineCount(req.user.id) }); }); // --- 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 ==== const httpServer = http.createServer(app); initRealtime(httpServer); httpServer.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));