repos.ts

18.6 KB · raw

1import type { Context, Hono } from "hono";2import { BucketWriteError } from "../bucket/reader.ts";3import { isSafePath } from "../bucket/repo.ts";4import { 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 { applyHidden, openMirror, quarantinedPaths } from "../mirror/store.ts";9import { html } from "../pages/html.ts";10import { formatDate, plural } from "../pages/layout.ts";11import {12	deleteRelease,13	deleteReleaseFile,14	LastReleaseFile,15	type OperatorRelease,16	operatorReleases,17	setRelease,18	visibleReleases,19} from "../releases/releases.ts";20import { hiddenPaths, type RepoText, repoText, setRepoText } from "../server/db.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;32const TABS = ["overview", "settings", "builds", "archives"] as const;33type Tab = (typeof TABS)[number];3435/** Shown languages as typed, comma-separated; blanks between commas are dropped. */36function languageNames(value: string): string[] {37	return value38		.split(",")39		.map((name) => name.trim())40		.filter((name) => name !== "");41}4243/** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */44export function registerRepos(app: Hono, ctx: AdminContext): void {45	app.get("/admin/repos", (c) => {46		const tab = TABS.find((t) => t === c.req.query("tab")) ?? "overview";47		return c.html(reposPage(c, ctx, "", c.req.query("repo"), tab));48	});4950	app.post("/admin/sync", async (c) => {51		await syncAll(ctx.sync);52		return c.redirect("/admin/repos", 303);53	});5455	app.post("/admin/repos/:repo", async (c) => {56		const repo = findRepo(ctx.kepr, c.req.param("repo"));57		if (!repo) return c.notFound();58		const fields = await textFields(c);59		const text = (name: string) => (fields[name] ?? "").trim();60		const languages = languageNames(fields.languages ?? "");61		const hidden = (fields.hidden_paths ?? "")62			.split(/\r?\n/)63			.map((path) => path.trim().replace(/\/+$/, ""))64			.filter((path) => path !== "");65		const edit: RepoEdit = {66			public: fields.public === "1" ? 1 : 0,67			category: text("category"),68			homepage: text("homepage"),69			// Joined with the separator `publicRepo` splits on, so a name never contains it.70			languages: languages.join(", "),71			issues_paused: fields.issues_paused === "1" ? 1 : 0,72			hidden_paths: hidden.join("\n"),73		};74		const refuse = (error: string) =>75			c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name, "settings"), 400);76		const texts = (["description", "details"] as const).map((field): [RepoText, string] => [77			field,78			(fields[field] ?? "").replace(/\r\n/g, "\n").trim(),79		]);80		const long = texts.find(([field, value]) => value.length > LIMITS[field]);81		if (long) {82			const [field] = long;83			return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);84		}85		if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {86			return refuse(`keep each field under ${MAX_FIELD} characters.`);87		}88		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {89			return refuse("the homepage must be an http or https address.");90		}91		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");92		if (languages.some((name) => name.length > MAX_LANGUAGE)) {93			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);94		}95		if (languages.some((name) => /\p{C}/u.test(name))) {96			return refuse("a language name is printable text.");97		}98		if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);99		const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));100		if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);101		updateRepo(ctx.kepr, repo.name, edit);102		for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);103		// The sync hands the list to Koh (`handHidden`); the mirror has it before the response.104		if (edit.hidden_paths !== repo.hidden_paths) {105			applyHidden(openMirror(ctx.mirrorDir, repo.name), hiddenPaths(ctx.kepr, repo.name));106		}107		return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings&saved=1`, 303);108	});109110	// A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched.111	app.post("/admin/releases/:repo/:version", async (c) => {112		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));113		if (!found) return c.notFound();114		const { repo, release } = found;115		const fields = await textFields(c);116		const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");117		if (notes.length > MAX_CHARS) {118			const limit = MAX_CHARS.toLocaleString("en-US");119			const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;120			return c.html(reposPage(c, ctx, error, repo.name, "builds"), 400);121		}122		// Notes equal to the record's are no override, so a later record edit would show through.123		const override = notes === release.recordNotes ? null : notes;124		setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);125		return c.redirect(126			`/admin/repos?repo=${repo.name}&tab=builds&saved=1#${repo.name}-${release.version}`,127			303,128		);129	});130131	app.post("/admin/repos/:repo/rename", async (c) => {132		const repo = findRepo(ctx.kepr, c.req.param("repo"));133		if (!repo) return c.notFound();134		const to = ((await textFields(c)).name ?? "").trim();135		const problem = await renameProblem(ctx, repo.name, to);136		if (problem) {137			return c.html(reposPage(c, ctx, `${repo.name}: ${problem}`, repo.name, "settings"), 400);138		}139		renameRepo(ctx.sync, repo.name, to);140		return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings`, 303);141	});142143	app.post("/admin/repos/:repo/tarballs/delete", async (c) => {144		const repo = findRepo(ctx.kepr, c.req.param("repo"));145		if (!repo) return c.notFound();146		try {147			await dropTarballs(ctx.sync.bucket, repo.name);148		} catch (error) {149			if (!(error instanceof BucketWriteError)) throw error;150			const message = `${repo.name}: ${error.message}`;151			return c.html(reposPage(c, ctx, message, repo.name, "archives"), 503);152		}153		return c.redirect(`/admin/repos?repo=${repo.name}&tab=archives&saved=1`, 303);154	});155156	app.post("/admin/releases/:repo/:version/delete", async (c) => {157		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));158		if (!found) return c.notFound();159		const { repo, mirror, release } = found;160		const refuse = (status: 400 | 503, error: string) =>161			c.html(162				reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name, "builds"),163				status,164			);165		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");166		try {167			await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);168		} catch (error) {169			if (error instanceof BucketWriteError) return refuse(503, error.message);170			throw error;171		}172		return c.redirect(`/admin/repos?repo=${repo.name}&tab=builds&saved=1`, 303);173	});174175	app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {176		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));177		const platform = c.req.param("platform");178		if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();179		const { repo, mirror, release } = found;180		const refuse = (status: 400 | 503, error: string) =>181			c.html(182				reposPage(183					c,184					ctx,185					`${repo.name} ${release.version} ${platform}: ${error}`,186					repo.name,187					"builds",188				),189				status,190			);191		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");192		try {193			await deleteReleaseFile(194				ctx.sync.bucket,195				ctx.kepr,196				mirror,197				repo.name,198				release.version,199				platform,200			);201		} catch (error) {202			if (error instanceof LastReleaseFile) return refuse(400, error.message);203			if (error instanceof BucketWriteError) return refuse(503, error.message);204			throw error;205		}206		return c.redirect(207			`/admin/repos?repo=${repo.name}&tab=builds&saved=1#${repo.name}-${release.version}`,208			303,209		);210	});211}212213/** A build of a known repo, as the console lists it, or null. */214function findRelease(ctx: AdminContext, name: string, version: string) {215	const repo = findRepo(ctx.kepr, name);216	const mirror = repo && openMirror(ctx.mirrorDir, repo.name);217	const release =218		repo &&219		mirror &&220		operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);221	return repo && mirror && release ? { repo, mirror, release } : null;222}223224function reposPage(225	c: Context,226	ctx: AdminContext,227	error: string,228	chosen: string | undefined,229	tab: Tab,230) {231	const repos = allRepos(ctx.kepr);232	const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];233	const body = html`${notice(error)}234<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>235<form method="post" action="/admin/sync">236${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>237${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}238<div class="admin-repos">239<nav aria-label="repositories">240${repos.map(241	(repo) => html`<a class="issue-row" id="repo-${repo.name}"242href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>243<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>244<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>245${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,246)}247</nav>248${selected && repoForm(c, ctx, selected, tab)}249</div>`;250	return adminPage(c, ctx, "repos", body);251}252253/** The selected repo at a glance: its numbers and languages (§24). */254function workspace(ctx: AdminContext, repo: AdminRepo) {255	const mirror = openMirror(ctx.mirrorDir, repo.name);256	const stats = repoStats(mirror);257	const releases = operatorReleases(ctx.kepr, mirror, repo.name);258	const downloads = (release: (typeof releases)[number]) =>259		release.downloads.reduce((sum, [, count]) => sum + count, 0);260	const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);261	const vital = (label: string, value: string | number) =>262		html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;263	return html`<div class="admin-vitals">264${vital("saves", stats.saves)}265${vital("lines", stats.lines)}266${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}267${vital("open issues", open.length)}268${vital(269	"downloads",270	releases.reduce((sum, release) => sum + downloads(release), 0),271)}272</div>273<p class="issue-meta mono">detected languages:274${languageLines(mirror).map(275	([language, lines], i) =>276		html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,277)}</p>278`;279}280281function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo, tab: Tab) {282	const synced =283		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;284	const link = (t: Tab) =>285		html`<a class="issues-tab${t === tab ? " active" : ""}" href="/admin/repos?repo=${repo.name}&amp;tab=${t}"${286			t === tab ? html` aria-current="page"` : ""287		}>${t}</a>`;288	const content = {289		overview: () => overviewTab(ctx, repo),290		settings: () => settingsTab(c, ctx, repo),291		builds: () => buildsTab(c, ctx, repo),292		archives: () => archivesTab(c, repo),293	}[tab]();294	return html`<article class="issue-post" id="repo-editor">295<p class="issue-post-head mono">296<span class="issue-author">297${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>298<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>299<nav class="issues-bar mono" aria-label="${repo.name} workspace">${TABS.map(link)}</nav>300${content}301</article>`;302}303304function overviewTab(ctx: AdminContext, repo: AdminRepo) {305	const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));306	return html`${workspace(ctx, repo)}307${308	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`309}310${311	quarantined.length > 0 &&312	html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`313}`;314}315316function settingsTab(c: Context, ctx: AdminContext, repo: AdminRepo) {317	const own = repoText(ctx.kepr, repo.name);318	const input = (name: keyof RepoEdit, label: string) =>319		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>320<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;321	return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}">322${formField(c)}323<label class="issues-label">324<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>325<label class="issues-label">326<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>327pause new issues</label>328${input("category", "category")}329${input("homepage", "homepage")}330${input("languages", "shown languages")}331<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>332${textArea(repo.name, "description", "description", 3, own.description)}333${textArea(repo.name, "details", "details", 12, own.details)}334<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>335<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>336<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"337rows="4">${repo.hidden_paths}</textarea>338<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,339the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>340<button class="issues-submit" type="submit">save ${repo.name}</button>341</form>342${renameForm(c, repo.name)}`;343}344345// The rename runs in the background; the console shows it under way, or why it last failed.346function renameForm(c: Context, name: string) {347	const to = renames.get(name);348	if (to)349		return html`<p class="admin-intro">Renaming ${name} to ${to}. This page shows ${to} once it is done.</p>`;350	return html`${notice(renameErrors.get(name) ? `rename failed: ${renameErrors.get(name)}` : "")}351<form class="issues-form" method="post" action="/admin/repos/${name}/rename">352${formField(c)}353<label class="issues-label" for="${name}-rename">rename</label>354<input class="issues-input mono" id="${name}-rename" name="name" maxlength="100" required>355<p class="issue-meta mono">Moves its history to the new name in the bucket, with its settings, issues and356counts, and sends old links there. Your machines follow it on their next save or steal.</p>357<button class="issues-submit" type="submit">rename ${name}</button>358</form>`;359}360361function buildsTab(c: Context, ctx: AdminContext, repo: AdminRepo) {362	const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);363	return html`<section class="repo-section" id="builds">364${365	releases.length > 0366		? releases.map((release) => releaseForm(c, repo.name, release))367		: html`<p class="muted mono save-empty">no builds yet</p>`368}</section>`;369}370371function archivesTab(c: Context, repo: AdminRepo) {372	return html`<section class="repo-section">373<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">374${formField(c)}375<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.376Each one is listed under <a href="/admin/storage">storage</a>.</p>377<button class="issues-submit" type="submit">delete kept tarballs</button>378</form></section>`;379}380381function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {382	return html`<label class="issues-label" for="${repo}-${name}">${label}</label>383<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"384maxlength="${String(LIMITS[name])}">${value}</textarea>`;385}386387function releaseForm(c: Context, repo: string, release: OperatorRelease) {388	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);389	const id = `${repo}-${release.version}`;390	return html`<article class="issue-post" id="${id}">391<p class="issue-post-head mono">392${/* 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(393	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,394)}395</p>396<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">397${formField(c)}398<label class="issues-label" for="${id}-notes">notes399${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>400<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"401maxlength="${MAX_CHARS}">${release.notes}</textarea>402${403	// One line on purpose: Biome folds a hole-by-hole split back together, and breaking the404	// tag itself would put whitespace in the HTML that tests/console.test.ts pins.405	""406}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>407<button class="issues-submit" type="submit">save ${release.version}</button>408</form>409${410	release.downloads.length > 1 &&411	release.downloads.map(412		([platform]) => html`<form class="issues-form" method="post"413action="/admin/releases/${repo}/${release.version}/${platform}/delete">414${formField(c)}415<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>416delete the ${platform} file from the bucket</label>417<button class="issues-submit" type="submit">delete ${platform}</button>418</form>`,419	)420}421<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">422${formField(c)}423<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>424delete ${release.version} and its files from the bucket; this cannot be undone</label>425<button class="issues-submit" type="submit">delete ${release.version}</button>426</form>427</article>`;428}