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'; 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); }); // ===== 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.6.1/Shivao-v1.6.1.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(`
Aponte a câmera do celular pro QR Code:
Ú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, 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(`Você está conectado como ${userInfo.email}
Volte pro app — ele vai detectar o login automaticamente em alguns segundos.
Conectado como ${userInfo.email}
Pode fechar esta janela e voltar pro app.
${errorMsg}