bucket.ts

7.3 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", "access key ID"],18];19const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]);20// Keys, names and URLs have neither; a newline would also end the TOML line it is written to.21const UNPRINTABLE = /[\s\p{C}]/u;2223/**24 * What is wrong with a form's S3 fields, or "": the required ones filled, none of them nor any25 * secret holding spaces or control characters, and an http or https endpoint.26 */27export function s3FieldsProblem(28	fields: Record<string, string>,29	shown: [key: string, label: string][],30	required: Set<string>,31): string {32	const value = (key: string) => (fields[key] ?? "").trim();33	for (const [key, label] of shown) {34		if (required.has(key) && value(key) === "") return `The ${label} is required.`;35		if (UNPRINTABLE.test(fields[key] ?? ""))36			return `The ${label} cannot contain spaces or control characters.`;37	}38	for (const key of Object.keys(fields).filter((name) => name.endsWith("secret_access_key"))) {39		if (UNPRINTABLE.test(value(key)))40			return "A secret cannot contain spaces or control characters.";41	}42	const endpoint = value("endpoint");43	if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol))44		return "The endpoint must be an http or https URL.";45	return "";46}4748/** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */49export function s3Candidate(50	fields: Record<string, string>,51	current: S3Source | null,52): S3Source | string {53	const value = (key: string) => (fields[key] ?? "").trim();54	const problem = s3FieldsProblem(fields, SHOWN, REQUIRED);55	if (problem) return problem;56	const endpoint = value("endpoint");57	const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;58	if (!secretAccessKey) return "The secret key is required.";59	return {60		kind: "s3",61		endpoint,62		name: value("name"),63		region: value("region"),64		accessKeyId: value("access_key_id"),65		secretAccessKey,66	};67}6869/** A folder remote a form names, which Kepr must be able to list; or what is wrong. */70export async function folderCandidate(folder: string): Promise<BucketSource | string> {71	const path = folder.trim();72	if (!isAbsolute(path)) return "The folder is a full path, starting with /.";73	try {74		if (!(await stat(path)).isDirectory()) throw new Error("not a directory");75		await listRepos(openBucket({ kind: "folder", path }));76	} catch {77		return `${path} is not a folder Kepr can read.`;78	}79	return { kind: "folder", path };80}8182/**83 * §24's Bucket section: edit and test the S3 settings, or name a folder remote, then write them to84 * kepr.toml (§19). Either kind switches to the other here (June, 2026-09-30).85 */86export function registerBucket(app: Hono, ctx: AdminContext): void {87	app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));8889	app.post("/admin/bucket", async (c) => {90		const current = ctx.config.bucket;91		const fields = await textFields(c);92		const next =93			fields.action === "folder"94				? await folderCandidate(fields.folder ?? "")95				: s3Candidate(fields, current.kind === "s3" ? current : null);96		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);97		const failure = next.kind === "s3" ? await checkS3Access(next) : null;98		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);99		if (fields.action === "test") {100			return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));101		}102		try {103			await saveConfig(ctx.configPath, { ...ctx.config, bucket: next });104		} catch (error) {105			const reason = error instanceof Error ? error.message : String(error);106			const failed = `The settings work, but kepr.toml could not be written: ${reason}`;107			return c.html(bucketPage(c, ctx, { fields, error: failed }), 500);108		}109		// In place, so the sync, downloads, the image policy and /connect all see it now.110		ctx.config.bucket = next;111		Object.assign(ctx.sync.bucket, openBucket(next));112		ctx.syncer.setBucket(next);113		return c.redirect("/admin/bucket?saved=1", 303);114	});115}116117function bucketPage(118	c: Context,119	ctx: AdminContext,120	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},121): string {122	const source = ctx.config.bucket;123	const s3 = source.kind === "s3" ? source : null;124	const stored: Record<string, string> = {125		endpoint: s3?.endpoint ?? "",126		name: s3?.name ?? "",127		region: s3?.region ?? "",128		access_key_id: s3?.accessKeyId ?? "",129	};130	const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";131	const input = ([key, label]: (typeof SHOWN)[number]) =>132		html`<label class="issues-label" for="${key}">${label}</label>133<input class="issues-input mono" id="${key}" name="${key}" value="${value(key)}"134autocomplete="off" spellcheck="false">`;135	const secret = (key: string, label: string, hint: string) =>136		html`<label class="issues-label" for="${key}">${label}</label>137<input class="issues-input mono" id="${key}" name="${key}" type="password"138autocomplete="new-password" placeholder="${hint}">`;139	const body = html`${notice(shown.error ?? "")}140${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}141${142	source.kind === "folder"143		? html`<p class="admin-intro">This instance reads a Koh folder remote at144<span class="mono">${source.path}</span>. Saving S3 settings switches it to that bucket.</p>`145		: html`<p class="admin-intro">The S3 bucket Kepr reads and writes. Secrets are never shown.</p>`146}147<form class="issues-form" method="post" action="/admin/bucket">148${formField(c)}149${SHOWN.map(input)}150${secret("secret_access_key", "secret key", s3 ? "unchanged" : "")}151<p class="muted">One key reads and writes the bucket: Kepr keeps tarballs and uploads under .kepr/,152deletes builds you delete, and hands the key to machines that sign in (/connect). Saving tests the153settings first; secrets are never shown.</p>154<div class="issues-actions">155<button class="issues-submit" type="submit" name="action" value="test">test connection</button>156<button class="issues-submit" type="submit" name="action" value="save">save</button>157</div>158</form>159<section class="repo-section"><h2 class="section-label mono">folder remote</h2>160<form class="issues-form" method="post" action="/admin/bucket">161${formField(c)}162<label class="issues-label" for="folder">folder</label>163<input class="issues-input mono" id="folder" name="folder"164value="${shown.fields?.folder ?? (source.kind === "folder" ? source.path : "")}"165autocomplete="off" spellcheck="false" placeholder="/path/to/koh/folder/remote">166<p class="muted">For running Kepr on your own machine: a Koh folder remote on this server instead167of S3.</p>168<button class="issues-submit" type="submit" name="action" value="folder">use this folder</button>169</form></section>`;170	return adminPage(c, ctx, "bucket", body);171}