feat: secure React dashboard cutover

This commit is contained in:
jjbrunton committed 2026-08-08 15:51:06 +01:00
1 parent a51e0b6b31
commit ad770a792a
9 files changed
+477 -73

No files matched your search

+45 -11
View File
@@ -1,6 +1,7 @@
import express from 'express';
import path from 'path';
import type { Server } from 'http';
import type { AddressInfo } from 'node:net';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type {
@@ -31,6 +32,8 @@ import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createLoginRouter } from './routes/login.js';
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
@@ -63,6 +66,7 @@ export class WebServer {
private activityLogRepo: ActivityLogRepository,
private logger: Logger,
broadcaster?: LogBroadcaster,
private workspaceDirectory = process.cwd(),
) {
this.broadcaster = broadcaster ?? new LogBroadcaster();
this.activity = new ActivityLog(activityLogRepo);
@@ -105,19 +109,29 @@ export class WebServer {
}
private setupRoutes(): void {
const dashboardToken = this.config.web.authToken;
if (!dashboardToken) {
throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN');
}
this.app.use(express.json({ limit: '64kb' }));
this.app.use(createDashboardSessionRouter(dashboardToken));
this.app.use(createDashboardAuth(dashboardToken));
const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory);
// index.html serves the entire inline JS — never cache it.
this.app.use((req, res, next) => {
if (req.path === '/' || req.path === '/index.html') {
const p = path.join(process.cwd(), 'public', 'index.html');
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
return res.sendFile(p);
return res.sendFile(dashboardAssets.indexFile);
}
next();
});
this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media')));
this.app.use(express.static(path.join(process.cwd(), 'public')));
this.app.use(express.json());
// Media can contain private chat attachments, so it is intentionally behind
// the same dashboard authentication middleware as the API and UI.
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { index: false }));
this.app.use(express.static(dashboardAssets.directory, { index: false }));
const ctx = this.context();
this.app.use('/api', createStatsRouter(ctx));
@@ -125,15 +139,21 @@ export class WebServer {
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));
this.app.use('/api', createControlRouter(ctx));
this.app.use('/api', createDebugRouter(ctx));
if (this.config.web.debugEnabled) {
this.app.use('/api', createDebugRouter(ctx));
}
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
this.app.use('/api', (_req, res) => {
res.status(404).json({ error: 'API endpoint not found' });
});
// SPA catch-all.
this.app.get(/.*/, (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
res.sendFile(dashboardAssets.indexFile);
});
}
@@ -200,10 +220,18 @@ export class WebServer {
this.messagesThisHour = messagesThisHour;
}
start(): void {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
this.broadcaster.push('info', `Web dashboard started on port ${this.port}`);
start(): Promise<number> {
return new Promise((resolve, reject) => {
const server = this.app.listen(this.port);
this.server = server;
server.once('error', reject);
server.once('listening', () => {
const address = server.address() as AddressInfo | null;
const listeningPort = address?.port ?? this.port;
this.logger.info(`Web dashboard running at http://localhost:${listeningPort}`);
this.broadcaster.push('info', `Web dashboard started on port ${listeningPort}`);
resolve(listeningPort);
});
});
}
@@ -214,4 +242,10 @@ export class WebServer {
this.server = null;
}
}
/** Exposed for health probes and integration tests when configured with port 0. */
getListeningPort(): number | null {
const address = this.server?.address();
return address && typeof address !== 'string' ? (address as AddressInfo).port : null;
}
}