feat: BizGaze Connect home, BizGaze login, modular backend, /api/v1
User-facing - New post-login home (/home): chat rail + Share/Connect (embedded) + Meeting; login lives here when logged out - Landing: "Log in with BizGaze" + no-login screen share - Console replaced by a role-scoped Dashboard (/dashboard): admins see all team sessions, others see only their own; stats + CSV/PDF export - Recordings saved as MP4 (H.264/AAC) with WebM fallback; old .webm still downloadable - Fix: duplicate "Sign in" on the login card Auth / integration - BizGaze as identity provider: /api/login validates against BIZGAZE_LOGIN_URL (env-gated) and provisions a local user - Phase 2 start: /api/v1 alias for all /api routes; Authorization: Bearer accepted across HTTP + WS; login returns a token (for native desktop/mobile clients) Backend refactor (Phase 1, behavior-preserving) - Split server.js into config/lib/session/presence/routes/static/signaling + repos (data-access) + bizgaze (service) - All SQL behind repos.js, tenant-scoped (tenantId == team_id for now) - e2e updated to current flow (21/21 pass before and after) Docs: ARCHITECTURE.md (target architecture + phased plan), CLAUDE.md repo layout, .env.example BIZGAZE_LOGIN_URL Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+19
-592
@@ -1,610 +1,37 @@
|
||||
// Remote Access Platform — backend server
|
||||
// HTTP JSON API (auth, MFA, machines, audit) + authenticated WebSocket signaling.
|
||||
// BizGaze Connect — backend entry point.
|
||||
// Thin wiring layer: HTTP request dispatch + WebSocket attach + listeners.
|
||||
// All logic lives in focused modules:
|
||||
// repos.js data-access (all SQL)
|
||||
// bizgaze.js BizGaze identity provider
|
||||
// lib.js HTTP helpers (json/readBody/parseCookies/now)
|
||||
// session.js currentUser / audit
|
||||
// presence.js shared in-memory live state (agents/sessions/shares)
|
||||
// routes.js HTTP JSON API (/api/*, /sso)
|
||||
// static.js static files + authenticated downloads (GET fallback)
|
||||
// signaling.js WebSocket signaling (consent + SDP/ICE relay)
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const db = require('./db');
|
||||
const A = require('./auth');
|
||||
const { PORT, HTTPS_PORT } = require('./config');
|
||||
const { json } = require('./lib');
|
||||
const routes = require('./routes');
|
||||
const { handleGet } = require('./static');
|
||||
const { onConnection } = require('./signaling');
|
||||
|
||||
const PORT = process.env.PORT || 8090;
|
||||
const HTTPS_PORT = process.env.HTTPS_PORT || 8443;
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const REC_DIR = path.join(__dirname, 'recordings');
|
||||
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
|
||||
const TRANS_DIR = path.join(__dirname, 'transcripts');
|
||||
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
|
||||
const SESSION_TTL = 1000 * 60 * 60 * 24; // 24h auto-logout
|
||||
|
||||
// ---------- helpers ----------
|
||||
const now = () => Date.now();
|
||||
const json = (res, code, body) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => (data += c));
|
||||
req.on('end', () => {
|
||||
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
(req.headers.cookie || '').split(';').forEach((c) => {
|
||||
const [k, ...v] = c.trim().split('=');
|
||||
if (k) out[k] = decodeURIComponent(v.join('='));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function audit(entry) {
|
||||
db.prepare(
|
||||
`INSERT INTO audit_log (team_id,user_id,user_email,machine_id,machine_name,action,detail,at)
|
||||
VALUES (@team_id,@user_id,@user_email,@machine_id,@machine_name,@action,@detail,@at)`
|
||||
).run({
|
||||
team_id: entry.team_id, user_id: entry.user_id || null, user_email: entry.user_email || null,
|
||||
machine_id: entry.machine_id || null, machine_name: entry.machine_name || null,
|
||||
action: entry.action, detail: entry.detail || null, at: now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the logged-in user from the session cookie. Returns user row (with mfa state) or null.
|
||||
function currentUser(req, { requireMfa = true } = {}) {
|
||||
const tok = parseCookies(req).sid;
|
||||
if (!tok) return null;
|
||||
const s = db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
|
||||
if (!s || s.expires_at < now()) return null;
|
||||
if (requireMfa && !s.mfa_passed) return null;
|
||||
const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
|
||||
if (!u || u.active === 0) return null;
|
||||
return { ...u, _session: s };
|
||||
}
|
||||
|
||||
// ---------- HTTP API ----------
|
||||
const routes = {};
|
||||
const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);
|
||||
|
||||
// Register: creates a team + admin user. MFA must be set up before full access.
|
||||
route('POST', '/api/register', async (req, res) => {
|
||||
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
|
||||
if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
|
||||
return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
|
||||
const { email, password, teamName } = await readBody(req);
|
||||
if (!email || !password) return json(res, 400, { error: 'email and password required' });
|
||||
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
|
||||
return json(res, 409, { error: 'email already registered' });
|
||||
const teamId = A.id(), userId = A.id();
|
||||
const { hash, salt } = A.hashPassword(password);
|
||||
const mfaSecret = A.newMfaSecret();
|
||||
db.prepare('INSERT INTO teams (id,name,created_at) VALUES (?,?,?)')
|
||||
.run(teamId, teamName || `${email}'s team`, now());
|
||||
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,mfa_secret,mfa_enabled,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,0,?)`)
|
||||
.run(userId, teamId, email, hash, salt, 'admin', mfaSecret, now());
|
||||
audit({ team_id: teamId, user_id: userId, user_email: email, action: 'user_registered' });
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// Verify MFA enrollment (confirm the user scanned the QR / entered code)
|
||||
route('POST', '/api/mfa/enable', async (req, res) => {
|
||||
const { email, code } = await readBody(req);
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
|
||||
if (!u) return json(res, 404, { error: 'no such user' });
|
||||
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
|
||||
db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(u.id);
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// Login step 1: email + password -> sets a session cookie (mfa not yet passed)
|
||||
route('POST', '/api/login', async (req, res) => {
|
||||
const { email, password, remember } = await readBody(req);
|
||||
const u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email);
|
||||
if (!u || !A.verifyPassword(password, u.pw_salt, u.pw_hash))
|
||||
return json(res, 401, { error: 'invalid credentials' });
|
||||
if (u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
|
||||
const tok = A.token();
|
||||
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
|
||||
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
|
||||
.run(tok, u.id, now(), now() + ttl);
|
||||
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
||||
json(res, 200, { ok: true, mfaRequired: false });
|
||||
});
|
||||
|
||||
// Login step 2: TOTP code -> marks session mfa_passed
|
||||
route('POST', '/api/login/mfa', async (req, res) => {
|
||||
const { code } = await readBody(req);
|
||||
const tok = parseCookies(req).sid;
|
||||
const s = tok && db.prepare('SELECT * FROM sessions_auth WHERE token=?').get(tok);
|
||||
if (!s) return json(res, 401, { error: 'no session' });
|
||||
const u = db.prepare('SELECT * FROM users WHERE id=?').get(s.user_id);
|
||||
if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
|
||||
db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(tok);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
route('POST', '/api/logout', async (req, res) => {
|
||||
const tok = parseCookies(req).sid;
|
||||
if (tok) db.prepare('DELETE FROM sessions_auth WHERE token=?').run(tok);
|
||||
res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
route('GET', '/api/setup-state', async (req, res) => {
|
||||
const anyUser = db.prepare('SELECT 1 FROM users LIMIT 1').get();
|
||||
json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
|
||||
});
|
||||
|
||||
// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if
|
||||
// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL).
|
||||
// Sign up for a managed TURN service (Cloudflare / Metered / Twilio) and set these
|
||||
// three env vars — nothing to install or run on your side.
|
||||
route('GET', '/api/ice', async (req, res) => {
|
||||
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
if (process.env.TURN_URLS) {
|
||||
iceServers.push({
|
||||
urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean),
|
||||
username: process.env.TURN_USERNAME || '',
|
||||
credential: process.env.TURN_CREDENTIAL || '',
|
||||
});
|
||||
}
|
||||
json(res, 200, { iceServers });
|
||||
});
|
||||
|
||||
route('GET', '/api/me', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null });
|
||||
});
|
||||
|
||||
// ---------- BizGaze SSO: agent arrives already logged in ----------
|
||||
route('GET', '/sso', async (req, res) => {
|
||||
if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
|
||||
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
const token = q.get('token') || '';
|
||||
const [payloadB64, sig] = token.split('.');
|
||||
const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
|
||||
if (!payloadB64 || !sig) return fail('Invalid SSO token');
|
||||
const crypto = require('crypto');
|
||||
const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
|
||||
const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
|
||||
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
|
||||
let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
|
||||
if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
|
||||
let u = db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(p.email);
|
||||
if (!u) {
|
||||
const team = db.prepare('SELECT * FROM teams LIMIT 1').get();
|
||||
if (!team) return fail('No team configured');
|
||||
const userId = A.id();
|
||||
const { hash, salt } = A.hashPassword(A.token());
|
||||
const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
|
||||
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,?)`)
|
||||
.run(userId, team.id, p.email, hash, salt, role, (p.name || null), A.newMfaSecret(), now());
|
||||
u = db.prepare('SELECT * FROM users WHERE id=?').get(userId);
|
||||
audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
|
||||
} else if (p.name && p.name !== u.name) {
|
||||
db.prepare('UPDATE users SET name=? WHERE id=?').run(p.name, u.id);
|
||||
}
|
||||
if (u.active === 0) return fail('Account deactivated');
|
||||
const tok = A.token();
|
||||
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,1,?,?)')
|
||||
.run(tok, u.id, now(), now() + SESSION_TTL);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
|
||||
const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
|
||||
res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Admin adds an agent login to their team
|
||||
route('POST', '/api/users', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
|
||||
const { email, password, name, role } = await readBody(req);
|
||||
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
|
||||
if (db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email))
|
||||
return json(res, 409, { error: 'email already registered' });
|
||||
const userId = A.id();
|
||||
const { hash, salt } = A.hashPassword(password);
|
||||
const mfaSecret = A.newMfaSecret();
|
||||
const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
|
||||
db.prepare(`INSERT INTO users (id,team_id,email,pw_hash,pw_salt,role,name,mfa_secret,mfa_enabled,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,?)`)
|
||||
.run(userId, u.team_id, email, hash, salt, r, (name || null), mfaSecret, now());
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
|
||||
json(res, 200, { ok: true, id: userId, email, role: r });
|
||||
});
|
||||
|
||||
// List the team's agents
|
||||
route('GET', '/api/users', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const rows = db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(u.team_id);
|
||||
json(res, 200, rows);
|
||||
});
|
||||
|
||||
// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
|
||||
route('GET', '/api/mfa/setup', async (req, res) => {
|
||||
const u = currentUser(req, { requireMfa: false });
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
|
||||
json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
|
||||
});
|
||||
|
||||
// Admin manages an agent: reset password, rename, deactivate/activate, delete.
|
||||
// (Display names are owned by the admin/BizGaze app — agents cannot edit them.)
|
||||
route('POST', '/api/users/manage', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
|
||||
const { id, action, password, name } = await readBody(req);
|
||||
const target = db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, u.team_id);
|
||||
if (!target) return json(res, 404, { error: 'no such agent' });
|
||||
switch (action) {
|
||||
case 'reset-password': {
|
||||
if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
|
||||
const { hash, salt } = A.hashPassword(password);
|
||||
db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, target.id);
|
||||
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id); // force re-login
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
case 'rename': {
|
||||
const clean = String(name || '').trim().slice(0, 60);
|
||||
if (!clean) return json(res, 400, { error: 'name required' });
|
||||
db.prepare('UPDATE users SET name=? WHERE id=?').run(clean, target.id);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
|
||||
return json(res, 200, { ok: true, name: clean });
|
||||
}
|
||||
case 'deactivate': {
|
||||
if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
|
||||
db.prepare('UPDATE users SET active=0 WHERE id=?').run(target.id);
|
||||
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
case 'activate': {
|
||||
db.prepare('UPDATE users SET active=1 WHERE id=?').run(target.id);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
case 'delete': {
|
||||
if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
|
||||
db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(target.id);
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(target.id);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
default: return json(res, 400, { error: 'unknown action' });
|
||||
}
|
||||
});
|
||||
|
||||
// Session report: one row per session, filterable by agent and date period
|
||||
route('GET', '/api/report', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
let sql = 'SELECT * FROM sessions_log WHERE team_id=?';
|
||||
const args = [u.team_id];
|
||||
if (q.get('agent')) { sql += ' AND agent_email=?'; args.push(q.get('agent')); }
|
||||
if (q.get('from')) { sql += ' AND started_at>=?'; args.push(new Date(q.get('from') + 'T00:00:00').getTime()); }
|
||||
if (q.get('to')) { sql += ' AND started_at<=?'; args.push(new Date(q.get('to') + 'T23:59:59').getTime()); }
|
||||
sql += ' ORDER BY started_at DESC LIMIT 500';
|
||||
json(res, 200, db.prepare(sql).all(...args));
|
||||
});
|
||||
|
||||
// List machines for the team (with live online status from signaling layer)
|
||||
route('GET', '/api/machines', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const rows = db.prepare('SELECT id,name,unattended,last_seen FROM machines WHERE team_id=?').all(u.team_id);
|
||||
json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
|
||||
});
|
||||
|
||||
// Create a machine enrollment token (admin/technician). Agent uses it to come online.
|
||||
route('POST', '/api/machines', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
|
||||
const { name, unattended } = await readBody(req);
|
||||
const mId = A.id(), enroll = A.token();
|
||||
db.prepare('INSERT INTO machines (id,team_id,name,enroll_token,unattended,created_at) VALUES (?,?,?,?,?,?)')
|
||||
.run(mId, u.team_id, name || 'Unnamed PC', enroll, unattended ? 1 : 0, now());
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
|
||||
json(res, 200, { id: mId, enrollToken: enroll });
|
||||
});
|
||||
|
||||
route('GET', '/api/audit', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const rows = db.prepare("SELECT * FROM audit_log WHERE team_id=? OR team_id='adhoc' ORDER BY at DESC LIMIT 200").all(u.team_id);
|
||||
json(res, 200, rows);
|
||||
});
|
||||
|
||||
// ---------- session recording: upload (agent) + download (team) ----------
|
||||
const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
|
||||
route('POST', '/api/recording', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
|
||||
if (!sid) return json(res, 400, { error: 'sessionId required' });
|
||||
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
|
||||
if (!row) return json(res, 404, { error: 'no such session' });
|
||||
const chunks = []; let total = 0, aborted = false;
|
||||
req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
||||
req.on('end', () => {
|
||||
if (aborted) return json(res, 413, { error: 'recording too large' });
|
||||
const fname = sid + '.webm';
|
||||
try {
|
||||
fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
|
||||
db.prepare('UPDATE sessions_log SET recording=? WHERE id=?').run(fname, sid);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
|
||||
json(res, 200, { ok: true });
|
||||
} catch (e) { json(res, 500, { error: 'could not save recording' }); }
|
||||
});
|
||||
req.on('error', () => { try { res.end(); } catch (e) {} });
|
||||
});
|
||||
|
||||
route('POST', '/api/transcript', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
|
||||
if (!sid) return json(res, 400, { error: 'sessionId required' });
|
||||
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
|
||||
if (!row) return json(res, 404, { error: 'no such session' });
|
||||
const chunks = []; let total = 0, aborted = false;
|
||||
req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
|
||||
req.on('end', () => {
|
||||
if (aborted) return json(res, 413, { error: 'transcript too large' });
|
||||
const fname = sid + '.txt';
|
||||
try {
|
||||
fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
|
||||
db.prepare('UPDATE sessions_log SET transcript=? WHERE id=?').run(fname, sid);
|
||||
json(res, 200, { ok: true });
|
||||
} catch (e) { json(res, 500, { error: 'could not save transcript' }); }
|
||||
});
|
||||
req.on('error', () => { try { res.end(); } catch (e) {} });
|
||||
});
|
||||
|
||||
// ---------- static + router ----------
|
||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
|
||||
function serveStatic(req, res) {
|
||||
let p = req.url.split('?')[0];
|
||||
if (p === '/') p = '/index.html';
|
||||
if (p === '/console') p = '/console.html';
|
||||
if (p === '/share') p = '/share.html';
|
||||
if (p === '/connect') p = '/connect.html';
|
||||
const fp = path.join(PUBLIC_DIR, path.normalize(p));
|
||||
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
fs.readFile(fp, (err, data) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
const ct = MIME[path.extname(fp)] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// ---------- HTTP request dispatch ----------
|
||||
const server = http.createServer((req, res) => {
|
||||
const key = `${req.method} ${req.url.split('?')[0]}`;
|
||||
if (routes[key]) return routes[key](req, res);
|
||||
if (req.method === 'GET' && req.url.split('?')[0].startsWith('/transcripts/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
|
||||
const sid = name.replace(/\.txt$/i, '');
|
||||
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
|
||||
if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
|
||||
const fp = path.join(TRANS_DIR, row.transcript);
|
||||
if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' });
|
||||
const rs = fs.createReadStream(fp);
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
if (req.method === 'GET' && req.url.split('?')[0].startsWith('/recordings/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const name = path.basename(decodeURIComponent(req.url.split('?')[0]));
|
||||
const sid = name.replace(/\.webm$/i, '');
|
||||
const row = db.prepare('SELECT * FROM sessions_log WHERE id=? AND team_id=?').get(sid, u.team_id);
|
||||
if (!row || !row.recording) return json(res, 404, { error: 'not found' });
|
||||
const fp = path.join(REC_DIR, row.recording);
|
||||
if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
res.writeHead(200, { 'Content-Type': 'video/webm', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.webm"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
|
||||
const rs = fs.createReadStream(fp);
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
if (req.method === 'GET') return serveStatic(req, res);
|
||||
if (req.method === 'GET') return handleGet(req, res); // downloads + static
|
||||
json(res, 404, { error: 'not found' });
|
||||
});
|
||||
|
||||
// ---------- WebSocket signaling ----------
|
||||
// Two kinds of WS clients:
|
||||
// agent -> authenticates with machine enroll_token, waits for session requests
|
||||
// viewer -> authenticated technician, requests a session to a machine
|
||||
// The server brokers consent and relays SDP/ICE. Media never traverses the server.
|
||||
const onlineAgents = new Map(); // machineId -> { ws, machine }
|
||||
const liveSessions = new Map(); // sessionId -> { agentWs, viewerWs, machine, user }
|
||||
const pendingShares = new Map(); // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
|
||||
|
||||
function onConnection(ws, req) {
|
||||
const hb = setInterval(() => {
|
||||
if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); }
|
||||
}, 25000);
|
||||
ws.on('message', (raw) => {
|
||||
let m; try { m = JSON.parse(raw); } catch { return; }
|
||||
handle(ws, m, req);
|
||||
});
|
||||
ws.on('close', () => { clearInterval(hb); cleanup(ws); });
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
wss.on('connection', onConnection);
|
||||
|
||||
function handle(ws, m, req) {
|
||||
switch (m.type) {
|
||||
// --- Agent comes online ---
|
||||
case 'agent-hello': {
|
||||
const machine = db.prepare('SELECT * FROM machines WHERE enroll_token=?').get(m.enrollToken);
|
||||
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'invalid enroll token' }));
|
||||
ws.kind = 'agent'; ws.machineId = machine.id;
|
||||
onlineAgents.set(machine.id, { ws, machine });
|
||||
db.prepare('UPDATE machines SET last_seen=? WHERE id=?').run(now(), machine.id);
|
||||
ws.send(JSON.stringify({ type: 'agent-registered', machineId: machine.id, name: machine.name }));
|
||||
break;
|
||||
}
|
||||
// --- Technician requests control of a machine ---
|
||||
case 'viewer-connect': {
|
||||
const u = currentUser(req); // cookie sent on WS upgrade
|
||||
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
||||
const agent = onlineAgents.get(m.machineId);
|
||||
const machine = db.prepare('SELECT * FROM machines WHERE id=? AND team_id=?').get(m.machineId, u.team_id);
|
||||
if (!machine) return ws.send(JSON.stringify({ type: 'error', message: 'no such machine' }));
|
||||
if (!agent) return ws.send(JSON.stringify({ type: 'error', message: 'machine offline' }));
|
||||
if (u.role === 'viewer' && false) {} // view-only still allowed to watch; control gated agent-side
|
||||
const sessionId = A.token(8);
|
||||
ws.kind = 'viewer'; ws.sessionId = sessionId;
|
||||
liveSessions.set(sessionId, { agentWs: agent.ws, viewerWs: ws, machine, user: u });
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: machine.id, machine_name: machine.name, action: 'session_requested' });
|
||||
// Ask the agent for consent (or auto-grant if unattended policy is on)
|
||||
agent.ws.sessionId = sessionId;
|
||||
agent.ws.send(JSON.stringify({
|
||||
type: 'session-request', sessionId,
|
||||
technician: u.email, unattended: !!machine.unattended,
|
||||
}));
|
||||
ws.send(JSON.stringify({ type: 'session-pending', sessionId, machineName: machine.name }));
|
||||
break;
|
||||
}
|
||||
// --- Agent grants/denies consent ---
|
||||
case 'consent': {
|
||||
const sess = liveSessions.get(m.sessionId);
|
||||
if (!sess) return;
|
||||
if (m.granted) {
|
||||
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_granted', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
||||
try {
|
||||
db.prepare('INSERT INTO sessions_log (id,team_id,agent_email,agent_name,ticket,started_at) VALUES (?,?,?,?,?,?)')
|
||||
.run(m.sessionId, sess.machine.team_id, sess.user.email, (sess.agentName || sess.user.email), sess.ticket || null, now());
|
||||
} catch (e) { /* duplicate consent */ }
|
||||
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
|
||||
sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
|
||||
} else {
|
||||
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'consent_denied', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
||||
sess.viewerWs.send(JSON.stringify({ type: 'session-denied', sessionId: m.sessionId }));
|
||||
liveSessions.delete(m.sessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// --- No-install: end user opens /share, gets a one-time code ---
|
||||
case 'share-create': {
|
||||
let code;
|
||||
do { code = A.numericCode(6); } while (pendingShares.has(code));
|
||||
const sessionId = A.token(8);
|
||||
ws.kind = 'sharer'; ws.shareCode = code; ws.sessionId = sessionId;
|
||||
pendingShares.set(code, { sharerWs: ws, sessionId });
|
||||
ws.send(JSON.stringify({ type: 'share-code', code }));
|
||||
break;
|
||||
}
|
||||
// --- Logged-in agent enters the code (+ ticket) to connect ---
|
||||
case 'code-connect': {
|
||||
const agent = currentUser(req); // identity from the agent's authenticated session
|
||||
if (!agent) {
|
||||
return ws.send(JSON.stringify({ type: 'error', message: 'Please sign in as an agent first' }));
|
||||
}
|
||||
const ticket = String(m.ticket || '').trim() || null; // optional: direct sessions have no ticket
|
||||
const pend = pendingShares.get(String(m.code || '').trim());
|
||||
if (!pend || pend.sharerWs.readyState !== 1) {
|
||||
return ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired code' }));
|
||||
}
|
||||
pendingShares.delete(pend.sharerWs.shareCode);
|
||||
const sessionId = pend.sessionId;
|
||||
ws.kind = 'viewer'; ws.sessionId = sessionId;
|
||||
const agentName = agent.name || agent.email;
|
||||
const machine = { id: null, name: 'Support session ' + pend.sharerWs.shareCode, team_id: agent.team_id };
|
||||
const user = { id: agent.id, email: agent.email, team_id: agent.team_id };
|
||||
liveSessions.set(sessionId, { agentWs: pend.sharerWs, viewerWs: ws, machine, user, ticket, agentName });
|
||||
pend.sharerWs.sessionId = sessionId;
|
||||
audit({ team_id: agent.team_id, user_id: agent.id, user_email: agent.email, machine_name: machine.name, action: 'code_session_requested', detail: (ticket ? 'Ticket ' + ticket : 'Direct session (no ticket)') + ' · agent ' + agentName });
|
||||
pend.sharerWs.send(JSON.stringify({ type: 'share-request', sessionId, technician: agentName, ticket }));
|
||||
ws.send(JSON.stringify({ type: 'code-pending', sessionId }));
|
||||
break;
|
||||
}
|
||||
// --- Relay WebRTC signaling between the two peers ---
|
||||
case 'offer': case 'answer': case 'ice-candidate': {
|
||||
const sess = liveSessions.get(m.sessionId || ws.sessionId);
|
||||
if (!sess) return;
|
||||
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
|
||||
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
|
||||
break;
|
||||
}
|
||||
case 'transcript': {
|
||||
const sess = liveSessions.get(m.sessionId || ws.sessionId);
|
||||
if (!sess) return;
|
||||
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
|
||||
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
|
||||
break;
|
||||
}
|
||||
case 'recording': {
|
||||
const sess = liveSessions.get(m.sessionId || ws.sessionId);
|
||||
if (!sess) return;
|
||||
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
|
||||
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
|
||||
break;
|
||||
}
|
||||
case 'end-session': {
|
||||
endSession(ws.sessionId, m.reason || null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyBizGaze(sessionId) {
|
||||
const url = process.env.BIZGAZE_WEBHOOK_URL;
|
||||
if (!url) return;
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM sessions_log WHERE id=?').get(sessionId);
|
||||
if (!row) return;
|
||||
const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
|
||||
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
|
||||
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
|
||||
const crypto = require('crypto');
|
||||
const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
|
||||
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
|
||||
} catch (e) {}
|
||||
}
|
||||
function endSession(sessionId, reason) {
|
||||
const sess = liveSessions.get(sessionId);
|
||||
if (!sess) return;
|
||||
try { db.prepare('UPDATE sessions_log SET ended_at=? WHERE id=? AND ended_at IS NULL').run(now(), sessionId); } catch (e) {}
|
||||
notifyBizGaze(sessionId);
|
||||
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
||||
[sess.agentWs, sess.viewerWs].forEach((p) => {
|
||||
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
|
||||
});
|
||||
liveSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
function cleanup(ws) {
|
||||
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
||||
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
||||
if (ws.sessionId) {
|
||||
for (const [sid, sess] of liveSessions) {
|
||||
if (sess.agentWs === ws || sess.viewerWs === ws) endSession(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`HTTP on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user