22230a3e3f
- Add SPA shell with hash-based router, sidebar navigation, and WebSocket RPC client - Add dashboard page with system health cards, channel status, and auto-refresh - Add chat page with session selector, streaming tool events, and markdown rendering - Add sessions page with list, history viewer, and delete functionality - Add settings page with hook pattern editor, tool list, and config viewer - Add backend handlers: sessions.delete, sessions.switch, system.channels, system.usage - Wire channelRegistry into gateway server for channel status reporting - Extend static file server with .mjs, .png, .ico, .woff2 content types
81 lines
2.2 KiB
TypeScript
81 lines
2.2 KiB
TypeScript
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',
|
|
'.mjs': 'application/javascript',
|
|
'.json': 'application/json',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.ico': 'image/x-icon',
|
|
'.woff2': 'font/woff2',
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|