1import type { Context, Hono } from "hono";2import { BucketWriteError } from "../bucket/reader.ts";3import { isSafePath } from "../bucket/repo.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 { type Html, html } from "../pages/html.ts";9import { formatDate, plural } from "../pages/layout.ts";10import {11 deleteRelease,12 deleteReleaseFile,13 LastReleaseFile,14 type OperatorRelease,15 operatorReleases,16 setRelease,17 visibleReleases,18} from "../releases/releases.ts";19import { type RepoText, repoText, setRepoText } from "../server/db.ts";20import { dropTarballs } from "../tarball/cache.ts";21import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";22import { renameErrors, renameProblem, renameRepo, renames } from "./rename.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) => {43 return c.html(reposPage(c, ctx, "", c.req.query("repo")));44 });4546 app.post("/admin/sync", async (c) => {47 await ctx.syncer.syncAll();48 return c.redirect("/admin/repos", 303);49 });5051 app.post("/admin/repos/:repo", async (c) => {52 const repo = findRepo(ctx.kepr, c.req.param("repo"));53 if (!repo) return c.notFound();54 const fields = await textFields(c);55 const text = (name: string) => (fields[name] ?? "").trim();56 const languages = languageNames(fields.languages ?? "");57 const hidden = (fields.hidden_paths ?? "")58 .split(/\r?\n/)59 .map((path) => path.trim().replace(/\/+$/, ""))60 .filter((path) => path !== "");61 const edit: RepoEdit = {62 public: fields.public === "1" ? 1 : 0,63 category: text("category"),64 homepage: text("homepage"),65 66 languages: languages.join(", "),67 issues_paused: fields.issues_paused === "1" ? 1 : 0,68 hidden_paths: hidden.join("\n"),69 };70 const refuse = (error: string) =>71 c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);72 const texts = (["description", "details"] as const).map((field): [RepoText, string] => [73 field,74 (fields[field] ?? "").replace(/\r\n/g, "\n").trim(),75 ]);76 const long = texts.find(([field, value]) => value.length > LIMITS[field]);77 if (long) {78 const [field] = long;79 return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);80 }81 if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {82 return refuse(`keep each field under ${MAX_FIELD} characters.`);83 }84 if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {85 return refuse("the homepage must be an http or https address.");86 }87 if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");88 if (languages.some((name) => name.length > MAX_LANGUAGE)) {89 return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);90 }91 if (languages.some((name) => /\p{C}/u.test(name))) {92 return refuse("a language name is printable text.");93 }94 if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);95 const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));96 if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);97 updateRepo(ctx.kepr, repo.name, edit);98 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);99 100 if (edit.hidden_paths !== repo.hidden_paths) await ctx.syncer.applyHidden([repo.name]);101 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1`, 303);102 });103104 105 app.post("/admin/releases/:repo/:version", async (c) => {106 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));107 if (!found) return c.notFound();108 const { repo, release } = found;109 const fields = await textFields(c);110 const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");111 if (notes.length > MAX_CHARS) {112 const limit = MAX_CHARS.toLocaleString("en-US");113 const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;114 return c.html(reposPage(c, ctx, error, repo.name), 400);115 }116 117 const override = notes === release.recordNotes ? null : notes;118 setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);119 return c.redirect(120 `/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`,121 303,122 );123 });124125 app.post("/admin/repos/:repo/rename", async (c) => {126 const repo = findRepo(ctx.kepr, c.req.param("repo"));127 if (!repo) return c.notFound();128 const to = ((await textFields(c)).name ?? "").trim();129 const problem = await renameProblem(ctx, repo.name, to);130 if (problem) {131 return c.html(reposPage(c, ctx, `${repo.name}: ${problem}`, repo.name), 400);132 }133 renameRepo(ctx, repo.name, to);134 return c.redirect(`/admin/repos?repo=${repo.name}#settings`, 303);135 });136137 app.post("/admin/repos/:repo/tarballs/delete", async (c) => {138 const repo = findRepo(ctx.kepr, c.req.param("repo"));139 if (!repo) return c.notFound();140 try {141 await dropTarballs(ctx.sync.bucket, repo.name);142 } catch (error) {143 if (!(error instanceof BucketWriteError)) throw error;144 const message = `${repo.name}: ${error.message}`;145 return c.html(reposPage(c, ctx, message, repo.name), 503);146 }147 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#archives`, 303);148 });149150 app.post("/admin/releases/:repo/:version/delete", async (c) => {151 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));152 if (!found) return c.notFound();153 const { repo, mirror, release } = found;154 const refuse = (status: 400 | 503, error: string) =>155 c.html(reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name), status);156 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");157 try {158 await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);159 } catch (error) {160 if (error instanceof BucketWriteError) return refuse(503, error.message);161 throw error;162 }163 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);164 });165166 app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {167 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));168 const platform = c.req.param("platform");169 if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();170 const { repo, mirror, release } = found;171 const refuse = (status: 400 | 503, error: string) =>172 c.html(173 reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name),174 status,175 );176 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");177 try {178 await deleteReleaseFile(179 ctx.sync.bucket,180 ctx.kepr,181 mirror,182 repo.name,183 release.version,184 platform,185 );186 } catch (error) {187 if (error instanceof LastReleaseFile) return refuse(400, error.message);188 if (error instanceof BucketWriteError) return refuse(503, error.message);189 throw error;190 }191 return c.redirect(192 `/admin/repos?repo=${repo.name}&saved=1#release-${repo.name}-${release.version}`,193 303,194 );195 });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: string | undefined) {210 const repos = allRepos(ctx.kepr);211 const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];212 213 const repoLink = (repo: AdminRepo) => {214 const current = repo.name === selected?.name;215 return html`<a class="admin-repo-link${current ? " active" : ""}" href="/admin/repos?repo=${repo.name}"${216 current ? html` aria-current="page"` : ""217 }>${repo.name}${repo.public ? "" : html` <span class="muted">private</span>`}${218 repo.last_sync_error ? html` <span class="issues-error">sync failed</span>` : ""219 }</a>`;220 };221 const body = html`${notice(error)}222<div class="admin-repos-layout">223<aside class="admin-repo-picker mono">224<nav aria-label="repositories">${repos.map(repoLink)}</nav>225<a href="/admin/site#global-hide">global hide ↗</a>226<form method="post" action="/admin/sync">227${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>228</aside>229<div class="admin-repo-workspace">230${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}231${selected && repoWorkspace(c, ctx, selected)}232</div></div>`;233 return adminPage(c, ctx, "repos", body);234}235236237function workspace(ctx: AdminContext, repo: AdminRepo) {238 const mirror = openMirror(ctx.mirrorDir, repo.name);239 const stats = repoStats(mirror);240 const releases = operatorReleases(ctx.kepr, mirror, repo.name);241 const downloads = (release: (typeof releases)[number]) =>242 release.downloads.reduce((sum, [, count]) => sum + count, 0);243 const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);244 const vital = (label: string, value: string | number) =>245 html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;246 return html`<div class="admin-vitals">247${vital("saves", stats.saves)}248${vital("lines", stats.lines)}249${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}250${vital("open issues", open.length)}251${vital(252 "downloads",253 releases.reduce((sum, release) => sum + downloads(release), 0),254)}255</div>256<p class="issue-meta mono">detected languages:257${languageLines(mirror).map(258 ([language, lines], i) =>259 html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,260)}</p>261`;262}263264function repoWorkspace(c: Context, ctx: AdminContext, repo: AdminRepo) {265 const synced =266 repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;267 const section = (id: string, body: Html | Html[]) =>268 html`<section class="repo-section" id="${id}"><h2 class="section-label mono">${id}</h2>269${body}</section>`;270 return html`<article class="issue-post" id="repo-editor">271<p class="issue-post-head mono">272<span class="issue-author">273${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>274<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>275${section("overview", overviewSection(ctx, repo))}276${section("settings", settingsSection(c, ctx, repo))}277${section("builds", buildsSection(c, ctx, repo))}278${section("archives", archivesSection(c, repo))}279</article>`;280}281282function overviewSection(ctx: AdminContext, repo: AdminRepo) {283 const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));284 return html`${workspace(ctx, repo)}285${286 repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`287}288${289 quarantined.length > 0 &&290 html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`291}`;292}293294function settingsSection(c: Context, ctx: AdminContext, repo: AdminRepo) {295 const own = repoText(ctx.kepr, repo.name);296 const input = (name: keyof RepoEdit, label: string) =>297 html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>298<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;299 return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}">300${formField(c)}301<label class="issues-label">302<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>303<label class="issues-label">304<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>305pause new issues</label>306${input("category", "category")}307${input("homepage", "homepage")}308${input("languages", "shown languages")}309<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>310${textArea(repo.name, "description", "description", 3, own.description)}311${textArea(repo.name, "details", "details", 12, own.details)}312<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>313<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>314<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"315rows="4">${repo.hidden_paths}</textarea>316<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,317the stats and tarballs, and passed to Koh for .kohignore when there is a write key.318For every repo, use <a href="/admin/site#global-hide">global hide</a>.</p>319<button class="issues-submit" type="submit">save ${repo.name}</button>320</form>321${renameForm(c, repo.name)}`;322}323324325function renameForm(c: Context, name: string) {326 const to = renames.get(name);327 if (to)328 return html`<p class="admin-intro">Renaming ${name} to ${to}. This page shows ${to} once it is done.</p>`;329 return html`${notice(renameErrors.get(name) ? `rename failed: ${renameErrors.get(name)}` : "")}330<form class="issues-form" method="post" action="/admin/repos/${name}/rename">331${formField(c)}332<label class="issues-label" for="${name}-rename">rename</label>333<input class="issues-input mono" id="${name}-rename" name="name" maxlength="100" required>334<p class="issue-meta mono">Moves its history to the new name in the bucket, with its settings, issues and335counts, and sends old links there. Your machines follow it on their next save or steal.</p>336<button class="issues-submit" type="submit">rename ${name}</button>337</form>`;338}339340function buildsSection(c: Context, ctx: AdminContext, repo: AdminRepo) {341 const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);342 return releases.length > 0343 ? releases.map((release) => releaseForm(c, repo.name, release))344 : html`<p class="muted mono save-empty">no builds yet</p>`;345}346347function archivesSection(c: Context, repo: AdminRepo) {348 return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">349${formField(c)}350<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.351Each one is listed under <a href="/admin/storage">storage</a>.</p>352<button class="issues-submit" type="submit">delete kept tarballs</button>353</form>`;354}355356function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {357 return html`<label class="issues-label" for="${repo}-${name}">${label}</label>358<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"359maxlength="${String(LIMITS[name])}">${value}</textarea>`;360}361362function releaseForm(c: Context, repo: string, release: OperatorRelease) {363 const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);364 365 const id = `release-${repo}-${release.version}`;366 return html`<article class="issue-post" id="${id}">367<p class="issue-post-head mono">368${ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(369 ([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,370)}371</p>372<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">373${formField(c)}374<label class="issues-label" for="notes-${repo}-${release.version}">notes375${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>376<textarea class="issues-textarea" id="notes-${repo}-${release.version}" name="notes" rows="3"377maxlength="${MAX_CHARS}">${release.notes}</textarea>378${379 380 381 ""382}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>383<button class="issues-submit" type="submit">save ${release.version}</button>384</form>385${386 release.downloads.length > 1 &&387 release.downloads.map(388 ([platform]) => html`<form class="issues-form" method="post"389action="/admin/releases/${repo}/${release.version}/${platform}/delete">390${formField(c)}391<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>392delete the ${platform} file from the bucket</label>393<button class="issues-submit" type="submit">delete ${platform}</button>394</form>`,395 )396}397<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">398${formField(c)}399<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>400delete ${release.version} and its files from the bucket; this cannot be undone</label>401<button class="issues-submit" type="submit">delete ${release.version}</button>402</form>403</article>`;404}