import type { Context, Hono } from "hono"; import { checkS3Access, openBucket } from "../bucket/reader.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; /** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */ function candidate(fields: Record, current: S3Source): S3Source | 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 ["secret_access_key", "write_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."; const source: S3Source = { kind: "s3", endpoint, name: value("name"), region: value("region"), accessKeyId: value("access_key_id"), secretAccessKey: value("secret_access_key") || current.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 }, }; } /** §24's Bucket section: edit and test the S3 settings, then write them to kepr.toml (§19). */ 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; if (current.kind !== "s3") return c.html(bucketPage(c, ctx), 400); const fields = await textFields(c); const next = candidate(fields, current); if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400); const failure = await checkS3Access(next); if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400); if (fields.action !== "save") { 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 loop, downloads, the image policy and /connect all see it now. ctx.config.bucket = next; Object.assign(ctx.sync.bucket, openBucket(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; if (source.kind === "folder") { const body = html`

This instance reads a Koh folder remote at ${source.path}.

A folder remote is set in kepr.toml only.

`; return adminPage(c, ctx, "bucket", body); } const stored: Record = { endpoint: source.endpoint, name: source.name, region: source.region, access_key_id: source.accessKeyId, write_access_key_id: source.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}

`}

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", "unchanged")} ${input(SHOWN[4] as (typeof SHOWN)[number])} ${secret("write_secret_access_key", "write secret key", source.write ? "unchanged" : "")}

The write key is optional: it signs machines in (/connect). Clear its ID to turn that off. Saving tests the settings first and rewrites kepr.toml without its comments.

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