Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/canvas/src/server.ts

Raw
import { randomBytes, timingSafeEqual } from "node:crypto";
import { readFile, realpath } from "node:fs/promises";
import {
	createServer,
	type IncomingMessage,
	type Server,
	type ServerResponse,
} from "node:http";
import { basename, dirname, extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { CanvasAction, CanvasState, CanvasViewport } from "./store.js";

const BODY_LIMIT = 1024 * 1024;
const MAX_CLIENTS = 8;
const PUBLISH_DELAY_MS = 100;

type Client = { response: ServerResponse; blocked: boolean };

export type CanvasServer = {
	url: string;
	publish(state: CanvasState): void;
	close(): Promise<void>;
};

export async function startCanvasServer(
	state: () => CanvasState,
	onAction: (action: CanvasAction) => void | Promise<void>,
): Promise<CanvasServer> {
	const token = randomBytes(32).toString("base64url");
	const browserRoot = await browserDirectory();
	const clients = new Set<Client>();
	const sockets = new Set<import("node:net").Socket>();
	let expectedHost = "";
	let pending: CanvasState | undefined;
	let timer: ReturnType<typeof setTimeout> | undefined;
	let closePromise: Promise<void> | undefined;
	let publish: (next: CanvasState) => void = () => undefined;
	const server = createServer((req, res) => {
		void dispatch(req, res, {
			token,
			expectedHost,
			state,
			onAction,
			browserRoot,
			clients,
			publish,
		}).catch(() => {
			if (!res.headersSent && !res.destroyed)
				json(res, 400, { error: "Invalid request." });
			else res.destroy();
		});
	});
	server.on("connection", (socket) => {
		sockets.add(socket);
		socket.once("close", () => sockets.delete(socket));
	});
	await listen(server);
	const address = server.address();
	if (!address || typeof address === "string") {
		server.close();
		throw new Error("Canvas loopback bind failed.");
	}
	expectedHost = `127.0.0.1:${address.port}`;
	publish = (next: CanvasState): void => {
		pending = next;
		if (!timer) timer = setTimeout(flush, PUBLISH_DELAY_MS);
	};
	function flush(): void {
		timer = undefined;
		const next = pending;
		pending = undefined;
		if (!next) return;
		let event: string;
		try {
			event = `event: state\ndata: ${JSON.stringify(next)}\n\n`;
		} catch {
			return;
		}
		for (const client of clients) sendEvent(client, event, clients);
	}
	return {
		url: `http://${expectedHost}/#${token}`,
		publish,
		close(): Promise<void> {
			if (closePromise) return closePromise;
			if (timer) clearTimeout(timer);
			timer = undefined;
			pending = undefined;
			for (const client of clients) client.response.end();
			clients.clear();
			for (const socket of sockets) socket.destroy();
			closePromise = closeServer(server);
			return closePromise;
		},
	};
}

type DispatchContext = {
	token: string;
	expectedHost: string;
	state: () => CanvasState;
	onAction: (action: CanvasAction) => void | Promise<void>;
	browserRoot: string | undefined;
	clients: Set<Client>;
	publish: (state: CanvasState) => void;
};

async function dispatch(
	req: IncomingMessage,
	res: ServerResponse,
	ctx: DispatchContext,
): Promise<void> {
	if (req.headers.host !== ctx.expectedHost)
		return json(res, 400, { error: "Invalid Host header." });
	const url = new URL(req.url ?? "/", `http://${ctx.expectedHost}`);
	if (url.pathname === "/")
		return req.method === "GET" ? page(res) : methodNotAllowed(res, "GET");
	if (url.pathname === "/browser/config.js")
		return req.method === "GET" ? config(res) : methodNotAllowed(res, "GET");
	if (url.pathname.startsWith("/browser/"))
		return serveAsset(req, res, url.pathname, ctx.browserRoot);
	if (url.pathname === "/api/state") {
		if (req.method !== "GET") return methodNotAllowed(res, "GET");
		if (!authorized(req, ctx.token)) return unauthorized(res);
		return json(res, 200, ctx.state());
	}
	if (url.pathname === "/api/events") {
		if (req.method !== "GET") return methodNotAllowed(res, "GET");
		if (!authorized(req, ctx.token)) return unauthorized(res);
		if (ctx.clients.size >= MAX_CLIENTS)
			return json(res, 503, { error: "Too many canvas connections." });
		return events(res, ctx.state, ctx.clients);
	}
	if (url.pathname === "/api/action") {
		if (req.method !== "POST") return methodNotAllowed(res, "POST");
		if (!authorized(req, ctx.token)) return unauthorized(res);
		if (req.headers.origin !== `http://${ctx.expectedHost}`)
			return json(res, 403, { error: "Invalid Origin header." });
		try {
			const action = validateAction(parseJson(await bodyText(req)));
			await ctx.onAction(action);
			const next = ctx.state();
			ctx.publish(next);
			return json(res, 200, { ok: true, state: next });
		} catch (error) {
			return json(res, isHttpError(error) ? error.status : 409, {
				error: safeError(error),
			});
		}
	}
	return json(res, 404, { error: "Not found." });
}

async function browserDirectory(): Promise<string | undefined> {
	const here = dirname(fileURLToPath(import.meta.url));
	const candidate = resolve(
		here,
		basename(here) === "src" ? "../browser" : "browser",
	);
	try {
		return await realpath(candidate);
	} catch {
		return undefined;
	}
}

async function serveAsset(
	req: IncomingMessage,
	res: ServerResponse,
	pathname: string,
	root: string | undefined,
): Promise<void> {
	if (req.method !== "GET") return methodNotAllowed(res, "GET");
	if (!root) return json(res, 404, { error: "Not found." });
	const name = pathname.slice("/browser/".length);
	if (
		!name ||
		name
			.split("/")
			.some(
				(part) =>
					!/^[A-Za-z0-9._-]+$/u.test(part) || part === "." || part === "..",
			)
	)
		return json(res, 404, { error: "Not found." });
	const extension = extname(name);
	if (extension !== ".js" && extension !== ".css" && extension !== ".woff2")
		return json(res, 404, { error: "Not found." });
	try {
		const file = await realpath(resolve(root, name));
		if (relative(root, file).startsWith(".."))
			return json(res, 404, { error: "Not found." });
		asset(res, await readFile(file), extension);
	} catch {
		json(res, 404, { error: "Not found." });
	}
}

function validateAction(value: unknown): CanvasAction {
	if (!record(value) || typeof value["kind"] !== "string")
		throw requestError("Invalid action.");
	switch (value["kind"]) {
		case "scene":
			fields(value, ["kind", "revision", "elements", "viewport"]);
			if (!revision(value["revision"]))
				throw requestError("Invalid scene revision.");
			return {
				kind: "scene",
				revision: value["revision"],
				elements: value["elements"],
				...(value["viewport"] === undefined
					? {}
					: { viewport: viewport(value["viewport"]) }),
			};
		case "prepare":
			fields(value, ["kind", "requestId", "clientId", "additions"]);
			return {
				kind: "prepare",
				requestId: identifier(value["requestId"], "request"),
				clientId: identifier(value["clientId"], "client"),
				additions: value["additions"],
			};
		case "commit":
			fields(value, ["kind", "requestId", "clientId", "revision", "elements"]);
			if (!revision(value["revision"]))
				throw requestError("Invalid drawing revision.");
			return {
				kind: "commit",
				requestId: identifier(value["requestId"], "request"),
				clientId: identifier(value["clientId"], "client"),
				revision: value["revision"],
				elements: value["elements"],
			};
		case "cancel":
			fields(value, ["kind", "requestId", "clientId"]);
			return {
				kind: "cancel",
				requestId: identifier(value["requestId"], "request"),
				clientId: identifier(value["clientId"], "client"),
			};
		default:
			throw requestError("Unknown action.");
	}
}

function viewport(value: unknown): CanvasViewport {
	if (!record(value)) throw requestError("Invalid viewport image.");
	fields(value, [
		"data",
		"mimeType",
		"imageWidth",
		"imageHeight",
		"width",
		"height",
		"scrollX",
		"scrollY",
		"zoom",
	]);
	const data = value["data"];
	const mimeType = value["mimeType"];
	if (
		typeof data !== "string" ||
		!data.length ||
		data.length > 256 * 1024 ||
		data.length % 4 !== 0 ||
		!/^[A-Za-z0-9+/]+={0,2}$/u.test(data) ||
		(mimeType !== "image/png" && mimeType !== "image/jpeg")
	)
		throw requestError("Invalid viewport image.");
	const bytes = Buffer.from(data, "base64");
	if (
		mimeType === "image/png"
			? !bytes
					.subarray(0, 8)
					.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
			: bytes[0] !== 255 || bytes[1] !== 216 || bytes[2] !== 255
	)
		throw requestError("Invalid viewport image signature.");
	for (const key of [
		"imageWidth",
		"imageHeight",
		"width",
		"height",
		"scrollX",
		"scrollY",
		"zoom",
	]) {
		if (typeof value[key] !== "number" || !Number.isFinite(value[key]))
			throw requestError("Invalid viewport geometry.");
	}
	for (const key of ["imageWidth", "imageHeight"])
		if (
			!Number.isInteger(value[key]) ||
			Number(value[key]) < 1 ||
			Number(value[key]) > 1024
		)
			throw requestError("Invalid viewport image dimensions.");
	for (const key of ["width", "height"])
		if (Number(value[key]) <= 0 || Number(value[key]) > 16384)
			throw requestError("Invalid viewport dimensions.");
	if (
		Number(value["zoom"]) < 0.1 ||
		Number(value["zoom"]) > 30 ||
		Math.abs(Number(value["scrollX"])) > 1e9 ||
		Math.abs(Number(value["scrollY"])) > 1e9
	)
		throw requestError("Invalid viewport transform.");
	return value as CanvasViewport;
}

function fields(value: Record<string, unknown>, allowed: string[]): void {
	if (Object.keys(value).some((key) => !allowed.includes(key)))
		throw requestError("Action has unknown fields.");
}
function revision(value: unknown): value is number {
	return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function identifier(value: unknown, type: string): string {
	if (typeof value !== "string" || !value.trim() || value.length > 120)
		throw requestError(`Invalid ${type} ID.`);
	return value;
}
function record(value: unknown): value is Record<string, unknown> {
	return value !== null && typeof value === "object" && !Array.isArray(value);
}
function requestError(message: string): Error & { status: number } {
	return Object.assign(new Error(message), { status: 400 });
}
function parseJson(value: string): unknown {
	try {
		return JSON.parse(value);
	} catch {
		throw requestError("Invalid JSON.");
	}
}
function isHttpError(error: unknown): error is Error & { status: number } {
	const status =
		error instanceof Error ? (error as { status?: unknown }).status : undefined;
	return (
		typeof status === "number" &&
		Number.isInteger(status) &&
		status >= 400 &&
		status < 500
	);
}
function safeError(error: unknown): string {
	if (!(error instanceof Error)) return "Action was rejected.";
	const message = error.message.replace(/[\r\n\t]/gu, " ").trim();
	return message && message.length <= 500 ? message : "Action was rejected.";
}

function events(
	res: ServerResponse,
	state: () => CanvasState,
	clients: Set<Client>,
): void {
	res.writeHead(200, {
		"Content-Type": "text/event-stream",
		"Cache-Control": "no-store",
		Connection: "keep-alive",
		"X-Content-Type-Options": "nosniff",
		"Referrer-Policy": "no-referrer",
	});
	const client = { response: res, blocked: false };
	clients.add(client);
	try {
		sendEvent(
			client,
			`event: state\ndata: ${JSON.stringify(state())}\n\n`,
			clients,
		);
	} catch {
		res.end();
	}
	res.once("close", () => clients.delete(client));
}
function sendEvent(client: Client, event: string, clients: Set<Client>): void {
	if (client.blocked) {
		client.response.destroy();
		clients.delete(client);
		return;
	}
	if (!client.response.write(event)) {
		client.blocked = true;
		client.response.once("drain", () => {
			client.blocked = false;
		});
	}
}

function authorized(req: IncomingMessage, token: string): boolean {
	const value = req.headers.authorization;
	if (!value?.startsWith("Bearer ")) return false;
	const supplied = Buffer.from(value.slice(7));
	const expected = Buffer.from(token);
	return (
		supplied.length === expected.length && timingSafeEqual(supplied, expected)
	);
}
async function bodyText(req: IncomingMessage): Promise<string> {
	const chunks: Buffer[] = [];
	let size = 0;
	for await (const chunk of req) {
		const bytes = Buffer.from(chunk);
		size += bytes.length;
		if (size > BODY_LIMIT)
			throw Object.assign(new Error("Request body exceeds 1 MiB."), {
				status: 413,
			});
		chunks.push(bytes);
	}
	return Buffer.concat(chunks).toString("utf8");
}
function page(res: ServerResponse): void {
	res.writeHead(200, securityHeaders("text/html; charset=utf-8"));
	res.end(
		'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Pi canvas</title><link rel="stylesheet" href="/browser/excalidraw.css"><link rel="stylesheet" href="/browser/style.css"></head><body><div id="root"></div><script src="/browser/config.js"></script><script type="module" src="/browser/main.js"></script></body></html>',
	);
}
function config(res: ServerResponse): void {
	res.writeHead(200, securityHeaders("text/javascript; charset=utf-8"));
	res.end('globalThis.EXCALIDRAW_ASSET_PATH="/browser/";');
}
function json(res: ServerResponse, status: number, value: unknown): void {
	res.writeHead(status, securityHeaders("application/json; charset=utf-8"));
	res.end(JSON.stringify(value));
}
function unauthorized(res: ServerResponse): void {
	res.writeHead(401, {
		...securityHeaders("application/json; charset=utf-8"),
		"WWW-Authenticate": "Bearer",
	});
	res.end('{"error":"Unauthorized."}');
}
function methodNotAllowed(res: ServerResponse, allow: string): void {
	res.writeHead(405, {
		...securityHeaders("application/json; charset=utf-8"),
		Allow: allow,
	});
	res.end('{"error":"Method not allowed."}');
}
function asset(res: ServerResponse, body: Buffer, extension: string): void {
	res.writeHead(
		200,
		securityHeaders(
			extension === ".css"
				? "text/css; charset=utf-8"
				: extension === ".woff2"
					? "font/woff2"
					: "text/javascript; charset=utf-8",
		),
	);
	res.end(body);
}
function securityHeaders(contentType: string): Record<string, string> {
	return {
		"Content-Type": contentType,
		"Cache-Control": "no-store",
		"Content-Security-Policy":
			"default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self' data:; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'",
		"X-Content-Type-Options": "nosniff",
		"Referrer-Policy": "no-referrer",
	};
}
function listen(server: Server): Promise<void> {
	return new Promise((resolve, reject) => {
		server.once("error", reject);
		server.listen(0, "127.0.0.1", () => {
			server.off("error", reject);
			resolve();
		});
	});
}
function closeServer(server: Server): Promise<void> {
	return new Promise((resolve) => server.close(() => resolve()));
}