bucket.ts

6.4 KB · raw

1import type { Context, Hono } from "hono";2import { checkS3Access, openBucket } from "../bucket/reader.ts";3import { html } from "../pages/html.ts";4import { type BucketSource, saveConfig } from "../server/config.ts";5import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";67type S3Source = Extract<BucketSource, { kind: "s3" }>;89// The fields shown back to the operator; the two secrets are never among them.10const SHOWN: [key: string, label: string][] = [11	["endpoint", "endpoint"],12	["name", "bucket name"],13	["region", "region"],14	["access_key_id", "read access key ID"],15	["write_access_key_id", "write access key ID"],16];17const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]);18// Keys, names and URLs have neither; a newline would also end the TOML line it is written to.19const UNPRINTABLE = /[\s\p{C}]/u;2021/**22 * What is wrong with a form's S3 fields, or "": the required ones filled, none of them nor any23 * secret holding spaces or control characters, and an http or https endpoint.24 */25export function s3FieldsProblem(26	fields: Record<string, string>,27	shown: [key: string, label: string][],28	required: Set<string>,29): string {30	const value = (key: string) => (fields[key] ?? "").trim();31	for (const [key, label] of shown) {32		if (required.has(key) && value(key) === "") return `The ${label} is required.`;33		if (UNPRINTABLE.test(fields[key] ?? ""))34			return `The ${label} cannot contain spaces or control characters.`;35	}36	for (const key of Object.keys(fields).filter((name) => name.endsWith("secret_access_key"))) {37		if (UNPRINTABLE.test(value(key)))38			return "A secret cannot contain spaces or control characters.";39	}40	const endpoint = value("endpoint");41	if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol))42		return "The endpoint must be an http or https URL.";43	return "";44}4546/** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */47function candidate(fields: Record<string, string>, current: S3Source | null): S3Source | string {48	const value = (key: string) => (fields[key] ?? "").trim();49	const problem = s3FieldsProblem(fields, SHOWN, REQUIRED);50	if (problem) return problem;51	const endpoint = value("endpoint");52	const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;53	if (!secretAccessKey) return "The read secret key is required.";54	const source: S3Source = {55		kind: "s3",56		endpoint,57		name: value("name"),58		region: value("region"),59		accessKeyId: value("access_key_id"),60		secretAccessKey,61	};62	if (value("write_access_key_id") === "") return source;63	const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey;64	if (!writeSecret) return "A new write key needs its secret.";65	return {66		...source,67		write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret },68	};69}7071/**72 * §24's Bucket section: edit and test the S3 settings, then write them to kepr.toml (§19). A folder73 * instance can switch to S3 here (June, 2026-09-30); only the file switches it back.74 */75export function registerBucket(app: Hono, ctx: AdminContext): void {76	app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));7778	app.post("/admin/bucket", async (c) => {79		const current = ctx.config.bucket;80		const fields = await textFields(c);81		const next = candidate(fields, current.kind === "s3" ? current : null);82		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);83		const failure = await checkS3Access(next);84		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);85		if (fields.action !== "save") {86			return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));87		}88		try {89			await saveConfig(ctx.configPath, { ...ctx.config, bucket: next });90		} catch (error) {91			const reason = error instanceof Error ? error.message : String(error);92			const failed = `The settings work, but kepr.toml could not be written: ${reason}`;93			return c.html(bucketPage(c, ctx, { fields, error: failed }), 500);94		}95		// In place, so the sync loop, downloads, the image policy and /connect all see it now.96		ctx.config.bucket = next;97		Object.assign(ctx.sync.bucket, openBucket(next));98		return c.redirect("/admin/bucket?saved=1", 303);99	});100}101102function bucketPage(103	c: Context,104	ctx: AdminContext,105	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},106): string {107	const source = ctx.config.bucket;108	const s3 = source.kind === "s3" ? source : null;109	const stored: Record<string, string> = {110		endpoint: s3?.endpoint ?? "",111		name: s3?.name ?? "",112		region: s3?.region ?? "",113		access_key_id: s3?.accessKeyId ?? "",114		write_access_key_id: s3?.write?.accessKeyId ?? "",115	};116	const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";117	const input = ([key, label]: (typeof SHOWN)[number]) =>118		html`<label class="issues-label" for="${key}">${label}</label>119<input class="issues-input mono" id="${key}" name="${key}" value="${value(key)}"120autocomplete="off" spellcheck="false">`;121	const secret = (key: string, label: string, hint: string) =>122		html`<label class="issues-label" for="${key}">${label}</label>123<input class="issues-input mono" id="${key}" name="${key}" type="password"124autocomplete="new-password" placeholder="${hint}">`;125	const body = html`${notice(shown.error ?? "")}126${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}127${128	source.kind === "folder"129		? html`<p class="admin-intro">This instance reads a Koh folder remote at130<span class="mono">${source.path}</span>. Saving S3 settings below switches it to that bucket.</p>`131		: html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`132}133<form class="issues-form" method="post" action="/admin/bucket">134${formField(c)}135${SHOWN.slice(0, 4).map(input)}136${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}137${input(SHOWN[4] as (typeof SHOWN)[number])}138${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}139<p class="muted">The write key is optional: it signs machines in (/connect). Clear its ID to turn that140off. Saving tests the settings first and rewrites kepr.toml without its comments.</p>141<div class="issues-actions">142<button class="issues-submit" type="submit" name="action" value="test">test connection</button>143<button class="issues-submit" type="submit" name="action" value="save">save</button>144</div>145</form>`;146	return adminPage(c, ctx, "bucket", body);147}