import type { Context, Hono } from "hono"; import { BucketWriteError } from "../bucket/reader.ts"; import { isSafePath } from "../bucket/repo.ts"; import { MAX_CHARS } from "../issues/spam.ts"; import { operatorIssues } from "../issues/store.ts"; import { languageLines, repoStats } from "../mirror/queries.ts"; import { openMirror, quarantinedPaths } from "../mirror/store.ts"; import { type Html, html } from "../pages/html.ts"; import { formatDate, plural } from "../pages/layout.ts"; import { deleteRelease, deleteReleaseFile, LastReleaseFile, type OperatorRelease, operatorReleases, setRelease, visibleReleases, } from "../releases/releases.ts"; import { type RepoText, repoText, setRepoText } from "../server/db.ts"; import { dropTarballs } from "../tarball/cache.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; import { renameErrors, renameProblem, renameRepo, renames } from "./rename.ts"; import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts"; import { LIMITS } from "./text.ts"; const MAX_FIELD = 500; const HTTP_URL = /^https?:\/\/[^\s]+$/i; const MAX_LANGUAGES = 5; const MAX_LANGUAGE = 30; const MAX_HIDDEN = 100; /** Shown languages as typed, comma-separated; blanks between commas are dropped. */ function languageNames(value: string): string[] { return value .split(",") .map((name) => name.trim()) .filter((name) => name !== ""); } /** §24's Repos section: visibility (§20), card settings, texts, builds, sync status and "sync now". */ export function registerRepos(app: Hono, ctx: AdminContext): void { app.get("/admin/repos", (c) => { return c.html(reposPage(c, ctx, "", c.req.query("repo"))); }); app.post("/admin/sync", async (c) => { await ctx.syncer.syncAll(); return c.redirect("/admin/repos", 303); }); app.post("/admin/repos/:repo", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const fields = await textFields(c); const text = (name: string) => (fields[name] ?? "").trim(); const languages = languageNames(fields.languages ?? ""); const hidden = (fields.hidden_paths ?? "") .split(/\r?\n/) .map((path) => path.trim().replace(/\/+$/, "")) .filter((path) => path !== ""); const edit: RepoEdit = { public: fields.public === "1" ? 1 : 0, category: text("category"), homepage: text("homepage"), // Joined with the separator `publicRepo` splits on, so a name never contains it. languages: languages.join(", "), issues_paused: fields.issues_paused === "1" ? 1 : 0, hidden_paths: hidden.join("\n"), }; const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400); const texts = (["description", "details"] as const).map((field): [RepoText, string] => [ field, (fields[field] ?? "").replace(/\r\n/g, "\n").trim(), ]); const long = texts.find(([field, value]) => value.length > LIMITS[field]); if (long) { const [field] = long; return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`); } if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) { return refuse(`keep each field under ${MAX_FIELD} characters.`); } if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) { return refuse("the homepage must be an http or https address."); } if (languages.length > MAX_LANGUAGES) return refuse("up to five languages."); if (languages.some((name) => name.length > MAX_LANGUAGE)) { return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`); } if (languages.some((name) => /\p{C}/u.test(name))) { return refuse("a language name is printable text."); } if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`); const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path)); if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`); updateRepo(ctx.kepr, repo.name, edit); for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value); // The sync hands the list to Koh (`handHidden`); the mirror has it before the response. if (edit.hidden_paths !== repo.hidden_paths) await ctx.syncer.applyHidden([repo.name]); return c.redirect(`/admin/repos?repo=${repo.name}&saved=1`, 303); }); // A build's displayed notes and hiding live in kepr.db; the record in the bucket is untouched. app.post("/admin/releases/:repo/:version", async (c) => { const found = findRelease(ctx, c.req.param("repo"), c.req.param("version")); if (!found) return c.notFound(); const { repo, release } = found; const fields = await textFields(c); const notes = (fields.notes ?? "").replace(/\r\n/g, "\n"); if (notes.length > MAX_CHARS) { const limit = MAX_CHARS.toLocaleString("en-US"); const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`; return c.html(reposPage(c, ctx, error, repo.name), 400); } // Notes equal to the record's are no override, so a later record edit would show through. const override = notes === release.recordNotes ? null : notes; setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override); return c.redirect( `/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`, 303, ); }); app.post("/admin/repos/:repo/rename", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const to = ((await textFields(c)).name ?? "").trim(); const problem = await renameProblem(ctx, repo.name, to); if (problem) { return c.html(reposPage(c, ctx, `${repo.name}: ${problem}`, repo.name), 400); } renameRepo(ctx, repo.name, to); return c.redirect(`/admin/repos?repo=${repo.name}#settings`, 303); }); app.post("/admin/repos/:repo/tarballs/delete", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); try { await dropTarballs(ctx.sync.bucket, repo.name); } catch (error) { if (!(error instanceof BucketWriteError)) throw error; const message = `${repo.name}: ${error.message}`; return c.html(reposPage(c, ctx, message, repo.name), 503); } return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#archives`, 303); }); app.post("/admin/releases/:repo/:version/delete", async (c) => { const found = findRelease(ctx, c.req.param("repo"), c.req.param("version")); if (!found) return c.notFound(); const { repo, mirror, release } = found; const refuse = (status: 400 | 503, error: string) => c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status); if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it."); try { await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version); } catch (error) { if (error instanceof BucketWriteError) return refuse(503, error.message); throw error; } return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303); }); app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => { const found = findRelease(ctx, c.req.param("repo"), c.req.param("version")); const platform = c.req.param("platform"); if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound(); const { repo, mirror, release } = found; const refuse = (status: 400 | 503, error: string) => c.html( reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name), status, ); if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it."); try { await deleteReleaseFile( ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version, platform, ); } catch (error) { if (error instanceof LastReleaseFile) return refuse(400, error.message); if (error instanceof BucketWriteError) return refuse(503, error.message); throw error; } return c.redirect( `/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`, 303, ); }); } /** A build of a known repo, as the console lists it, or null. */ function findRelease(ctx: AdminContext, name: string, version: string) { const repo = findRepo(ctx.kepr, name); const mirror = repo && openMirror(ctx.mirrorDir, repo.name); const release = repo && mirror && operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version); return repo && mirror && release ? { repo, mirror, release } : null; } function reposPage(c: Context, ctx: AdminContext, error: string, chosen: string | undefined) { const repos = allRepos(ctx.kepr); const selected = repos.find((repo) => repo.name === chosen) ?? repos[0]; // Each repo keeps its whole workspace behind one link. const repoLink = (repo: AdminRepo) => { const current = repo.name === selected?.name; return html`${repo.name}${repo.public ? "" : html` private`}${ repo.last_sync_error ? html` sync failed` : "" }`; }; const body = html`${notice(error)}
${repos.length === 0 && html`

no repos found in the bucket yet

`} ${selected && repoWorkspace(c, ctx, selected)}
`; return adminPage(c, ctx, "repos", body); } /** The selected repo at a glance: its numbers and languages (§24). */ function workspace(ctx: AdminContext, repo: AdminRepo) { const mirror = openMirror(ctx.mirrorDir, repo.name); const stats = repoStats(mirror); const releases = operatorReleases(ctx.kepr, mirror, repo.name); const downloads = (release: (typeof releases)[number]) => release.downloads.reduce((sum, [, count]) => sum + count, 0); const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name); const vital = (label: string, value: string | number) => html`
${String(value)}${label}
`; return html`
${vital("saves", stats.saves)} ${vital("lines", stats.lines)} ${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")} ${vital("open issues", open.length)} ${vital( "downloads", releases.reduce((sum, release) => sum + downloads(release), 0), )}

detected languages: ${languageLines(mirror).map( ([language, lines], i) => html`${i > 0 ? " · " : " "}${language} ${plural(lines, "line")}`, )}

`; } function repoWorkspace(c: Context, ctx: AdminContext, repo: AdminRepo) { const synced = repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`; const section = (id: string, body: Html | Html[]) => html`

${id}

${body}
`; return html`

${repo.public ? html`${repo.name}` : repo.name} ${repo.public ? "public" : "private"}${synced}

${section("overview", overviewSection(ctx, repo))} ${section("settings", settingsSection(c, ctx, repo))} ${section("builds", buildsSection(c, ctx, repo))} ${section("archives", archivesSection(c, repo))}
`; } function overviewSection(ctx: AdminContext, repo: AdminRepo) { const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name)); return html`${workspace(ctx, repo)} ${ repo.last_sync_error && html`

sync error: ${repo.last_sync_error}

` } ${ quarantined.length > 0 && html`

${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}

` }`; } function settingsSection(c: Context, ctx: AdminContext, repo: AdminRepo) { const own = repoText(ctx.kepr, repo.name); const input = (name: keyof RepoEdit, label: string) => html` `; return html`
${formField(c)} ${input("category", "category")} ${input("homepage", "homepage")} ${input("languages", "shown languages")}

Up to five, separated by commas. Leave blank to show the detected one.

${textArea(repo.name, "description", "description", 3, own.description)} ${textArea(repo.name, "details", "details", 12, own.details)}

Markdown. Leave either blank to use the README's.

One path per line, such as notes.md or docs/private. Hidden from every page, the stats and tarballs, and passed to Koh for .kohignore. For every repo, use global hide.

${renameForm(c, repo.name)}`; } // The rename runs in the background; the console shows it under way, or why it last failed. function renameForm(c: Context, name: string) { const to = renames.get(name); if (to) return html`

Renaming ${name} to ${to}. This page shows ${to} once it is done.

`; return html`${notice(renameErrors.get(name) ? `rename failed: ${renameErrors.get(name)}` : "")}
${formField(c)}

Moves its history to the new name in the bucket, with its settings, issues and counts, and sends old links there. Your machines follow it on their next save or steal.

`; } function buildsSection(c: Context, ctx: AdminContext, repo: AdminRepo) { const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name); return releases.length > 0 ? releases.map((release) => releaseForm(c, repo.name, release)) : html`

no builds yet

`; } function archivesSection(c: Context, repo: AdminRepo) { return html`
${formField(c)}

Tarballs are kept in the bucket once downloaded, and rebuilt on the next download. Each one is listed under storage.

`; } function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) { return html` `; } function releaseForm(c: Context, repo: string, release: OperatorRelease) { const total = release.downloads.reduce((sum, [, n]) => sum + n, 0); // Its own prefix: a version may be named like a settings field, which uses `-`. const id = `release-${repo}-${release.version}`; return html`

${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`${release.version}`}${html`${release.hidden ? "hidden" : "shown"}`}${html`${plural(total, "download")}`}${release.downloads.map( ([platform, n]) => html`${platform} ${n}`, )}

${formField(c)} ${ // One line on purpose: Biome folds a hole-by-hole split back together, and breaking the // tag itself would put whitespace in the HTML that tests/console.test.ts pins. "" }
${ release.downloads.length > 1 && release.downloads.map( ([platform]) => html`
${formField(c)}
`, ) }
${formField(c)}
`; }