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>
</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 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>
@ -1289,7 +1290,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div>
<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 TRACKING_KEY='diario_tracking_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()})}
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 openModal(id){document.getElementById(id).classList.add('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={}){
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||{})}});
if(!r.ok)throw new Error(`HTTP ${r.status}`);
// Usa JWT se logado (multi-tenant SaaS), senão BOAT_TOKEN legado (single-tenant pessoal)
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;
}
// ===== 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(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
if(!u||!t)return;

117
server/package-lock.json generated
View file

@ -9,9 +9,11 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0",
"express": "^4.21.0",
"express-rate-limit": "^8.4.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15",
"zod": "^4.3.6"
@ -65,6 +67,15 @@
],
"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": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
@ -173,6 +184,12 @@
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -366,6 +383,15 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -732,6 +758,97 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -12,9 +12,11 @@
"node": ">=20.0.0"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0",
"express": "^4.21.0",
"express-rate-limit": "^8.4.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15",
"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>
</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 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>
@ -1289,7 +1290,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div>
<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 TRACKING_KEY='diario_tracking_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()})}
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 openModal(id){document.getElementById(id).classList.add('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={}){
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||{})}});
if(!r.ok)throw new Error(`HTTP ${r.status}`);
const auth=(state.auth&&state.auth.accessToken)?state.auth.accessToken:state.cloud.token;
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;
}
// ===== 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(){
const u=document.getElementById('cloud-url'),t=document.getElementById('cloud-token');
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.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 (
id INTEGER PRIMARY KEY CHECK (id = 1),
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
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 (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
parent_id TEXT,
parent_type TEXT,
kind TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER 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_user ON media(user_id);
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,
boat_name TEXT,
anchor_lat REAL,
@ -41,27 +74,34 @@ db.exec(`
last_lat REAL,
last_lng 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 1,
ts INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT,
sent TEXT,
failed TEXT
failed TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS shares (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
boat_name TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
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_user ON shares(user_id);
CREATE TABLE IF NOT EXISTS share_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -93,96 +133,163 @@ try {
}
} catch (e) { /* ignore */ }
// ---- State (whole app data as a single JSON blob) ----
export function getState() {
const row = db.prepare('SELECT data, updated_at FROM state WHERE id = 1').get();
// ===== Migração multi-tenant: adicionar user_id em tabelas existentes =====
// Idempotente: roda toda startup, só ALTER se coluna não existir
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 };
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 now = Date.now();
db.prepare(`
INSERT INTO state (id, data, updated_at) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(json, now);
INSERT INTO state (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, json, now);
return now;
}
// ---- Media metadata ----
export function insertMedia(m) {
// ---- Media metadata (per-user) ----
export function insertMedia(userId, m) {
db.prepare(`
INSERT INTO media (id, parent_id, parent_type, kind, mime, size, filename, created_at)
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());
INSERT INTO media (id, user_id, parent_id, parent_type, kind, mime, size, filename, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).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() {
return db.prepare('SELECT * FROM media ORDER BY created_at DESC').all();
export function listMedia(userId) {
return db.prepare('SELECT * FROM media WHERE user_id = ? ORDER BY created_at DESC').all(userId);
}
export function getMedia(id) {
return db.prepare('SELECT * FROM media WHERE id = ?').get(id);
export function getMedia(userId, id) {
return db.prepare('SELECT * FROM media WHERE user_id = ? AND id = ?').get(userId, id);
}
export function deleteMedia(id) {
return db.prepare('DELETE FROM media WHERE id = ?').run(id);
export function deleteMedia(userId, id) {
return db.prepare('DELETE FROM media WHERE user_id = ? AND id = ?').run(userId, id);
}
// ---- Anchor session ----
export function getAnchor() {
return db.prepare('SELECT * FROM anchor_session WHERE id = 1').get();
// ---- Anchor session (per-user) ----
export function getAnchor(userId) {
return db.prepare('SELECT * FROM anchor_session WHERE user_id = ?').get(userId);
}
export function setAnchor(a) {
const cur = getAnchor();
export function setAnchor(userId, a) {
const cur = getAnchor(userId);
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`)
.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);
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, userId);
} 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, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.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);
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(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() {
db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE id=1').run();
export function clearAnchor(userId) {
db.prepare('UPDATE anchor_session SET active=0, alarm_fired=0 WHERE user_id=?').run(userId);
}
export function updateHeartbeat(lat, lng, dist) {
db.prepare('UPDATE anchor_session SET last_heartbeat=?, last_lat=?, last_lng=?, last_distance=? WHERE id=1')
.run(Date.now(), 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 user_id=?')
.run(Date.now(), lat, lng, dist, userId);
}
export function setAlarmFired(fired) {
db.prepare('UPDATE anchor_session SET alarm_fired=? WHERE id=1').run(fired ? 1 : 0);
export function setAlarmFired(userId, fired) {
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 ----
export function logAlarm(type, payload, sent, failed) {
db.prepare('INSERT INTO alarm_log (ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?)')
.run(Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || []));
// ---- Alarm log (per-user) ----
export function logAlarm(userId, type, payload, sent, failed) {
db.prepare('INSERT INTO alarm_log (user_id, ts, type, payload, sent, failed) VALUES (?, ?, ?, ?, ?, ?)')
.run(userId, Date.now(), type, JSON.stringify(payload || {}), JSON.stringify(sent || []), JSON.stringify(failed || []));
}
export function recentAlarms(limit = 50) {
return db.prepare('SELECT * FROM alarm_log ORDER BY ts DESC LIMIT ?').all(limit);
export function recentAlarms(userId, limit = 50) {
return db.prepare('SELECT * FROM alarm_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
}
// ---- Shares ----
export function createShare(token, boatName, expiresAt, zones) {
db.prepare('INSERT INTO shares (token, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?)')
.run(token, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null);
// ---- Shares (per-user) ----
export function createShare(userId, token, boatName, expiresAt, zones) {
db.prepare('INSERT INTO shares (token, user_id, boat_name, created_at, expires_at, zones) VALUES (?, ?, ?, ?, ?, ?)')
.run(token, userId, boatName, Date.now(), expiresAt, zones ? JSON.stringify(zones) : null);
}
export function updateShareZones(token, zones) {
db.prepare('UPDATE shares SET zones = ? WHERE token = ?').run(zones ? JSON.stringify(zones) : null, token);
export function updateShareZones(userId, token, zones) {
// 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) {
// Público — não filtra por user (qualquer um com o token vê)
return db.prepare('SELECT * FROM shares WHERE token = ?').get(token);
}
export function listActiveShares() {
export function listActiveShares(userId) {
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) {
return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ?').run(token);
export function revokeShare(userId, token) {
return db.prepare('UPDATE shares SET revoked = 1 WHERE token = ? AND user_id = ?').run(token, userId);
}
export function addSharePosition(token, lat, lng, speed) {
db.prepare('INSERT INTO share_positions (token, lat, lng, speed, ts) VALUES (?, ?, ?, ?, ?)')
.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);
}
export function getSharePositions(token, limit = 500) {
@ -200,13 +307,13 @@ export function cleanupExpiredShares() {
return toDelete.length;
}
// ---- Audit log (ações sensíveis: who/what/when para investigação de incidentes) ----
export function audit(action, entity, entityId, summary, ip) {
db.prepare('INSERT INTO audit_log (ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?)')
.run(Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null);
// ---- Audit log (per-user) ----
export function audit(userId, action, entity, entityId, summary, ip) {
db.prepare('INSERT INTO audit_log (user_id, ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(userId, Date.now(), action, entity || null, entityId || null, summary ? JSON.stringify(summary) : null, ip || null);
}
export function recentAudit(limit = 100) {
return db.prepare('SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?').all(limit);
export function recentAudit(userId, limit = 100) {
return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
}
export const dataDir = DATA_DIR;

View file

@ -6,7 +6,8 @@ import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as db from './db.js';
import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js';
import { validate, setStateSchema, updateZonesSchema } 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 PORT = parseInt(process.env.PORT || '3000');
@ -44,17 +45,95 @@ const publicShareLimiter = rateLimit({
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) {
const auth = req.headers.authorization || '';
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();
}
// ==== Public endpoints ====
app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
// ===== Auth endpoints (multi-tenant SaaS) =====
app.post('/api/auth/signup', validate(signupSchema), async (req, res) => {
const { email, password, name } = req.body;
if (db.findUserByEmail(email)) return res.status(409).json({ error: 'Email já cadastrado' });
try {
const hash = await hashPassword(password);
const id = db.createUser(email, hash, name);
db.audit(id, 'user_signup', 'user', String(id), { email }, req.ip);
const user = db.findUserById(id);
db.updateLastLogin(id);
res.json({
user,
accessToken: signAccessToken(user),
refreshToken: signRefreshToken(user),
});
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post('/api/auth/login', validate(loginSchema), async (req, res) => {
const { email, password } = req.body;
const user = db.findUserByEmail(email);
if (!user) return res.status(401).json({ error: 'Credenciais inválidas' });
const ok = await verifyPassword(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Credenciais inválidas' });
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
res.json({
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
});
});
app.post('/api/auth/refresh', (req, res) => {
const { refreshToken } = req.body || {};
if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' });
const payload = verifyToken(refreshToken);
if (!payload || payload.type !== 'refresh') return res.status(401).json({ error: 'Invalid refresh' });
const user = db.findUserById(payload.uid);
if (!user) return res.status(401).json({ error: 'User not found' });
res.json({ accessToken: signAccessToken(user) });
});
app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// 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
app.get('/.well-known/assetlinks.json', (req, res) => {
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) => {
const s = db.getState();
const s = db.getState(req.user.id);
res.json(s);
});
app.post('/api/data', requireAuth, validate(setStateSchema), (req, res) => {
const { data } = req.body;
const ts = db.setState(data);
db.audit('state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
const ts = db.setState(req.user.id, data);
db.audit(req.user.id, 'state_set', 'state', null, { bytes: JSON.stringify(data).length }, req.ip);
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,
created_at: parseInt(req.body.created_at) || Date.now()
};
// remove existing if any (overwrite)
const ex = db.getMedia(id);
// remove existing if any (overwrite — escopo do user)
const ex = db.getMedia(req.user.id, id);
if (ex && ex.filename !== meta.filename) {
try { fs.unlinkSync(path.join(mediaDir, ex.filename)); } catch (e) {}
db.deleteMedia(id);
db.deleteMedia(req.user.id, id);
} else if (ex) {
db.deleteMedia(id);
db.deleteMedia(req.user.id, id);
}
db.insertMedia(meta);
db.audit('media_insert', 'media', id, { kind: meta.kind, mime: meta.mime, size: meta.size }, req.ip);
db.insertMedia(req.user.id, meta);
db.audit(req.user.id, 'media_insert', 'media', id, { kind: meta.kind, mime: meta.mime, size: meta.size }, req.ip);
res.json({ ok: true, id, url: `/api/media/${id}` });
});
app.get('/api/media/list', requireAuth, (req, res) => {
res.json(db.listMedia().map(m => ({
res.json(db.listMedia(req.user.id).map(m => ({
id: m.id, parent_id: m.parent_id, parent_type: m.parent_type,
kind: m.kind, mime: m.mime, size: m.size, created_at: m.created_at
})));
});
app.get('/api/media/:id', requireAuth, (req, res) => {
const m = db.getMedia(req.params.id);
const m = db.getMedia(req.user.id, req.params.id);
if (!m) return res.status(404).json({ error: 'not found' });
const filepath = path.join(mediaDir, m.filename);
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'file missing' });
@ -175,11 +254,11 @@ app.get('/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' });
try { fs.unlinkSync(path.join(mediaDir, m.filename)); } catch (e) {}
db.deleteMedia(req.params.id);
db.audit('media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip);
db.deleteMedia(req.user.id, req.params.id);
db.audit(req.user.id, 'media_delete', 'media', req.params.id, { kind: m.kind, size: m.size }, req.ip);
res.json({ ok: true });
});
@ -188,7 +267,7 @@ app.post('/api/anchor/start', requireAuth, (req, res) => {
const { boat_name, anchor_lat, anchor_lng, radius } = req.body;
if (typeof anchor_lat !== 'number' || typeof anchor_lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' });
db.setAnchor({
db.setAnchor(req.user.id, {
active: true,
boat_name: boat_name || 'Veleiro',
anchor_lat, anchor_lng,
@ -205,13 +284,13 @@ app.post('/api/anchor/start', requireAuth, (req, res) => {
app.post('/api/anchor/heartbeat', requireAuth, (req, res) => {
const { lat, lng, distance } = req.body;
db.updateHeartbeat(lat, lng, distance || 0);
const a = db.getAnchor();
db.updateHeartbeat(req.user.id, lat, lng, distance || 0);
const a = db.getAnchor(req.user.id);
res.json({ ok: true, active: !!a?.active });
});
app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
const a = db.getAnchor();
const a = db.getAnchor(req.user.id);
const payload = {
boat: req.body.boat_name || a?.boat_name || 'Veleiro',
lat: req.body.lat ?? a?.last_lat,
@ -221,36 +300,36 @@ app.post('/api/anchor/alarm', requireAuth, async (req, res) => {
reason: req.body.reason || 'drift',
ts: Date.now()
};
db.setAlarmFired(true);
db.setAlarmFired(req.user.id, true);
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);
});
app.post('/api/anchor/stop', requireAuth, (req, res) => {
db.clearAnchor();
db.clearAnchor(req.user.id);
res.json({ ok: true });
});
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 ---
app.post('/api/test', requireAuth, async (req, res) => {
const result = await dispatchTest();
db.logAlarm('test', {}, result.sent, result.failed);
db.logAlarm(req.user.id, 'test', {}, result.sent, result.failed);
res.json(result);
});
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) => {
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 ====
@ -262,8 +341,8 @@ app.post('/api/share/create', requireAuth, (req, res) => {
return res.status(400).json({ error: 'invalid duration' });
const token = crypto.randomBytes(12).toString('base64url');
const expiresAt = Date.now() + durationMinutes * 60 * 1000;
db.createShare(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.createShare(req.user.id, token, boatName || 'Shivao', expiresAt, zones);
db.audit(req.user.id, 'share_create', 'share', token, { boatName: boatName || 'Shivao', expiresAt, zonesCount: Array.isArray(zones) ? zones.length : 0 }, req.ip);
const proto = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.headers['x-forwarded-host'] || req.headers.host;
const url = `${proto}://${host}/share/${token}`;
@ -271,21 +350,21 @@ app.post('/api/share/create', 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
})));
});
app.delete('/api/share/:token', requireAuth, (req, res) => {
db.revokeShare(req.params.token);
db.audit('share_revoke', 'share', req.params.token, {}, req.ip);
db.revokeShare(req.user.id, req.params.token);
db.audit(req.user.id, 'share_revoke', 'share', req.params.token, {}, req.ip);
res.json({ ok: true });
});
app.post('/api/share/:token/zones', requireAuth, validate(updateZonesSchema), (req, res) => {
const { zones } = req.body;
db.updateShareZones(req.params.token, zones);
db.audit('share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip);
db.updateShareZones(req.user.id, req.params.token, zones);
db.audit(req.user.id, 'share_zones_update', 'share', req.params.token, { zonesCount: zones.length }, req.ip);
res.json({ ok: true });
});
@ -293,8 +372,8 @@ app.post('/api/share/position', requireAuth, (req, res) => {
const { lat, lng, speed, boatName } = req.body;
if (typeof lat !== 'number' || typeof lng !== 'number')
return res.status(400).json({ error: 'lat/lng required' });
// posta para todos os shares ativos do barco
const active = db.listActiveShares();
// posta apenas para shares do user logado
const active = db.listActiveShares(req.user.id);
let posted = 0;
for (const s of active) {
if (boatName && s.boat_name && s.boat_name !== boatName) continue;
@ -436,29 +515,35 @@ setInterval(() => {
} catch (e) { console.warn(e); }
}, 24 * 3600 * 1000);
// ==== Dead-man switch background check ====
let lastDeadmanFire = 0;
// ==== Dead-man switch background check (multi-user) ====
const lastDeadmanFire = new Map(); // user_id -> ts
async function checkDeadman() {
const a = db.getAnchor();
if (!a || !a.active) return;
const now = Date.now();
const since = now - (a.last_heartbeat || a.started_at);
if (since < HEARTBEAT_TIMEOUT) return;
// already fired recently? avoid spam
if (now - lastDeadmanFire < HEARTBEAT_TIMEOUT) return;
lastDeadmanFire = now;
console.log(`[deadman] No heartbeat in ${Math.round(since/1000)}s — firing remote alarm`);
const payload = {
boat: a.boat_name || 'Veleiro',
lat: a.last_lat, lng: a.last_lng,
distance: a.last_distance,
radius: a.radius,
reason: 'heartbeat_lost',
ts: now,
minutes_lost: Math.round(since / 60000)
};
const result = await dispatchAlarm(payload);
db.logAlarm('heartbeat_lost', payload, result.sent, result.failed);
const sessions = db.listActiveAnchors();
for (const a of sessions) {
if (!a.active) continue;
const since = now - (a.last_heartbeat || a.started_at);
if (since < HEARTBEAT_TIMEOUT) continue;
const last = lastDeadmanFire.get(a.user_id) || 0;
if (now - last < HEARTBEAT_TIMEOUT) continue;
lastDeadmanFire.set(a.user_id, now);
console.log(`[deadman] user=${a.user_id} no heartbeat in ${Math.round(since/1000)}s — firing alarm`);
const payload = {
boat: a.boat_name || 'Veleiro',
lat: a.last_lat, lng: a.last_lng,
distance: a.last_distance,
radius: a.radius,
reason: 'heartbeat_lost',
ts: now,
minutes_lost: Math.round(since / 60000)
};
try {
const result = await dispatchAlarm(payload);
db.logAlarm(a.user_id, 'heartbeat_lost', payload, result.sent, result.failed);
} catch (e) {
console.warn(`[deadman] dispatch failed user=${a.user_id}:`, e.message);
}
}
}
setInterval(checkDeadman, 30000); // check every 30s

View file

@ -30,6 +30,18 @@ export const updateZonesSchema = z.object({
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 =====
// Uso: app.post('/x', requireAuth, validate(mySchema), handler)
// Em caso de falha: 400 com até 5 issues do Zod (path + message).