|
|
|
@@ -0,0 +1,121 @@
|
|
|
|
|
#!/usr/bin/env bun
|
|
|
|
|
/**
|
|
|
|
|
* Zesdex web frontend interface — serves static browser assets.
|
|
|
|
|
* Mirrors `apps/interfaces/web/src/lib.rs`.
|
|
|
|
|
*
|
|
|
|
|
* Serves files from a `dist/`/static directory with path-traversal protection:
|
|
|
|
|
* every requested path is canonicalized and must resolve inside `static_dir`.
|
|
|
|
|
* Falls back to a minimal placeholder page when no frontend is built.
|
|
|
|
|
*/
|
|
|
|
|
import * as fs from "node:fs";
|
|
|
|
|
import * as path from "node:path";
|
|
|
|
|
|
|
|
|
|
/** Web server state. */
|
|
|
|
|
export interface WebState {
|
|
|
|
|
static_dir: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MIME: Record<string, string> = {
|
|
|
|
|
".html": "text/html; charset=utf-8",
|
|
|
|
|
".js": "text/javascript; charset=utf-8",
|
|
|
|
|
".mjs": "text/javascript; charset=utf-8",
|
|
|
|
|
".css": "text/css; charset=utf-8",
|
|
|
|
|
".json": "application/json",
|
|
|
|
|
".png": "image/png",
|
|
|
|
|
".jpg": "image/jpeg",
|
|
|
|
|
".jpeg": "image/jpeg",
|
|
|
|
|
".gif": "image/gif",
|
|
|
|
|
".svg": "image/svg+xml",
|
|
|
|
|
".ico": "image/x-icon",
|
|
|
|
|
".webp": "image/webp",
|
|
|
|
|
".woff": "font/woff",
|
|
|
|
|
".woff2": "font/woff2",
|
|
|
|
|
".ttf": "font/ttf",
|
|
|
|
|
".map": "application/json",
|
|
|
|
|
".txt": "text/plain; charset=utf-8",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function mimeFor(file: string): string {
|
|
|
|
|
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Placeholder page when no frontend is built. */
|
|
|
|
|
const PLACEHOLDER = `<!DOCTYPE html>
|
|
|
|
|
<html><head><title>Zesdex Web</title>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
|
|
|
|
|
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
|
|
|
|
|
<body>
|
|
|
|
|
<h1>Zesdex Web</h1>
|
|
|
|
|
<p>Web interface is ready.</p>
|
|
|
|
|
<p>To connect the frontend:</p>
|
|
|
|
|
<ol>
|
|
|
|
|
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
|
|
|
|
|
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
|
|
|
|
|
</ol>
|
|
|
|
|
</body></html>`;
|
|
|
|
|
|
|
|
|
|
/** Resolve a requested path safely inside `staticDir`; returns null on traversal/not-found. */
|
|
|
|
|
function safeResolve(staticDir: string, rel: string): string | null {
|
|
|
|
|
// Strip any leading slash so a relative path is resolved against staticDir
|
|
|
|
|
// (path.resolve would otherwise ignore staticDir for absolute-ish inputs).
|
|
|
|
|
const clean = rel.replace(/^\/+/, "");
|
|
|
|
|
const candidate = path.resolve(staticDir, clean || "index.html");
|
|
|
|
|
const canonStatic = path.resolve(staticDir);
|
|
|
|
|
// Prevent directory traversal: resolved path must stay inside static dir.
|
|
|
|
|
if (!candidate.startsWith(canonStatic + path.sep) && candidate !== canonStatic) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
// Only serve regular files; reject if the target is a directory or missing.
|
|
|
|
|
try {
|
|
|
|
|
const st = fs.statSync(candidate);
|
|
|
|
|
return st.isFile() ? candidate : null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Build the web router (static file serving). */
|
|
|
|
|
export function handleWebRequest(state: WebState, req: Request): Response {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
let rel = decodeURIComponent(url.pathname);
|
|
|
|
|
if (rel.endsWith("/")) rel += "index.html";
|
|
|
|
|
|
|
|
|
|
const file = safeResolve(state.static_dir, rel);
|
|
|
|
|
if (!file) {
|
|
|
|
|
// Root always returns the placeholder index (even without a built frontend).
|
|
|
|
|
if (rel === "/index.html") {
|
|
|
|
|
return new Response(PLACEHOLDER, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = fs.readFileSync(file);
|
|
|
|
|
return new Response(data, {
|
|
|
|
|
status: 200,
|
|
|
|
|
headers: { "Content-Type": mimeFor(file) },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Start the web frontend server. */
|
|
|
|
|
export function startWebServer(port: number, staticDir?: string): { stop: () => void } {
|
|
|
|
|
const dir =
|
|
|
|
|
staticDir && staticDir !== ""
|
|
|
|
|
? staticDir
|
|
|
|
|
: (() => {
|
|
|
|
|
const p = path.resolve("apps/interfaces/web/dist");
|
|
|
|
|
return fs.existsSync(p) ? p : process.cwd();
|
|
|
|
|
})();
|
|
|
|
|
const state: WebState = { static_dir: dir };
|
|
|
|
|
const server = Bun.serve({
|
|
|
|
|
port,
|
|
|
|
|
fetch(req) {
|
|
|
|
|
return handleWebRequest(state, req);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
console.log(`zesdex-web listening on http://0.0.0.0:${server.port} (dir: ${state.static_dir})`);
|
|
|
|
|
return { stop: () => server.stop(true) };
|
|
|
|
|
}
|