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" }>;111213const 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"]);2122const UNPRINTABLE = /[\s\p{C}]/u;232425262728export 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}484950function candidate(fields: Record<string, string>, current: S3Source | null): S3Source | string {51 const value = (key: string) => (fields[key] ?? "").trim();52 const problem = s3FieldsProblem(fields, SHOWN, REQUIRED);53 if (problem) return problem;54 const endpoint = value("endpoint");55 const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;56 if (!secretAccessKey) return "The read secret key is required.";57 const source: S3Source = {58 kind: "s3",59 endpoint,60 name: value("name"),61 region: value("region"),62 accessKeyId: value("access_key_id"),63 secretAccessKey,64 };65 if (value("write_access_key_id") === "") return source;66 const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey;67 if (!writeSecret) return "A new write key needs its secret.";68 return {69 ...source,70 write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret },71 };72}737475async function folderCandidate(folder: string): Promise<BucketSource | string> {76 const path = folder.trim();77 if (!isAbsolute(path)) return "The folder is a full path, starting with /.";78 try {79 if (!(await stat(path)).isDirectory()) throw new Error("not a directory");80 await listRepos(openBucket({ kind: "folder", path }));81 } catch {82 return `${path} is not a folder Kepr can read.`;83 }84 return { kind: "folder", path };85}868788899091export function registerBucket(app: Hono, ctx: AdminContext): void {92 app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));9394 app.post("/admin/bucket", async (c) => {95 const current = ctx.config.bucket;96 const fields = await textFields(c);97 const next =98 fields.action === "folder"99 ? await folderCandidate(fields.folder ?? "")100 : candidate(fields, current.kind === "s3" ? current : null);101 if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);102 const failure = next.kind === "s3" ? await checkS3Access(next) : null;103 if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);104 if (fields.action === "test") {105 return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));106 }107 try {108 await saveConfig(ctx.configPath, { ...ctx.config, bucket: next });109 } catch (error) {110 const reason = error instanceof Error ? error.message : String(error);111 const failed = `The settings work, but kepr.toml could not be written: ${reason}`;112 return c.html(bucketPage(c, ctx, { fields, error: failed }), 500);113 }114 115 ctx.config.bucket = next;116 Object.assign(ctx.sync.bucket, openBucket(next));117 return c.redirect("/admin/bucket?saved=1", 303);118 });119}120121function bucketPage(122 c: Context,123 ctx: AdminContext,124 shown: { fields?: Record<string, string>; error?: string; status?: string } = {},125): string {126 const source = ctx.config.bucket;127 const s3 = source.kind === "s3" ? source : null;128 const stored: Record<string, string> = {129 endpoint: s3?.endpoint ?? "",130 name: s3?.name ?? "",131 region: s3?.region ?? "",132 access_key_id: s3?.accessKeyId ?? "",133 write_access_key_id: s3?.write?.accessKeyId ?? "",134 };135 const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";136 const input = ([key, label]: (typeof SHOWN)[number]) =>137 html`<label class="issues-label" for="${key}">${label}</label>138<input class="issues-input mono" id="${key}" name="${key}" value="${value(key)}"139autocomplete="off" spellcheck="false">`;140 const secret = (key: string, label: string, hint: string) =>141 html`<label class="issues-label" for="${key}">${label}</label>142<input class="issues-input mono" id="${key}" name="${key}" type="password"143autocomplete="new-password" placeholder="${hint}">`;144 const body = html`${notice(shown.error ?? "")}145${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}146${147 source.kind === "folder"148 ? html`<p class="admin-intro">This instance reads a Koh folder remote at149<span class="mono">${source.path}</span>. Saving S3 settings switches it to that bucket.</p>`150 : html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`151}152<form class="issues-form" method="post" action="/admin/bucket">153${formField(c)}154${SHOWN.slice(0, 4).map(input)}155${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}156${input(SHOWN[4] as (typeof SHOWN)[number])}157${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}158<p class="muted">The read key is all Kepr needs to show your repos. The write key is optional: with159it Kepr keeps tarballs and uploads under .kepr/, deletes builds you delete, and signs machines in160(/connect). Clear its ID to go without. Saving tests the settings first; secrets are never shown.</p>161<div class="issues-actions">162<button class="issues-submit" type="submit" name="action" value="test">test connection</button>163<button class="issues-submit" type="submit" name="action" value="save">save</button>164</div>165</form>166<section class="repo-section"><h2 class="section-label mono">folder remote</h2>167<form class="issues-form" method="post" action="/admin/bucket">168${formField(c)}169<label class="issues-label" for="folder">folder</label>170<input class="issues-input mono" id="folder" name="folder"171value="${shown.fields?.folder ?? (source.kind === "folder" ? source.path : "")}"172autocomplete="off" spellcheck="false" placeholder="/path/to/koh/folder/remote">173<p class="muted">For running Kepr on your own machine: a Koh folder remote on this server instead174of S3.</p>175<button class="issues-submit" type="submit" name="action" value="folder">use this folder</button>176</form></section>`;177 return adminPage(c, ctx, "bucket", body);178}