feat: secure React dashboard cutover
This commit is contained in:
1 parent
a51e0b6b31
commit
ad770a792a
9 files changed
+477
-73
No files matched your search
+45
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user