Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Problema: Google Sign-In popup (GSI) não funciona em WebView nativo do Capacitor. FedCM bloqueia, popup não abre, ou retorna erro silenciosamente. Solução: detectar Capacitor (window.Capacitor) ou WebView (UA com 'wv') e usar OAuth redirect tradicional + polling em vez do popup GSI. Backend (server/src/index.js): - GET /api/auth/google/start — gera URL OAuth com state contendo session_id + flow:'login'. App chama isso e abre URL no browser externo. - /api/google/callback adaptado — quando state.flow=='login', cria/loga user por email do Google, gera JWT, armazena em pendingGoogleSessions (Map em memória, TTL 10min) por session_id, mostra HTML "logado, volte pro app". - GET /api/auth/google/poll?session=xxx — app faz polling 2s. Retorna 204 se ainda esperando, 200 com tokens (one-shot, deleta após). Frontend (app/diario-bordo.html): - Detecta Capacitor/WebView, força fluxo redirect+polling - Browser web: tenta GSI popup primeiro, fallback redirect se prompt for bloqueado (FedCM/popup blocker) - window.open abre Custom Tabs no Android (ou nova aba no PC) - Timeout de 4min (120 tries × 2s) pro polling Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1152 lines
56 KiB
JavaScript
1152 lines
56 KiB
JavaScript
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(`<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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));
|