feat(gateway): add web UI with dashboard and chat interface
Refactor GatewayServer to serve HTTP and WebSocket on a shared http.Server. Add static file serving with path traversal protection, a dark-themed dashboard (system health, sessions, tools) and a WebSocket chat interface with streaming tool events and markdown rendering.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { resolve, extname } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
/** Supported content types by file extension. */
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve static files from a directory.
|
||||
*
|
||||
* - Only handles GET requests
|
||||
* - Maps `/` to `/index.html`
|
||||
* - Protects against path traversal (resolved path must be within uiDir)
|
||||
* - Returns true if a file was served, false otherwise (caller should 404)
|
||||
*/
|
||||
export async function serveStatic(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
uiDir: string,
|
||||
): Promise<boolean> {
|
||||
// Only serve GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the pathname from the request URL
|
||||
const urlStr = req.url ?? '/';
|
||||
let pathname: string;
|
||||
try {
|
||||
// Use URL constructor to safely parse; base doesn't matter for pathname extraction
|
||||
const parsed = new URL(urlStr, 'http://localhost');
|
||||
pathname = parsed.pathname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map root to index.html
|
||||
if (pathname === '/') {
|
||||
pathname = '/index.html';
|
||||
}
|
||||
|
||||
// Check that the file extension is one we serve
|
||||
const ext = extname(pathname);
|
||||
const contentType = CONTENT_TYPES[ext];
|
||||
if (!contentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the absolute path to the requested file
|
||||
// Strip leading slash to get a relative path for resolve()
|
||||
const relativePath = pathname.slice(1);
|
||||
const resolvedUiDir = resolve(uiDir);
|
||||
const filePath = resolve(resolvedUiDir, relativePath);
|
||||
|
||||
// Path traversal protection: ensure the resolved path is within uiDir
|
||||
if (!filePath.startsWith(resolvedUiDir + '/') && filePath !== resolvedUiDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to read and serve the file
|
||||
try {
|
||||
const content = await readFile(filePath);
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
return true;
|
||||
} catch {
|
||||
// File doesn't exist or can't be read — let the caller handle it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user