import { stat } from "node:fs/promises"; import { isAbsolute } from "node:path"; import type { Context, Hono } from "hono"; import { checkS3Access, openBucket } from "../bucket/reader.ts"; import { listRepos } from "../bucket/repo.ts"; import { html } from "../pages/html.ts"; import { type BucketSource, saveConfig } from "../server/config.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; type S3Source = Extract; // The fields shown back to the operator; the two secrets are never among them. const SHOWN: [key: string, label: string][] = [ ["endpoint", "endpoint"], ["name", "bucket name"], ["region", "region"], ["access_key_id", "read access key ID"], ["write_access_key_id", "write access key ID"], ]; const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]); // Keys, names and URLs have neither; a newline would also end the TOML line it is written to. const UNPRINTABLE = /[\s\p{C}]/u; /** * What is wrong with a form's S3 fields, or "": the required ones filled, none of them nor any * secret holding spaces or control characters, and an http or https endpoint. */ export function s3FieldsProblem( fields: Record, shown: [key: string, label: string][], required: Set, ): string { const value = (key: string) => (fields[key] ?? "").trim(); for (const [key, label] of shown) { if (required.has(key) && value(key) === "") return `The ${label} is required.`; if (UNPRINTABLE.test(fields[key] ?? "")) return `The ${label} cannot contain spaces or control characters.`; } for (const key of Object.keys(fields).filter((name) => name.endsWith("secret_access_key"))) { if (UNPRINTABLE.test(value(key))) return "A secret cannot contain spaces or control characters."; } const endpoint = value("endpoint"); if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol)) return "The endpoint must be an http or https URL."; return ""; } /** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */ export function s3Candidate( fields: Record, current: S3Source | null, ): S3Source | string { const value = (key: string) => (fields[key] ?? "").trim(); const problem = s3FieldsProblem(fields, SHOWN, REQUIRED); if (problem) return problem; const endpoint = value("endpoint"); const secretAccessKey = value("secret_access_key") || current?.secretAccessKey; if (!secretAccessKey) return "The read secret key is required."; const source: S3Source = { kind: "s3", endpoint, name: value("name"), region: value("region"), accessKeyId: value("access_key_id"), secretAccessKey, }; if (value("write_access_key_id") === "") return source; const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey; if (!writeSecret) return "A new write key needs its secret."; return { ...source, write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret }, }; } /** A folder remote a form names, which Kepr must be able to list; or what is wrong. */ export async function folderCandidate(folder: string): Promise { const path = folder.trim(); if (!isAbsolute(path)) return "The folder is a full path, starting with /."; try { if (!(await stat(path)).isDirectory()) throw new Error("not a directory"); await listRepos(openBucket({ kind: "folder", path })); } catch { return `${path} is not a folder Kepr can read.`; } return { kind: "folder", path }; } /** * §24's Bucket section: edit and test the S3 settings, or name a folder remote, then write them to * kepr.toml (§19). Either kind switches to the other here (June, 2026-09-30). */ export function registerBucket(app: Hono, ctx: AdminContext): void { app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx))); app.post("/admin/bucket", async (c) => { const current = ctx.config.bucket; const fields = await textFields(c); const next = fields.action === "folder" ? await folderCandidate(fields.folder ?? "") : s3Candidate(fields, current.kind === "s3" ? current : null); if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400); const failure = next.kind === "s3" ? await checkS3Access(next) : null; if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400); if (fields.action === "test") { return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." })); } try { await saveConfig(ctx.configPath, { ...ctx.config, bucket: next }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); const failed = `The settings work, but kepr.toml could not be written: ${reason}`; return c.html(bucketPage(c, ctx, { fields, error: failed }), 500); } // In place, so the sync, downloads, the image policy and /connect all see it now. ctx.config.bucket = next; Object.assign(ctx.sync.bucket, openBucket(next)); ctx.syncer.setBucket(next); return c.redirect("/admin/bucket?saved=1", 303); }); } function bucketPage( c: Context, ctx: AdminContext, shown: { fields?: Record; error?: string; status?: string } = {}, ): string { const source = ctx.config.bucket; const s3 = source.kind === "s3" ? source : null; const stored: Record = { endpoint: s3?.endpoint ?? "", name: s3?.name ?? "", region: s3?.region ?? "", access_key_id: s3?.accessKeyId ?? "", write_access_key_id: s3?.write?.accessKeyId ?? "", }; const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? ""; const input = ([key, label]: (typeof SHOWN)[number]) => html` `; const secret = (key: string, label: string, hint: string) => html` `; const body = html`${notice(shown.error ?? "")} ${shown.status && html`

${shown.status}

`} ${ source.kind === "folder" ? html`

This instance reads a Koh folder remote at ${source.path}. Saving S3 settings switches it to that bucket.

` : html`

The S3 bucket Kepr reads with its read-only key. Secrets are never shown.

` }
${formField(c)} ${SHOWN.slice(0, 4).map(input)} ${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")} ${input(SHOWN[4] as (typeof SHOWN)[number])} ${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}

The read key is all Kepr needs to show your repos. The write key is optional: with it Kepr keeps tarballs and uploads under .kepr/, deletes builds you delete, and signs machines in (/connect). Clear its ID to go without. Saving tests the settings first; secrets are never shown.

${formField(c)}

For running Kepr on your own machine: a Koh folder remote on this server instead of S3.

`; return adminPage(c, ctx, "bucket", body); }