import { createServer, type Server } from 'node:http'; import { createReadStream } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { extname, normalize, join } from 'node:path'; import { buildWorkspaceModel } from './workspace.js'; import { renderMissionControlHtml } from './overview-html.js'; import { discoverLooseDeliverables, discoverWorkspaceRoots } from './discovery.js'; import { ensureLooseThumbnails, looseThumbPath } from './thumbnails.js'; import { renderProjectDetail } from './project-detail.js'; import { isProjectSlug } from '../projects.js'; const TYPES: Record = { '.html': 'text/html; charset=utf-8', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.json': 'application/json', '.css': 'text/css', '.js': 'text/javascript', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.svg': 'image/svg+xml', }; export interface MissionControlServerOptions { roots?: string[]; extraRoots?: string[]; port?: number; // 0 = ephemeral (tests) /** Watch-config path for loose-deliverable discovery (injectable for hermetic tests). */ configPath?: string; } export interface MissionControlServer { port: number; close: () => Promise; } export async function startMissionControlServer(options: MissionControlServerOptions = {}): Promise { // Resolve roots once so media requests are guarded against exactly these prefixes. const roots = options.roots ?? (await discoverWorkspaceRoots(options.extraRoots ?? [])); // Loose deliverables live OUTSIDE any workspace root, so the root allowlist // can't cover them. Resolve the exact set of discovered loose-file paths once // (from watch-config roots only) and admit a `/m/` request when it targets // one of them (exact-path allowlist — never arbitrary siblings in a loose dir). const looseVideos = await discoverLooseDeliverables(options.configPath); const looseFiles = new Set(looseVideos.map((d) => d.path)); // Warm the poster-frame cache once (extract any missing thumbs via ffmpeg) // BEFORE listening, so the first overview render can link cached thumbnails. // Then admit each resolved thumb path through the same exact-path allowlist. await ensureLooseThumbnails(looseVideos.map((d) => d.path)); for (const d of looseVideos) { const thumb = looseThumbPath(d.path); if (existsSync(thumb)) looseFiles.add(thumb); } /** * Stream a static file with Range support. Used by both `/m/` (raw media) and * the non-HTML sub-resources resolved from `/p/` portal surfaces. */ async function serveStaticFile(fp: string, req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): Promise { const st = await stat(fp); if (st.isDirectory()) { res.writeHead(403); res.end('forbidden'); return; } const type = TYPES[extname(fp).toLowerCase()] ?? 'application/octet-stream'; const range = req.headers.range; if (range && /^bytes=/.test(range)) { const m = /bytes=(\d*)-(\d*)/.exec(range); let start = m && m[1] ? parseInt(m[1], 10) : 0; let end = m && m[2] ? parseInt(m[2], 10) : st.size - 1; if (Number.isNaN(start)) start = 0; if (Number.isNaN(end) || end >= st.size) end = st.size - 1; if (start > end) { res.writeHead(416, { 'Content-Range': `bytes */${st.size}` }); res.end(); return; } res.writeHead(206, { 'Content-Type': type, 'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'no-cache' }); createReadStream(fp, { start, end }).pipe(res); return; } res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache' }); createReadStream(fp).pipe(res); } const server: Server = createServer(async (req, res) => { try { const url = new URL(req.url ?? '/', 'http://localhost'); const path = decodeURIComponent(url.pathname); if (path === '/' || path === '/index.html') { const model = await buildWorkspaceModel({ roots, showAll: url.searchParams.get('all') === '1' }); const html = renderMissionControlHtml(model); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }); res.end(html); return; } // Media: /m// // Parse from the RAW request target (NOT url.pathname): the WHATWG URL // parser collapses dot-segments before we run, so a literal `../` escape // would silently become `/etc/passwd` and never reach this branch. Reading // the un-normalized request path keeps the `..` intact so the traversal // guard below actually sees — and rejects — it. const rawPath = (req.url ?? '/').split('?')[0]; if (rawPath.startsWith('/m/')) { const rawRest = rawPath.slice(3); const slash = rawRest.indexOf('/'); if (slash < 0) { res.writeHead(403); res.end('forbidden'); return; } const root = decodeURIComponent(rawRest.slice(0, slash)); const rel = decodeURIComponent(rawRest.slice(slash + 1)); const fp = normalize(join(root, rel)); if (fp !== root && !fp.startsWith(root + '/')) { res.writeHead(403); res.end('forbidden'); return; } // Admit a request when EITHER the prefix is a known workspace root, OR // the resolved path is one of the discovered loose-deliverable files // (exact-path allowlist — never arbitrary siblings in a loose dir). if (!roots.includes(root) && !looseFiles.has(fp)) { res.writeHead(403); res.end('forbidden'); return; } if (!existsSync(fp)) { res.writeHead(404); res.end('not found'); return; } await serveStaticFile(fp, req, res); return; } // Drill-in: /p// // Serves a project's portal HTML surface (with file:///→/m/ rewrite), // AND any relative subresource the browser fetches from that page (images, // videos, audio, JSON) — so `src="final/videos/x.mp4"` resolves to // /p//projects//final/videos/x.mp4 and gets streamed as media. // Parses the RAW request target (same traversal-guard reasoning as /m/). if (rawPath.startsWith('/p/')) { const rawRest = rawPath.slice(3); const slash = rawRest.indexOf('/'); if (slash < 0) { res.writeHead(403); res.end('forbidden'); return; } const root = decodeURIComponent(rawRest.slice(0, slash)); const rel = decodeURIComponent(rawRest.slice(slash + 1)); if (!roots.includes(root)) { res.writeHead(403); res.end('forbidden'); return; } const fp = normalize(join(root, rel)); if (fp !== root && !fp.startsWith(root + '/')) { res.writeHead(403); res.end('forbidden'); return; } if (!existsSync(fp)) { res.writeHead(404); res.end('not found'); return; } if (extname(fp).toLowerCase() === '.html') { // HTML surface: rewrite absolute file:/// subresource URLs to /m/. let body = await readFile(fp, 'utf-8'); body = body.split('file://' + root + '/').join('/m/' + encodeURIComponent(root) + '/'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }); res.end(body); } else { // Non-HTML: stream as media with Range support (relative subresources // from the portal page — videos, images, audio, JSON, etc.). await serveStaticFile(fp, req, res); } return; } // Project detail gallery: /project// // A generated page listing the project's videos + images, so EVERY project // card is openable even when it has no preview/review.html surface. Parses // the RAW target and validates root∈roots + isProjectSlug (no traversal). if (rawPath.startsWith('/project/')) { const rawRest = rawPath.slice('/project/'.length); const slash = rawRest.indexOf('/'); if (slash < 0) { res.writeHead(403); res.end('forbidden'); return; } const root = decodeURIComponent(rawRest.slice(0, slash)); const slug = decodeURIComponent(rawRest.slice(slash + 1)); if (!roots.includes(root) || !isProjectSlug(slug)) { res.writeHead(403); res.end('forbidden'); return; } if (!existsSync(join(root, 'projects', slug))) { res.writeHead(404); res.end('not found'); return; } const html = await renderProjectDetail(root, slug); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }); res.end(html); return; } res.writeHead(404); res.end('not found'); } catch (e) { res.writeHead(500); res.end(String((e as Error)?.message ?? e)); } }); await new Promise((resolve) => server.listen(options.port ?? 8765, '127.0.0.1', resolve)); const address = server.address(); const port = typeof address === 'object' && address ? address.port : (options.port ?? 8765); return { port, close: () => new Promise((resolve) => server.close(() => resolve())) }; }