repos.ts

5.6 KB · raw

1import type { Context, Hono } from "hono";2import { syncAll } from "../bucket/sync.ts";3import { openMirror, quarantinedPaths } from "../mirror/store.ts";4import { html } from "../pages/html.ts";5import { formatDate, plural } from "../pages/layout.ts";6import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";7import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";89const MAX_FIELD = 500;10const HTTP_URL = /^https?:\/\/[^\s]+$/i;11const SORT_ORDER = /^-?\d{1,6}$/;1213/** §24's Repos section: visibility (§20), card settings, sync status and "sync now". */14export function registerRepos(app: Hono, ctx: AdminContext): void {15	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));1617	app.post("/admin/sync", async (c) => {18		await syncAll(ctx.sync);19		return c.redirect("/admin/repos", 303);20	});2122	app.post("/admin/repos/:repo", async (c) => {23		const repo = findRepo(ctx.kepr, c.req.param("repo"));24		if (!repo) return c.notFound();25		const fields = await textFields(c);26		const text = (name: string) => (fields[name] ?? "").trim();27		const edit: RepoEdit = {28			public: fields.public === "1" ? 1 : 0,29			category: text("category"),30			homepage: text("homepage"),31			sort_order: Number(text("sort_order") || "0"),32			install1: text("install1"),33			install2: text("install2"),34			install3: text("install3"),35		};36		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);37		if (Object.values(fields).some((value) => value.length > MAX_FIELD)) {38			return refuse(`keep each field under ${MAX_FIELD} characters.`);39		}40		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {41			return refuse("the homepage must be an http or https address.");42		}43		if (!SORT_ORDER.test(text("sort_order") || "0")) return refuse("the order must be a number.");44		if ([edit.install1, edit.install2, edit.install3].some((line) => /[\r\n]/.test(line))) {45			return refuse("an install line is one line.");46		}47		updateRepo(ctx.kepr, repo.name, edit);48		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);49	});50}5152function reposPage(c: Context, ctx: AdminContext, error: string): string {53	const repos = allRepos(ctx.kepr);54	const query = (c.req.query("q") ?? "").slice(0, 100);55	const filtered = repos.filter((repo) =>56		`${repo.name} ${repo.category}`.toLowerCase().includes(query.toLowerCase()),57	);58	const selected = filtered.find((repo) => repo.name === c.req.query("repo")) ?? filtered[0];59	const body = html`${notice(error)}60<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>61<form class="issues-form" method="get" action="/admin/repos">62<label class="issues-label" for="repo-search">find a repository</label>63<input class="issues-input" id="repo-search" name="q" value="${query}" maxlength="100" type="search">64<button class="issues-submit" type="submit">search</button></form>65<form method="post" action="/admin/sync">66${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>67${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}68<div class="admin-repos">69<nav aria-label="repositories">70${filtered.map(71	(72		repo,73	) => html`<a class="issue-row" id="repo-${repo.name}" href="/admin/repos?repo=${repo.name}&q=${encodeURIComponent(query)}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>74<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>75<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>76${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,77)}78${repos.length > 0 && filtered.length === 0 && html`<p class="muted">No repositories match “${query}”. <a href="/admin/repos">clear search</a></p>`}79</nav>80${selected && repoForm(c, selected, quarantinedPaths(openMirror(ctx.mirrorDir, selected.name)))}81</div>`;82	return adminPage(c, ctx, "repos", body);83}8485function repoForm(c: Context, repo: AdminRepo, quarantined: string[]) {86	const input = (name: keyof RepoEdit, label: string) =>87		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>88<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;89	const synced =90		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;91	return html`<article class="issue-post" id="repo-editor">92<p class="issue-post-head mono">93<span class="issue-author">94${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>95<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>96${97	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`98}99${100	quarantined.length > 0 &&101	html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`102}103<form class="issues-form" method="post" action="/admin/repos/${repo.name}">104${formField(c)}105<label class="issues-label">106<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>107${input("category", "category")}108${input("homepage", "homepage")}109${input("sort_order", "card order")}110<p class="issue-meta mono">Lower numbers appear first. Repositories with the same number sort by name.</p>111${input("install1", "install line 1")}112${input("install2", "install line 2")}113${input("install3", "install line 3")}114<button class="issues-submit" type="submit">save ${repo.name}</button>115</form>116</article>`;117}