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:
parent
d1a2401048
commit
85b60a800c
8 changed files with 716 additions and 132 deletions
|
|
@ -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
117
server/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
65
server/src/auth.js
Normal 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);
|
||||||
|
}
|
||||||
235
server/src/db.js
235
server/src/db.js
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue