Files
UNITRONIX d7a2fe6562 fix(security): harden update restore and notification CSRF
Protect backup restores and session-authenticated notification writes from unsafe state changes, while clearing CodeQL false positives without weakening intentional TLS pinning.

Refs CodeQL alerts #297-310

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-08-23 21:08:05 +02:00

1109 lines
44 KiB
JavaScript

/**
* BetterDesk Console — Desktop Client API Routes
*
* REST endpoints consumed by the BetterDesk desktop client (Tauri).
* These run on the main console server (port 5000) under /api/bd/*.
*
* Endpoints:
* POST /api/bd/register — Register / heartbeat a desktop device
* POST /api/bd/connect — Request relay session to a target device
* GET /api/bd/session/:id — Check session status
* POST /api/bd/heartbeat — Lightweight keepalive
* GET /api/bd/peers — List online peers (requires auth)
* GET /api/bd/peer/:id — Get single peer info
* DELETE /api/bd/session/:id — Cancel/close a relay session
*
* Authentication:
* Desktop clients authenticate via access tokens (Bearer header) obtained
* from the RustDesk Client API (/api/login on port 21121).
* Token is validated with the same authService.
*
* @author UNITRONIX
* @version 1.0.0
*/
'use strict';
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const config = require('../config/config');
const db = require('../services/database');
const bdRelay = require('../services/bdRelay');
const remoteRelay = require('../services/remoteRelay');
const brandingService = require('../services/brandingService');
const authService = require('../services/authService');
const betterdeskApi = require('../services/betterdeskApi');
const {
requireDeviceToken,
requireTokenDeviceMatch,
} = require('../middleware/deviceAuth');
const { doubleCsrfProtection } = require('../middleware/csrf');
// ---------------------------------------------------------------------------
// Help requests & chat are stored on the Go server (single source of truth).
// The panel is a read proxy: it forwards reads/writes to the Go REST API and
// fans out Go events to browsers via socket.io (see helpChatPush service).
//
// Go uses status values pending/acknowledged/resolved/cancelled. The panel UI
// historically uses pending/accepted/resolved, so we normalize on the way out.
// ---------------------------------------------------------------------------
/** Map a Go help-request record to the shape the panel UI expects. */
function normalizeHelpRequest(r) {
if (!r || typeof r !== 'object') return null;
const statusMap = { acknowledged: 'accepted' };
const createdMs = r.created_at ? Date.parse(r.created_at) : Date.now();
return {
id: String(r.id),
device_id: r.device_id || '',
hostname: r.hostname || '',
message: r.message || '',
status: statusMap[r.status] || r.status || 'pending',
accepted_by: r.status === 'acknowledged' ? (r.handled_by || '') : '',
resolved_by: r.status === 'resolved' ? (r.handled_by || '') : '',
created_at: Number.isFinite(createdMs) ? createdMs : Date.now(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getClientIp(req) {
return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|| req.headers['x-real-ip']
|| req.socket?.remoteAddress
|| 'unknown';
}
function extractBearerToken(req) {
const auth = req.headers['authorization'];
if (!auth || !auth.startsWith('Bearer ')) return null;
return auth.substring(7).trim();
}
function requireOperatorRole(req, res, next) {
const role = req.deviceUser?.role;
if (role !== 'admin' && role !== 'operator') {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
}
function normalizeSessionAction(value) {
const action = String(value || '').trim().toLowerCase();
if (action === 'start' || action === 'session_start') return 'session_start';
if (action === 'end' || action === 'session_end') return 'session_end';
return null;
}
function toTimestamp(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
const parsed = Date.parse(String(value || ''));
return Number.isFinite(parsed) ? parsed : 0;
}
function buildSessionHistory(entries, limit) {
const now = Date.now();
const grouped = new Map();
const rows = [...entries]
.filter((entry) => entry.action === 'session_start' || entry.action === 'session_end')
.sort((a, b) => toTimestamp(a.created_at) - toTimestamp(b.created_at));
for (const entry of rows) {
const key = entry.session_id || `${entry.host_id}:${entry.peer_id}:${entry.id}`;
const existing = grouped.get(key) || {
id: key,
device_id: entry.peer_id || '',
hostname: entry.peer_name || '',
operator: entry.host_id || 'operator',
started_at: '',
ended_at: '',
duration_secs: 0,
action: entry.action || 'session_start',
};
if (!existing.device_id && entry.peer_id) existing.device_id = entry.peer_id;
if (!existing.hostname && entry.peer_name) existing.hostname = entry.peer_name;
if (!existing.operator && entry.host_id) existing.operator = entry.host_id;
if (entry.action === 'session_start') {
existing.started_at = String(entry.created_at || existing.started_at || '');
existing.action = 'session_start';
}
if (entry.action === 'session_end') {
existing.ended_at = String(entry.created_at || existing.ended_at || '');
existing.action = 'session_end';
}
grouped.set(key, existing);
}
return [...grouped.values()]
.map((entry) => {
const started = toTimestamp(entry.started_at);
const ended = entry.ended_at ? toTimestamp(entry.ended_at) : now;
if (started > 0 && ended >= started) {
entry.duration_secs = Math.max(0, Math.round((ended - started) / 1000));
}
return entry;
})
.sort((a, b) => toTimestamp(b.started_at || b.ended_at) - toTimestamp(a.started_at || a.ended_at))
.slice(0, limit);
}
// ---------------------------------------------------------------------------
// GET /api/bd/server-info — Public panel identity (RdClient URL validation)
// ---------------------------------------------------------------------------
router.get('/server-info', (_req, res) => {
const branding = brandingService.getBranding();
res.json({
ok: true,
product: 'betterdesk-panel',
version: config.appVersion,
panel_name: branding.appName || config.appName,
appearance_contract: {
version: '2.0',
endpoint: '/api/bd/appearance',
revision: brandingService.getBrandingRevision()
},
});
});
// ---------------------------------------------------------------------------
// Middleware — authenticate operator-facing desktop API requests
// ---------------------------------------------------------------------------
async function requireDeviceAuth(req, res, next) {
// SECURITY (audit fix H-02, 2026-04-10): /api/bd/* now accepts ONLY
// Bearer access tokens. The previous session-cookie fallback combined
// with the CSRF skip for /api/bd/* enabled CSRF on device-management
// endpoints when called from an authenticated browser session.
// Browser-based operators must obtain a Bearer token from
// POST /api/auth/access-token before calling /api/bd/* endpoints.
const token = extractBearerToken(req);
if (token) {
try {
const tokenRow = await db.getAccessToken(token);
if (tokenRow) {
const user = await db.getUserById(tokenRow.user_id);
req.deviceToken = tokenRow;
req.deviceUser = user || null;
await db.touchAccessToken(token);
return next();
}
} catch (err) {
console.error('[BD-API] Token auth error:', err.message);
}
}
// Log auth attempt without leaking the token (redact entirely).
const hasAuth = !!req.headers['authorization'];
console.warn(`[BD-API] Auth FAILED ${req.method} ${req.path} — Bearer=${hasAuth ? '[present-invalid]' : '[absent]'} ip=${req.ip}`);
return res.status(401).json({ error: 'Missing or invalid Bearer access token' });
}
/**
* Registration-only compatibility auth. The initial register request is the
* one endpoint that may identify a device before a server token exists.
* Every endpoint that reads or mutates existing device state uses
* requireDeviceToken instead.
*/
async function identifyDevice(req, res, next) {
const token = extractBearerToken(req);
if (token) {
try {
const tokenRow = await db.getAccessToken(token);
if (tokenRow) {
const boundDeviceId = String(tokenRow.client_id || '');
req.deviceId = /^[A-Za-z0-9_-]{3,64}$/.test(boundDeviceId) ? boundDeviceId : null;
req.deviceToken = tokenRow;
await db.touchAccessToken(token);
if (req.deviceId) return next();
}
} catch (_) {}
}
// Fallback: X-Device-Id header (for registration before login)
const deviceId = req.headers['x-device-id'];
if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
req.deviceId = deviceId;
return next();
}
return res.status(401).json({ error: 'Missing device identification' });
}
// ---------------------------------------------------------------------------
// POST /api/bd/register — Register or update a desktop device
// ---------------------------------------------------------------------------
router.post('/register', identifyDevice, async (req, res) => {
try {
const ip = getClientIp(req);
const { device_id, uuid, hostname, platform, version, public_key } = req.body;
const claimedId = typeof device_id === 'string' ? device_id.trim() : '';
if (req.deviceId && claimedId && req.deviceId !== claimedId) {
return res.status(403).json({ error: 'device_id mismatch' });
}
const id = claimedId || req.deviceId;
if (!id) {
return res.status(400).json({ error: 'device_id is required' });
}
let effectiveId = id;
// Reject registration with a stale/renamed peer ID unless same device (#97, #213)
if (typeof db.shouldRejectRenamedPeerRegistration === 'function') {
const renameCheck = await db.shouldRejectRenamedPeerRegistration(id, {
uuid: uuid || '',
pk: public_key || '',
ip,
});
if (renameCheck.reject && renameCheck.new_id) {
return res.status(409).json({
error: 'Device ID has been changed',
new_id: renameCheck.new_id,
message: `This device ID was renamed to ${renameCheck.new_id}. Please update your configuration.`,
});
}
if (renameCheck.redirect_id) {
effectiveId = renameCheck.redirect_id;
}
} else {
const newId = db.getRenamedPeerId ? await db.getRenamedPeerId(id) : null;
if (newId) {
return res.status(409).json({
error: 'Device ID has been changed',
new_id: newId,
message: `This device ID was renamed to ${newId}. Please update your configuration.`,
});
}
}
// Build info JSON
const info = JSON.stringify({
hostname: hostname || '',
os: platform || '',
version: version || '',
ip: ip,
});
// Upsert peer in DB
await db.upsertPeer({ id: effectiveId, uuid: uuid || '', pk: public_key || null, info, ip });
// Update online status
try {
await db.updatePeerOnlineStatus(effectiveId);
} catch (_) {}
res.json({
success: true,
device_id: effectiveId,
server_time: Date.now(),
heartbeat_interval: 15, // seconds
});
} catch (err) {
console.error('[BD-API] Register error:', err.message);
res.status(500).json({ error: 'Registration failed' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/heartbeat — Lightweight keepalive
// ---------------------------------------------------------------------------
router.post('/heartbeat', requireDeviceToken, requireTokenDeviceMatch, async (req, res) => {
try {
const id = req.body.device_id || req.deviceId;
if (!id) {
return res.status(400).json({ error: 'device_id is required' });
}
// Touch online status
try {
await db.updatePeerOnlineStatus(id);
} catch (_) {}
// Check for pending incoming connection requests
const pending = [];
for (const [sid, session] of bdRelay.activeSessions) {
if (session.targetId === id && session.status === 'pending') {
pending.push({
session_id: sid,
initiator_id: session.initiatorId,
created_at: session.createdAt,
});
}
}
res.json({
success: true,
server_time: Date.now(),
pending_connections: pending,
});
} catch (err) {
console.error('[BD-API] Heartbeat error:', err.message);
res.status(500).json({ error: 'Heartbeat failed' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/remote-agent-token — Single-use token for /ws/remote-agent
// ---------------------------------------------------------------------------
router.post('/remote-agent-token', requireDeviceToken, requireTokenDeviceMatch, async (req, res) => {
try {
const id = req.body.device_id || req.deviceId;
if (!id || !/^[A-Za-z0-9_-]{3,64}$/.test(id)) {
return res.status(400).json({ error: 'device_id is required' });
}
if (req.deviceId && req.deviceId !== id) {
return res.status(403).json({ error: 'device_id mismatch' });
}
const issued = remoteRelay.issueRemoteAgentToken(id);
res.json({ success: true, device_id: id, ...issued });
} catch (err) {
console.error('[BD-API] remote-agent-token error:', err.message);
res.status(500).json({ error: 'Token issuance failed' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/connect — Request relay session to a target device
// ---------------------------------------------------------------------------
router.post('/connect', requireDeviceAuth, async (req, res) => {
try {
const { target_id, initiator_id, public_key } = req.body;
if (!target_id) {
return res.status(400).json({ error: 'target_id is required' });
}
const authenticatedDeviceId = req.deviceToken?.client_id;
if (!authenticatedDeviceId) {
return res.status(403).json({ error: 'A device-bound access token is required' });
}
if (initiator_id && initiator_id !== authenticatedDeviceId) {
return res.status(403).json({ error: 'initiator_id mismatch' });
}
const srcId = authenticatedDeviceId;
if (!srcId) {
return res.status(400).json({ error: 'initiator_id is required' });
}
// Check if target exists
const target = await db.getDeviceById(target_id);
if (!target) {
return res.status(404).json({ error: 'Target device not found' });
}
// Check if target is banned
if (target.is_banned) {
return res.status(403).json({ error: 'Target device is banned' });
}
// Create relay session
const { sessionId, initiatorToken, targetToken } = bdRelay.createRelaySession(srcId, target_id);
// Try to notify target via WebSocket signal channel
const targetNotified = bdRelay.notifyTarget(target_id, {
type: 'incoming_connection',
session_id: sessionId,
initiator_id: srcId,
initiator_pk: public_key || null,
});
res.json({
success: true,
session_id: sessionId,
token: initiatorToken,
target_online: targetNotified,
relay_url: `/ws/bd-relay?session=${sessionId}&token=${initiatorToken}&role=initiator`,
});
} catch (err) {
console.error('[BD-API] Connect error:', err.message);
res.status(500).json({ error: 'Connection request failed' });
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/session/:id — Check session status
// ---------------------------------------------------------------------------
router.get('/session/:id', requireDeviceToken, (req, res) => {
const session = bdRelay.getRelaySession(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found or expired' });
}
if (session.initiatorId !== req.deviceId && session.targetId !== req.deviceId) {
return res.status(403).json({ error: 'Not a participant of this session' });
}
res.json({ success: true, session });
});
// ---------------------------------------------------------------------------
// DELETE /api/bd/session/:id — Cancel relay session
// ---------------------------------------------------------------------------
router.delete('/session/:id', requireDeviceToken, (req, res) => {
const sessionId = req.params.id;
const session = bdRelay.getRelaySession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Only participants can cancel
const id = req.deviceId;
if (session.initiatorId !== id && session.targetId !== id) {
return res.status(403).json({ error: 'Not a participant of this session' });
}
// Teardown handled internally
bdRelay.activeSessions.delete(sessionId);
res.json({ success: true });
});
// ---------------------------------------------------------------------------
// GET /api/bd/peers — List online peers
// ---------------------------------------------------------------------------
router.get('/peers', requireDeviceToken, async (req, res) => {
try {
const onlineIds = bdRelay.getOnlineDeviceIds();
const peers = [];
for (const id of onlineIds) {
const device = await db.getDeviceById(id);
if (device && !device.is_banned && !device.is_deleted) {
peers.push({
id: device.id,
hostname: device.note || '',
platform: '',
online: true,
});
}
}
res.json({ success: true, peers });
} catch (err) {
console.error('[BD-API] Peers error:', err.message);
res.status(500).json({ error: 'Failed to list peers' });
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/peer/:id — Get single peer info
// ---------------------------------------------------------------------------
router.get('/peer/:id', requireDeviceToken, async (req, res) => {
try {
const device = await db.getDeviceById(req.params.id);
if (!device) {
return res.status(404).json({ error: 'Device not found' });
}
res.json({
success: true,
peer: {
id: device.id,
hostname: device.note || '',
online: bdRelay.isDeviceOnline(device.id),
banned: !!device.is_banned,
},
});
} catch (err) {
console.error('[BD-API] Peer error:', err.message);
res.status(500).json({ error: 'Failed to get peer info' });
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/branding — Public branding for desktop client (no auth)
// ---------------------------------------------------------------------------
router.get('/branding', (req, res) => {
try {
const branding = brandingService.getBranding();
const publicAppearance = brandingService.getPublicAppearance();
res.json({
company_name: branding.appName || 'BetterDesk',
accent_color: branding.colors?.accentBlue || '#3b82f6',
support_contact: branding.supportContact || '',
appearance: publicAppearance
});
} catch (err) {
console.error('[BD-API] Branding error:', err.message);
// Return defaults on error — never block the client
res.json({
company_name: 'BetterDesk',
accent_color: '#3b82f6',
support_contact: '',
});
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/appearance — Public, sanitized appearance contract for RdClient
// ---------------------------------------------------------------------------
router.get('/appearance', (_req, res) => {
try {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.json({ success: true, data: brandingService.getPublicAppearance() });
} catch (err) {
console.error('[BD-API] Appearance error:', err.message);
res.json({
success: true,
data: {
version: '2.0',
revision: 'fallback',
product: 'betterdesk-appearance',
identity: { appName: 'BetterDesk', logoType: 'icon', logoIcon: 'dns' },
palette: {
mode: 'dark',
primary: '#58a6ff',
background: '#0d1117',
surface: '#161b22',
surfaceRaised: '#21262d',
text: '#e6edf3',
muted: '#8b949e',
border: '#30363d',
danger: '#f85149',
warning: '#d29922',
success: '#2ea44f'
},
surfaces: { glassEnabled: true, glassBlur: '16', glassOpacity: '55' },
background: { type: 'none', color: '', gradient: '', imageUrl: '', overlay: '', size: 'cover' },
readability: { ok: true, issues: [] }
}
});
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/help-request — Desktop client requests operator assistance
// ---------------------------------------------------------------------------
router.post('/help-request', requireDeviceToken, requireTokenDeviceMatch, async (req, res) => {
try {
const { device_id, hostname, message } = req.body;
if (!device_id || typeof device_id !== 'string') {
return res.status(400).json({ error: 'Missing device_id' });
}
const cleanDeviceId = String(device_id).substring(0, 32);
const cleanHostname = String(hostname || '').substring(0, 128);
const cleanMessage = String(message || '').substring(0, 500);
// Help requests live on the Go server. Legacy agents that still POST to
// the panel are proxied through; modern agents send help requests over
// CDAP directly. The Go server publishes a help_request event which the
// helpChatPush service fans out to browser clients.
let requestId = null;
try {
const goRes = await betterdeskApi.apiClient.post('/help/requests', {
device_id: cleanDeviceId,
hostname: cleanHostname,
message: cleanMessage,
});
requestId = goRes.data && (goRes.data.id || goRes.data.request_id);
} catch (goErr) {
console.warn('[BD-API] Help request Go proxy failed:', goErr.message);
}
// Audit locally for history/searchability.
await db.logAction(null, 'help_request', `Help requested by ${cleanDeviceId}: ${cleanMessage}`, getClientIp(req));
console.log(`[BD-API] Help request from ${cleanDeviceId} (${cleanHostname}): ${cleanMessage}`);
res.json({ success: true, request_id: requestId ? String(requestId) : crypto.randomUUID() });
} catch (err) {
console.error('[BD-API] Help request error:', err.message);
res.status(500).json({ error: 'Failed to process help request' });
}
});
// ===========================================================================
// Operator Authentication (desktop client operator mode)
// ===========================================================================
// ---------------------------------------------------------------------------
// POST /api/bd/chat/send — Agent client sends a message to connected operators
// ---------------------------------------------------------------------------
// Chat messages are persisted on the Go server (single source of truth). The
// panel proxies sends/reads through the Go REST API. Live fan-out to operator
// browsers happens through the Go event bus (see helpChatPush service).
router.post('/chat/send', requireDeviceToken, requireTokenDeviceMatch, async (req, res) => {
try {
const { device_id, sender, content, timestamp } = req.body;
if (!device_id || typeof device_id !== 'string') {
return res.status(400).json({ error: 'Missing device_id' });
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return res.status(400).json({ error: 'Missing content' });
}
if (content.length > 4096) {
return res.status(400).json({ error: 'Message too long (max 4096 chars)' });
}
const cleanDeviceId = String(device_id).slice(0, 64);
const sanitizedSender = typeof sender === 'string' ? sender.trim().slice(0, 128) : cleanDeviceId;
// conversation_id is the device id; from_id identifies the device sender.
const result = await betterdeskApi.sendChatMessage({
conversation_id: cleanDeviceId,
from_id: cleanDeviceId,
from_name: sanitizedSender,
to_id: '',
text: content.trim().slice(0, 4096),
});
if (!result.success) {
console.warn('[BD-API] Chat send Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to send message' });
}
const messageId = result.data && (result.data.id || result.data.message_id);
res.json({ success: true, message_id: messageId ? String(messageId) : `${Date.now()}` });
} catch (err) {
console.error('[BD-API] Chat send error:', err.message);
res.status(500).json({ error: 'Failed to send message' });
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/chat/history — Fetch recent messages for a device
// ---------------------------------------------------------------------------
router.get('/chat/history', requireDeviceToken, requireTokenDeviceMatch, async (req, res) => {
const deviceId = String(req.query.device_id || '').slice(0, 64);
if (!deviceId) return res.status(400).json({ error: 'Missing device_id' });
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 200);
const result = await betterdeskApi.getChatHistory(deviceId, limit);
if (!result.success) {
console.warn('[BD-API] Chat history Go proxy failed:', result.error);
return res.json([]);
}
// Map Go chat messages to the panel's {id, device_id, sender, content, timestamp} shape.
const history = (result.data || []).map((m) => ({
id: String(m.id),
device_id: m.conversation_id || deviceId,
sender: m.from_name || m.from_id || '',
content: m.text || '',
timestamp: m.created_at || new Date().toISOString(),
}));
res.json(history.slice(-limit));
});
// ---------------------------------------------------------------------------
// POST /api/bd/operator/login — Authenticate operator from desktop client
// ---------------------------------------------------------------------------
router.post('/operator/login', async (req, res) => {
try {
const ip = getClientIp(req);
const { username, password, device_id } = req.body;
if (!username || typeof username !== 'string' || !password || typeof password !== 'string') {
return res.status(400).json({ error: 'Username and password are required' });
}
// Brute-force protection
const blocked = await authService.checkBruteForce(username, ip);
if (blocked) {
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
}
// Authenticate
const user = await authService.authenticate(username, password);
if (authService.isAuthFailure(user) && user.__authFailure === 'username_collision') {
authService.recordAttempt(username, ip, false);
return res.status(409).json({ error: 'Username collision: a local account with this name already exists', code: 'username_collision' });
}
if (!user) {
authService.recordAttempt(username, ip, false);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Only admin and operator roles can use operator mode
if (user.role !== 'admin' && user.role !== 'operator') {
authService.recordAttempt(username, ip, false);
return res.status(403).json({ error: 'Insufficient permissions. Admin or operator role required.' });
}
// Issue access token
authService.recordAttempt(username, ip, true);
const token = await authService.generateAccessToken(
user.id,
String(device_id || '').substring(0, 32),
'',
ip
);
await db.updateLastLogin(user.id);
await db.logAction(user.id, 'operator_login', `Operator login from desktop client (${device_id || 'unknown'})`, ip);
res.json({
access_token: token,
user: {
name: user.username || user.name || username,
role: user.role,
},
});
} catch (err) {
console.error('[BD-API] Operator login error:', err.message);
res.status(500).json({ error: 'Authentication failed' });
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/operator/devices — List all devices (requires operator auth)
// ---------------------------------------------------------------------------
router.get('/operator/devices', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
// Only admin/operator can list devices
if (req.deviceUser && req.deviceUser.role !== 'admin' && req.deviceUser.role !== 'operator') {
return res.status(403).json({ error: 'Insufficient permissions' });
}
const peers = await db.getAllPeers({});
const devices = peers.map((p) => {
let hostname = '';
let platform = '';
try {
const info = typeof p.info === 'string' ? JSON.parse(p.info) : (p.info || {});
hostname = info.hostname || p.note || '';
platform = info.os || info.platform || '';
} catch (_) {
hostname = p.note || '';
}
return {
id: p.id,
hostname: hostname,
platform: platform,
online: !!(p.status_online || p.online),
last_online: p.last_online || '',
};
});
res.json({ success: true, devices });
} catch (err) {
console.error('[BD-API] Operator devices error:', err.message);
res.status(500).json({ error: 'Failed to fetch devices' });
}
});
// ===========================================================================
// Help Request Management (web panel operators)
// ===========================================================================
// ---------------------------------------------------------------------------
// GET /api/bd/help-requests — List all help requests (requires auth)
// ---------------------------------------------------------------------------
router.get('/help-requests', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const filter = { limit: 200 };
if (req.query.status) filter.status = String(req.query.status);
if (req.query.device_id) filter.device_id = String(req.query.device_id);
const result = await betterdeskApi.listHelpRequests(filter);
if (!result.success) {
console.warn('[BD-API] List help requests Go proxy failed:', result.error);
return res.json({ success: true, requests: [] });
}
const items = (result.data || [])
.map(normalizeHelpRequest)
.filter(Boolean)
.sort((a, b) => b.created_at - a.created_at);
res.json({ success: true, requests: items });
} catch (err) {
console.error('[BD-API] List help requests error:', err.message);
res.status(500).json({ error: 'Failed to list help requests' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/help-requests/:id/accept — Accept a help request
// ---------------------------------------------------------------------------
router.post('/help-requests/:id/accept', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const result = await betterdeskApi.acknowledgeHelpRequest(req.params.id);
if (!result.success) {
console.warn('[BD-API] Accept help request Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to accept help request' });
}
await db.logAction(
req.deviceUser?.id || null,
'help_request_accept',
`Accepted help request ${req.params.id}`,
getClientIp(req)
);
res.json({ success: true, request: result.data });
} catch (err) {
console.error('[BD-API] Accept help request error:', err.message);
res.status(500).json({ error: 'Failed to accept help request' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/help-requests/:id/resolve — Resolve a help request
// ---------------------------------------------------------------------------
router.post('/help-requests/:id/resolve', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const result = await betterdeskApi.resolveHelpRequest(req.params.id);
if (!result.success) {
console.warn('[BD-API] Resolve help request Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to resolve help request' });
}
await db.logAction(
req.deviceUser?.id || null,
'help_request_resolve',
`Resolved help request ${req.params.id}`,
getClientIp(req)
);
res.json({ success: true, request: result.data });
} catch (err) {
console.error('[BD-API] Resolve help request error:', err.message);
res.status(500).json({ error: 'Failed to resolve help request' });
}
});
// ---------------------------------------------------------------------------
// DELETE /api/bd/help-requests/:id — Delete a help request
// ---------------------------------------------------------------------------
router.delete('/help-requests/:id', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
// The Go server has no hard-delete for help requests; closing it (resolve)
// removes it from the active list, which is what the panel UI expects.
const result = await betterdeskApi.resolveHelpRequest(req.params.id);
if (!result.success) {
console.warn('[BD-API] Delete help request Go proxy failed:', result.error);
return res.status(502).json({ error: 'Failed to delete help request' });
}
res.json({ success: true });
} catch (err) {
console.error('[BD-API] Delete help request error:', err.message);
res.status(500).json({ error: 'Failed to delete help request' });
}
});
// ===========================================================================
// Notification Center (panel navbar bell)
// ===========================================================================
//
// These routes are consumed by web-nodejs/public/js/notif-center.js. They use
// session-based auth (panel cookies) rather than Bearer tokens because the
// dropdown is part of the web UI, not the desktop client.
//
// Storage model: notifications are a per-user read-status overlay over
// helpRequests. Read state is persisted in notification_reads (per user).
// ---------------------------------------------------------------------------
const { requireAuth, roleHasPermission } = require('../middleware/auth');
const { listRegistrationNotifications } = require('../services/registrationNotifications');
function sessionUserId(req) {
return req.session?.userId ?? req.session?.user?.id ?? null;
}
function helpRequestToNotif(req, readIds) {
return {
id: req.id,
title: req.hostname || req.device_id,
message: req.message || '',
icon: 'support_agent',
link: '/help-requests',
read: readIds.has(String(req.id)),
created_at: new Date(req.created_at).toISOString(),
kind: 'help_request',
status: req.status,
};
}
function canViewRegistrationNotifications(req) {
return roleHasPermission(req.session?.user?.role, 'enrollment.approve');
}
async function getRegistrationNotifications(req, readIds) {
if (!canViewRegistrationNotifications(req)) {
return [];
}
const result = await listRegistrationNotifications(db, betterdeskApi);
return result.items.map((item) => ({
...item,
read: readIds.has(String(item.id)),
}));
}
// ---------------------------------------------------------------------------
// GET /api/bd/notifications — list recent notifications for current user
// ---------------------------------------------------------------------------
router.get('/notifications', requireAuth, async (req, res) => {
try {
const rawLimit = parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 50) : 20;
const unreadOnly = String(req.query.unread_only || '').toLowerCase() === 'true';
const userId = sessionUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const readIds = await db.getReadNotificationIds(userId);
const result = await betterdeskApi.listHelpRequests({ limit: 200 });
const requests = (result.success ? (result.data || []) : [])
.map(normalizeHelpRequest)
.filter(Boolean);
const helpItems = requests
.sort((a, b) => b.created_at - a.created_at)
.map(r => helpRequestToNotif(r, readIds))
const registrationItems = await getRegistrationNotifications(req, readIds);
const allItems = [...helpItems, ...registrationItems]
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
const items = allItems
.filter(n => (unreadOnly ? !n.read : true))
.slice(0, limit);
const unreadCount = allItems.filter((item) => !item.read).length;
res.json({ success: true, items, unread_count: unreadCount });
} catch (err) {
console.error('[BD-API] List notifications error:', err.message);
res.status(500).json({ error: 'Failed to list notifications' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/notifications/:id/read — mark single notification read
// ---------------------------------------------------------------------------
router.post('/notifications/:id/read', doubleCsrfProtection, requireAuth, async (req, res) => {
try {
const userId = sessionUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const id = String(req.params.id || '').slice(0, 128);
// Idempotent: the help request lives on the Go server; the read overlay
// is a local per-user state, so we simply record it.
await db.markNotificationRead(userId, id);
res.json({ success: true });
} catch (err) {
console.error('[BD-API] Mark notification read error:', err.message);
res.status(500).json({ error: 'Failed to mark notification read' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/notifications/read-all — mark all notifications read
// ---------------------------------------------------------------------------
router.post('/notifications/read-all', doubleCsrfProtection, requireAuth, async (req, res) => {
try {
const userId = sessionUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const result = await betterdeskApi.listHelpRequests({ limit: 200 });
const requests = (result.success ? (result.data || []) : [])
.map(normalizeHelpRequest)
.filter(Boolean);
const registrationItems = await getRegistrationNotifications(req, new Set());
await db.markAllNotificationsRead(userId, [
...requests.map((r) => r.id),
...registrationItems.map((item) => item.id),
]);
res.json({ success: true });
} catch (err) {
console.error('[BD-API] Mark all read error:', err.message);
res.status(500).json({ error: 'Failed to mark all notifications read' });
}
});
// ---------------------------------------------------------------------------
// POST /api/bd/operator/sessions — Record operator session start/end
// ---------------------------------------------------------------------------
router.post('/operator/sessions', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const action = normalizeSessionAction(req.body?.action);
const deviceId = String(req.body?.device_id || '').trim();
const hostname = String(req.body?.hostname || '').trim().substring(0, 128);
const sessionId = String(req.body?.session_id || crypto.randomUUID()).trim().substring(0, 96);
if (!action) {
return res.status(400).json({ error: 'Invalid session action' });
}
if (!/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
return res.status(400).json({ error: 'Invalid device_id' });
}
const operatorName = req.deviceUser?.username || req.deviceUser?.name || 'operator';
const operatorClientId = String(req.deviceToken?.client_id || '').substring(0, 64);
await db.insertAuditConnection({
host_id: operatorName,
host_uuid: operatorClientId,
peer_id: deviceId,
peer_name: hostname,
action,
conn_type: 1,
session_id: sessionId,
ip: getClientIp(req),
});
res.json({ success: true, session_id: sessionId, action });
} catch (err) {
console.error('[BD-API] Record operator session error:', err.message);
res.status(500).json({ error: 'Failed to record operator session' });
}
});
// ---------------------------------------------------------------------------
// GET /api/bd/operator/sessions — Operator session history
// ---------------------------------------------------------------------------
router.get('/operator/sessions', requireDeviceAuth, requireOperatorRole, async (req, res) => {
try {
const requestedLimit = parseInt(req.query.limit, 10);
const limit = Number.isFinite(requestedLimit)
? Math.min(Math.max(requestedLimit, 1), 200)
: 100;
const rows = await db.getAuditConnections({
limit: limit * 4,
offset: 0,
});
const sessions = buildSessionHistory(rows || [], limit);
res.json({ success: true, sessions, count: sessions.length });
} catch (err) {
console.error('[BD-API] Session history error:', err.message);
res.status(500).json({ error: 'Failed to fetch session history' });
}
});
module.exports = router;