History opens a seat's conversation from the Waiting card, table row and inspector. It pages the whole branch through the CHAT-02 board routes, renders untrusted text inert, polls with the follow cursor, and marks every switch (branch, newer, reconcile, gone). The WebUI proxy passes only the two conversation routes' queries upstream. Dewey authored it. Filbert asked for changes on r1 (24b046af) and approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer', not 'reconcile', a deviation from brief 2.3 item 6 that Filbert accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
99 lines
5.5 KiB
JavaScript
99 lines
5.5 KiB
JavaScript
import { createServer } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { isIP } from 'node:net';
|
|
|
|
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
|
export function isLoopback(host) {
|
|
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
|
|
}
|
|
export function boardURL(value) {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
|
|
}
|
|
// Avoid hostname resolution for localhost.
|
|
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
|
|
return url.origin;
|
|
}
|
|
const root = resolve(import.meta.dirname, 'public');
|
|
const files = new Map([
|
|
['/', ['index.html', 'text/html; charset=utf-8']],
|
|
...['app.js', 'brand.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
|
...['shared/app.css', 'console.css', 'live.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
|
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
|
]);
|
|
function json(res, status, body) {
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
async function body(req) {
|
|
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json');
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of req) {
|
|
size += chunk.length;
|
|
if (size > 4096) throw new Error('body larger than 4096 bytes');
|
|
chunks.push(chunk);
|
|
}
|
|
const bytes = Buffer.concat(chunks);
|
|
const value = JSON.parse(bytes.toString('utf8'));
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
|
|
return bytes;
|
|
}
|
|
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000 } = {}) {
|
|
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
|
if (host === 'localhost') host = '127.0.0.1';
|
|
const upstream = boardURL(board);
|
|
const server = createServer(async (req, res) => {
|
|
res.setHeader('x-content-type-options', 'nosniff');
|
|
res.setHeader('referrer-policy', 'no-referrer');
|
|
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
|
let path, search;
|
|
try {
|
|
const authority = new URL(`http://${req.headers.host}`);
|
|
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
|
|
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
|
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
|
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
|
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
|
|
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
|
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
|
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
|
if (allowed) {
|
|
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
|
|
let bytes;
|
|
if (allowed === 'POST') {
|
|
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
|
}
|
|
try {
|
|
const response = await fetch(upstream + path + (conversation ? search : ''), {
|
|
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
|
|
redirect: 'error', signal: AbortSignal.timeout(timeout),
|
|
});
|
|
const text = await response.text();
|
|
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
|
|
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
return res.end(text);
|
|
} catch {
|
|
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
|
|
}
|
|
}
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
|
if (path === '/api/config') return json(res, 200, { board: upstream });
|
|
if (path === '/healthz') return json(res, 200, { ok: true });
|
|
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
|
const file = files.get(path);
|
|
if (!file) return json(res, 404, { error: 'not found' });
|
|
try {
|
|
const content = readFileSync(resolve(root, file[0]));
|
|
res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' });
|
|
res.end(req.method === 'HEAD' ? undefined : content);
|
|
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
|
|
});
|
|
return new Promise((resolvePromise, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
|
|
});
|
|
}
|