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", "access key ID"],18];19const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]);2021const UNPRINTABLE = /[\s\p{C}]/u;222324252627export 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}474849export 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}686970export 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}818283848586export 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 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}