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 { renameErrors, renameProblem, renameRepo, renames } from "./rename.ts";24import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";25import { LIMITS } from "./text.ts";2627const MAX_FIELD = 500;28const HTTP_URL = /^https?:\/\/[^\s]+$/i;29const MAX_LANGUAGES = 5;30const MAX_LANGUAGE = 30;31const MAX_HIDDEN = 100;32const TABS = ["overview", "settings", "builds", "archives"] as const;33type Tab = (typeof TABS)[number];343536function languageNames(value: string): string[] {37 return value38 .split(",")39 .map((name) => name.trim())40 .filter((name) => name !== "");41}424344export function registerRepos(app: Hono, ctx: AdminContext): void {45 app.get("/admin/repos", (c) => {46 const tab = TABS.find((t) => t === c.req.query("tab")) ?? "overview";47 return c.html(reposPage(c, ctx, "", c.req.query("repo"), tab));48 });4950 app.post("/admin/sync", async (c) => {51 await syncAll(ctx.sync);52 return c.redirect("/admin/repos", 303);53 });5455 app.post("/admin/repos/:repo", async (c) => {56 const repo = findRepo(ctx.kepr, c.req.param("repo"));57 if (!repo) return c.notFound();58 const fields = await textFields(c);59 const text = (name: string) => (fields[name] ?? "").trim();60 const languages = languageNames(fields.languages ?? "");61 const hidden = (fields.hidden_paths ?? "")62 .split(/\r?\n/)63 .map((path) => path.trim().replace(/\/+$/, ""))64 .filter((path) => path !== "");65 const edit: RepoEdit = {66 public: fields.public === "1" ? 1 : 0,67 category: text("category"),68 homepage: text("homepage"),69 70 languages: languages.join(", "),71 issues_paused: fields.issues_paused === "1" ? 1 : 0,72 hidden_paths: hidden.join("\n"),73 };74 const refuse = (error: string) =>75 c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name, "settings"), 400);76 const texts = (["description", "details"] as const).map((field): [RepoText, string] => [77 field,78 (fields[field] ?? "").replace(/\r\n/g, "\n").trim(),79 ]);80 const long = texts.find(([field, value]) => value.length > LIMITS[field]);81 if (long) {82 const [field] = long;83 return refuse(`Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`);84 }85 if ([edit.category, edit.homepage, fields.languages ?? ""].some((v) => v.length > MAX_FIELD)) {86 return refuse(`keep each field under ${MAX_FIELD} characters.`);87 }88 if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {89 return refuse("the homepage must be an http or https address.");90 }91 if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");92 if (languages.some((name) => name.length > MAX_LANGUAGE)) {93 return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);94 }95 if (languages.some((name) => /\p{C}/u.test(name))) {96 return refuse("a language name is printable text.");97 }98 if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);99 const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));100 if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);101 updateRepo(ctx.kepr, repo.name, edit);102 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);103 if (edit.hidden_paths !== repo.hidden_paths) await applyHidden(ctx, repo.name, hidden);104 return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings&saved=1`, 303);105 });106107 108 app.post("/admin/releases/:repo/:version", async (c) => {109 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));110 if (!found) return c.notFound();111 const { repo, release } = found;112 const fields = await textFields(c);113 const notes = (fields.notes ?? "").replace(/\r\n/g, "\n");114 if (notes.length > MAX_CHARS) {115 const limit = MAX_CHARS.toLocaleString("en-US");116 const error = `${repo.name} ${release.version}: notes are over ${limit} characters.`;117 return c.html(reposPage(c, ctx, error, repo.name, "builds"), 400);118 }119 120 const override = notes === release.recordNotes ? null : notes;121 setRelease(ctx.kepr, repo.name, release.version, fields.hidden === "1", override);122 return c.redirect(123 `/admin/repos?repo=${repo.name}&tab=builds&saved=1#${repo.name}-${release.version}`,124 303,125 );126 });127128 app.post("/admin/repos/:repo/rename", async (c) => {129 const repo = findRepo(ctx.kepr, c.req.param("repo"));130 if (!repo) return c.notFound();131 const to = ((await textFields(c)).name ?? "").trim();132 const problem = await renameProblem(ctx, repo.name, to);133 if (problem) {134 return c.html(reposPage(c, ctx, `${repo.name}: ${problem}`, repo.name, "settings"), 400);135 }136 renameRepo(ctx.sync, repo.name, to);137 return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings`, 303);138 });139140 app.post("/admin/repos/:repo/tarballs/delete", async (c) => {141 const repo = findRepo(ctx.kepr, c.req.param("repo"));142 if (!repo) return c.notFound();143 try {144 await dropTarballs(ctx.sync.bucket, repo.name);145 } catch (error) {146 if (!(error instanceof BucketWriteError)) throw error;147 const message = `${repo.name}: ${error.message}`;148 return c.html(reposPage(c, ctx, message, repo.name, "archives"), 503);149 }150 return c.redirect(`/admin/repos?repo=${repo.name}&tab=archives&saved=1`, 303);151 });152153 app.post("/admin/releases/:repo/:version/delete", async (c) => {154 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));155 if (!found) return c.notFound();156 const { repo, mirror, release } = found;157 const refuse = (status: 400 | 503, error: string) =>158 c.html(159 reposPage(c, ctx, `${repo.name} ${release.version}: ${error}`, repo.name, "builds"),160 status,161 );162 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");163 try {164 await deleteRelease(ctx.sync.bucket, ctx.kepr, mirror, repo.name, release.version);165 } catch (error) {166 if (error instanceof BucketWriteError) return refuse(503, error.message);167 throw error;168 }169 return c.redirect(`/admin/repos?repo=${repo.name}&tab=builds&saved=1`, 303);170 });171172 app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {173 const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));174 const platform = c.req.param("platform");175 if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();176 const { repo, mirror, release } = found;177 const refuse = (status: 400 | 503, error: string) =>178 c.html(179 reposPage(180 c,181 ctx,182 `${repo.name} ${release.version} ${platform}: ${error}`,183 repo.name,184 "builds",185 ),186 status,187 );188 if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");189 try {190 await deleteReleaseFile(191 ctx.sync.bucket,192 ctx.kepr,193 mirror,194 repo.name,195 release.version,196 platform,197 );198 } catch (error) {199 if (error instanceof LastReleaseFile) return refuse(400, error.message);200 if (error instanceof BucketWriteError) return refuse(503, error.message);201 throw error;202 }203 return c.redirect(204 `/admin/repos?repo=${repo.name}&tab=builds&saved=1#${repo.name}-${release.version}`,205 303,206 );207 });208}209210211212213214215async function applyHidden(ctx: AdminContext, repo: string, hidden: string[]): Promise<void> {216 const list = new TextEncoder().encode(hidden.map((path) => `${path}\n`).join(""));217 try {218 await ctx.sync.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, list, "text/plain");219 await dropTarballs(ctx.sync.bucket, repo);220 } catch (error) {221 if (!(error instanceof BucketWriteError)) throw error;222 }223 await rebuildRepo(ctx.sync, repo);224}225226227function findRelease(ctx: AdminContext, name: string, version: string) {228 const repo = findRepo(ctx.kepr, name);229 const mirror = repo && openMirror(ctx.mirrorDir, repo.name);230 const release =231 repo &&232 mirror &&233 operatorReleases(ctx.kepr, mirror, repo.name).find((r) => r.version === version);234 return repo && mirror && release ? { repo, mirror, release } : null;235}236237function reposPage(238 c: Context,239 ctx: AdminContext,240 error: string,241 chosen: string | undefined,242 tab: Tab,243) {244 const repos = allRepos(ctx.kepr);245 const selected = repos.find((repo) => repo.name === chosen) ?? repos[0];246 const body = html`${notice(error)}247<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>248<form method="post" action="/admin/sync">249${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>250${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}251<div class="admin-repos">252<nav aria-label="repositories">253${repos.map(254 (repo) => html`<a class="issue-row" id="repo-${repo.name}"255href="/admin/repos?repo=${repo.name}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>256<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>257<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>258${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,259)}260</nav>261${selected && repoForm(c, ctx, selected, tab)}262</div>`;263 return adminPage(c, ctx, "repos", body);264}265266267function workspace(ctx: AdminContext, repo: AdminRepo) {268 const mirror = openMirror(ctx.mirrorDir, repo.name);269 const stats = repoStats(mirror);270 const releases = operatorReleases(ctx.kepr, mirror, repo.name);271 const downloads = (release: (typeof releases)[number]) =>272 release.downloads.reduce((sum, [, count]) => sum + count, 0);273 const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);274 const vital = (label: string, value: string | number) =>275 html`<div><strong>${String(value)}</strong><span class="mono">${label}</span></div>`;276 return html`<div class="admin-vitals">277${vital("saves", stats.saves)}278${vital("lines", stats.lines)}279${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}280${vital("open issues", open.length)}281${vital(282 "downloads",283 releases.reduce((sum, release) => sum + downloads(release), 0),284)}285</div>286<p class="issue-meta mono">detected languages:287${languageLines(mirror).map(288 ([language, lines], i) =>289 html`${i > 0 ? " · " : " "}${language} <span class="muted">${plural(lines, "line")}</span>`,290)}</p>291`;292}293294function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo, tab: Tab) {295 const synced =296 repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;297 const link = (t: Tab) =>298 html`<a class="issues-tab${t === tab ? " active" : ""}" href="/admin/repos?repo=${repo.name}&tab=${t}"${299 t === tab ? html` aria-current="page"` : ""300 }>${t}</a>`;301 const content = {302 overview: () => overviewTab(ctx, repo),303 settings: () => settingsTab(c, ctx, repo),304 builds: () => buildsTab(c, ctx, repo),305 archives: () => archivesTab(c, repo),306 }[tab]();307 return html`<article class="issue-post" id="repo-editor">308<p class="issue-post-head mono">309<span class="issue-author">310${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>311<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>312<nav class="issues-bar mono" aria-label="${repo.name} workspace">${TABS.map(link)}</nav>313${content}314</article>`;315}316317function overviewTab(ctx: AdminContext, repo: AdminRepo) {318 const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));319 return html`${workspace(ctx, repo)}320${321 repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`322}323${324 quarantined.length > 0 &&325 html`<p class="issues-error mono">${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}</p>`326}`;327}328329function settingsTab(c: Context, ctx: AdminContext, repo: AdminRepo) {330 const own = repoText(ctx.kepr, repo.name);331 const input = (name: keyof RepoEdit, label: string) =>332 html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>333<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;334 return html`<form class="issues-form" method="post" action="/admin/repos/${repo.name}">335${formField(c)}336<label class="issues-label">337<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>338<label class="issues-label">339<input type="checkbox" name="issues_paused" value="1"${repo.issues_paused ? html` checked` : ""}>340pause new issues</label>341${input("category", "category")}342${input("homepage", "homepage")}343${input("languages", "shown languages")}344<p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>345${textArea(repo.name, "description", "description", 3, own.description)}346${textArea(repo.name, "details", "details", 12, own.details)}347<p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>348<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>349<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"350rows="4">${repo.hidden_paths}</textarea>351<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,352the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>353<button class="issues-submit" type="submit">save ${repo.name}</button>354</form>355${renameForm(c, repo.name)}`;356}357358359function renameForm(c: Context, name: string) {360 const to = renames.get(name);361 if (to)362 return html`<p class="admin-intro">Renaming ${name} to ${to}. This page shows ${to} once it is done.</p>`;363 return html`${notice(renameErrors.get(name) ? `rename failed: ${renameErrors.get(name)}` : "")}364<form class="issues-form" method="post" action="/admin/repos/${name}/rename">365${formField(c)}366<label class="issues-label" for="${name}-rename">rename</label>367<input class="issues-input mono" id="${name}-rename" name="name" maxlength="100" required>368<p class="issue-meta mono">Moves its history to the new name in the bucket, with its settings, issues and369counts, and sends old links there. Your machines follow it on their next save or steal.</p>370<button class="issues-submit" type="submit">rename ${name}</button>371</form>`;372}373374function buildsTab(c: Context, ctx: AdminContext, repo: AdminRepo) {375 const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);376 return html`<section class="repo-section" id="builds">377${378 releases.length > 0379 ? releases.map((release) => releaseForm(c, repo.name, release))380 : html`<p class="muted mono save-empty">no builds yet</p>`381}</section>`;382}383384function archivesTab(c: Context, repo: AdminRepo) {385 return html`<section class="repo-section">386<form class="issues-form" method="post" action="/admin/repos/${repo.name}/tarballs/delete">387${formField(c)}388<p class="issue-meta mono">Tarballs are kept in the bucket once downloaded, and rebuilt on the next download.389Each one is listed under <a href="/admin/storage">storage</a>.</p>390<button class="issues-submit" type="submit">delete kept tarballs</button>391</form></section>`;392}393394function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {395 return html`<label class="issues-label" for="${repo}-${name}">${label}</label>396<textarea class="issues-textarea" id="${repo}-${name}" name="${name}" rows="${String(rows)}"397maxlength="${String(LIMITS[name])}">${value}</textarea>`;398}399400function releaseForm(c: Context, repo: string, release: OperatorRelease) {401 const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);402 const id = `${repo}-${release.version}`;403 return html`<article class="issue-post" id="${id}">404<p class="issue-post-head mono">405${ ""}${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(406 ([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,407)}408</p>409<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">410${formField(c)}411<label class="issues-label" for="${id}-notes">notes412${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>413<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"414maxlength="${MAX_CHARS}">${release.notes}</textarea>415${416 417 418 ""419}<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>420<button class="issues-submit" type="submit">save ${release.version}</button>421</form>422${423 release.downloads.length > 1 &&424 release.downloads.map(425 ([platform]) => html`<form class="issues-form" method="post"426action="/admin/releases/${repo}/${release.version}/${platform}/delete">427${formField(c)}428<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>429delete the ${platform} file from the bucket</label>430<button class="issues-submit" type="submit">delete ${platform}</button>431</form>`,432 )433}434<form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">435${formField(c)}436<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>437delete ${release.version} and its files from the bucket; this cannot be undone</label>438<button class="issues-submit" type="submit">delete ${release.version}</button>439</form>440</article>`;441}