Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/the-system/board-server.ts

Raw
import { createHash, randomUUID } from "node:crypto";
import {
	createReadStream,
	type Dirent,
	lstatSync,
	readdirSync,
	readFileSync,
	realpathSync,
	rmSync,
} from "node:fs";
import {
	createServer,
	type IncomingMessage,
	type Server,
	type ServerResponse,
} from "node:http";
import path from "node:path";
import {
	type BoardItemState,
	type BoardProposal,
	type BoardState,
	readBoardState,
	updateBoardState,
	writeBoardState,
} from "./board-state.ts";
import { parseSystemConfig } from "./documents.ts";
import { writePlanStatus } from "./plan-status.ts";
import { loadProjectIndex, type ProjectIndex } from "./project-index.ts";

const BODY_LIMIT = 64 * 1024;
const SIGNATURE_BYTE_LIMIT = 1024 * 1024;
const COOKIE = "the_system_board";
const STATES = ["todo", "done", "open"] as const;
const STATUSES = ["draft", "approved"] as const;

export type BoardServer = Readonly<{ url: string; close(): void }>;
export type BoardSignature = Readonly<{ documents: string; board: string }>;
export type BoardRenderer = (
	index: ProjectIndex,
	board: BoardState,
	proposals: readonly BoardProposal[],
	revision: string,
) => Readonly<{ directory: string }>;

export function boardSignature(root: string): BoardSignature {
	const documents = createHash("sha256");
	const board = createHash("sha256");
	const systemRoot = path.join(root, ".system");
	for (const file of governedFiles(systemRoot)) {
		const relative = path.relative(systemRoot, file).split(path.sep).join("/");
		const digest = relative === "board.json" ? board : documents;
		digest.update(`${relative}\u0000`);
		const stats = lstatSync(file, { throwIfNoEntry: false });
		if (!stats?.isFile()) continue;
		if (stats.size > SIGNATURE_BYTE_LIMIT) {
			digest.update(`${stats.size}:${stats.mtimeMs}`);
			continue;
		}
		digest.update(readFileSync(file));
	}
	return { documents: documents.digest("hex"), board: board.digest("hex") };
}

function governedFiles(directory: string): readonly string[] {
	let entries: Dirent[];
	try {
		entries = readdirSync(directory, { withFileTypes: true });
	} catch {
		return [];
	}
	return entries
		.sort((left, right) => left.name.localeCompare(right.name))
		.flatMap((entry) => {
			const target = path.join(directory, entry.name);
			if (entry.isSymbolicLink()) return [];
			if (entry.isDirectory()) return governedFiles(target);
			return entry.isFile() ? [target] : [];
		});
}

type BoardSite = Readonly<{ signature: BoardSignature; directory: string }>;

export async function startBoardServer(
	root: string,
	proposals: Map<string, BoardProposal>,
	render: BoardRenderer,
): Promise<BoardServer> {
	const token = `${randomUUID()}-${randomUUID()}`;
	const cookieName = `${COOKIE}_${token.slice(0, 8)}`;
	const stale = new Set<string>();
	let site = renderSite(root, proposals, render);
	let renderError: string | undefined;
	const ensureSite = (): BoardSite => {
		const signature = boardSignature(root);
		if (
			signature.documents === site.signature.documents &&
			signature.board === site.signature.board
		)
			return site;
		const previous = site;
		try {
			site = renderSite(root, proposals, render, signature);
			renderError = undefined;
		} catch (error) {
			renderError = error instanceof Error ? error.message : String(error);
			return previous;
		}
		discard(previous.directory, stale);
		return site;
	};
	let mutation = Promise.resolve();
	const enqueue = <T>(work: () => T): Promise<T> => {
		const result = mutation.then(work, work);
		mutation = result.then(
			() => undefined,
			() => undefined,
		);
		return result;
	};
	const server: Server = createServer((request, response) => {
		void dispatch(request, response, {
			root,
			proposals,
			token,
			cookieName,
			enqueue,
			ensureSite,
			currentSite: () => site,
			renderProblem: () => renderError,
		});
	});
	await new Promise<void>((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")
		throw new Error("Failed to bind System board server");
	return {
		url: `http://127.0.0.1:${address.port}/?k=${token}`,
		close() {
			server.closeAllConnections();
			server.close();
			discard(site.directory, stale);
			for (const directory of [...stale]) discard(directory, stale);
		},
	};
}

function renderSite(
	root: string,
	proposals: Map<string, BoardProposal>,
	render: BoardRenderer,
	signature = boardSignature(root),
): BoardSite {
	const index = loadProjectIndex(root);
	const board = readBoardState(index.root, index.prefix);
	const output = render(
		index,
		board,
		[...proposals.values()].sort((left, right) =>
			left.id.localeCompare(right.id),
		),
		signature.documents,
	);
	return { signature, directory: output.directory };
}

function discard(directory: string, stale: Set<string>): void {
	try {
		rmSync(directory, { recursive: true, force: true });
		stale.delete(directory);
	} catch {
		stale.add(directory);
	}
}

type DispatchState = Readonly<{
	root: string;
	proposals: Map<string, BoardProposal>;
	token: string;
	cookieName: string;
	enqueue<T>(work: () => T): Promise<T>;
	ensureSite(): BoardSite;
	currentSite(): BoardSite;
	renderProblem(): string | undefined;
}>;

async function dispatch(
	request: IncomingMessage,
	response: ServerResponse,
	state: DispatchState,
): Promise<void> {
	try {
		const url = new URL(request.url ?? "/", "http://127.0.0.1");
		const queryToken = url.searchParams.get("k");
		const cookieToken = cookie(request.headers.cookie ?? "", state.cookieName);
		if (queryToken === state.token) {
			response.statusCode = 303;
			response.setHeader("Location", url.pathname);
			response.setHeader(
				"Set-Cookie",
				`${state.cookieName}=${state.token}; HttpOnly; SameSite=Strict; Path=/`,
			);
			response.end();
			return;
		}
		if (cookieToken !== state.token) {
			respondJson(response, 403, { error: "Forbidden" });
			return;
		}
		if (
			request.method === "POST" &&
			request.headers.origin !== `http://${request.headers.host}`
		) {
			respondJson(response, 403, { error: "Invalid board origin" });
			return;
		}
		if (request.method === "POST" && url.pathname === "/api/state") {
			const input = await readJsonBody(request);
			const result = await state.enqueue(() =>
				applyState(state.root, input, "manual"),
			);
			respondJson(response, 200, result);
			return;
		}
		if (request.method === "POST" && url.pathname === "/api/status") {
			const input = await readJsonBody(request);
			const result = await state.enqueue(() => applyStatus(state.root, input));
			respondJson(response, 200, result);
			return;
		}
		if (request.method === "POST" && url.pathname === "/api/proposal") {
			const input = await readJsonBody(request);
			if (typeof input.id !== "string" || typeof input.state !== "string")
				throw new Error("Expected assessment proposal with id and state");
			const proposal = state.proposals.get(input.id);
			if (!proposal || proposal.state !== input.state)
				throw new Error(`No matching assessment proposal: ${input.id}`);
			const result = await state.enqueue(() =>
				applyState(state.root, input, "assess"),
			);
			state.proposals.delete(input.id);
			respondJson(response, 200, result);
			return;
		}
		if (request.method !== "GET") {
			respondJson(response, 405, { error: "Method not allowed" });
			return;
		}
		if (url.pathname === "/api/state") {
			respondJson(response, 200, snapshot(state));
			return;
		}
		const site = isDocument(url.pathname)
			? state.ensureSite()
			: state.currentSite();
		serveFile(response, site.directory, url.pathname);
	} catch (error) {
		respondJson(response, 400, {
			error: error instanceof Error ? error.message : String(error),
		});
	}
}

const isDocument = (pathname: string) =>
	pathname === "/" || pathname.endsWith(".html");

function snapshot(state: DispatchState) {
	const prefix = parseSystemConfig(
		readFileSync(path.join(state.root, ".system/config.json"), "utf8"),
	).prefix;
	const board = readBoardState(state.root, prefix);
	const problem = state.renderProblem();
	return {
		revision: boardSignature(state.root).documents,
		items: board.items,
		...(problem ? { problem } : {}),
	};
}

function applyState(
	root: string,
	input: Record<string, unknown>,
	source: "manual" | "assess",
) {
	if (
		Object.keys(input).some((key) => key !== "id" && key !== "state") ||
		typeof input.id !== "string" ||
		typeof input.state !== "string" ||
		!STATES.includes(input.state as (typeof STATES)[number])
	)
		throw new Error("Expected board update with id and state");
	const index = loadProjectIndex(root);
	const current = readBoardState(index.root, index.prefix);
	const updated = updateBoardState(
		current,
		index.documents,
		input.id,
		input.state as BoardItemState,
		source,
	);
	writeBoardState(index.root, updated);
	return { items: updated.items };
}

function applyStatus(root: string, input: Record<string, unknown>) {
	if (
		Object.keys(input).some((key) => key !== "id" && key !== "status") ||
		typeof input.id !== "string" ||
		typeof input.status !== "string" ||
		!STATUSES.includes(input.status as (typeof STATUSES)[number])
	)
		throw new Error("Expected plan update with id and status");
	const index = loadProjectIndex(root);
	writePlanStatus(index, input.id, input.status as (typeof STATUSES)[number]);
	return { id: input.id, status: input.status };
}

function readJsonBody(
	request: IncomingMessage,
): Promise<Record<string, unknown>> {
	return new Promise((resolve, reject) => {
		let source = "";
		request.setEncoding("utf8");
		request.on("data", (chunk: string) => {
			source += chunk;
			if (Buffer.byteLength(source) > BODY_LIMIT) {
				request.destroy();
				reject(new Error("Board request body is too large"));
			}
		});
		request.on("end", () => {
			try {
				const parsed = JSON.parse(source);
				if (
					typeof parsed !== "object" ||
					parsed === null ||
					Array.isArray(parsed)
				)
					throw new Error("Board request body must be an object");
				resolve(parsed as Record<string, unknown>);
			} catch (error) {
				reject(error);
			}
		});
		request.on("error", reject);
	});
}

function serveFile(
	response: ServerResponse,
	directory: string,
	pathname: string,
): void {
	const relative = decodeURIComponent(
		pathname === "/" ? "index.html" : pathname.slice(1),
	);
	const target = path.resolve(directory, relative);
	const inside = path.relative(directory, target);
	if (
		inside === ".." ||
		inside.startsWith(`..${path.sep}`) ||
		path.isAbsolute(inside)
	) {
		respondJson(response, 404, { error: "Not found" });
		return;
	}
	const stats = lstatSync(target, { throwIfNoEntry: false });
	if (!stats?.isFile() || stats.isSymbolicLink()) {
		respondJson(response, 404, { error: "Not found" });
		return;
	}
	const realRoot = realpathSync(directory);
	const realTarget = realpathSync(target);
	const realInside = path.relative(realRoot, realTarget);
	if (
		realInside === ".." ||
		realInside.startsWith(`..${path.sep}`) ||
		path.isAbsolute(realInside)
	) {
		respondJson(response, 404, { error: "Not found" });
		return;
	}
	response.statusCode = 200;
	response.setHeader("Content-Type", contentType(target));
	response.setHeader("Cache-Control", "no-store");
	createReadStream(target).pipe(response);
}

function contentType(file: string): string {
	switch (path.extname(file).toLowerCase()) {
		case ".html":
			return "text/html; charset=utf-8";
		case ".js":
			return "text/javascript; charset=utf-8";
		case ".css":
			return "text/css; charset=utf-8";
		case ".svg":
			return "image/svg+xml";
		case ".png":
			return "image/png";
		case ".jpg":
		case ".jpeg":
			return "image/jpeg";
		case ".gif":
			return "image/gif";
		case ".webp":
			return "image/webp";
		case ".avif":
			return "image/avif";
		case ".mp4":
			return "video/mp4";
		case ".webm":
			return "video/webm";
		case ".md":
			return "text/markdown; charset=utf-8";
		default:
			return "application/octet-stream";
	}
}

function respondJson(
	response: ServerResponse,
	status: number,
	value: unknown,
): void {
	if (response.headersSent) return;
	response.statusCode = status;
	response.setHeader("Content-Type", "application/json; charset=utf-8");
	response.setHeader("Cache-Control", "no-store");
	response.end(JSON.stringify(value));
}

function cookie(source: string, name: string): string | undefined {
	for (const part of source.split(";")) {
		const [key, ...value] = part.trim().split("=");
		if (key === name) return value.join("=");
	}
	return undefined;
}