import type { Context, Hono } from "hono"; import { checkFormat, listRepos } from "../bucket/repo.ts"; import type { SyncTarget } from "../bucket/sync.ts"; import { type Html, html } from "../pages/html.ts"; import { type PageContext, page, plural } from "../pages/layout.ts"; import { backups, newestBackupTarball } from "../server/backup.ts"; import { formToken } from "../server/login.ts"; export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string }; const SECTIONS = [ ["/admin", "status"], ["/admin/repos", "repos"], ["/admin/issues", "issues"], ["/admin/releases", "releases"], ["/admin/site", "site"], ] as const; /** The hidden same-site token (§25) that every admin form posts back. */ export function formField(c: Context): Html { return html``; } /** A posted form's text fields; the guard has already parsed and checked the body. */ export async function textFields(c: Context): Promise> { const fields: Record = {}; for (const [name, value] of Object.entries(await c.req.parseBody())) { if (typeof value === "string") fields[name] = value; } return fields; } export function notice(error: string): Html | "" { return error && html``; } /** An admin page: the site's own look, with the console's sections and sign-out on top. */ export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string { const tab = ([href, name]: (typeof SECTIONS)[number]) => html`${name}`; return page(ctx, { title: `${title} · admin`, description: "", path: c.req.path, body: html`
${SECTIONS.map(tab)}${formField(c)}

${title}

${body}`, }); } // ponytail: counted once per process by walking the source, since there is no build step. let runningLines: number | null = null; async function sourceLines(): Promise { if (runningLines !== null) return runningLines; let total = 0; const root = `${import.meta.dir}/../..`; for await (const path of new Bun.Glob("{src,scripts}/**/*.ts").scan(root)) { if (path.endsWith(".test.ts")) continue; const text = await Bun.file(`${root}/${path}`).text(); total += text.split("\n").length - (text.endsWith("\n") ? 1 : 0); } runningLines = total; return total; } /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */ export function registerStatus(app: Hono, ctx: AdminContext): void { app.get("/admin", async (c) => { let bucket: Html; try { const repos = await listRepos(ctx.sync.bucket); const formats = await Promise.all( repos.map(async (repo) => { try { await checkFormat(ctx.sync.bucket, repo); return html`
  • ${repo} format ok
  • `; } catch (error) { return html`
  • ${repo} ${String(error)}
  • `; } }), ); bucket = html`

    Bucket reachable: ${plural(repos.length, "repo")}.

      ${formats}
    `; } catch (error) { bucket = html``; } const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as { version: string; }; const lines = (await sourceLines()).toLocaleString("en-US"); const newest = backups(ctx.dataDir).at(-1); const backup = newest ? html`

    Last backup: ${newest.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC")}. download backup

    ` : html`

    No backup yet.

    `; return c.html( adminPage( c, ctx, "status", html`
    ${bucket} ${backup}

    Running kepr ${version}: ${lines} lines of TypeScript.

    `, ), ); }); app.get("/admin/backup", (c) => { const backup = newestBackupTarball(ctx.dataDir); if (!backup) return c.notFound(); return c.body(backup.bytes, 200, { "Content-Type": "application/gzip", "Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`, }); }); }