${repo.public ? "public" : "private"}${synced}
${content}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 { 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; const TABS = ["overview", "settings", "builds", "archives"] as const; type Tab = (typeof TABS)[number]; /** 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) => { const tab = TABS.find((t) => t === c.req.query("tab")) ?? "overview"; return c.html(reposPage(c, ctx, "", c.req.query("repo"), tab)); }); 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, "settings"), 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}&tab=settings&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, "builds"), 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}&tab=builds&saved=1#${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, "settings"), 400); } renameRepo(ctx, repo.name, to); return c.redirect(`/admin/repos?repo=${repo.name}&tab=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, "archives"), 503); } return c.redirect(`/admin/repos?repo=${repo.name}&tab=archives&saved=1`, 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, "builds"), 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}&tab=builds&saved=1`, 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, "builds", ), 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}&tab=builds&saved=1#${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, tab: Tab, ) { const repos = allRepos(ctx.kepr); const selected = repos.find((repo) => repo.name === chosen) ?? repos[0]; const body = html`${notice(error)}
Choose a repository to manage its visibility and public page.
${repos.length === 0 && html`no repos found in the bucket yet
`}${repo.public ? "public" : "private"}${synced}
${content}sync error: ${repo.last_sync_error}
` } ${ quarantined.length > 0 && html`${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}
` }`; } function settingsTab(c: Context, ctx: AdminContext, repo: AdminRepo) { const own = repoText(ctx.kepr, repo.name); const input = (name: keyof RepoEdit, label: string) => html` `; return html` ${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)}` : "")} `; } function buildsTab(c: Context, ctx: AdminContext, repo: AdminRepo) { const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name); return html`no builds yet
` }${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html``}${html`${release.hidden ? "hidden" : "shown"}`}${html`${plural(total, "download")}`}${release.downloads.map( ([platform, n]) => html`${platform} ${n}`, )}
${ release.downloads.length > 1 && release.downloads.map( ([platform]) => html``, ) }