1import type { Context, Hono } from "hono";2import { BucketWriteError } from "../bucket/reader.ts";3import { syncAll } from "../bucket/sync.ts";4import { MAX_CHARS } from "../issues/spam.ts";5import { operatorIssues } from "../issues/store.ts";6import { languageLines, repoStats } from "../mirror/queries.ts";7import { openMirror, quarantinedPaths } from "../mirror/store.ts";8import { html } from "../pages/html.ts";9import { formatDate, plural } from "../pages/layout.ts";10import {11 deleteRelease,12 type OperatorRelease,13 operatorReleases,14 setRelease,15 visibleReleases,16} from "../releases/releases.ts";17import { type RepoText, repoText, setRepoText } from "../server/db.ts";18import { dropTarballs } from "../tarball/cache.ts";19import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";20import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";21import { LIMITS } from "./text.ts";2223const MAX_FIELD = 500;24const HTTP_URL = /^https?:\/\/[^\s]+$/i;25const MAX_LANGUAGES = 5;26const MAX_LANGUAGE = 30;272829function languageNames(value: string): string[] {30 return value31 .split(",")32 .map((name) => name.trim())33 .filter((name) => name !== "");34}353637export function registerRepos(app: Hono, ctx: AdminContext): void {38 app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));3940 app.post("/admin/sync", async (c) => {41 await syncAll(ctx.sync);42 return c.redirect("/admin/repos", 303);43 });4445 app.post("/admin/repos/:repo", async (c) => {46 const repo = findRepo(ctx.kepr, c.req.param("repo"));47 if (!repo) return c.notFound();48 const fields = await textFields(c);49 const text = (name: string) => (fields[name] ?? "").trim();50 const languages = languageNames(fields.languages ?? "");51 const edit: RepoEdit = {52 public: fields.public === "1" ? 1 : 0,53 category: text("category"),54 homepage: text("homepage"),55 56 languages: languages.join(", "),57 issues_paused: fields.issues_paused === "1" ? 1 : 0,58 };59 const refuse = (error: string) =>60 c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);61 const texts = (["description", "details"] as const).map((field): [RepoText, string] => [62 field,63 (fields[field] ?? "").replace(/\r\n/g, "\n").trim(),64 ]);65 const long = texts.find(([field, value]) => value.length > LIMITS[field]);66 if (long) {67 const [field] = long;68 return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);69 }70 if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {71 return refuse(`keep each field under ${MAX_FIELD} characters.`);72 }73 if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {74 return refuse("the homepage must be an http or https address.");75 }76 if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");77 if (languages.some((name) => name.length > MAX_LANGUAGE)) {78 return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);79 }80 if (languages.some((name) => /\p{C}/u.test(name))) {81 return refuse("a language name is printable text.");82 }83 updateRepo(ctx.kepr, repo.name, edit);84 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);85 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);86 });8788 89 app.post("/admin/releases/:repo/:version", async (c) => {90 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));91 if (!found) return c.notFound();92 const { repo, release } = found;93 const fields = await textFields(c);94 const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");95 if (notes.length > MAX_CHARS) {96 const limit = MAX_CHARS.toLocaleString("en-US");97 const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;98 return c.html(reposPage(c, ctx, error, repo.name), 400);99 }100 101 const override = notes === release.recordNotes ? null : notes;102 setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);103 return c.redirect(104 `/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,105 303,106 );107 });108109 app.post("/admin/repos/:repo/tarballs/delete", async (c) => {110 const repo = findRepo(ctx.kepr, c.req.param("repo"));111 if (!repo) return c.notFound();112 try {113 await dropTarballs(ctx.sync.bucket, repo.name);114 } catch (error) {115 if (!(error instanceof BucketWriteError)) throw error;116 return c.html(reposPage(c, ctx, `${repo.name}: ${error.message}`, repo.name), 503);117 }118 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);119 });120121 app.post("/admin/releases/:repo/:version/delete", async (c) => {122 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));123 if (!found) return c.notFound();124 const { repo, mirror, release } = found;125 const refuse = (status: 400 | 503, error: string) =>126 c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);127 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");128 try {129 await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);130 } catch (error) {131 if (error instanceof BucketWriteError) return refuse(503, error.message);132 throw error;133 }134 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);135 });136}137138139function findRelease(ctx: AdminContext, name: string, version: string) {140 const repo = findRepo(ctx.kepr, name);141 const mirror = repo && openMirror(ctx.mirrorDir, repo.name);142 const release =143 repo &&144 mirror &&145 operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);146 return repo && mirror && release ? { repo, mirror, release } : null;147}148149function reposPage(c: Context, ctx: AdminContext, error: string, chosen = c.req.query("repo")) {150 const repos = allRepos(ctx.kepr);151 const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];152 const body = html`${notice(error)}153<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>154<form method="post" action="/admin/sync">155${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>156${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}157<div class="admin-repos">158<nav aria-label="repositories">159${repos.map(160 (repo) => html`<a class="issue-row" id="repo-${repo.name}"161href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>162<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>163<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>164${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,165)}166</nav>167${selected && repoForm(c, ctx, selected)}168</div>`;169 return adminPage(c, ctx, "repos", body);170}171172173function workspace(ctx: AdminContext, repo: AdminRepo) {174 const mirror = openMirror(ctx.mirrorDir, repo.name);175 const stats = repoStats(mirror);176 const releases = operatorReleases(ctx.kepr, mirror, repo.name);177 const downloads = (release: (typeof releases)[number]) =>178 release.downloads.reduce((sum, [, count]) => sum + count, 0);179 const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);180 const vital = (label: string, value: string | number) =>181 html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;182 return html`<div class="admin-vitals">183${vital("saves", stats.saves)}184${vital("lines", stats.lines)}185${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}186${vital("open issues", open.length)}187${vital(188 "downloads",189 releases.reduce((sum, release) => sum + downloads(release), 0),190)}191</div>192<p class="issue-meta mono">detected languages:193${languageLines(mirror).map(194 ([language, lines], i) =>195 html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,196)}</p>197`;198}199200function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {201 const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));202 const own = repoText(ctx.kepr, repo.name);203 const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);204 const input = (name: keyof RepoEdit, label: string) =>205 html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>206<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;207 const synced =208 repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;209 return html`<article class="issue-post" id="repo-editor">210<p class="issue-post-head mono">211<span class="issue-author">212${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>213<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>214${workspace(ctx, repo)}215${216 repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`217}218${219 quarantined.length > 0 &&220 html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`221}222<form class="issues-form" method="post" action="/admin/repos/${repo.name}">223${formField(c)}224<label class="issues-label">225<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>226<label class="issues-label">227<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>228pause new issues</label>229${input("category", "category")}230${input("homepage", "homepage")}231${input("languages", "shown languages")}232<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>233${textArea(repo.name, "description", "description", 3, own.description)}234${textArea(repo.name, "details", "details", 12, own.details)}235<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>236<button class="issues-submit" type="submit">save ${repo.name}</button>237</form>238${239 releases.length > 0 &&240 html`<section class="repo-section" id="builds"><h2 class="section-label mono">builds</h2>241${releases.map((release) => releaseForm(c, repo.name, release))}</section>`242}243<section class="repo-section"><h2 class="section-label mono">archives</h2>244<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">245${formField(c)}246<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.</p>247<button class="issues-submit" type="submit">delete kept tarballs</button>248</form></section>249</article>`;250}251252function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {253 return html`<label class="issues-label" for="${repo}-${name}">${label}</label>254<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"255maxlength="${String(LIMITS[name])}">${value}</textarea>`;256}257258function releaseForm(c: Context, repo: string, release: OperatorRelease) {259 const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);260 const id = `${repo}-${release.version}`;261 return html`<article class="issue-post" id="${id}">262<p class="issue-post-head mono">263${ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(264 ([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,265)}266</p>267<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">268${formField(c)}269<label class="issues-label" for="${id}-notes">notes270${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>271<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"272maxlength="${MAX_CHARS}">${release.notes}</textarea>273${274 275 276 ""277}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>278<button class="issues-submit" type="submit">save ${release.version}</button>279</form>280<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">281${formField(c)}282<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>283delete ${release.version} and its files from the bucket; this cannot be undone</label>284<button class="issues-submit" type="submit">delete ${release.version}</button>285</form>286</article>`;287}