import { createServer } from "node:http"; import { createHash, randomBytes } from "node:crypto"; import { readFile } from "node:fs/promises"; /** Loaded for first-run onboarding or explicit settings commands; idle-closes. */ export async function startSettingsWeb( snapshot: () => { settings: unknown }, update: (value: unknown) => Promise, idleMs: () => number, ) { const html = await readFile(new URL("./settings.html", import.meta.url)); const welcome = await readFile(new URL("./welcome.html", import.meta.url)); const assets = new Map(await Promise.all([ ["/vendor/slimselect.js", "slimselect.js", "text/javascript; charset=utf-8"], ["/vendor/page-select.js", "page-select.js", "text/javascript; charset=utf-8"], ["/vendor/slimselect.css", "slimselect.css", "text/css; charset=utf-8"], ].map(async ([path, file, type]) => [path, { body: await readFile(new URL("./vendor/" + file, import.meta.url)), type }] as const))); const token = randomBytes(24).toString("hex"); let origin = ""; let closed = false; let saving = 0; let timer: ReturnType | undefined; let queue = Promise.resolve(); const version = () => '"' + createHash("sha256").update(JSON.stringify(snapshot().settings)).digest("hex") + '"'; const close = () => { if (closed) return; closed = true; clearTimeout(timer); server.close(); server.closeAllConnections(); }; const touch = () => { if (closed) return; clearTimeout(timer); timer = setTimeout(() => saving ? touch() : close(), idleMs()); timer.unref(); }; const server = createServer(async (req, res) => { res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); if (req.headers.host !== origin.slice(7) || (req.headers.origin && req.headers.origin !== origin)) { res.writeHead(403).end(); return; } const asset = assets.get(req.url ?? ""); if (req.method === "GET" && asset) { res.setHeader("Content-Type", asset.type); res.end(asset.body); return; } if (req.method === "GET" && req.url === "/welcome") { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(welcome); return; } if (req.method === "GET" && req.url === "/") { res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(html); return; } if (req.url !== "/settings" || req.headers.authorization !== `Bearer ${token}`) { res.writeHead(403).end(); return; } try { if (req.method === "PUT") { if (req.headers["content-type"] !== "application/json") { res.writeHead(415).end(); return; } let body = ""; for await (const chunk of req) { body += chunk; if (Buffer.byteLength(body) > 16384) { res.writeHead(413).end(); return; } } const value: unknown = JSON.parse(body); saving++; try { const pending = queue.then(async () => { if (req.headers["if-match"] !== version()) return false; await update(value); return true; }); queue = pending.then(() => {}, () => {}); if (!await pending) { res.writeHead(409).end("Settings changed elsewhere. Reopen settings before saving."); return; } } finally { saving--; } } else if (req.method !== "GET") { res.writeHead(405).end(); return; } touch(); res.setHeader("ETag", version()); res.setHeader("Content-Type", "application/json; charset=utf-8"); res.end(JSON.stringify(snapshot())); } catch (error) { res.writeHead(error instanceof TypeError || error instanceof SyntaxError ? 400 : 500); res.end("Unable to save settings. Check the values and file permissions."); } }); server.requestTimeout = 10000; server.headersTimeout = 10000; await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address(); if (!address || typeof address === "string") { close(); throw new Error("Unable to start settings"); } origin = `http://127.0.0.1:${address.port}`; server.unref(); touch(); return { url: `${origin}/#${token}`, welcomeUrl: `${origin}/welcome#${token}`, close, touch, get closed() { return closed; } }; }