bucket.ts

8.0 KB · raw

1import { stat } from "node:fs/promises";2import { isAbsolute } from "node:path";3import type { Context, Hono } from "hono";4import { checkS3Access, openBucket } from "../bucket/reader.ts";5import { listRepos } from "../bucket/repo.ts";6import { html } from "../pages/html.ts";7import { type BucketSource, saveConfig } from "../server/config.ts";8import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";910type S3Source = Extract<BucketSource, { kind: "s3" }>;1112// The fields shown back to the operator; the two secrets are never among them.13const SHOWN: [key: string, label: string][] = [14	["endpoint", "endpoint"],15	["name", "bucket name"],16	["region", "region"],17	["access_key_id", "read access key ID"],18	["write_access_key_id", "write access key ID"],19];20const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]);21// Keys, names and URLs have neither; a newline would also end the TOML line it is written to.22const UNPRINTABLE = /[\s\p{C}]/u;2324/**25 * What is wrong with a form's S3 fields, or "": the required ones filled, none of them nor any26 * secret holding spaces or control characters, and an http or https endpoint.27 */28export function s3FieldsProblem(29	fields: Record<string, string>,30	shown: [key: string, label: string][],31	required: Set<string>,32): string {33	const value = (key: string) => (fields[key] ?? "").trim();34	for (const [key, label] of shown) {35		if (required.has(key) && value(key) === "") return `The ${label} is required.`;36		if (UNPRINTABLE.test(fields[key] ?? ""))37			return `The ${label} cannot contain spaces or control characters.`;38	}39	for (const key of Object.keys(fields).filter((name) => name.endsWith("secret_access_key"))) {40		if (UNPRINTABLE.test(value(key)))41			return "A secret cannot contain spaces or control characters.";42	}43	const endpoint = value("endpoint");44	if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol))45		return "The endpoint must be an http or https URL.";46	return "";47}4849/** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */50export function s3Candidate(51	fields: Record<string, string>,52	current: S3Source | null,53): S3Source | string {54	const value = (key: string) => (fields[key] ?? "").trim();55	const problem = s3FieldsProblem(fields, SHOWN, REQUIRED);56	if (problem) return problem;57	const endpoint = value("endpoint");58	const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;59	if (!secretAccessKey) return "The read secret key is required.";60	const source: S3Source = {61		kind: "s3",62		endpoint,63		name: value("name"),64		region: value("region"),65		accessKeyId: value("access_key_id"),66		secretAccessKey,67	};68	if (value("write_access_key_id") === "") return source;69	const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey;70	if (!writeSecret) return "A new write key needs its secret.";71	return {72		...source,73		write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret },74	};75}7677/** A folder remote a form names, which Kepr must be able to list; or what is wrong. */78export async function folderCandidate(folder: string): Promise<BucketSource | string> {79	const path = folder.trim();80	if (!isAbsolute(path)) return "The folder is a full path, starting with /.";81	try {82		if (!(await stat(path)).isDirectory()) throw new Error("not a directory");83		await listRepos(openBucket({ kind: "folder", path }));84	} catch {85		return `${path} is not a folder Kepr can read.`;86	}87	return { kind: "folder", path };88}8990/**91 * §24's Bucket section: edit and test the S3 settings, or name a folder remote, then write them to92 * kepr.toml (§19). Either kind switches to the other here (June, 2026-09-30).93 */94export function registerBucket(app: Hono, ctx: AdminContext): void {95	app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));9697	app.post("/admin/bucket", async (c) => {98		const current = ctx.config.bucket;99		const fields = await textFields(c);100		const next =101			fields.action === "folder"102				? await folderCandidate(fields.folder ?? "")103				: s3Candidate(fields, current.kind === "s3" ? current : null);104		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);105		const failure = next.kind === "s3" ? await checkS3Access(next) : null;106		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);107		if (fields.action === "test") {108			return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));109		}110		try {111			await saveConfig(ctx.configPath, { ...ctx.config, bucket: next });112		} catch (error) {113			const reason = error instanceof Error ? error.message : String(error);114			const failed = `The settings work, but kepr.toml could not be written: ${reason}`;115			return c.html(bucketPage(c, ctx, { fields, error: failed }), 500);116		}117		// In place, so the sync, downloads, the image policy and /connect all see it now.118		ctx.config.bucket = next;119		Object.assign(ctx.sync.bucket, openBucket(next));120		ctx.syncer.setBucket(next);121		return c.redirect("/admin/bucket?saved=1", 303);122	});123}124125function bucketPage(126	c: Context,127	ctx: AdminContext,128	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},129): string {130	const source = ctx.config.bucket;131	const s3 = source.kind === "s3" ? source : null;132	const stored: Record<string, string> = {133		endpoint: s3?.endpoint ?? "",134		name: s3?.name ?? "",135		region: s3?.region ?? "",136		access_key_id: s3?.accessKeyId ?? "",137		write_access_key_id: s3?.write?.accessKeyId ?? "",138	};139	const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";140	const input = ([key, label]: (typeof SHOWN)[number]) =>141		html`<label class="issues-label" for="${key}">${label}</label>142<input class="issues-input mono" id="${key}" name="${key}" value="${value(key)}"143autocomplete="off" spellcheck="false">`;144	const secret = (key: string, label: string, hint: string) =>145		html`<label class="issues-label" for="${key}">${label}</label>146<input class="issues-input mono" id="${key}" name="${key}" type="password"147autocomplete="new-password" placeholder="${hint}">`;148	const body = html`${notice(shown.error ?? "")}149${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}150${151	source.kind === "folder"152		? html`<p class="admin-intro">This instance reads a Koh folder remote at153<span class="mono">${source.path}</span>. Saving S3 settings switches it to that bucket.</p>`154		: html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`155}156<form class="issues-form" method="post" action="/admin/bucket">157${formField(c)}158${SHOWN.slice(0, 4).map(input)}159${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}160${input(SHOWN[4] as (typeof SHOWN)[number])}161${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}162<p class="muted">The read key is all Kepr needs to show your repos. The write key is optional: with163it Kepr keeps tarballs and uploads under .kepr/, deletes builds you delete, and signs machines in164(/connect). Clear its ID to go without. Saving tests the settings first; secrets are never shown.</p>165<div class="issues-actions">166<button class="issues-submit" type="submit" name="action" value="test">test connection</button>167<button class="issues-submit" type="submit" name="action" value="save">save</button>168</div>169</form>170<section class="repo-section"><h2 class="section-label mono">folder remote</h2>171<form class="issues-form" method="post" action="/admin/bucket">172${formField(c)}173<label class="issues-label" for="folder">folder</label>174<input class="issues-input mono" id="folder" name="folder"175value="${shown.fields?.folder ?? (source.kind === "folder" ? source.path : "")}"176autocomplete="off" spellcheck="false" placeholder="/path/to/koh/folder/remote">177<p class="muted">For running Kepr on your own machine: a Koh folder remote on this server instead178of S3.</p>179<button class="issues-submit" type="submit" name="action" value="folder">use this folder</button>180</form></section>`;181	return adminPage(c, ctx, "bucket", body);182}