Files
clawdbot/src/gateway/control-ui.ts
2025-12-19 19:21:26 +01:00

138 lines
3.9 KiB
TypeScript

import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const _UI_PREFIX = "/ui/";
const ROOT_PREFIX = "/";
function resolveControlUiRoot(): string | null {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Running from dist: dist/gateway/control-ui.js -> dist/control-ui
path.resolve(here, "../control-ui"),
// Running from source: src/gateway/control-ui.ts -> dist/control-ui
path.resolve(here, "../../dist/control-ui"),
// Fallback to cwd (dev)
path.resolve(process.cwd(), "dist", "control-ui"),
];
for (const dir of candidates) {
if (fs.existsSync(path.join(dir, "index.html"))) return dir;
}
return null;
}
function contentTypeForExt(ext: string): string {
switch (ext) {
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "application/javascript; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".ico":
return "image/x-icon";
case ".txt":
return "text/plain; charset=utf-8";
default:
return "application/octet-stream";
}
}
function respondNotFound(res: ServerResponse) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
function serveFile(res: ServerResponse, filePath: string) {
const ext = path.extname(filePath).toLowerCase();
res.setHeader("Content-Type", contentTypeForExt(ext));
// Static UI should never be cached aggressively while iterating; allow the
// browser to revalidate.
res.setHeader("Cache-Control", "no-cache");
res.end(fs.readFileSync(filePath));
}
function isSafeRelativePath(relPath: string) {
if (!relPath) return false;
const normalized = path.posix.normalize(relPath);
if (normalized.startsWith("../") || normalized === "..") return false;
if (normalized.includes("\0")) return false;
return true;
}
export function handleControlUiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): boolean {
const urlRaw = req.url;
if (!urlRaw) return false;
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const url = new URL(urlRaw, "http://localhost");
if (url.pathname === "/ui" || url.pathname.startsWith("/ui/")) {
respondNotFound(res);
return true;
}
const root = resolveControlUiRoot();
if (!root) {
res.statusCode = 503;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(
"Control UI assets not found. Build them with `pnpm ui:build` (or run `pnpm ui:dev` during development).",
);
return true;
}
const rel = (() => {
if (url.pathname === ROOT_PREFIX) return "";
if (url.pathname.startsWith("/assets/")) return url.pathname.slice(1);
return url.pathname.slice(1);
})();
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
const fileRel = requested || "index.html";
if (!isSafeRelativePath(fileRel)) {
respondNotFound(res);
return true;
}
const filePath = path.join(root, fileRel);
if (!filePath.startsWith(root)) {
respondNotFound(res);
return true;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
serveFile(res, filePath);
return true;
}
// SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path.join(root, "index.html");
if (fs.existsSync(indexPath)) {
serveFile(res, indexPath);
return true;
}
respondNotFound(res);
return true;
}