shivao-projeto/server/src/index.js
PontualTech / Karlão 5a64e0897f
Some checks failed
Build Android (APK + AAB) / build-android (push) Has been cancelled
feat(iot): controle Smart Life/Tuya — Casa do Barco v1.12.0
Servidor (proxy assinado):
- server/src/tuya.js: cliente Tuya OpenAPI com HMAC-SHA256 + token cache
  (2h TTL, retry 1x em token expirado), helpers categoryLabel
- 3 endpoints novos em server/src/index.js (todos requireAuth):
  * GET  /api/iot/devices              → lista devices da conta Smart Life
  * GET  /api/iot/status/:deviceId     → DPs (data points) atuais
  * POST /api/iot/command/:deviceId    → envia comandos {code,value}
- Audit log via db.audit('iot_command', ...) pra histórico de toggles
- 503 graceful quando TUYA_ACCESS_ID/SECRET ausentes

Client (UI):
- Card 🏠 Casa do Barco em Arquivo (após Bluetooth, antes Raymarine)
- Modal "Adicionar dispositivo" lista devices da conta Smart Life,
  permite escolher quais aparecem no Shivão (multi-select via toque)
- Cards por device com ícone por categoria (cz=tomada, dj=lâmpada,
  fs=ventilador, kt=ar-cond, sd=robô, etc.) + toggle ON/OFF + status
  online/offline + tempo desde último ping
- Toggle optimistic UI: marca novo estado imediato, reverte se falhar
- Polling 10s pra sync de status, pausa em background (economiza
  Starlink + bateria)
- Backoff: 3 falhas consec → marca offline, retry 30s
- state.smartDevices[] persistido no localStorage (mesmo padrão btDevices)

Setup (admin, 1x):
- Karlão precisa criar projeto em iot.tuya.com (5 min, gratuito) e
  adicionar TUYA_ACCESS_ID + TUYA_ACCESS_SECRET no env Coolify
- Documentação completa no .env.example com passo a passo
- Sem credenciais: card mostra "⚙ Tuya não configurado"

Bumps:
- APP_VERSION 1.11.0 → 1.12.0
- sw.js VERSION shivao-v1.11.0 → shivao-v1.12.0
- mobile/package.json + build.gradle (versionCode 32→33)
- LATEST_APK_URL atualizado pro release v1.12.0

Fix gitignore:
- .env.example em pastas nested (server/.env.example) estava bloqueado
  por **/.env.* — adicionado !**/.env.example pra liberar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:34:02 -03:00

1272 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><title>Imprimir · QR Code Shivao</title><style>
@page{size:A4;margin:0}
*{box-sizing:border-box;-webkit-print-color-adjust:exact;print-color-adjust:exact}
body{margin:0;padding:0;background:#efe5cd;font-family:Georgia,serif;color:#0e2a3d}
.page{width:210mm;min-height:297mm;padding:25mm 20mm;margin:0 auto;background:linear-gradient(180deg,#fbf5e2 0%,#efe5cd 100%);position:relative;overflow:hidden}
.page::before{content:'';position:absolute;top:0;left:0;right:0;height:8mm;background:linear-gradient(90deg,#0e2a3d,#a07832,#0e2a3d)}
.page::after{content:'';position:absolute;bottom:0;left:0;right:0;height:8mm;background:linear-gradient(90deg,#0e2a3d,#a07832,#0e2a3d)}
.head{text-align:center;margin-bottom:8mm}
.brand{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:6mm}
.compass{width:42px;height:42px;border:2.5px solid #a07832;border-radius:50%;position:relative;flex-shrink:0}
.compass::before,.compass::after{content:'';position:absolute;background:#a07832}
.compass::before{top:50%;left:-3px;right:-3px;height:2px;transform:translateY(-50%)}
.compass::after{top:-3px;bottom:-3px;left:50%;width:2px;transform:translateX(-50%)}
h1{font-style:italic;font-size:34pt;color:#a07832;margin:0;letter-spacing:-0.5px}
.subtitle{font-style:italic;font-size:13pt;color:#5d7186;margin-top:1mm}
.tagline{font-family:'Courier New',monospace;font-size:9pt;letter-spacing:3px;text-transform:uppercase;color:#7d6943;margin-top:2mm}
.qr-section{background:#fff;padding:10mm;border-radius:6mm;box-shadow:0 4px 16px rgba(14,42,61,.12);margin:8mm auto;width:fit-content;text-align:center;border:1px solid rgba(184,156,108,.3)}
.qr-section img{display:block;margin:0 auto;width:140mm;height:140mm;max-width:100%}
.qr-label{font-family:'Courier New',monospace;font-size:11pt;color:#0e2a3d;margin-top:5mm;letter-spacing:1px}
.steps{margin:8mm 0;display:grid;grid-template-columns:repeat(2,1fr);gap:5mm}
.step{background:rgba(255,255,255,.55);padding:5mm 6mm;border-radius:4mm;border-left:3px solid #a07832}
.step-num{font-family:'Courier New',monospace;font-size:9pt;color:#a07832;letter-spacing:2px;font-weight:600}
.step-text{font-size:11pt;line-height:1.5;margin-top:2mm;color:#0e2a3d}
.step-text strong{color:#0e2a3d}
.url-box{background:#0e2a3d;color:#efe5cd;padding:5mm 8mm;text-align:center;border-radius:4mm;margin-top:4mm}
.url-box .label{font-family:'Courier New',monospace;font-size:8pt;letter-spacing:3px;text-transform:uppercase;opacity:.7;margin-bottom:2mm}
.url-box .url{font-family:'Courier New',monospace;font-size:14pt;letter-spacing:1px;color:#c89f54;font-weight:600}
.foot{position:absolute;bottom:14mm;left:20mm;right:20mm;text-align:center;font-size:8pt;color:#5d7186;font-style:italic;line-height:1.6}
.foot a{color:#a07832;text-decoration:none}
.print-btn{position:fixed;bottom:20px;right:20px;background:#a07832;color:#fff;padding:14px 24px;border-radius:8px;border:none;font-family:Georgia,serif;font-size:14pt;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:1000}
@media print{.print-btn{display:none}}
</style></head><body>
<div class="page">
<div class="head">
<div class="brand"><div class="compass"></div><h1>Shivao</h1></div>
<div class="subtitle">Diário de Bordo · Aplicativo Náutico</div>
<div class="tagline">GPS · Vigia de Fundeio · Meteorologia</div>
</div>
<div class="qr-section">
<img src="${qrApi}" alt="QR Code">
<div class="qr-label">aponte a câmera do celular Android aqui ↑</div>
</div>
<div class="steps">
<div class="step"><div class="step-num">PASSO 1</div><div class="step-text">Abra a <strong>câmera</strong> do seu Android e aponte pro QR Code acima</div></div>
<div class="step"><div class="step-num">PASSO 2</div><div class="step-text">Toque na notificação que aparece pra abrir o link no <strong>Chrome</strong></div></div>
<div class="step"><div class="step-num">PASSO 3</div><div class="step-text">O Chrome baixa o <strong>APK</strong> (3,4 MB). Toque no download → <strong>Instalar</strong></div></div>
<div class="step"><div class="step-num">PASSO 4</div><div class="step-text">Aceite "fontes desconhecidas" se pedir. <strong>Pronto</strong> — abra o ícone Shivao</div></div>
</div>
<div class="url-box">
<div class="label">Não tem leitor de QR? Digite no Chrome:</div>
<div class="url">shivao.pontualtech.work/apk</div>
</div>
<div class="foot">
Shivao · PontualTech · CNPJ 32.772.178/0001-47<br>
<a href="https://shivao.pontualtech.work/politica">shivao.pontualtech.work/politica</a> · <a href="https://shivao.pontualtech.work/termos">/termos</a>
</div>
</div>
<button class="print-btn" onclick="window.print()">🖨️ Imprimir / Salvar PDF</button>
</body></html>`);
});
// 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(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Baixar app · Shivao</title><style>body{font-family:Georgia,serif;background:#efe5cd;color:#0e2a3d;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:20px}h1{font-style:italic;color:#a07832;margin:0 0 8px}p{margin:0 0 24px;color:#5d7186}.qr{background:#fff;padding:24px;border-radius:16px;box-shadow:0 8px 32px rgba(14,42,61,.15);margin:8px 0}img{display:block}.url{font-family:'Courier New',monospace;font-size:13px;background:#fff;padding:10px 14px;border-radius:8px;margin-top:14px;border:1px solid rgba(184,156,108,.3)}.btn{display:inline-block;background:linear-gradient(180deg,#143a52,#0e2a3d);color:#efe5cd;padding:14px 24px;border-radius:8px;text-decoration:none;margin-top:20px;font-family:Georgia,serif;font-style:italic;letter-spacing:.5px;box-shadow:0 4px 12px rgba(14,42,61,.2)}.btn:hover{transform:translateY(-1px)}small{opacity:.6;margin-top:24px;text-align:center;max-width:340px;line-height:1.5}</style></head><body>
<h1>Shivao · Diário de Bordo</h1>
<p>Aponte a câmera do celular pro QR Code:</p>
<div class="qr"><img src="${qrApi}" alt="QR Code APK Shivao" width="400" height="400"></div>
<div class="url">${url}</div>
<a class="btn" href="/apk">Baixar APK direto</a>
<small>Android: o Chrome vai baixar o APK. Toque na notificação de download e siga as instruções pra instalar.</small>
</body></html>`);
});
// Termos de Uso
app.get('/termos', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Termos de Uso · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}.warn{background:#8c343422;border-left:4px solid #8c3434;padding:12px 16px;margin:20px 0}a{color:#3f7768}small{opacity:.7}</style></head><body>
<h1>Termos de Uso — Shivao</h1>
<p><small>Última atualização: 27 de abril de 2026 · Versão 1.0</small></p>
<p>Ao usar o aplicativo <strong>Shivao</strong> (operado por <strong>PontualTech</strong>, CNPJ 32.772.178/0001-47), você concorda com estes termos. Leia com atenção.</p>
<h2>1. Aceitação</h2>
<p>O uso do app implica aceitação completa destes termos. Se não concorda, não use.</p>
<h2>2. Cadastro e conta</h2>
<ul>
<li>Você precisa ter ≥18 anos OU autorização do responsável legal.</li>
<li>Informações verdadeiras e atualizadas. Senha é responsabilidade sua.</li>
<li>1 conta por usuário. Compartilhamento de credenciais é proibido.</li>
<li>Podemos suspender contas com violação destes termos.</li>
</ul>
<h2>3. Planos, pagamentos e renovação</h2>
<ul>
<li><strong>Free:</strong> grátis, recursos limitados (vigia local + diário 10 últimas).</li>
<li><strong>Pro/Captain:</strong> assinatura mensal ou anual via Asaas (PIX, cartão, boleto).</li>
<li><strong>Renovação:</strong> ao fim do ciclo, você renova manualmente. Sem cobrança automática surpresa.</li>
<li><strong>Reembolso:</strong> 7 dias de arrependimento (CDC art. 49) — devolução integral via mesmo método. Após 7 dias: pro-rata do tempo restante.</li>
</ul>
<h2>4. Uso permitido</h2>
<ul>
<li>Uso pessoal ou profissional náutico (lazer, trabalho, charters).</li>
<li>1 usuário = 1 ou múltiplos barcos (no plano Captain).</li>
<li>Compartilhamento público de posição é OK pra tripulação/familia (links temporários).</li>
</ul>
<h2>5. Uso PROIBIDO</h2>
<ul>
<li>❌ Engenharia reversa do app ou backend (exceto pra interoperar legalmente).</li>
<li>❌ Revender o serviço como white-label sem licença comercial.</li>
<li>❌ Atacar a infraestrutura (DDoS, brute-force, exploit).</li>
<li>❌ Cadastrar bots ou contas falsas em massa.</li>
<li>❌ Usar pra atividade ilegal (pesca em área proibida, navegação clandestina, etc).</li>
</ul>
<h2>6. Limitação de responsabilidade — IMPORTANTE</h2>
<div class="warn"><strong>⚠️ AVISO CRÍTICO PARA NAVEGAÇÃO:</strong>
<p>O Shivao é uma <strong>FERRAMENTA AUXILIAR</strong> de navegação e segurança. <strong>NÃO substitui</strong>:</p>
<ul>
<li>Equipamentos náuticos certificados (chartplotter, AIS, VHF, balsas).</li>
<li>Cartas náuticas oficiais (Marinha do Brasil, NOAA).</li>
<li>Atenção do skipper.</li>
<li>Comunicação com a Capitania dos Portos.</li>
</ul>
<p>Não nos responsabilizamos por:</p>
<ul>
<li>Decisões tomadas com base no app (rota, fundeio, meteorologia).</li>
<li>Falha de GPS, internet, sensores ou notificações.</li>
<li>Danos materiais, pessoais ou ambientais decorrentes do uso.</li>
<li>Perda de dados (faça backups regulares).</li>
</ul>
<p><strong>O comandante da embarcação é o único responsável pela segurança a bordo.</strong></p></div>
<h2>7. Propriedade intelectual</h2>
<ul>
<li>Código, logo, nome "Shivao" pertencem à PontualTech.</li>
<li>Seus dados (viagens, mídia) pertencem a VOCÊ — exporte quando quiser, exclua a qualquer momento.</li>
<li>Bibliotecas open source: Leaflet (BSD-2), OpenStreetMap (ODbL), express-rate-limit (MIT), bcryptjs (MIT), jsonwebtoken (MIT).</li>
</ul>
<h2>8. Suspensão e cancelamento</h2>
<ul>
<li>Você pode cancelar a qualquer momento via app (Aba Conta → Sair → Excluir conta).</li>
<li>Podemos suspender se houver violação destes termos, com aviso por e-mail (exceto urgências de segurança).</li>
<li>Após cancelamento: 30 dias pra reativar, depois exclusão permanente.</li>
</ul>
<h2>9. Mudanças nos termos</h2>
<p>Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual em <a href="https://shivao.pontualtech.work/termos">shivao.pontualtech.work/termos</a>.</p>
<h2>10. Lei aplicável e foro</h2>
<p>Estes termos seguem a lei brasileira. Foro: comarca de São Paulo/SP. Disputas de consumo podem usar <a href="https://www.consumidor.gov.br">consumidor.gov.br</a> antes de judicializar.</p>
<h2>11. Contato</h2>
<p>Suporte: <a href="mailto:contato@pontualtech.com.br">contato@pontualtech.com.br</a><br>
Privacidade/LGPD: <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a></p>
<hr style="margin:40px 0;border:none;border-top:1px solid #a07832">
<p style="text-align:center"><small>Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}</small></p>
</body></html>`);
});
// Política de Privacidade (URL pública obrigatória pra Play Store + LGPD)
app.get('/politica', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Política de Privacidade · Shivao</title><style>body{font-family:Georgia,serif;max-width:780px;margin:40px auto;padding:0 20px;color:#0e2a3d;background:#efe5cd;line-height:1.7}h1,h2{font-style:italic;color:#a07832}h1{border-bottom:2px solid #a07832;padding-bottom:8px}h2{margin-top:32px}code{background:#fff;padding:2px 6px;border-radius:3px;font-size:.9em}a{color:#3f7768}small{opacity:.7}</style></head><body>
<h1>Política de Privacidade — Shivao</h1>
<p><small>Última atualização: 27 de abril de 2026 · Versão 1.0</small></p>
<p>O <strong>Shivao</strong> é um aplicativo de diário de bordo náutico operado por <strong>PontualTech</strong> (CNPJ 32.772.178/0001-47, São Paulo/SP, Brasil). Esta política descreve como coletamos, usamos e protegemos seus dados pessoais, em conformidade com a <strong>LGPD (Lei 13.709/2018)</strong> e o <strong>GDPR (Regulamento UE 2016/679)</strong>.</p>
<h2>1. Quais dados coletamos</h2>
<ul>
<li><strong>Conta:</strong> e-mail, senha (hash bcrypt), nome opcional.</li>
<li><strong>Dados de bordo:</strong> registros de viagens, manutenções, fotos/áudios/vídeos que VOCÊ adicionar, posições GPS quando você ativa rastreio ou vigia.</li>
<li><strong>Pagamentos:</strong> processados pela Asaas (parceiro PCI-DSS). Não armazenamos número de cartão.</li>
<li><strong>Logs técnicos:</strong> IP, timestamps, ações sensíveis (criar/revogar share, sync de estado) — guardados por 90 dias para auditoria de segurança.</li>
</ul>
<h2>2. O que NÃO coletamos</h2>
<ul>
<li>❌ Analytics de comportamento (Google Analytics, Facebook Pixel, etc).</li>
<li>❌ Tracking entre apps/sites.</li>
<li>❌ Anúncios de terceiros.</li>
<li>❌ Compartilhamento com brokers de dados.</li>
</ul>
<h2>3. Para que usamos seus dados</h2>
<ul>
<li>Operar o serviço (sync, vigia de fundeio, alarme remoto).</li>
<li>Processar pagamentos (apenas Asaas).</li>
<li>Enviar e-mails operacionais (recuperação de senha, confirmação de pagamento, alerta de fundeio).</li>
<li>Cumprir obrigações legais (notas fiscais, intimações judiciais quando aplicável).</li>
</ul>
<h2>4. Onde seus dados ficam</h2>
<p>Servidores próprios em data center na Alemanha (Hetzner Online GmbH, certificado ISO 27001), gerenciados pela PontualTech. Backups criptografados.</p>
<h2>5. Permissões do app Android</h2>
<ul>
<li><strong>Localização (incluindo background):</strong> imprescindível pra GPS de viagens, vigia de fundeio com alarme de drift e compartilhamento ao vivo.</li>
<li><strong>Câmera/Microfone/Galeria:</strong> apenas quando você anexar mídia a um registro.</li>
<li><strong>Notificações:</strong> alarme local de fundeio (toca som + vibra mesmo com tela apagada).</li>
<li><strong>Sensores (bússola, barômetro):</strong> usados localmente no celular, não transmitidos.</li>
</ul>
<h2>6. Compartilhamento de posição ao vivo</h2>
<p>Quando você cria um link público de compartilhamento, qualquer pessoa com o link vê a posição do barco em tempo real. Você controla a duração e pode revogar a qualquer momento. Os links usam tokens randômicos de 96 bits (impossíveis de adivinhar).</p>
<h2>7. Seus direitos (LGPD/GDPR)</h2>
<p>Você pode, a qualquer momento, solicitar:</p>
<ul>
<li>Acesso aos seus dados (exportar tudo via app).</li>
<li>Correção de dados incorretos.</li>
<li>Exclusão da conta e todos os dados (delete em até 30 dias).</li>
<li>Portabilidade (exportar GPX/CSV/JSON).</li>
<li>Revogar consentimento (cancelar assinatura).</li>
</ul>
<p>Solicitações por <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a>.</p>
<h2>8. Retenção de dados</h2>
<ul>
<li>Conta ativa: enquanto você usar.</li>
<li>Conta cancelada: 30 dias (período de arrependimento), depois exclusão permanente.</li>
<li>Logs de auditoria: 90 dias.</li>
<li>Notas fiscais: 5 anos (exigência legal).</li>
</ul>
<h2>9. Cookies</h2>
<p>O app usa apenas <code>localStorage</code> e <code>IndexedDB</code> locais (não são cookies HTTP). Sem cookies de tracking de terceiros.</p>
<h2>10. Crianças</h2>
<p>O Shivao não é destinado a menores de 13 anos. Não coletamos dados de menores intencionalmente.</p>
<h2>11. Mudanças nesta política</h2>
<p>Notificaremos por e-mail mudanças materiais com 30 dias de antecedência. Versão atual e histórico em <a href="https://shivao.pontualtech.work/politica">shivao.pontualtech.work/politica</a>.</p>
<h2>12. Contato e DPO</h2>
<p><strong>Encarregado de Dados (DPO):</strong> Karlão · <a href="mailto:dpo@pontualtech.com.br">dpo@pontualtech.com.br</a></p>
<p><strong>Suporte:</strong> <a href="mailto:contato@pontualtech.com.br">contato@pontualtech.com.br</a></p>
<p><strong>ANPD (autoridade brasileira):</strong> <a href="https://www.gov.br/anpd">gov.br/anpd</a></p>
<hr style="margin:40px 0;border:none;border-top:1px solid #a07832">
<p style="text-align:center"><small>Shivao · Diário de Bordo · PontualTech · ${new Date().getFullYear()}</small></p>
</body></html>`);
});
// PWA manifest (necessário pra "Add to Home Screen" + APK via PWABuilder)
app.get('/manifest.json', (req, res) => {
res.json({
name: 'Shivao · Diário de Bordo',
short_name: 'Shivao',
description: 'Diário de bordo do veleiro Shivao — viagens, manutenções, GPS, fundeio com alarme remoto',
start_url: '/',
display: 'standalone',
orientation: 'any',
background_color: '#0e2a3d',
theme_color: '#0e2a3d',
lang: 'pt-BR',
icons: [
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' }
],
categories: ['navigation', 'travel', 'productivity']
});
});
// ==== Static frontend ====
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
// ==== Authenticated API ====
// Server info (channels configured, version)
app.get('/api/info', requireAuth, (req, res) => {
res.json({
channels: listConfiguredChannels(),
heartbeatTimeoutSec: HEARTBEAT_TIMEOUT / 1000,
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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Login OK</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a;margin:0 0 16px}p{margin:8px 0;line-height:1.5}.big{font-size:64px;margin-bottom:16px}</style></head>
<body><div><div class="big">⚓</div><h1>Logado com sucesso</h1>
<p>Você está conectado como <strong>${userInfo.email}</strong></p>
<p style="opacity:.7">Volte pro app — ele vai detectar o login automaticamente em alguns segundos.</p>
<script>setTimeout(()=>{try{window.close()}catch(e){}},3000)</script>
</div></body></html>`);
}
// === 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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a}</style></head>
<body><div><h1>✓ Google Agenda conectado</h1>
<p>Conectado como <strong>${userInfo.email}</strong></p>
<p>Pode fechar esta janela e voltar pro app.</p>
<script>setTimeout(()=>{try{window.close()}catch(e){};location.href=${JSON.stringify(returnTo)}},2000)</script>
</div></body></html>`);
} 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 `<!DOCTYPE html><html lang="pt-BR"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Indisponível</title><style>body{font-family:system-ui,sans-serif;background:#0e2a3d;color:#faf2dd;margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}.box{text-align:center;max-width:380px}.box h1{font-family:Georgia,serif;font-style:italic;font-size:32px;margin:0 0 8px;color:#c89f54}.box p{opacity:.85;line-height:1.6}</style></head><body><div class="box"><h1>Shivao</h1><p>${errorMsg}</p></div></body></html>`;
}
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0e2a3d">
<title>${escapeHtml(share.boat_name || 'Shivao')} ao vivo</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0e2a3d;color:#faf2dd}
.bar{background:#0e2a3d;color:#faf2dd;padding:14px 16px;border-bottom:1px solid #6f5217;box-shadow:0 1px 0 #a07832 inset;display:flex;justify-content:space-between;align-items:center;gap:10px}
.bar-left h1{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-weight:500;font-size:22px;color:#c89f54;line-height:1}
.bar-left .sub{font-family:'JetBrains Mono','Courier New',monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#a07832;margin-top:3px}
.bar-right{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.04em;text-align:right;color:#a07832}
.bar-right .live::before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:#ff4444;margin-right:5px;animation:p 1.4s infinite}
@keyframes p{50%{opacity:.3}}
#map{height:calc(100vh - 56px);width:100%;background:#1a2733}
.empty-msg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(14,42,61,.9);color:#c89f54;padding:18px 24px;font-family:Georgia,serif;font-style:italic;text-align:center;border:1px solid #a07832}
</style>
</head>
<body>
<div class="bar">
<div class="bar-left"><h1>${escapeHtml(share.boat_name || 'Shivao')}</h1><div class="sub">posição ao vivo</div></div>
<div class="bar-right"><div class="live">AO VIVO</div><div id="last-update">aguardando…</div></div>
</div>
<div id="map"></div>
<script>
const TOKEN=${JSON.stringify(req.params.token)};
const map=L.map('map',{zoomControl:true});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,attribution:'© OSM'}).addTo(map);
let trail=null,marker=null,fitDone=false;
const boatIcon=L.divIcon({html:'<svg viewBox="0 0 24 24" width="28" height="28" fill="#c89f54" stroke="#0e2a3d" stroke-width="1.5"><path d="M3 18h18l-2-6H5z"/><path d="M12 3v9" stroke-width="1.5"/></svg>',iconSize:[28,28],iconAnchor:[14,14],className:''});
async function refresh(){
try{
// info (com zonas) na primeira vez
if(!window._zonesLoaded){
try{
const i=await fetch('/api/share/'+TOKEN+'/info');
if(i.ok){
const info=await i.json();
if(info.zones&&Array.isArray(info.zones)){
info.zones.forEach(z=>{
const color=z.type==='forbidden'?'#8c3434':'#b67025';
L.circle([z.center.lat,z.center.lng],{radius:z.radius,color,fillColor:color,fillOpacity:.15,weight:1.5,opacity:.6}).addTo(map).bindPopup(z.name+' · '+(z.type==='forbidden'?'Proibida':'Atenção'));
});
if(info.zones.length&&!fitDone){
const bounds=L.latLngBounds(info.zones.map(z=>[z.center.lat,z.center.lng]));
map.fitBounds(bounds,{padding:[40,40],maxZoom:13});
}
}
window._zonesLoaded=true;
}
}catch(e){}
}
const r=await fetch('/api/share/'+TOKEN+'/positions');
if(!r.ok){
if(r.status===404||r.status===410){
document.body.innerHTML='<div class="empty-msg">Link expirado ou revogado</div>';
return;
}
throw new Error('HTTP '+r.status);
}
const ps=await r.json();
if(!ps.length){
document.getElementById('last-update').textContent='sem posição';
if(!document.querySelector('.empty-msg')){
const e=document.createElement('div');e.className='empty-msg';e.textContent='Aguardando primeira posição…';document.body.appendChild(e);
}
return;
}
document.querySelectorAll('.empty-msg').forEach(e=>e.remove());
const last=ps[ps.length-1];
const ll=ps.map(p=>[p.lat,p.lng]);
if(trail)trail.remove();
if(ps.length>1)trail=L.polyline(ll,{color:'#a07832',weight:3,opacity:.6}).addTo(map);
if(!marker)marker=L.marker([last.lat,last.lng],{icon:boatIcon}).addTo(map);
else marker.setLatLng([last.lat,last.lng]);
if(!fitDone){fitDone=true;if(ps.length>1)map.fitBounds(ll,{padding:[40,40]});else map.setView([last.lat,last.lng],14)}
const ago=Math.round((Date.now()-last.ts)/1000);
const agoTxt=ago<60?ago+'s':ago<3600?Math.round(ago/60)+'min':Math.round(ago/3600)+'h';
const spd=last.speed?(last.speed*1.94384).toFixed(1)+' kn · ':'';
document.getElementById('last-update').textContent=spd+'há '+agoTxt;
}catch(e){console.error(e)}
}
refresh();
setInterval(refresh,15000);
</script>
</body>
</html>`;
}
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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));