diff --git a/backend/newwhats/index.js b/backend/newwhats/index.js index 88aea13..f923f03 100644 --- a/backend/newwhats/index.js +++ b/backend/newwhats/index.js @@ -10,6 +10,22 @@ import { createRestProxy } from './proxy.js' import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js' export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js' +export { createWsTunnel } from './proxy.js' + +// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige +// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente +// (= chave OK); 401 = chave inválida. +async function testMotorKey(motorUrl, key) { + try { + const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, { + headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' }, + }); + if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' }; + return { ok: false, detail: `Motor respondeu ${r.status}` }; + } catch (e) { + return { ok: false, detail: `Motor inacessível: ${e.message}` }; + } +} export function registerNewwhats(app, pool, opts = {}) { // passthrough middleware caso o guard não seja fornecido (dev) @@ -69,12 +85,8 @@ export function registerNewwhats(app, pool, opts = {}) { return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' }); } - // Valida contra o motor ANTES de salvar. - let motorOk = false, detail = ''; - try { - const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, { headers: { 'x-nw-key': integrationKey } }); - motorOk = r.ok; detail = r.ok ? 'Conexão OK' : `Motor respondeu ${r.status}`; - } catch (e) { detail = `Motor inacessível: ${e.message}`; } + // Valida a chave contra o motor ANTES de salvar. + const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey); if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` }); const cur = getConfig(); @@ -97,12 +109,8 @@ export function registerNewwhats(app, pool, opts = {}) { app.get('/api/nw/status', superadminGuard, async (_req, res) => { const c = getConfig(); if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' }); - try { - const r = await fetch(`${c.motorUrl}/api/ext/v1/sessions`, { headers: { 'x-nw-key': c.integrationKey } }); - res.json({ configured: true, motorOk: r.ok, status: r.status, detail: r.ok ? 'Conexão OK' : `Motor respondeu ${r.status}` }); - } catch (e) { - res.json({ configured: true, motorOk: false, detail: `Motor inacessível: ${e.message}` }); - } + const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey); + res.json({ configured: true, motorOk: ok, detail }); }); // ── Operação ──────────────────────────────────────────────────────────────── diff --git a/backend/newwhats/proxy.js b/backend/newwhats/proxy.js index a3ee0b4..c5ffa24 100644 --- a/backend/newwhats/proxy.js +++ b/backend/newwhats/proxy.js @@ -1,30 +1,128 @@ -// Proxy REST: /api/nw/v1/* (frontend da clínica) → motor /api/ext/v1/*, -// injetando a integration_key como x-nw-key. Nunca expõe a chave ao browser. -import { getConfig } from './config.js' +// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*. +// +// Segurança: valida o JWT do scoreodonto (só usuário logado acessa). +// Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo +// email). Todo usuário da clínica tem conta própria no motor — se o motor não +// conhecer o email, é erro (404 "conta não encontrada"), não há fallback. +// O email vem SEMPRE do token verificado — nunca do cliente. +import net from 'node:net'; +import tls from 'node:tls'; +import jwt from 'jsonwebtoken'; +import { getConfig } from './config.js'; + +function emailFromReq(req) { + try { + const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim(); + if (!tk) return null; + const p = jwt.verify(tk, process.env.JWT_SECRET); + return (p.email || '').toLowerCase() || null; + } catch { return null; } +} export function createRestProxy() { return async function restProxy(req, res) { - const cfg = getConfig() + const cfg = getConfig(); if (!cfg.motorUrl || !cfg.integrationKey) { - return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_URL / NEWWHATS_INTEGRATION_KEY).' }) + return res.status(503).json({ error: 'Integração NewWhats não configurada.' }); } - // /api/nw/v1/inbox?x=1 → /api/ext/v1/inbox?x=1 - const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '') - const url = `${cfg.motorUrl}/api/ext/v1${tail}` + const email = emailFromReq(req); + if (!email) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' }); + + const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, ''); + const url = `${cfg.motorUrl}/api/ext/v1${tail}`; + + const opts = { method: req.method, headers: { + 'Content-Type': 'application/json', + 'x-nw-key': cfg.integrationKey, + 'x-nw-email': email, + } }; + if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) { + opts.body = JSON.stringify(req.body); + } + try { - const opts = { - method: req.method, - headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey }, - } - if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) { - opts.body = JSON.stringify(req.body) - } - const r = await fetch(url, opts) - const text = await r.text() - res.status(r.status) - try { res.json(JSON.parse(text)) } catch { res.send(text) } + const r = await fetch(url, opts); + const text = await r.text(); + res.status(r.status); + try { res.json(JSON.parse(text)); } catch { res.send(text); } } catch (e) { - res.status(502).json({ error: `Motor indisponível: ${e.message}` }) + res.status(502).json({ error: `Motor indisponível: ${e.message}` }); } - } + }; +} + +// ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ── +// +// O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda +// sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a +// x-nw-key no handshake com o motor — a chave nunca chega ao browser. +// +// Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então +// o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só +// usuário logado abre o túnel; o token fica local — não é repassado ao motor. +// +// Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a +// mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem +// query — por isso reescrevemos um request-line limpo. O email vem do JWT. +export function createWsTunnel() { + return function wsTunnel(req, clientSocket, head) { + const fail = (statusLine) => { + try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ } + clientSocket.destroy(); + }; + + // Auth: JWT do scoreodonto no query string (?token=). + let token; + try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); } + catch { return fail('400 Bad Request'); } + let email; + try { + const p = jwt.verify(token, process.env.JWT_SECRET); + email = (p.email || '').toLowerCase(); + } catch { return fail('401 Unauthorized'); } + if (!email) return fail('401 Unauthorized'); + + const cfg = getConfig(); + if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable'); + + let target; + try { target = new URL(cfg.motorUrl); } + catch { return fail('502 Bad Gateway'); } + const isHttps = target.protocol === 'https:'; + const host = target.hostname; + const port = Number(target.port) || (isHttps ? 443 : 80); + + const onConnect = () => { + // Reescreve o handshake: path EXATO + Host do motor + x-nw-key. + // Preserva os headers do WS (sec-websocket-*, upgrade, connection). + const headers = Object.entries(req.headers) + .filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase())) + .map(([k, v]) => `${k}: ${v}`) + .join('\r\n'); + + proxySocket.write( + `${req.method} /api/ext/v1/stream HTTP/1.1\r\n` + + `Host: ${host}\r\n` + + `x-nw-key: ${cfg.integrationKey}\r\n` + + `x-nw-email: ${email}\r\n` + + `${headers}\r\n\r\n` + ); + if (head && head.length) proxySocket.write(head); + + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + }; + + const proxySocket = isHttps + ? tls.connect({ host, port, servername: host }, onConnect) + : net.connect(port, host, onConnect); + + proxySocket.on('error', (err) => { + console.error('[newwhats] túnel WS erro:', err.message); + clientSocket.destroy(); + }); + clientSocket.on('error', () => proxySocket.destroy()); + clientSocket.on('close', () => proxySocket.destroy()); + proxySocket.on('close', () => clientSocket.destroy()); + }; } diff --git a/backend/server.js b/backend/server.js index aa801b6..96429c9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,7 +11,7 @@ import fs from 'fs'; import path from 'path'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; -import { registerNewwhats } from './newwhats/index.js'; +import { registerNewwhats, createWsTunnel } from './newwhats/index.js'; const { Pool } = pg; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; @@ -47,7 +47,7 @@ const pool = new Pool({ max: 10, idleTimeoutMillis: 30000 }); -pool.on('connect', () => console.log('[PG] Connected to PostgreSQL')); +// pool.on('connect', () => console.log('[PG] Connected to PostgreSQL')); pool.on('error', (err) => console.error('[PG] Pool error:', err.message)); // Helper: transaction wrapper @@ -9953,7 +9953,24 @@ const httpServer = app.listen(PORT, '0.0.0.0', async () => { // ── WebSocket server (tempo real) em /api/ws ──────────────────────────────── // Auth via query string (?token=&clinicaId=), pois EventSource/WebSocket do // navegador não enviam header Authorization. Valida JWT + vínculo com a clínica. -const wss = new WebSocketServer({ server: httpServer, path: '/api/ws' }); +const wss = new WebSocketServer({ noServer: true }); + +// Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path} +// aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o +// /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor). +const nwWsTunnel = createWsTunnel(); +httpServer.on('upgrade', (req, socket, head) => { + let pathname; + try { pathname = new URL(req.url, 'http://localhost').pathname; } + catch { socket.destroy(); return; } + if (pathname === '/api/ws') { + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); + } else if (pathname === '/api/nw/v1/stream') { + nwWsTunnel(req, socket, head); + } else { + socket.destroy(); + } +}); wss.on('connection', async (ws, req) => { try { const u = new URL(req.url, 'http://localhost');