Files
flynn/src/gateway/static.ts
T
William Valentin 22230a3e3f feat: add web UI dashboard SPA with dashboard, chat, sessions, and settings pages
- 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
2026-02-07 10:07:45 -08:00

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;
}
}