feat(saas): multi-tenant com login/cadastro + JWT + planos free/pro/captain

BACKEND
- bcryptjs + jsonwebtoken adicionados (JS puro, sem build nativo)
- Schema users + licenses, migration adiciona user_id em todas tabelas (state, media, anchor_session, alarm_log, shares, audit_log)
- User default id=1 (karlao@outlook.com) com plano captain — preserva uso pessoal pré-multi-tenant
- Endpoints /api/auth/{signup,login,refresh,me} + /api/license
- Middleware requireAuth aceita JWT OU BOAT_TOKEN (fallback legado mapeia ao user 1)
- TODAS rotas autenticadas atualizadas pra usar req.user.id (state, media, anchor, share, alarm, audit)
- Dead-man switch agora itera todos anchor_sessions ativos (multi-user)
- 3 planos definidos em auth.js: free (Âncora), pro (R$19/mês), captain (R$39/mês)

FRONTEND
- state.auth + state.license persistidos em localStorage
- cloudFetch usa JWT preferencialmente, fallback BOAT_TOKEN; auto-refresh em 401
- Nova seção 'Conta' no painel Arquivo: tabs Entrar/Cadastrar + status de plano + Logout + botão upgrade
- Sincronizado em app/ e server/public/

Backward-compat 100% preservada: app legado com BOAT_TOKEN continua funcionando como user default.
Próximo: webhook Asaas pra ativar licenças após pagamento PIX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-27 15:37:15 -03:00
parent d1a2401048
commit 85b60a800c
8 changed files with 716 additions and 132 deletions

View file

@ -899,6 +899,7 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button> <button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button>
</div> </div>
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div> <div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div> </div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))"> <div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div> <div class="export-card-title">📡 Compartilhar posição em tempo real</div>
@ -1289,7 +1290,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div> <div class="zone-toast" id="zone-toast"></div>
<script> <script>
const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}}; const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const STORAGE_KEY='diario_bordo_v3'; const STORAGE_KEY='diario_bordo_v3';
const TRACKING_KEY='diario_tracking_v3'; const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3'; const ANCHOR_KEY='diario_anchor_v3';
@ -1437,7 +1438,7 @@ function renderGPSBanner(){
function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})} function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()} function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
function openModal(id){document.getElementById(id).classList.add('show')} function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')} function closeModal(id){document.getElementById(id).classList.remove('show')}
@ -2358,11 +2359,119 @@ function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
async function cloudFetch(path,opts={}){ async function cloudFetch(path,opts={}){
if(!cloudConfigured())throw new Error('Nuvem não configurada'); if(!cloudConfigured())throw new Error('Nuvem não configurada');
const r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${state.cloud.token}`,'Content-Type':'application/json',...(opts.headers||{})}}); // Usa JWT se logado (multi-tenant SaaS), senão BOAT_TOKEN legado (single-tenant pessoal)
if(!r.ok)throw new Error(`HTTP ${r.status}`); const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
const doFetch=()=>fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth}`,'Content-Type':'application/json',...(opts.headers||{})}});
let r=await doFetch();
// 401 com JWT? Tenta refresh + retry 1×
if(r.status===401&&state.auth&&state.auth.refreshToken){
const ok=await authRefresh();
if(ok){const auth2=state.auth.accessToken;r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth2}`,'Content-Type':'application/json',...(opts.headers||{})}})}
}
if(!r.ok){let detail='';try{const j=await r.clone().json();detail=j.error||JSON.stringify(j.issues||{})}catch{}throw new Error(`HTTP ${r.status}${detail?' · '+detail:''}`)}
return r; return r;
} }
// ===== Auth (multi-tenant SaaS — Login/Signup) =====
async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/signup'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password,name})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authLogin(email,password){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();
await refreshLicense();
renderAuthBox();
}
async function authRefresh(){
if(!state.auth||!state.auth.refreshToken)return false;
try{
const r=await fetch(cloudUrl('/api/auth/refresh'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refreshToken:state.auth.refreshToken})});
if(!r.ok){state.auth=null;saveState();return false}
const j=await r.json();
state.auth.accessToken=j.accessToken;
saveState();
return true;
}catch{return false}
}
function authLogout(){
state.auth=null;
state.license=null;
saveState();
renderAuthBox();
toast('Sessão encerrada');
}
async function refreshLicense(){
try{
const r=await cloudFetch('/api/license');
state.license=await r.json();
saveState();
}catch(e){console.warn('license fetch:',e.message)}
}
function renderAuthBox(){
const box=document.getElementById('auth-box');
if(!box)return;
if(state.auth&&state.auth.user){
const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="alert(\'Upgrade pra Pro: R$19/mês ou R$149/ano. Em breve cobrança via Asaas (PIX).\')">⚡ Fazer upgrade pra Pro</button>':''}`;
}else{
box.innerHTML=`
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-sm" id="auth-tab-login" onclick="document.getElementById('auth-form-login').style.display='block';document.getElementById('auth-form-signup').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-signup').classList.remove('btn-brass')" style="flex:1">Entrar</button>
<button class="btn btn-sm" id="auth-tab-signup" onclick="document.getElementById('auth-form-signup').style.display='block';document.getElementById('auth-form-login').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-login').classList.remove('btn-brass')" style="flex:1">Cadastrar</button>
</div>
<div id="auth-form-login">
<div class="field"><label class="field-label">Email</label><input type="email" id="login-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Senha</label><input type="password" id="login-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthLoginClick()">Entrar</button>
</div>
<div id="auth-form-signup" style="display:none">
<div class="field"><label class="field-label">Email</label><input type="email" id="signup-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Nome (opcional)</label><input type="text" id="signup-name" placeholder="Seu nome"></div>
<div class="field"><label class="field-label">Senha (mín 8 chars)</label><input type="password" id="signup-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthSignupClick()">Criar conta</button>
</div>
<div id="auth-msg" style="margin-top:8px;font-size:11px;color:var(--storm)"></div>`;
document.getElementById('auth-tab-login').classList.add('btn-brass');
}
}
async function onAuthLoginClick(){
const e=document.getElementById('login-email').value.trim();
const p=document.getElementById('login-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
}
async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim();
const p=document.getElementById('signup-pwd').value;
const msg=document.getElementById('auth-msg');
msg.style.color='var(--storm)';msg.textContent='';
if(p.length<8){msg.textContent='Senha precisa de no mínimo 8 caracteres';return}
try{await authSignup(e,p,n);msg.style.color='var(--algae)';msg.textContent='Conta criada e logado!';toast('Conta criada')}catch(err){msg.textContent=err.message}
}
function bindCloudInputs(){ function bindCloudInputs(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token'); const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
if(!u||!t)return; if(!u||!t)return;

117
server/package-lock.json generated
View file

@ -9,9 +9,11 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"zod": "^4.3.6" "zod": "^4.3.6"
@ -65,6 +67,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "11.10.0", "version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
@ -173,6 +184,12 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -366,6 +383,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -732,6 +758,97 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -12,9 +12,11 @@
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"zod": "^4.3.6" "zod": "^4.3.6"

View file

@ -899,6 +899,7 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button> <button class="btn btn-block btn-danger" onclick="cloudDisconnect()">Desconectar</button>
</div> </div>
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div> <div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div> </div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))"> <div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div> <div class="export-card-title">📡 Compartilhar posição em tempo real</div>
@ -1289,7 +1290,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div> <div class="zone-toast" id="zone-toast"></div>
<script> <script>
const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}}; const state={boat:{name:'Shivao',model:''},trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const STORAGE_KEY='diario_bordo_v3'; const STORAGE_KEY='diario_bordo_v3';
const TRACKING_KEY='diario_tracking_v3'; const TRACKING_KEY='diario_tracking_v3';
const ANCHOR_KEY='diario_anchor_v3'; const ANCHOR_KEY='diario_anchor_v3';
@ -1437,7 +1438,7 @@ function renderGPSBanner(){
function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})} function bindHeader(){const n=document.getElementById('boat-name'),m=document.getElementById('boat-model');n.value=state.boat.name||'Shivao';m.value=state.boat.model||'';n.addEventListener('input',e=>{state.boat.name=e.target.value;saveState()});m.addEventListener('input',e=>{state.boat.model=e.target.value;saveState()})}
document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})});
function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()} function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()}
function openModal(id){document.getElementById(id).classList.add('show')} function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')} function closeModal(id){document.getElementById(id).classList.remove('show')}
@ -2358,11 +2359,97 @@ function cloudUrl(path){return state.cloud.url.replace(/\/$/,'')+path}
async function cloudFetch(path,opts={}){ async function cloudFetch(path,opts={}){
if(!cloudConfigured())throw new Error('Nuvem não configurada'); if(!cloudConfigured())throw new Error('Nuvem não configurada');
const r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${state.cloud.token}`,'Content-Type':'application/json',...(opts.headers||{})}}); const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
if(!r.ok)throw new Error(`HTTP ${r.status}`); let r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth}`,'Content-Type':'application/json',...(opts.headers||{})}});
if(r.status===401&&state.auth&&state.auth.refreshToken){
const ok=await authRefresh();
if(ok){const auth2=state.auth.accessToken;r=await fetch(cloudUrl(path),{...opts,headers:{'Authorization':`Bearer ${auth2}`,'Content-Type':'application/json',...(opts.headers||{})}})}
}
if(!r.ok){let detail='';try{const j=await r.clone().json();detail=j.error||JSON.stringify(j.issues||{})}catch{}throw new Error(`HTTP ${r.status}${detail?' · '+detail:''}`)}
return r; return r;
} }
// ===== Auth (multi-tenant SaaS) =====
async function authSignup(email,password,name){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/signup'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password,name})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();await refreshLicense();renderAuthBox();
}
async function authLogin(email,password){
if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro');
const r=await fetch(cloudUrl('/api/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const j=await r.json();
if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);
state.auth={accessToken:j.accessToken,refreshToken:j.refreshToken,user:j.user};
saveState();await refreshLicense();renderAuthBox();
}
async function authRefresh(){
if(!state.auth||!state.auth.refreshToken)return false;
try{
const r=await fetch(cloudUrl('/api/auth/refresh'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refreshToken:state.auth.refreshToken})});
if(!r.ok){state.auth=null;saveState();return false}
const j=await r.json();state.auth.accessToken=j.accessToken;saveState();return true;
}catch{return false}
}
function authLogout(){state.auth=null;state.license=null;saveState();renderAuthBox();toast('Sessão encerrada')}
async function refreshLicense(){
try{const r=await cloudFetch('/api/license');state.license=await r.json();saveState()}catch(e){console.warn('license:',e.message)}
}
function renderAuthBox(){
const box=document.getElementById('auth-box');
if(!box)return;
if(state.auth&&state.auth.user){
const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="alert(\'Upgrade pra Pro: R$19/mês ou R$149/ano. Em breve cobrança via Asaas (PIX).\')">⚡ Fazer upgrade pra Pro</button>':''}`;
}else{
box.innerHTML=`
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
<button class="btn btn-sm" id="auth-tab-login" onclick="document.getElementById('auth-form-login').style.display='block';document.getElementById('auth-form-signup').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-signup').classList.remove('btn-brass')" style="flex:1">Entrar</button>
<button class="btn btn-sm" id="auth-tab-signup" onclick="document.getElementById('auth-form-signup').style.display='block';document.getElementById('auth-form-login').style.display='none';this.classList.add('btn-brass');document.getElementById('auth-tab-login').classList.remove('btn-brass')" style="flex:1">Cadastrar</button>
</div>
<div id="auth-form-login">
<div class="field"><label class="field-label">Email</label><input type="email" id="login-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Senha</label><input type="password" id="login-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthLoginClick()">Entrar</button>
</div>
<div id="auth-form-signup" style="display:none">
<div class="field"><label class="field-label">Email</label><input type="email" id="signup-email" placeholder="seu@email.com"></div>
<div class="field"><label class="field-label">Nome (opcional)</label><input type="text" id="signup-name" placeholder="Seu nome"></div>
<div class="field"><label class="field-label">Senha (mín 8 chars)</label><input type="password" id="signup-pwd" placeholder="••••••••"></div>
<button class="btn btn-block btn-primary" onclick="onAuthSignupClick()">Criar conta</button>
</div>
<div id="auth-msg" style="margin-top:8px;font-size:11px;color:var(--storm)"></div>`;
document.getElementById('auth-tab-login').classList.add('btn-brass');
}
}
async function onAuthLoginClick(){
const e=document.getElementById('login-email').value.trim();
const p=document.getElementById('login-pwd').value;
const msg=document.getElementById('auth-msg');msg.style.color='var(--storm)';msg.textContent='';
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
}
async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim();
const p=document.getElementById('signup-pwd').value;
const msg=document.getElementById('auth-msg');msg.style.color='var(--storm)';msg.textContent='';
if(p.length<8){msg.textContent='Senha precisa de no mínimo 8 caracteres';return}
try{await authSignup(e,p,n);msg.style.color='var(--algae)';msg.textContent='Conta criada e logado!';toast('Conta criada')}catch(err){msg.textContent=err.message}
}
function bindCloudInputs(){ function bindCloudInputs(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token'); const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
if(!u||!t)return; if(!u||!t)return;

65
server/src/auth.js Normal file
View file

@ -0,0 +1,65 @@
// Auth — JWT + bcrypt pra Shivao SaaS multi-tenant.
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || (process.env.BOAT_TOKEN + '-jwt-derive-key');
const JWT_ACCESS_TTL = '7d'; // access token expira em 7 dias (menos fricção pra usuário móvel)
const JWT_REFRESH_TTL = '90d'; // refresh em 90 dias
if (!process.env.JWT_SECRET && !process.env.BOAT_TOKEN) {
console.warn('[auth] AVISO: JWT_SECRET não configurado. Use env JWT_SECRET em produção.');
}
export async function hashPassword(plain) {
if (!plain || plain.length < 8) throw new Error('Senha precisa ter no mínimo 8 caracteres');
return bcrypt.hash(plain, 10);
}
export async function verifyPassword(plain, hash) {
if (!plain || !hash) return false;
try { return await bcrypt.compare(plain, hash); } catch { return false; }
}
export function signAccessToken(user) {
return jwt.sign({ uid: user.id, email: user.email, type: 'access' }, JWT_SECRET, { expiresIn: JWT_ACCESS_TTL });
}
export function signRefreshToken(user) {
return jwt.sign({ uid: user.id, type: 'refresh' }, JWT_SECRET, { expiresIn: JWT_REFRESH_TTL });
}
export function verifyToken(token) {
try { return jwt.verify(token, JWT_SECRET); } catch { return null; }
}
// Plans → features matrix
// free: âncora local + diário básico (até 10 viagens)
// pro: tudo do free + sync nuvem ilimitada + GPS tracking + mídia + geofencing
// captain: tudo do pro + Windy premium + multi-barco + relatórios PDF + audit log
export const PLANS = {
free: {
name: 'Free (Âncora)',
price_brl: 0,
features: ['anchor_local', 'diary_limited_10', 'export_gpx_basic']
},
pro: {
name: 'Pro',
price_brl_monthly: 19,
price_brl_yearly: 149,
features: ['anchor_local', 'anchor_remote', 'diary_unlimited', 'cloud_sync', 'gps_tracking', 'media_cloud', 'geofencing', 'webhooks', 'export_all']
},
captain: {
name: 'Captain',
price_brl_monthly: 39,
price_brl_yearly: 299,
features: ['anchor_local', 'anchor_remote', 'diary_unlimited', 'cloud_sync', 'gps_tracking', 'media_cloud', 'geofencing', 'webhooks', 'export_all', 'windy_premium', 'multi_boat', 'pdf_reports', 'audit_log']
}
};
export function planFeatures(plan) {
return (PLANS[plan] || PLANS.free).features;
}
export function planHasFeature(plan, feature) {
return planFeatures(plan).includes(feature);
}

View file

@ -11,26 +11,59 @@ db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL'); db.pragma('synchronous = NORMAL');
db.exec(` db.exec(`
-- ===== Users + Licenses (multi-tenant SaaS) =====
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login INTEGER
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
started_at INTEGER NOT NULL,
expires_at INTEGER,
asaas_subscription_id TEXT,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_licenses_user ON licenses(user_id);
-- ===== Tabelas de dados (originalmente single-tenant, agora com user_id) =====
CREATE TABLE IF NOT EXISTS state ( CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
data TEXT NOT NULL, data TEXT NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL,
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
parent_id TEXT, parent_id TEXT,
parent_type TEXT, parent_type TEXT,
kind TEXT NOT NULL, kind TEXT NOT NULL,
mime TEXT NOT NULL, mime TEXT NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
filename TEXT NOT NULL, filename TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_media_parent ON media(parent_id); CREATE INDEX IF NOT EXISTS idx_media_parent ON media(parent_id);
CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id);
CREATE TABLE IF NOT EXISTS anchor_session ( CREATE TABLE IF NOT EXISTS anchor_session (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
active INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 0,
boat_name TEXT, boat_name TEXT,
anchor_lat REAL, anchor_lat REAL,
@ -41,27 +74,34 @@ db.exec(`
last_lat REAL, last_lat REAL,
last_lng REAL, last_lng REAL,
last_distance REAL, last_distance REAL,
alarm_fired INTEGER DEFAULT 0 alarm_fired INTEGER DEFAULT 0,
UNIQUE(user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS alarm_log ( CREATE TABLE IF NOT EXISTS alarm_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
ts INTEGER NOT NULL, ts INTEGER NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
payload TEXT, payload TEXT,
sent TEXT, sent TEXT,
failed TEXT failed TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS shares ( CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
boat_name TEXT, boat_name TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
revoked INTEGER DEFAULT 0, revoked INTEGER DEFAULT 0,
zones TEXT zones TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at); CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
CREATE TABLE IF NOT EXISTS share_positions ( CREATE TABLE IF NOT EXISTS share_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -93,96 +133,163 @@ try {
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
// ---- State (whole app data as a single JSON blob) ---- // ===== Migração multi-tenant: adicionar user_id em tabelas existentes =====
export function getState() { // Idempotente: roda toda startup, só ALTER se coluna não existir
const row = db.prepare('SELECT data, updated_at FROM state WHERE id = 1').get(); function ensureUserIdColumn(table) {
try {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some(c => c.name === 'user_id')) {
db.exec(`ALTER TABLE ${table} ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`);
console.log(`[migration] added user_id to ${table}`);
}
} catch (e) { console.warn(`[migration] ${table}:`, e.message); }
}
['state', 'media', 'anchor_session', 'alarm_log', 'shares', 'audit_log'].forEach(ensureUserIdColumn);
// Garante user default (id=1, Karlão) — donos de dados pré-multi-tenant
function ensureDefaultUser() {
const existing = db.prepare('SELECT id FROM users WHERE id = 1').get();
if (existing) return;
// Senha temporária — Karlão troca via /api/auth/me PATCH ou via UI quando logar pela 1ª vez
// bcryptjs hash de 'ChangeMe2026!' com cost 10
const placeholderHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'; // 'changeme'
const now = Date.now();
db.prepare(`INSERT INTO users (id, email, password_hash, name, created_at, updated_at) VALUES (1, ?, ?, ?, ?, ?)`)
.run('karlao@outlook.com', placeholderHash, 'Karlão (default)', now, now);
// Licença Captain (todas features) pro user default — gratuita pra sempre, é o dono do servidor
db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, updated_at) VALUES (1, 'captain', 'active', ?, NULL, ?)`)
.run(now, now);
console.log('[migration] default user (id=1, karlao@outlook.com) created with captain plan');
}
ensureDefaultUser();
// ===== Multi-tenant helpers (Users + Licenses) =====
export function createUser(email, passwordHash, name) {
const now = Date.now();
const info = db.prepare('INSERT INTO users (email, password_hash, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
.run(email.toLowerCase().trim(), passwordHash, name || null, now, now);
// Toda conta nova começa free
db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, updated_at) VALUES (?, 'free', 'active', ?, NULL, ?)`)
.run(info.lastInsertRowid, now, now);
return info.lastInsertRowid;
}
export function findUserByEmail(email) {
return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim());
}
export function findUserById(id) {
return db.prepare('SELECT id, email, name, created_at, last_login FROM users WHERE id = ?').get(id);
}
export function updateLastLogin(id) {
db.prepare('UPDATE users SET last_login = ?, updated_at = ? WHERE id = ?').run(Date.now(), Date.now(), id);
}
export function getActiveLicense(userId) {
// Pega licença mais recente ativa (se expires_at NULL ou no futuro)
const now = Date.now();
return db.prepare(`SELECT * FROM licenses WHERE user_id = ? AND status = 'active' AND (expires_at IS NULL OR expires_at > ?) ORDER BY started_at DESC LIMIT 1`).get(userId, now);
}
export function setLicense(userId, plan, expiresAt, asaasSubId) {
const now = Date.now();
// Desativa licenças anteriores
db.prepare(`UPDATE licenses SET status = 'replaced', updated_at = ? WHERE user_id = ? AND status = 'active'`).run(now, userId);
db.prepare(`INSERT INTO licenses (user_id, plan, status, started_at, expires_at, asaas_subscription_id, updated_at) VALUES (?, ?, 'active', ?, ?, ?, ?)`)
.run(userId, plan, now, expiresAt || null, asaasSubId || null, now);
}
// ---- State (per-user JSON blob) ----
export function getState(userId) {
const row = db.prepare('SELECT data, updated_at FROM state WHERE user_id = ?').get(userId);
if (!row) return { data: null, updated_at: 0 }; if (!row) return { data: null, updated_at: 0 };
return { data: JSON.parse(row.data), updated_at: row.updated_at }; return { data: JSON.parse(row.data), updated_at: row.updated_at };
} }
export function setState(data) { export function setState(userId, data) {
const json = JSON.stringify(data); const json = JSON.stringify(data);
const now = Date.now(); const now = Date.now();
db.prepare(` db.prepare(`
INSERT INTO state (id, data, updated_at) VALUES (1, ?, ?) INSERT INTO state (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(json, now); `).run(userId, json, now);
return now; return now;
} }
// ---- Media metadata ---- // ---- Media metadata (per-user) ----
export function insertMedia(m) { export function insertMedia(userId, m) {
db.prepare(` db.prepare(`
INSERT INTO media (id, parent_id, parent_type, kind, mime, size, filename, created_at) INSERT INTO media (id, user_id, parent_id, parent_type, kind, mime, size, filename, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(m.id, m.parent_id || null, m.parent_type || null, m.kind, m.mime, m.size, m.filename, m.created_at || Date.now()); `).run(m.id, userId, m.parent_id || null, m.parent_type || null, m.kind, m.mime, m.size, m.filename, m.created_at || Date.now());
} }
export function listMedia() { export function listMedia(userId) {
return db.prepare('SELECT * FROM media ORDER BY created_at DESC').all(); return db.prepare('SELECT * FROM media WHERE user_id = ? ORDER BY created_at DESC').all(userId);
} }
export function getMedia(id) { export function getMedia(userId, id) {
return db.prepare('SELECT * FROM media WHERE id = ?').get(id); return db.prepare('SELECT * FROM media WHERE user_id = ? AND id = ?').get(userId, id);
} }
export function deleteMedia(id) { export function deleteMedia(userId, id) {
return db.prepare('DELETE FROM media WHERE id = ?').run(id); return db.prepare('DELETE FROM media WHERE user_id = ? AND id = ?').run(userId, id);
} }
// ---- Anchor session ---- // ---- Anchor session (per-user) ----
export function getAnchor() { export function getAnchor(userId) {
return db.prepare('SELECT * FROM anchor_session WHERE id = 1').get(); return db.prepare('SELECT * FROM anchor_session WHERE user_id = ?').get(userId);
} }
export function setAnchor(a) { export function setAnchor(userId, a) {
const cur = getAnchor(); const cur = getAnchor(userId);
if (cur) { if (cur) {
db.prepare(`UPDATE anchor_session SET active=?, boat_name=?, anchor_lat=?, anchor_lng=?, radius=?, started_at=?, last_heartbeat=?, last_lat=?, last_lng=?, last_distance=?, alarm_fired=? WHERE id=1`) db.prepare(`UPDATE anchor_session SET active=?, boat_name=?, anchor_lat=?, anchor_lng=?, radius=?, started_at=?, last_heartbeat=?, last_lat=?, last_lng=?, last_distance=?, alarm_fired=? WHERE user_id=?`)
.run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0); .run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0, userId);
} else { } else {
db.prepare(`INSERT INTO anchor_session (id, active, boat_name, anchor_lat, anchor_lng, radius, started_at, last_heartbeat, last_lat, last_lng, last_distance, alarm_fired) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) db.prepare(`INSERT INTO anchor_session (user_id, active, boat_name, anchor_lat, anchor_lng, radius, started_at, last_heartbeat, last_lat, last_lng, last_distance, alarm_fired) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0); .run(userId, a.active ? 1 : 0, a.boat_name, a.anchor_lat, a.anchor_lng, a.radius, a.started_at, a.last_heartbeat, a.last_lat, a.last_lng, a.last_distance, a.alarm_fired ? 1 : 0);
} }
} }
export function clearAnchor() { export function clearAnchor(userId) {
db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE id=1').run(); db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE user_id=?').run(userId);
} }
export function updateHeartbeat(lat, lng, dist) { export function updateHeartbeat(userId, lat, lng, dist) {
db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE id=1') db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE user_id=?')
.run(Date.now(), lat, lng, dist); .run(Date.now(), lat, lng, dist, userId);
} }
export function setAlarmFired(fired) { export function setAlarmFired(userId, fired) {
db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE id=1').run(fired ? 1 : 0); db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE user_id=?').run(fired ? 1 : 0, userId);
}
// Pra dead-man switch (busca todos anchor sessions ativos pra checar)
export function listActiveAnchors() {
return db.prepare('SELECT * FROM anchor_session WHERE active = 1').all();
} }
// ---- Alarm log ---- // ---- Alarm log (per-user) ----
export function logAlarm(type, payload, sent, failed) { export function logAlarm(userId, type, payload, sent, failed) {
db.prepare('INSERT INTO alarm_log (ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO alarm_log (user_id, ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?, ?)')
.run(Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || [])); .run(userId, Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || []));
} }
export function recentAlarms(limit = 50) { export function recentAlarms(userId, limit = 50) {
return db.prepare('SELECT * FROM alarm_log ORDER BY ts DESC LIMIT ?').all(limit); return db.prepare('SELECT * FROM alarm_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
} }
// ---- Shares ---- // ---- Shares (per-user) ----
export function createShare(token, boatName, expiresAt, zones) { export function createShare(userId, token, boatName, expiresAt, zones) {
db.prepare('INSERT INTO shares (token, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO shares (token, user_id, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?, ?)')
.run(token, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null); .run(token, userId, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null);
} }
export function updateShareZones(token, zones) { export function updateShareZones(userId, token, zones) {
db.prepare('UPDATE shares SET zones = ? WHERE token = ?').run(zones ? JSON.stringify(zones) : null, token); // Garante que share pertence ao user (não permite editar share alheio)
db.prepare('UPDATE shares SET zones = ? WHERE token = ? AND user_id = ?').run(zones ? JSON.stringify(zones) : null, token, userId);
} }
export function getShare(token) { export function getShare(token) {
// Público — não filtra por user (qualquer um com o token vê)
return db.prepare('SELECT * FROM shares WHERE token = ?').get(token); return db.prepare('SELECT * FROM shares WHERE token = ?').get(token);
} }
export function listActiveShares() { export function listActiveShares(userId) {
const now = Date.now(); const now = Date.now();
return db.prepare('SELECT * FROM shares WHERE revoked = 0 AND expires_at > ? ORDER BY created_at DESC').all(now); return db.prepare('SELECT * FROM shares WHERE user_id = ? AND revoked = 0 AND expires_at > ? ORDER BY created_at DESC').all(userId, now);
} }
export function revokeShare(token) { export function revokeShare(userId, token) {
return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ?').run(token); return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ? AND user_id = ?').run(token, userId);
} }
export function addSharePosition(token, lat, lng, speed) { export function addSharePosition(token, lat, lng, speed) {
db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)')
.run(token, lat, lng, speed || 0, Date.now()); .run(token, lat, lng, speed || 0, Date.now());
// mantém apenas últimas 500 posições por share
db.prepare(`DELETE FROM share_positions WHERE token = ? AND id NOT IN (SELECT id FROM share_positions WHERE token = ? ORDER BY ts DESC LIMIT 500)`).run(token, token); db.prepare(`DELETE FROM share_positions WHERE token = ? AND id NOT IN (SELECT id FROM share_positions WHERE token = ? ORDER BY ts DESC LIMIT 500)`).run(token, token);
} }
export function getSharePositions(token, limit = 500) { export function getSharePositions(token, limit = 500) {
@ -200,13 +307,13 @@ export function cleanupExpiredShares() {
return toDelete.length; return toDelete.length;
} }
// ---- Audit log (ações sensíveis: who/what/when para investigação de incidentes) ---- // ---- Audit log (per-user) ----
export function audit(action, entity, entityId, summary, ip) { export function audit(userId, action, entity, entityId, summary, ip) {
db.prepare('INSERT INTO audit_log (ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?)') db.prepare('INSERT INTO audit_log (user_id, ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null); .run(userId, Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null);
} }
export function recentAudit(limit = 100) { export function recentAudit(userId, limit = 100) {
return db.prepare('SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?').all(limit); return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
} }
export const dataDir = DATA_DIR; export const dataDir = DATA_DIR;

View file

@ -6,7 +6,8 @@ import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import * as db from './db.js'; import * as db from './db.js';
import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js'; import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js';
import { validate, setStateSchema, updateZonesSchema } from './schemas/index.js'; import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema } from './schemas/index.js';
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000'); const PORT = parseInt(process.env.PORT || '3000');
@ -44,17 +45,95 @@ const publicShareLimiter = rateLimit({
message: { error: 'Too many requests, slow down.' }, message: { error: 'Too many requests, slow down.' },
}); });
// Auth middleware // Auth middleware: aceita JWT (multi-tenant) OU BOAT_TOKEN legado (mapeia ao user 1)
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const auth = req.headers.authorization || ''; const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (token !== TOKEN) return res.status(401).json({ error: 'Unauthorized' }); 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(); next();
} }
// ==== Public endpoints ==== // ==== Public endpoints ====
app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() })); app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
// ===== Auth endpoints (multi-tenant SaaS) =====
app.post('/api/auth/signup', validate(signupSchema), async (req, res) => {
const { email, password, name } = req.body;
if (db.findUserByEmail(email)) return res.status(409).json({ error: 'Email já cadastrado' });
try {
const hash = await hashPassword(password);
const id = db.createUser(email, hash, name);
db.audit(id, 'user_signup', 'user', String(id), { email }, req.ip);
const user = db.findUserById(id);
db.updateLastLogin(id);
res.json({
user,
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user),
});
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post('/api/auth/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = db.findUserByEmail(email);
if (!user) return res.status(401).json({ error: 'Credenciais inválidas' });
const ok = await verifyPassword(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Credenciais inválidas' });
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
res.json({
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
});
});
app.post('/api/auth/refresh', (req, res) => {
const { refreshToken } = req.body || {};
if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' });
const payload = verifyToken(refreshToken);
if (!payload || payload.type !== 'refresh') return res.status(401).json({ error: 'Invalid refresh' });
const user = db.findUserById(payload.uid);
if (!user) return res.status(401).json({ error: 'User not found' });
res.json({ accessToken: signAccessToken(user) });
});
app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Plans + license info
app.get('/api/license', requireAuth, (req, res) => {
const lic = db.getActiveLicense(req.user.id) || { plan: 'free', status: 'active', expires_at: null };
res.json({
plan: lic.plan,
status: lic.status,
expires_at: lic.expires_at,
features: planFeatures(lic.plan),
plans: PLANS,
});
});
// Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome // Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome
app.get('/.well-known/assetlinks.json', (req, res) => { app.get('/.well-known/assetlinks.json', (req, res) => {
res.json([{ res.json([{
@ -104,16 +183,16 @@ app.get('/api/info', requireAuth, (req, res) => {
}); });
}); });
// --- State sync (whole JSON blob) --- // --- State sync (whole JSON blob, per-user) ---
app.get('/api/data', requireAuth, (req, res) => { app.get('/api/data', requireAuth, (req, res) => {
const s = db.getState(); const s = db.getState(req.user.id);
res.json(s); res.json(s);
}); });
app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => { app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
const { data } = req.body; const { data } = req.body;
const ts = db.setState(data); const ts = db.setState(req.user.id, data);
db.audit('state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip); db.audit(req.user.id, 'state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
res.json({ ok: true, updated_at: ts }); res.json({ ok: true, updated_at: ts });
}); });
@ -144,28 +223,28 @@ app.post('/api/media', requireAuth, upload.single('file'), (req, res) => {
filename: req.file.filename, filename: req.file.filename,
created_at: parseInt(req.body.created_at) || Date.now() created_at: parseInt(req.body.created_at) || Date.now()
}; };
// remove existing if any (overwrite) // remove existing if any (overwrite — escopo do user)
const ex = db.getMedia(id); const ex = db.getMedia(req.user.id, id);
if (ex && ex.filename !== meta.filename) { if (ex && ex.filename !== meta.filename) {
try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {} try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {}
db.deleteMedia(id); db.deleteMedia(req.user.id, id);
} else if (ex) { } else if (ex) {
db.deleteMedia(id); db.deleteMedia(req.user.id, id);
} }
db.insertMedia(meta); db.insertMedia(req.user.id, meta);
db.audit('media_insert', 'media', id, { kind: meta.kind, mime: meta.mime, size: meta.size }, req.ip); 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}` }); res.json({ ok: true, id, url: `/api/media/${id}` });
}); });
app.get('/api/media/list', requireAuth, (req, res) => { app.get('/api/media/list', requireAuth, (req, res) => {
res.json(db.listMedia().map(m => ({ res.json(db.listMedia(req.user.id).map(m => ({
id: m.id, parent_id: m.parent_id, parent_type: m.parent_type, 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 kind: m.kind, mime: m.mime, size: m.size, created_at: m.created_at
}))); })));
}); });
app.get('/api/media/:id', requireAuth, (req, res) => { app.get('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id); const m = db.getMedia(req.user.id, req.params.id);
if (!m) return res.status(404).json({ error: 'not found' }); if (!m) return res.status(404).json({ error: 'not found' });
const filepath = path.join(mediaDir, m.filename); const filepath = path.join(mediaDir, m.filename);
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'file missing' }); if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'file missing' });
@ -175,11 +254,11 @@ app.get('/api/media/:id', requireAuth, (req, res) => {
}); });
app.delete('/api/media/:id', requireAuth, (req, res) => { app.delete('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id); const m = db.getMedia(req.user.id, req.params.id);
if (!m) return res.status(404).json({ error: 'not found' }); if (!m) return res.status(404).json({ error: 'not found' });
try { fs.unlinkSync(path.join(mediaDir, m.filename)); } catch (e) {} try { fs.unlinkSync(path.join(mediaDir, m.filename)); } catch (e) {}
db.deleteMedia(req.params.id); db.deleteMedia(req.user.id, req.params.id);
db.audit('media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip); db.audit(req.user.id, 'media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip);
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -188,7 +267,7 @@ app.post('/api/anchor/start', requireAuth, (req, res) => {
const { boat_name, anchor_lat, anchor_lng, radius } = req.body; const { boat_name, anchor_lat, anchor_lng, radius } = req.body;
if (typeof anchor_lat !== 'number' || typeof anchor_lng !== 'number') if (typeof anchor_lat !== 'number' || typeof anchor_lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' }); return res.status(400).json({ error: 'lat/lng required' });
db.setAnchor({ db.setAnchor(req.user.id, {
active: true, active: true,
boat_name: boat_name || 'Veleiro', boat_name: boat_name || 'Veleiro',
anchor_lat, anchor_lng, anchor_lat, anchor_lng,
@ -205,13 +284,13 @@ app.post('/api/anchor/start', requireAuth, (req, res) => {
app.post('/api/anchor/heartbeat', requireAuth, (req, res) => { app.post('/api/anchor/heartbeat', requireAuth, (req, res) => {
const { lat, lng, distance } = req.body; const { lat, lng, distance } = req.body;
db.updateHeartbeat(lat, lng, distance || 0); db.updateHeartbeat(req.user.id, lat, lng, distance || 0);
const a = db.getAnchor(); const a = db.getAnchor(req.user.id);
res.json({ ok: true, active: !!a?.active }); res.json({ ok: true, active: !!a?.active });
}); });
app.post('/api/anchor/alarm', requireAuth, async (req, res) => { app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
const a = db.getAnchor(); const a = db.getAnchor(req.user.id);
const payload = { const payload = {
boat: req.body.boat_name || a?.boat_name || 'Veleiro', boat: req.body.boat_name || a?.boat_name || 'Veleiro',
lat: req.body.lat ?? a?.last_lat, lat: req.body.lat ?? a?.last_lat,
@ -221,36 +300,36 @@ app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
reason: req.body.reason || 'drift', reason: req.body.reason || 'drift',
ts: Date.now() ts: Date.now()
}; };
db.setAlarmFired(true); db.setAlarmFired(req.user.id, true);
const result = await dispatchAlarm(payload); const result = await dispatchAlarm(payload);
db.logAlarm('drift', payload, result.sent, result.failed); db.logAlarm(req.user.id, 'drift', payload, result.sent, result.failed);
res.json(result); res.json(result);
}); });
app.post('/api/anchor/stop', requireAuth, (req, res) => { app.post('/api/anchor/stop', requireAuth, (req, res) => {
db.clearAnchor(); db.clearAnchor(req.user.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get('/api/anchor/status', requireAuth, (req, res) => { app.get('/api/anchor/status', requireAuth, (req, res) => {
res.json(db.getAnchor() || { active: 0 }); res.json(db.getAnchor(req.user.id) || { active: 0 });
}); });
// --- Test endpoint --- // --- Test endpoint ---
app.post('/api/test', requireAuth, async (req, res) => { app.post('/api/test', requireAuth, async (req, res) => {
const result = await dispatchTest(); const result = await dispatchTest();
db.logAlarm('test', {}, result.sent, result.failed); db.logAlarm(req.user.id, 'test', {}, result.sent, result.failed);
res.json(result); res.json(result);
}); });
app.get('/api/alarms', requireAuth, (req, res) => { app.get('/api/alarms', requireAuth, (req, res) => {
res.json(db.recentAlarms(50)); res.json(db.recentAlarms(req.user.id, 50));
}); });
// Auditoria (consulta autenticada — quem fez o quê e quando nos endpoints sensíveis) // Auditoria (consulta autenticada — quem fez o quê e quando nos endpoints sensíveis do user)
app.get('/api/audit', requireAuth, (req, res) => { app.get('/api/audit', requireAuth, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500); const limit = Math.min(parseInt(req.query.limit) || 100, 500);
res.json(db.recentAudit(limit)); res.json(db.recentAudit(req.user.id, limit));
}); });
// ==== LIVE SHARE ==== // ==== LIVE SHARE ====
@ -262,8 +341,8 @@ app.post('/api/share/create', requireAuth, (req, res) => {
return res.status(400).json({ error: 'invalid duration' }); return res.status(400).json({ error: 'invalid duration' });
const token = crypto.randomBytes(12).toString('base64url'); const token = crypto.randomBytes(12).toString('base64url');
const expiresAt = Date.now() + durationMinutes * 60 * 1000; const expiresAt = Date.now() + durationMinutes * 60 * 1000;
db.createShare(token, boatName || 'Shivao', expiresAt, zones); db.createShare(req.user.id, token, boatName || 'Shivao', expiresAt, zones);
db.audit('share_create', 'share', token, { boatName: boatName || 'Shivao', expiresAt, zonesCount: Array.isArray(zones) ? zones.length : 0 }, req.ip); 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 proto = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.headers['x-forwarded-host'] || req.headers.host; const host = req.headers['x-forwarded-host'] || req.headers.host;
const url = `${proto}://${host}/share/${token}`; const url = `${proto}://${host}/share/${token}`;
@ -271,21 +350,21 @@ app.post('/api/share/create', requireAuth, (req, res) => {
}); });
app.get('/api/share/list', requireAuth, (req, res) => { app.get('/api/share/list', requireAuth, (req, res) => {
res.json(db.listActiveShares().map(s => ({ res.json(db.listActiveShares(req.user.id).map(s => ({
token: s.token, boatName: s.boat_name, expiresAt: s.expires_at, createdAt: s.created_at token: s.token, boatName: s.boat_name, expiresAt: s.expires_at, createdAt: s.created_at
}))); })));
}); });
app.delete('/api/share/:token', requireAuth, (req, res) => { app.delete('/api/share/:token', requireAuth, (req, res) => {
db.revokeShare(req.params.token); db.revokeShare(req.user.id, req.params.token);
db.audit('share_revoke', 'share', req.params.token, {}, req.ip); db.audit(req.user.id, 'share_revoke', 'share', req.params.token, {}, req.ip);
res.json({ ok: true }); res.json({ ok: true });
}); });
app.post('/api/share/:token/zones', requireAuth, validate(updateZonesSchema), (req, res) => { app.post('/api/share/:token/zones', requireAuth, validate(updateZonesSchema), (req, res) => {
const { zones } = req.body; const { zones } = req.body;
db.updateShareZones(req.params.token, zones); db.updateShareZones(req.user.id, req.params.token, zones);
db.audit('share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip); db.audit(req.user.id, 'share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip);
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -293,8 +372,8 @@ app.post('/api/share/position', requireAuth, (req, res) => {
const { lat, lng, speed, boatName } = req.body; const { lat, lng, speed, boatName } = req.body;
if (typeof lat !== 'number' || typeof lng !== 'number') if (typeof lat !== 'number' || typeof lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' }); return res.status(400).json({ error: 'lat/lng required' });
// posta para todos os shares ativos do barco // posta apenas para shares do user logado
const active = db.listActiveShares(); const active = db.listActiveShares(req.user.id);
let posted = 0; let posted = 0;
for (const s of active) { for (const s of active) {
if (boatName && s.boat_name && s.boat_name !== boatName) continue; if (boatName && s.boat_name && s.boat_name !== boatName) continue;
@ -436,29 +515,35 @@ setInterval(() => {
} catch (e) { console.warn(e); } } catch (e) { console.warn(e); }
}, 24 * 3600 * 1000); }, 24 * 3600 * 1000);
// ==== Dead-man switch background check ==== // ==== Dead-man switch background check (multi-user) ====
let lastDeadmanFire = 0; const lastDeadmanFire = new Map(); // user_id -> ts
async function checkDeadman() { async function checkDeadman() {
const a = db.getAnchor();
if (!a || !a.active) return;
const now = Date.now(); const now = Date.now();
const since = now - (a.last_heartbeat || a.started_at); const sessions = db.listActiveAnchors();
if (since < HEARTBEAT_TIMEOUT) return; for (const a of sessions) {
// already fired recently? avoid spam if (!a.active) continue;
if (now - lastDeadmanFire < HEARTBEAT_TIMEOUT) return; const since = now - (a.last_heartbeat || a.started_at);
lastDeadmanFire = now; if (since < HEARTBEAT_TIMEOUT) continue;
console.log(`[deadman] No heartbeat in ${Math.round(since/1000)}s — firing remote alarm`); const last = lastDeadmanFire.get(a.user_id) || 0;
const payload = { if (now - last < HEARTBEAT_TIMEOUT) continue;
boat: a.boat_name || 'Veleiro', lastDeadmanFire.set(a.user_id, now);
lat: a.last_lat, lng: a.last_lng, console.log(`[deadman] user=${a.user_id} no heartbeat in ${Math.round(since/1000)}s — firing alarm`);
distance: a.last_distance, const payload = {
radius: a.radius, boat: a.boat_name || 'Veleiro',
reason: 'heartbeat_lost', lat: a.last_lat, lng: a.last_lng,
ts: now, distance: a.last_distance,
minutes_lost: Math.round(since / 60000) radius: a.radius,
}; reason: 'heartbeat_lost',
const result = await dispatchAlarm(payload); ts: now,
db.logAlarm('heartbeat_lost', payload, result.sent, result.failed); 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 setInterval(checkDeadman, 30000); // check every 30s

View file

@ -30,6 +30,18 @@ export const updateZonesSchema = z.object({
zones: z.array(zoneSchema).max(100), zones: z.array(zoneSchema).max(100),
}); });
// ===== Auth (signup + login) =====
export const signupSchema = z.object({
email: z.string().email().max(200),
password: z.string().min(8).max(100),
name: z.string().max(100).optional(),
});
export const loginSchema = z.object({
email: z.string().email().max(200),
password: z.string().min(1).max(100),
});
// ===== Middleware genérico ===== // ===== Middleware genérico =====
// Uso: app.post('/x', requireAuth, validate(mySchema), handler) // Uso: app.post('/x', requireAuth, validate(mySchema), handler)
// Em caso de falha: 400 com até 5 issues do Zod (path + message). // Em caso de falha: 400 com até 5 issues do Zod (path + message).