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" }>;8910const 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"]);1819const UNPRINTABLE = /[\s\p{C}]/u;202122function candidate(fields: Record<string, string>, current: S3Source | null): S3Source | string {23 const value = (key: string) => (fields[key] ?? "").trim();24 for (const [key, label] of SHOWN) {25 if (REQUIRED.has(key) && value(key) === "") return `The ${label} is required.`;26 if (UNPRINTABLE.test(fields[key] ?? ""))27 return `The ${label} cannot contain spaces or control characters.`;28 }29 for (const key of ["secret_access_key", "write_secret_access_key"]) {30 if (UNPRINTABLE.test(value(key)))31 return "A secret cannot contain spaces or control characters.";32 }33 const endpoint = value("endpoint");34 if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol))35 return "The endpoint must be an http or https URL.";36 const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;37 if (!secretAccessKey) return "The read secret key is required.";38 const source: S3Source = {39 kind: "s3",40 endpoint,41 name: value("name"),42 region: value("region"),43 accessKeyId: value("access_key_id"),44 secretAccessKey,45 };46 if (value("write_access_key_id") === "") return source;47 const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey;48 if (!writeSecret) return "A new write key needs its secret.";49 return {50 ...source,51 write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret },52 };53}545556575859export function registerBucket(app: Hono, ctx: AdminContext): void {60 app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));6162 app.post("/admin/bucket", async (c) => {63 const current = ctx.config.bucket;64 const fields = await textFields(c);65 const next = candidate(fields, current.kind === "s3" ? current : null);66 if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);67 const failure = await checkS3Access(next);68 if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);69 if (fields.action !== "save") {70 return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));71 }72 try {73 await saveConfig(ctx.configPath, { ...ctx.config, bucket: next });74 } catch (error) {75 const reason = error instanceof Error ? error.message : String(error);76 const failed = `The settings work, but kepr.toml could not be written: ${reason}`;77 return c.html(bucketPage(c, ctx, { fields, error: failed }), 500);78 }79 80 ctx.config.bucket = next;81 Object.assign(ctx.sync.bucket, openBucket(next));82 return c.redirect("/admin/bucket?saved=1", 303);83 });84}8586function bucketPage(87 c: Context,88 ctx: AdminContext,89 shown: { fields?: Record<string, string>; error?: string; status?: string } = {},90): string {91 const source = ctx.config.bucket;92 const s3 = source.kind === "s3" ? source : null;93 const stored: Record<string, string> = {94 endpoint: s3?.endpoint ?? "",95 name: s3?.name ?? "",96 region: s3?.region ?? "",97 access_key_id: s3?.accessKeyId ?? "",98 write_access_key_id: s3?.write?.accessKeyId ?? "",99 };100 const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";101 const input = ([key, label]: (typeof SHOWN)[number]) =>102 html`<label class="issues-label" for="${key}">${label}</label>103<input class="issues-input mono" id="${key}" name="${key}" value="${value(key)}"104autocomplete="off" spellcheck="false">`;105 const secret = (key: string, label: string, hint: string) =>106 html`<label class="issues-label" for="${key}">${label}</label>107<input class="issues-input mono" id="${key}" name="${key}" type="password"108autocomplete="new-password" placeholder="${hint}">`;109 const body = html`${notice(shown.error ?? "")}110${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}111${112 source.kind === "folder"113 ? html`<p class="admin-intro">This instance reads a Koh folder remote at114<span class="mono">${source.path}</span>. Saving S3 settings below switches it to that bucket.</p>`115 : html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`116}117<form class="issues-form" method="post" action="/admin/bucket">118${formField(c)}119${SHOWN.slice(0, 4).map(input)}120${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}121${input(SHOWN[4] as (typeof SHOWN)[number])}122${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}123<p class="muted">The write key is optional: it signs machines in (/connect). Clear its ID to turn that124off. Saving tests the settings first and rewrites kepr.toml without its comments.</p>125<div class="issues-actions">126<button class="issues-submit" type="submit" name="action" value="test">test connection</button>127<button class="issues-submit" type="submit" name="action" value="save">save</button>128</div>129</form>`;130 return adminPage(c, ctx, "bucket", body);131}