repos.ts

20.5 KB · raw

1import type { Context, Hono } from "hono";2import { BucketWriteError } from "../bucket/reader.ts";3import { isSafePath } from "../bucket/repo.ts";4import { MAX_CHARS } from "../issues/spam.ts";5import { operatorIssues } from "../issues/store.ts";6import { languageLines, repoStats } from "../mirror/queries.ts";7import { openMirror, quarantinedPaths } from "../mirror/store.ts";8import { type Html, html } from "../pages/html.ts";9import { formatDate, plural } from "../pages/layout.ts";10import {11	deleteRelease,12	deleteReleaseFile,13	LastReleaseFile,14	type OperatorRelease,15	operatorReleases,16	setRelease,17	visibleReleases,18} from "../releases/releases.ts";19import { type RepoText, repoText, setRepoText } from "../server/db.ts";20import { type Removal, removal, startRemoval } from "../server/remove.ts";21import { dropTarballs } from "../tarball/cache.ts";22import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";23import { renameErrors, renameProblem, renameRepo, renames } from "./rename.ts";24import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";25import { LIMITS } from "./text.ts";2627const MAX_FIELD = 500;28const HTTP_URL = /^https?:\/\/[^\s]+$/i;29const MAX_LANGUAGES = 5;30const MAX_LANGUAGE = 30;31const MAX_HIDDEN = 100;3233/** Shown languages as typed, comma-separated; blanks between commas are dropped. */34function languageNames(value: string): string[] {35	return value36		.split(",")37		.map((name) => name.trim())38		.filter((name) => name !== "");39}4041/** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */42export function registerRepos(app: Hono, ctx: AdminContext): void {43	app.get("/admin/repos", (c) => {44		return c.html(reposPage(c, ctx, "", c.req.query("repo")));45	});4647	app.post("/admin/sync", async (c) => {48		await ctx.syncer.syncAll();49		return c.redirect("/admin/repos", 303);50	});5152	app.post("/admin/repos/:repo", async (c) => {53		const repo = findRepo(ctx.kepr, c.req.param("repo"));54		if (!repo) return c.notFound();55		const fields = await textFields(c);56		const text = (name: string) => (fields[name] ?? "").trim();57		const languages = languageNames(fields.languages ?? "");58		const hidden = (fields.hidden_paths ?? "")59			.split(/\r?\n/)60			.map((path) => path.trim().replace(/\/+$/, ""))61			.filter((path) => path !== "");62		const edit: RepoEdit = {63			public: fields.public === "1" ? 1 : 0,64			category: text("category"),65			homepage: text("homepage"),66			// Joined with the separator `publicRepo` splits on, so a name never contains it.67			languages: languages.join(", "),68			issues_paused: fields.issues_paused === "1" ? 1 : 0,69			hidden_paths: hidden.join("\n"),70		};71		const refuse = (error: string) =>72			c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);73		const texts = (["description", "details"] as const).map((field): [RepoText, string] => [74			field,75			(fields[field] ?? "").replace(/\r\n/g, "\n").trim(),76		]);77		const long = texts.find(([field, value]) => value.length > LIMITS[field]);78		if (long) {79			const [field] = long;80			return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);81		}82		if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {83			return refuse(`keep each field under ${MAX_FIELD} characters.`);84		}85		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {86			return refuse("the homepage must be an http or https address.");87		}88		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");89		if (languages.some((name) => name.length > MAX_LANGUAGE)) {90			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);91		}92		if (languages.some((name) => /\p{C}/u.test(name))) {93			return refuse("a language name is printable text.");94		}95		if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);96		const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));97		if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);98		updateRepo(ctx.kepr, repo.name, edit);99		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);100		// The sync hands the list to Koh (`handHidden`); the mirror has it before the response.101		if (edit.hidden_paths !== repo.hidden_paths) await ctx.syncer.applyHidden([repo.name]);102		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1`, 303);103	});104105	// A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.106	app.post("/admin/releases/:repo/:version", async (c) => {107		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));108		if (!found) return c.notFound();109		const { repo, release } = found;110		const fields = await textFields(c);111		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");112		if (notes.length > MAX_CHARS) {113			const limit = MAX_CHARS.toLocaleString("en-US");114			const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;115			return c.html(reposPage(c, ctx, error, repo.name), 400);116		}117		// Notes equal to the record's are no override, so a later record edit would show through.118		const override = notes === release.recordNotes ? null : notes;119		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);120		return c.redirect(121			`/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`,122			303,123		);124	});125126	app.post("/admin/repos/:repo/delete", async (c) => {127		const repo = findRepo(ctx.kepr, c.req.param("repo"));128		if (!repo) return c.notFound();129		const fields = await textFields(c);130		const typed = (fields.name ?? "").trim();131		if (fields.delete !== "on" || typed !== repo.name) {132			return c.html(133				reposPage(c, ctx, `Tick the box and type ${repo.name} to delete it.`, repo.name),134				400,135			);136		}137		if (!startRemoval(ctx.sync, repo.name)) {138			return c.html(139				reposPage(c, ctx, "A repo is already being deleted. Let it finish first.", repo.name),140				400,141			);142		}143		return c.redirect(`/admin/repos?repo=${encodeURIComponent(repo.name)}`, 303);144	});145146	app.post("/admin/repos/:repo/rename", async (c) => {147		const repo = findRepo(ctx.kepr, c.req.param("repo"));148		if (!repo) return c.notFound();149		const to = ((await textFields(c)).name ?? "").trim();150		const problem = await renameProblem(ctx, repo.name, to);151		if (problem) {152			return c.html(reposPage(c, ctx, `${repo.name}: ${problem}`, repo.name), 400);153		}154		renameRepo(ctx, repo.name, to);155		return c.redirect(`/admin/repos?repo=${repo.name}#settings`, 303);156	});157158	app.post("/admin/repos/:repo/tarballs/delete", async (c) => {159		const repo = findRepo(ctx.kepr, c.req.param("repo"));160		if (!repo) return c.notFound();161		try {162			await dropTarballs(ctx.sync.bucket, repo.name);163		} catch (error) {164			if (!(error instanceof BucketWriteError)) throw error;165			const message = `${repo.name}: ${error.message}`;166			return c.html(reposPage(c, ctx, message, repo.name), 503);167		}168		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#archives`, 303);169	});170171	app.post("/admin/releases/:repo/:version/delete", async (c) => {172		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));173		if (!found) return c.notFound();174		const { repo, mirror, release } = found;175		const refuse = (status: 400 | 503, error: string) =>176			c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);177		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");178		try {179			await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);180		} catch (error) {181			if (error instanceof BucketWriteError) return refuse(503, error.message);182			throw error;183		}184		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);185	});186187	app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {188		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));189		const platform = c.req.param("platform");190		if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();191		const { repo, mirror, release } = found;192		const refuse = (status: 400 | 503, error: string) =>193			c.html(194				reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name),195				status,196			);197		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");198		try {199			await deleteReleaseFile(200				ctx.sync.bucket,201				ctx.kepr,202				mirror,203				repo.name,204				release.version,205				platform,206			);207		} catch (error) {208			if (error instanceof LastReleaseFile) return refuse(400, error.message);209			if (error instanceof BucketWriteError) return refuse(503, error.message);210			throw error;211		}212		return c.redirect(213			`/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`,214			303,215		);216	});217}218219/** A build of a known repo, as the console lists it, or null. */220function findRelease(ctx: AdminContext, name: string, version: string) {221	const repo = findRepo(ctx.kepr, name);222	const mirror = repo && openMirror(ctx.mirrorDir, repo.name);223	const release =224		repo &&225		mirror &&226		operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);227	return repo && mirror && release ? { repo, mirror, release } : null;228}229230function reposPage(c: Context, ctx: AdminContext, error: string, chosen: string | undefined) {231	const repos = allRepos(ctx.kepr);232	const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];233	// Each repo keeps its whole workspace behind one link.234	const repoLink = (repo: AdminRepo) => {235		const current = repo.name === selected?.name;236		return html`<a class="admin-repo-link${current ? " active" : ""}" href="/admin/repos?repo=${repo.name}"${237			current ? html` aria-current="page"` : ""238		}>${repo.name}${repo.public ? "" : html` <span class="muted">private</span>`}${239			repo.last_sync_error ? html` <span class="issues-error">sync failed</span>` : ""240		}</a>`;241	};242	const body = html`${notice(error)}243<div class="admin-repos-layout">244<aside class="admin-repo-picker mono">245<nav aria-label="repositories">${repos.map(repoLink)}</nav>246<a href="/admin/site#global-hide">global hide ↗</a>247<form method="post" action="/admin/sync">248${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>249</aside>250<div class="admin-repo-workspace">251${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}252${selected && repoWorkspace(c, ctx, selected)}253</div></div>`;254	return adminPage(c, ctx, "repos", body);255}256257/** The selected repo at a glance: its numbers and languages (§24). */258function workspace(ctx: AdminContext, repo: AdminRepo) {259	const mirror = openMirror(ctx.mirrorDir, repo.name);260	const stats = repoStats(mirror);261	const releases = operatorReleases(ctx.kepr, mirror, repo.name);262	const downloads = (release: (typeof releases)[number]) =>263		release.downloads.reduce((sum, [, count]) => sum + count, 0);264	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);265	const vital = (label: string, value: string | number) =>266		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;267	return html`<div class="admin-vitals">268${vital("saves", stats.saves)}269${vital("lines", stats.lines)}270${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}271${vital("open issues", open.length)}272${vital(273	"downloads",274	releases.reduce((sum, release) => sum + downloads(release), 0),275)}276</div>277<p class="issue-meta mono">detected languages:278${languageLines(mirror).map(279	([language, lines], i) =>280		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,281)}</p>282`;283}284285function repoWorkspace(c: Context, ctx: AdminContext, repo: AdminRepo) {286	const synced =287		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;288	const section = (id: string, body: Html | Html[]) =>289		html`<section class="repo-section" id="${id}"><h2 class="section-label mono">${id}</h2>290${body}</section>`;291	return html`<article class="issue-post" id="repo-editor">292<p class="issue-post-head mono">293<span class="issue-author">294${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>295<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>296${section("overview", overviewSection(ctx, repo))}297${section("settings", settingsSection(c, ctx, repo))}298${section("builds", buildsSection(c, ctx, repo))}299${section("archives", archivesSection(c, repo))}300${section("delete", deleteSection(c, ctx, repo))}301</article>`;302}303304/**305 * §24: deleting a repo is the one thing Kepr writes into Koh's tree for, so it takes a tick and the306 * name typed out. The page follows the delete while it runs; nothing polls, so it says to reload.307 */308function deleteSection(c: Context, ctx: AdminContext, repo: AdminRepo) {309	const under = removal(ctx.kepr);310	const state =311		under && under.repo === repo.name312			? html`<p class="issues-error mono" role="status">${deleteProgress(under)}</p>`313			: "";314	return html`${state}315<form class="issues-form" method="post" action="/admin/repos/${repo.name}/delete">316${formField(c)}317<p>This deletes every key of <span class="mono">${repo.name}</span> in the bucket: its history,318releases, issues and counts. Then it drops Kepr's own copy of it. No other repo is touched, and319nothing here can undo it.</p>320<label class="issues-label">321<input type="checkbox" name="delete" value="on" required> delete ${repo.name} from the bucket</label>322<label class="issues-label" for="${repo.name}-delete-name">type ${repo.name} to confirm</label>323<input class="issues-input mono" id="${repo.name}-delete-name" name="name" autocomplete="off" required>324<button class="issues-submit" type="submit">delete ${repo.name}</button>325</form>`;326}327328function deleteProgress(under: Removal): string {329	const gone = `${String(under.done)} of ${String(under.total)} keys`;330	if (under.error !== "") {331		return `deleting ${under.repo} stopped after ${gone}: ${under.error}. Delete it again to finish the rest.`;332	}333	return `deleting ${under.repo}: ${gone} gone. Reload to follow it.`;334}335336function overviewSection(ctx: AdminContext, repo: AdminRepo) {337	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));338	return html`${workspace(ctx, repo)}339${340	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`341}342${343	quarantined.length > 0 &&344	html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`345}`;346}347348function settingsSection(c: Context, ctx: AdminContext, repo: AdminRepo) {349	const own = repoText(ctx.kepr, repo.name);350	const input = (name: keyof RepoEdit, label: string) =>351		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>352<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;353	return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}">354${formField(c)}355<label class="issues-label">356<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>357<label class="issues-label">358<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>359pause new issues</label>360${input("category", "category")}361${input("homepage", "homepage")}362${input("languages", "shown languages")}363<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>364${textArea(repo.name, "description", "description", 3, own.description)}365${textArea(repo.name, "details", "details", 12, own.details)}366<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>367<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>368<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"369rows="4">${repo.hidden_paths}</textarea>370<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,371the stats and tarballs, and passed to Koh for .kohignore.372For every repo, use <a href="/admin/site#global-hide">global hide</a>.</p>373<button class="issues-submit" type="submit">save ${repo.name}</button>374</form>375${renameForm(c, repo.name)}`;376}377378// The rename runs in the background; the console shows it under way, or why it last failed.379function renameForm(c: Context, name: string) {380	const to = renames.get(name);381	if (to)382		return html`<p class="admin-intro">Renaming ${name} to ${to}. This page shows ${to} once it is done.</p>`;383	return html`${notice(renameErrors.get(name) ? `rename failed: ${renameErrors.get(name)}` : "")}384<form class="issues-form" method="post" action="/admin/repos/${name}/rename">385${formField(c)}386<label class="issues-label" for="${name}-rename">rename</label>387<input class="issues-input mono" id="${name}-rename" name="name" maxlength="100" required>388<p class="issue-meta mono">Moves its history to the new name in the bucket, with its settings, issues and389counts, and sends old links there. Your machines follow it on their next save or steal.</p>390<button class="issues-submit" type="submit">rename ${name}</button>391</form>`;392}393394function buildsSection(c: Context, ctx: AdminContext, repo: AdminRepo) {395	const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);396	return releases.length > 0397		? releases.map((release) => releaseForm(c, repo.name, release))398		: html`<p class="muted mono save-empty">no builds yet</p>`;399}400401function archivesSection(c: Context, repo: AdminRepo) {402	return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">403${formField(c)}404<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.405Each one is listed under <a href="/admin/storage">storage</a>.</p>406<button class="issues-submit" type="submit">delete kept tarballs</button>407</form>`;408}409410function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {411	return html`<label class="issues-label" for="${repo}-${name}">${label}</label>412<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"413maxlength="${String(LIMITS[name])}">${value}</textarea>`;414}415416function releaseForm(c: Context, repo: string, release: OperatorRelease) {417	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);418	// Its own prefix: a version may be named like a settings field, which uses `<repo>-<name>`.419	const id = `release-${repo}-${release.version}`;420	return html`<article class="issue-post" id="${id}">421<p class="issue-post-head mono">422${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`<span class="repo-version">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(423	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,424)}425</p>426<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">427${formField(c)}428<label class="issues-label" for="notes-${repo}-${release.version}">notes429${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>430<textarea class="issues-textarea" id="notes-${repo}-${release.version}" name="notes" rows="3"431maxlength="${MAX_CHARS}">${release.notes}</textarea>432${433	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the434	// tag itself would put whitespace in the HTML that tests/console.test.ts pins.435	""436}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>437<button class="issues-submit" type="submit">save ${release.version}</button>438</form>439${440	release.downloads.length > 1 &&441	release.downloads.map(442		([platform]) => html`<form class="issues-form" method="post"443action="/admin/releases/${repo}/${release.version}/${platform}/delete">444${formField(c)}445<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>446delete the ${platform} file from the bucket</label>447<button class="issues-submit" type="submit">delete ${platform}</button>448</form>`,449	)450}451<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">452${formField(c)}453<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>454delete ${release.version} and its files from the bucket; this cannot be undone</label>455<button class="issues-submit" type="submit">delete ${release.version}</button>456</form>457</article>`;458}