import type { Context, Hono } from "hono";
import { BucketWriteError } from "../bucket/reader.ts";
import { syncAll } from "../bucket/sync.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,
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 { 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;
/** 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) => c.html(reposPage(c, ctx, "")));
app.post("/admin/sync", async (c) => {
await syncAll(ctx.sync);
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 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,
};
const refuse = (error: string) =>
c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 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.");
}
updateRepo(ctx.kepr, repo.name, edit);
for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);
return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 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), 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}&saved=1#${repo.name}-${release.version}`,
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;
return c.html(reposPage(c, ctx, `${repo.name}: ${error.message}`, repo.name), 503);
}
return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 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), 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}&saved=1#builds`, 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 = c.req.query("repo")) {
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
`}
`;
return adminPage(c, ctx, "repos", body);
}
/** The selected repo at a glance: its numbers and languages (§24). */
function workspace(ctx: AdminContext, repo: AdminRepo) {
const mirror = openMirror(ctx.mirrorDir, repo.name);
const stats = repoStats(mirror);
const releases = operatorReleases(ctx.kepr, mirror, repo.name);
const downloads = (release: (typeof releases)[number]) =>
release.downloads.reduce((sum, [, count]) => sum + count, 0);
const open = operatorIssues(ctx.kepr, "open").filter((issue) => issue.repo === repo.name);
const vital = (label: string, value: string | number) =>
html`${String(value)} ${label}
`;
return html`
${vital("saves", stats.saves)}
${vital("lines", stats.lines)}
${vital("newest version", visibleReleases(ctx.kepr, mirror, repo.name)[0]?.version ?? "none")}
${vital("open issues", open.length)}
${vital(
"downloads",
releases.reduce((sum, release) => sum + downloads(release), 0),
)}
detected languages:
${languageLines(mirror).map(
([language, lines], i) =>
html`${i > 0 ? " · " : " "}${language} ${plural(lines, "line")} `,
)}
`;
}
function repoForm(c: Context, ctx: AdminContext, repo: AdminRepo) {
const quarantined = quarantinedPaths(openMirror(ctx.mirrorDir, repo.name));
const own = repoText(ctx.kepr, repo.name);
const releases = operatorReleases(ctx.kepr, openMirror(ctx.mirrorDir, repo.name), repo.name);
const input = (name: keyof RepoEdit, label: string) =>
html`${label}
`;
const synced =
repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;
return html`
${repo.public ? html`${repo.name} ` : repo.name}
${repo.public ? "public" : "private"} ${synced}
${workspace(ctx, repo)}
${
repo.last_sync_error && html`sync error: ${repo.last_sync_error}
`
}
${
quarantined.length > 0 &&
html`${plural(quarantined.length, "path")} quarantined: ${quarantined.join(", ")}
`
}
${
releases.length > 0 &&
html`builds
${releases.map((release) => releaseForm(c, repo.name, release))} `
}
`;
}
function textArea(repo: string, name: RepoText, label: string, rows: number, value: string) {
return html`${label}
`;
}
function releaseForm(c: Context, repo: string, release: OperatorRelease) {
const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);
const id = `${repo}-${release.version}`;
return html`
${/* this row stays one line: splitting its spans changes the HTML that tests/console.test.ts pins */ ""}${html`${release.version} `}${html`${release.hidden ? "hidden" : "shown"} `}${html`${plural(total, "download")} `}${release.downloads.map(
([platform, n]) => html`${platform} ${n} `,
)}
${formField(c)}
delete ${release.version} and its files from the bucket; this cannot be undone
delete ${release.version}
`;
}