mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
d7a2fe6562
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)
1109 lines
44 KiB
JavaScript
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;
|