console.ts

5.1 KB · raw

1import type { Context, Hono } from "hono";2import { checkFormat, listRepos } from "../bucket/repo.ts";3import type { SyncTarget } from "../bucket/sync.ts";4import { type Html, html } from "../pages/html.ts";5import { type PageContext, page, plural } from "../pages/layout.ts";6import { backups, newestBackupTarball } from "../server/backup.ts";7import { formToken } from "../server/login.ts";89export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; canConnect: boolean };1011const SECTIONS = [12	["/admin", "status"],13	["/admin/repos", "repos"],14	["/admin/issues", "issues"],15	["/admin/releases", "releases"],16	["/admin/site", "site"],17] as const;1819/** The hidden same-site token (§25) that every admin form posts back. */20export function formField(c: Context): Html {21	return html`<input type="hidden" name="form" value="${formToken(c)}">`;22}2324/** A posted form's text fields; the guard has already parsed and checked the body. */25export async function textFields(c: Context): Promise<Record<string, string>> {26	const fields: Record<string, string> = {};27	for (const [name, value] of Object.entries(await c.req.parseBody())) {28		if (typeof value === "string") fields[name] = value;29	}30	return fields;31}3233export function notice(error: string): Html | "" {34	return error && html`<p class="issues-error mono" role="alert">${error}</p>`;35}3637/** An admin page: the site's own look, with the console's sections and sign-out on top. */38export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {39	const tab = ([href, name]: (typeof SECTIONS)[number]) =>40		html`<a class="issues-tab${c.req.path === href ? " active" : ""}" href="${href}">${name}</a>`;41	return page(ctx, {42		title: `${title} · admin`,43		description: "",44		path: c.req.path,45		body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">46${SECTIONS.map(tab)}${formField(c)}47<button class="issues-tab issues-new" type="submit">sign out</button>48</form>49<h1 class="file-page-title">${title}</h1>50${body}`,51	});52}5354// ponytail: counted once per process by walking the source, since there is no build step.55let runningLines: number | null = null;56async function sourceLines(): Promise<number> {57	if (runningLines !== null) return runningLines;58	let total = 0;59	const root = `${import.meta.dir}/../..`;60	for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) {61		if (path.endsWith(".test.ts")) continue;62		const text = await Bun.file(`${root}/${path}`).text();63		total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0);64	}65	runningLines = total;66	return total;67}6869/** The last 20 machines that redeemed a code, and how to take their access away again. */70function connectedMachines(ctx: AdminContext): Html | "" {71	const rows = ctx.kepr72		.query<{ machine: string; used_at: number }, []>(73			`SELECT machine, used_at FROM connect_codes74			 WHERE used_at IS NOT NULL75			 ORDER BY used_at DESC76			 LIMIT 20`,77		)78		.all();79	if (rows.length === 0) return "";80	const led = rows.map((row) => {81		const at = new Date(row.used_at * 1000).toISOString();82		return html`<li>${row.machine} <span class="muted">${at}</span></li>`;83	});84	return html`<h2>Machines connected</h2>85<ul>${led}</ul>86<p class="muted">To revoke access, rotate the write key in Scaleway and update kepr.toml.</p>`;87}8889/** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */90export function registerStatus(app: Hono, ctx: AdminContext): void {91	app.get("/admin", async (c) => {92		let bucket: Html;93		try {94			const repos = await listRepos(ctx.sync.bucket);95			const formats = await Promise.all(96				repos.map(async (repo) => {97					try {98						await checkFormat(ctx.sync.bucket, repo);99						return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;100					} catch (error) {101						return html`<li><span class="mono">${repo}</span>102<span class="issues-error">${String(error)}</span></li>`;103					}104				}),105			);106			bucket = html`<p>Bucket reachable: ${plural(repos.length, "repo")}.</p><ul>${formats}</ul>`;107		} catch (error) {108			bucket = html`<p class="issues-error" role="alert">Bucket unreachable: ${String(error)}</p>`;109		}110		const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {111			version: string;112		};113		const lines = (await sourceLines()).toLocaleString("en-US");114		const newest = backups(ctx.dataDir).at(-1);115		const backup = newest116			? html`<p>Last backup: ${newest.replace(117					/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,118					"$1-$2-$3 $4:$5:$6 UTC",119				)}. <a href="/admin/backup">download backup</a></p>`120			: html`<p>No backup yet.</p>`;121		return c.html(122			adminPage(123				c,124				ctx,125				"status",126				html`<section class="repo-section markdown">${bucket}127${ctx.canConnect ? connectedMachines(ctx) : ""}128${backup}129<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,130			),131		);132	});133134	app.get("/admin/backup", (c) => {135		const backup = newestBackupTarball(ctx.dataDir);136		if (!backup) return c.notFound();137		return c.body(backup.bytes, 200, {138			"Content-Type": "application/gzip",139			"Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,140		});141	});142}