1import type { Context, Hono } from "hono";2import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";3import { isSafePath } from "../bucket/repo.ts";4import { rebuildRepo, 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 { 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 { 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 { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";24import { LIMITS } from "./text.ts";2526const MAX_FIELD = 500;27const HTTP_URL = /^https?:\/\/[^\s]+$/i;28const MAX_LANGUAGES = 5;29const MAX_LANGUAGE = 30;30const MAX_HIDDEN = 100;313233function languageNames(value: string): string[] {34 return value35 .split(",")36 .map((name) => name.trim())37 .filter((name) => name !== "");38}394041export function registerRepos(app: Hono, ctx: AdminContext): void {42 app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));4344 app.post("/admin/sync", async (c) => {45 await syncAll(ctx.sync);46 return c.redirect("/admin/repos", 303);47 });4849 app.post("/admin/repos/:repo", async (c) => {50 const repo = findRepo(ctx.kepr, c.req.param("repo"));51 if (!repo) return c.notFound();52 const fields = await textFields(c);53 const text = (name: string) => (fields[name] ?? "").trim();54 const languages = languageNames(fields.languages ?? "");55 const hidden = (fields.hidden_paths ?? "")56 .split(/\r?\n/)57 .map((path) => path.trim().replace(/\/+$/, ""))58 .filter((path) => path !== "");59 const edit: RepoEdit = {60 public: fields.public === "1" ? 1 : 0,61 category: text("category"),62 homepage: text("homepage"),63 64 languages: languages.join(", "),65 issues_paused: fields.issues_paused === "1" ? 1 : 0,66 hidden_paths: hidden.join("\n"),67 };68 const refuse = (error: string) =>69 c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);70 const texts = (["description", "details"] as const).map((field): [RepoText, string] => [71 field,72 (fields[field] ?? "").replace(/\r\n/g, "\n").trim(),73 ]);74 const long = texts.find(([field, value]) => value.length > LIMITS[field]);75 if (long) {76 const [field] = long;77 return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);78 }79 if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {80 return refuse(`keep each field under ${MAX_FIELD} characters.`);81 }82 if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {83 return refuse("the homepage must be an http or https address.");84 }85 if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");86 if (languages.some((name) => name.length > MAX_LANGUAGE)) {87 return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);88 }89 if (languages.some((name) => /\p{C}/u.test(name))) {90 return refuse("a language name is printable text.");91 }92 if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);93 const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));94 if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);95 updateRepo(ctx.kepr, repo.name, edit);96 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);97 if (edit.hidden_paths !== repo.hidden_paths) await applyHidden(ctx, repo.name, hidden);98 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);99 });100101 102 app.post("/admin/releases/:repo/:version", async (c) => {103 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));104 if (!found) return c.notFound();105 const { repo, release } = found;106 const fields = await textFields(c);107 const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");108 if (notes.length > MAX_CHARS) {109 const limit = MAX_CHARS.toLocaleString("en-US");110 const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;111 return c.html(reposPage(c, ctx, error, repo.name), 400);112 }113 114 const override = notes === release.recordNotes ? null : notes;115 setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);116 return c.redirect(117 `/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,118 303,119 );120 });121122 app.post("/admin/repos/:repo/tarballs/delete", async (c) => {123 const repo = findRepo(ctx.kepr, c.req.param("repo"));124 if (!repo) return c.notFound();125 try {126 await dropTarballs(ctx.sync.bucket, repo.name);127 } catch (error) {128 if (!(error instanceof BucketWriteError)) throw error;129 return c.html(reposPage(c, ctx, `${repo.name}: ${error.message}`, repo.name), 503);130 }131 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);132 });133134 app.post("/admin/releases/:repo/:version/delete", async (c) => {135 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));136 if (!found) return c.notFound();137 const { repo, mirror, release } = found;138 const refuse = (status: 400 | 503, error: string) =>139 c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);140 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");141 try {142 await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);143 } catch (error) {144 if (error instanceof BucketWriteError) return refuse(503, error.message);145 throw error;146 }147 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);148 });149150 app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {151 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));152 const platform = c.req.param("platform");153 if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();154 const { repo, mirror, release } = found;155 const refuse = (status: 400 | 503, error: string) =>156 c.html(157 reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name),158 status,159 );160 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");161 try {162 await deleteReleaseFile(163 ctx.sync.bucket,164 ctx.kepr,165 mirror,166 repo.name,167 release.version,168 platform,169 );170 } catch (error) {171 if (error instanceof LastReleaseFile) return refuse(400, error.message);172 if (error instanceof BucketWriteError) return refuse(503, error.message);173 throw error;174 }175 return c.redirect(176 `/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,177 303,178 );179 });180}181182183184185186187async function applyHidden(ctx: AdminContext, repo: string, hidden: string[]): Promise<void> {188 const list = new TextEncoder().encode(hidden.map((path) => `${path}\n`).join(""));189 try {190 await ctx.sync.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, list, "text/plain");191 await dropTarballs(ctx.sync.bucket, repo);192 } catch (error) {193 if (!(error instanceof BucketWriteError)) throw error;194 }195 await rebuildRepo(ctx.sync, repo);196}197198199function findRelease(ctx: AdminContext, name: string, version: string) {200 const repo = findRepo(ctx.kepr, name);201 const mirror = repo && openMirror(ctx.mirrorDir, repo.name);202 const release =203 repo &&204 mirror &&205 operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);206 return repo && mirror && release ? { repo, mirror, release } : null;207}208209function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {210 const repos = allRepos(ctx.kepr);211 const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];212 const body = html`${notice(error)}213<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>214<form method="post" action="/admin/sync">215${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>216${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}217<div class="admin-repos">218<nav aria-label="repositories">219${repos.map(220 (repo) => html`<a class="issue-row" id="repo-${repo.name}"221href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>222<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>223<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>224${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,225)}226</nav>227${selected && repoForm(c, ctx, selected)}228</div>`;229 return adminPage(c, ctx, "repos", body);230}231232233function workspace(ctx: AdminContext, repo: AdminRepo) {234 const mirror = openMirror(ctx.mirrorDir, repo.name);235 const stats = repoStats(mirror);236 const releases = operatorReleases(ctx.kepr, mirror, repo.name);237 const downloads = (release: (typeof releases)[number]) =>238 release.downloads.reduce((sum, [, count]) => sum + count, 0);239 const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);240 const vital = (label: string, value: string | number) =>241 html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;242 return html`<div class="admin-vitals">243${vital("saves", stats.saves)}244${vital("lines", stats.lines)}245${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}246${vital("open issues", open.length)}247${vital(248 "downloads",249 releases.reduce((sum, release) => sum + downloads(release), 0),250)}251</div>252<p class="issue-meta mono">detected languages:253${languageLines(mirror).map(254 ([language, lines], i) =>255 html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,256)}</p>257`;258}259260function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {261 const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));262 const own = repoText(ctx.kepr, repo.name);263 const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);264 const input = (name: keyof RepoEdit, label: string) =>265 html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>266<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;267 const synced =268 repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;269 return html`<article class="issue-post" id="repo-editor">270<p class="issue-post-head mono">271<span class="issue-author">272${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>273<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>274${workspace(ctx, repo)}275${276 repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`277}278${279 quarantined.length > 0 &&280 html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`281}282<form class="issues-form" method="post" action="/admin/repos/${repo.name}">283${formField(c)}284<label class="issues-label">285<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>286<label class="issues-label">287<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>288pause new issues</label>289${input("category", "category")}290${input("homepage", "homepage")}291${input("languages", "shown languages")}292<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>293${textArea(repo.name, "description", "description", 3, own.description)}294${textArea(repo.name, "details", "details", 12, own.details)}295<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>296<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>297<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"298rows="4">${repo.hidden_paths}</textarea>299<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,300the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>301<button class="issues-submit" type="submit">save ${repo.name}</button>302</form>303${304 releases.length > 0 &&305 html`<section class="repo-section" id="builds"><h2 class="section-label mono">builds</h2>306${releases.map((release) => releaseForm(c, repo.name, release))}</section>`307}308<section class="repo-section"><h2 class="section-label mono">archives</h2>309<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">310${formField(c)}311<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.</p>312<button class="issues-submit" type="submit">delete kept tarballs</button>313</form></section>314</article>`;315}316317function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {318 return html`<label class="issues-label" for="${repo}-${name}">${label}</label>319<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"320maxlength="${String(LIMITS[name])}">${value}</textarea>`;321}322323function releaseForm(c: Context, repo: string, release: OperatorRelease) {324 const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);325 const id = `${repo}-${release.version}`;326 return html`<article class="issue-post" id="${id}">327<p class="issue-post-head mono">328${ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(329 ([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,330)}331</p>332<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">333${formField(c)}334<label class="issues-label" for="${id}-notes">notes335${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>336<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"337maxlength="${MAX_CHARS}">${release.notes}</textarea>338${339 340 341 ""342}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>343<button class="issues-submit" type="submit">save ${release.version}</button>344</form>345${346 release.downloads.length > 1 &&347 release.downloads.map(348 ([platform]) => html`<form class="issues-form" method="post"349action="/admin/releases/${repo}/${release.version}/${platform}/delete">350${formField(c)}351<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>352delete the ${platform} file from the bucket</label>353<button class="issues-submit" type="submit">delete ${platform}</button>354</form>`,355 )356}357<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">358${formField(c)}359<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>360delete ${release.version} and its files from the bucket; this cannot be undone</label>361<button class="issues-submit" type="submit">delete ${release.version}</button>362</form>363</article>`;364}