repos.ts

15.0 KB · raw

1import type { Context, Hono } from "hono";2import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";3import { isSafePath } from "../bucket/repo.ts";4import { rebuildRepo, syncAll } from "../bucket/sync.ts";5import { MAX_CHARS } from "../issues/spam.ts";6import { operatorIssues } from "../issues/store.ts";7import { languageLines, repoStats } from "../mirror/queries.ts";8import { openMirror, quarantinedPaths } from "../mirror/store.ts";9import { html } from "../pages/html.ts";10import { formatDate, plural } from "../pages/layout.ts";11import {12	deleteRelease,13	type OperatorRelease,14	operatorReleases,15	setRelease,16	visibleReleases,17} from "../releases/releases.ts";18import { type RepoText, repoText, setRepoText } from "../server/db.ts";19import { dropTarballs } from "../tarball/cache.ts";20import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";21import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";22import { LIMITS } from "./text.ts";2324const MAX_FIELD = 500;25const HTTP_URL = /^https?:\/\/[^\s]+$/i;26const MAX_LANGUAGES = 5;27const MAX_LANGUAGE = 30;28const MAX_HIDDEN = 100;2930/** Shown languages as typed, comma-separated; blanks between commas are dropped. */31function languageNames(value: string): string[] {32	return value33		.split(",")34		.map((name) => name.trim())35		.filter((name) => name !== "");36}3738/** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */39export function registerRepos(app: Hono, ctx: AdminContext): void {40	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));4142	app.post("/admin/sync", async (c) => {43		await syncAll(ctx.sync);44		return c.redirect("/admin/repos", 303);45	});4647	app.post("/admin/repos/:repo", async (c) => {48		const repo = findRepo(ctx.kepr, c.req.param("repo"));49		if (!repo) return c.notFound();50		const fields = await textFields(c);51		const text = (name: string) => (fields[name] ?? "").trim();52		const languages = languageNames(fields.languages ?? "");53		const hidden = (fields.hidden_paths ?? "")54			.split(/\r?\n/)55			.map((path) => path.trim().replace(/\/+$/, ""))56			.filter((path) => path !== "");57		const edit: RepoEdit = {58			public: fields.public === "1" ? 1 : 0,59			category: text("category"),60			homepage: text("homepage"),61			// Joined with the separator `publicRepo` splits on, so a name never contains it.62			languages: languages.join(", "),63			issues_paused: fields.issues_paused === "1" ? 1 : 0,64			hidden_paths: hidden.join("\n"),65		};66		const refuse = (error: string) =>67			c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);68		const texts = (["description", "details"] as const).map((field): [RepoText, string] => [69			field,70			(fields[field] ?? "").replace(/\r\n/g, "\n").trim(),71		]);72		const long = texts.find(([field, value]) => value.length > LIMITS[field]);73		if (long) {74			const [field] = long;75			return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);76		}77		if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {78			return refuse(`keep each field under ${MAX_FIELD} characters.`);79		}80		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {81			return refuse("the homepage must be an http or https address.");82		}83		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");84		if (languages.some((name) => name.length > MAX_LANGUAGE)) {85			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);86		}87		if (languages.some((name) => /\p{C}/u.test(name))) {88			return refuse("a language name is printable text.");89		}90		if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);91		const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));92		if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);93		updateRepo(ctx.kepr, repo.name, edit);94		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);95		if (edit.hidden_paths !== repo.hidden_paths) await applyHidden(ctx, repo.name, hidden);96		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);97	});9899	// A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.100	app.post("/admin/releases/:repo/:version", async (c) => {101		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));102		if (!found) return c.notFound();103		const { repo, release } = found;104		const fields = await textFields(c);105		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");106		if (notes.length > MAX_CHARS) {107			const limit = MAX_CHARS.toLocaleString("en-US");108			const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;109			return c.html(reposPage(c, ctx, error, repo.name), 400);110		}111		// Notes equal to the record's are no override, so a later record edit would show through.112		const override = notes === release.recordNotes ? null : notes;113		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);114		return c.redirect(115			`/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,116			303,117		);118	});119120	app.post("/admin/repos/:repo/tarballs/delete", async (c) => {121		const repo = findRepo(ctx.kepr, c.req.param("repo"));122		if (!repo) return c.notFound();123		try {124			await dropTarballs(ctx.sync.bucket, repo.name);125		} catch (error) {126			if (!(error instanceof BucketWriteError)) throw error;127			return c.html(reposPage(c, ctx, `${repo.name}: ${error.message}`, repo.name), 503);128		}129		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);130	});131132	app.post("/admin/releases/:repo/:version/delete", async (c) => {133		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));134		if (!found) return c.notFound();135		const { repo, mirror, release } = found;136		const refuse = (status: 400 | 503, error: string) =>137			c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);138		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");139		try {140			await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);141		} catch (error) {142			if (error instanceof BucketWriteError) return refuse(503, error.message);143			throw error;144		}145		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);146	});147}148149/**150 * Hands a changed hidden list to Koh at `.kepr/hidden/<repo>`, one path per line, for it to add to151 * `.kohignore`; then rebuilds the mirror and drops kept tarballs, so hiding and unhiding both show152 * at once. Without a write key the list stays in Kepr: Koh reads it only from the bucket anyway.153 */154async function applyHidden(ctx: AdminContext, repo: string, hidden: string[]): Promise<void> {155	const list = new TextEncoder().encode(hidden.map((path) => `${path}\n`).join(""));156	try {157		await ctx.sync.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, list, "text/plain");158		await dropTarballs(ctx.sync.bucket, repo);159	} catch (error) {160		if (!(error instanceof BucketWriteError)) throw error;161	}162	await rebuildRepo(ctx.sync, repo);163}164165/** A build of a known repo, as the console lists it, or null. */166function findRelease(ctx: AdminContext, name: string, version: string) {167	const repo = findRepo(ctx.kepr, name);168	const mirror = repo && openMirror(ctx.mirrorDir, repo.name);169	const release =170		repo &&171		mirror &&172		operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);173	return repo && mirror && release ? { repo, mirror, release } : null;174}175176function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {177	const repos = allRepos(ctx.kepr);178	const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];179	const body = html`${notice(error)}180<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>181<form method="post" action="/admin/sync">182${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>183${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}184<div class="admin-repos">185<nav aria-label="repositories">186${repos.map(187	(repo) => html`<a class="issue-row" id="repo-${repo.name}"188href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>189<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>190<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>191${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,192)}193</nav>194${selected && repoForm(c, ctx, selected)}195</div>`;196	return adminPage(c, ctx, "repos", body);197}198199/** The selected repo at a glance: its numbers and languages (§24). */200function workspace(ctx: AdminContext, repo: AdminRepo) {201	const mirror = openMirror(ctx.mirrorDir, repo.name);202	const stats = repoStats(mirror);203	const releases = operatorReleases(ctx.kepr, mirror, repo.name);204	const downloads = (release: (typeof releases)[number]) =>205		release.downloads.reduce((sum, [, count]) => sum + count, 0);206	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);207	const vital = (label: string, value: string | number) =>208		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;209	return html`<div class="admin-vitals">210${vital("saves", stats.saves)}211${vital("lines", stats.lines)}212${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}213${vital("open issues", open.length)}214${vital(215	"downloads",216	releases.reduce((sum, release) => sum + downloads(release), 0),217)}218</div>219<p class="issue-meta mono">detected languages:220${languageLines(mirror).map(221	([language, lines], i) =>222		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,223)}</p>224`;225}226227function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {228	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));229	const own = repoText(ctx.kepr, repo.name);230	const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);231	const input = (name: keyof RepoEdit, label: string) =>232		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>233<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;234	const synced =235		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;236	return html`<article class="issue-post" id="repo-editor">237<p class="issue-post-head mono">238<span class="issue-author">239${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>240<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>241${workspace(ctx, repo)}242${243	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`244}245${246	quarantined.length > 0 &&247	html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`248}249<form class="issues-form" method="post" action="/admin/repos/${repo.name}">250${formField(c)}251<label class="issues-label">252<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>253<label class="issues-label">254<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>255pause new issues</label>256${input("category", "category")}257${input("homepage", "homepage")}258${input("languages", "shown languages")}259<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>260${textArea(repo.name, "description", "description", 3, own.description)}261${textArea(repo.name, "details", "details", 12, own.details)}262<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>263<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>264<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"265rows="4">${repo.hidden_paths}</textarea>266<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,267the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>268<button class="issues-submit" type="submit">save ${repo.name}</button>269</form>270${271	releases.length > 0 &&272	html`<section class="repo-section" id="builds"><h2 class="section-label mono">builds</h2>273${releases.map((release) => releaseForm(c, repo.name, release))}</section>`274}275<section class="repo-section"><h2 class="section-label mono">archives</h2>276<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">277${formField(c)}278<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.</p>279<button class="issues-submit" type="submit">delete kept tarballs</button>280</form></section>281</article>`;282}283284function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {285	return html`<label class="issues-label" for="${repo}-${name}">${label}</label>286<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"287maxlength="${String(LIMITS[name])}">${value}</textarea>`;288}289290function releaseForm(c: Context, repo: string, release: OperatorRelease) {291	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);292	const id = `${repo}-${release.version}`;293	return html`<article class="issue-post" id="${id}">294<p class="issue-post-head mono">295${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(296	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,297)}298</p>299<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">300${formField(c)}301<label class="issues-label" for="${id}-notes">notes302${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>303<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"304maxlength="${MAX_CHARS}">${release.notes}</textarea>305${306	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the307	// tag itself would put whitespace in the HTML that tests/console.test.ts pins.308	""309}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>310<button class="issues-submit" type="submit">save ${release.version}</button>311</form>312<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">313${formField(c)}314<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>315delete ${release.version} and its files from the bucket; this cannot be undone</label>316<button class="issues-submit" type="submit">delete ${release.version}</button>317</form>318</article>`;319}