import { timingSafeEqual } from "node:crypto"; import { join } from "node:path"; import { type Context, Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { checkS3Access } from "../bucket/reader.ts"; import { isSafePath } from "../bucket/repo.ts"; import { html } from "../pages/html.ts"; import { page } from "../pages/layout.ts"; import { PUBLIC_DIR, securityHeaders } from "../server/app.ts"; import { type Config, isDomain, saveConfig } from "../server/config.ts"; import { typedCode } from "../server/connect.ts"; import { openKeprDb } from "../server/db.ts"; import { MIN_PASSWORD } from "./account.ts"; import { folderCandidate, s3Candidate } from "./bucket.ts"; const MAX_WRONG_CODES = 10; type SetupOptions = { /** The one-time code printed in the terminal, as `oneTimeCode` makes it. */ code: string; configPath: string; dataDir: string; /** Called once with the saved config, so the caller can start the real app. */ done: (config: Config) => void; }; /** * First-run setup (June, 2026-09-30): with no kepr.toml, Kepr serves only this. Whoever holds the * code from the terminal sets the admin password, the domain and the bucket; Kepr checks them, writes * kepr.toml itself, and hands over. Ten wrong codes lock it until a restart makes a new code. */ export function createSetupApp(options: SetupOptions): Hono { const kepr = openKeprDb(options.dataDir); const app = new Hono(); let wrong = 0; let finished = false; // As for /admin: with no-referrer, browsers post the form with `Origin: null`. app.use("*", async (c, next) => { await next(); c.header("Referrer-Policy", "same-origin"); }); app.use(securityHeaders()); app.use(bodyLimit({ maxSize: 64 * 1024 })); app.get("/static/:path{.+}", async (c) => { const path = c.req.param("path"); const file = Bun.file(join(PUBLIC_DIR, path)); if (!isSafePath(path) || path.startsWith("assets/") || !(await file.exists())) { return c.notFound(); } return c.body(await file.bytes(), 200, { "Content-Type": file.type }); }); app.get("/setup", (c) => c.html(setupPage(c, kepr, ""))); app.post("/setup", async (c) => { if (finished) return c.notFound(); const refuse = (status: 400 | 403 | 429, error: string) => c.html(setupPage(c, kepr, error, fields), status); const fields: Record = {}; // No session yet to tie a form token to: the code is the credential, and the origin must // be this host, so another site can't post a guess on the operator's behalf. const host = c.req.header("host") ?? ""; const origin = c.req.header("origin") ?? ""; if (origin !== `http://${host}` && origin !== `https://${host}`) { return refuse(403, "Refused: wrong origin."); } for (const [name, value] of Object.entries(await c.req.parseBody())) { if (typeof value === "string") fields[name] = value; } if (wrong >= MAX_WRONG_CODES) return refuse(429, "Too many wrong codes. Restart Kepr for a new code."); const code = typedCode(fields.code ?? "") ?? ""; const right = Buffer.from(options.code); if (code.length !== right.length || !timingSafeEqual(Buffer.from(code), right)) { wrong++; return refuse(403, "That setup code is not right."); } const password = fields.password ?? ""; if (password.length < MIN_PASSWORD) { return refuse(400, `The password needs at least ${MIN_PASSWORD} characters.`); } if (password !== fields.confirm) return refuse(400, "The two passwords differ."); const domain = (fields.domain ?? "").trim().toLowerCase(); if (!isDomain(domain)) { return refuse( 400, "The domain is a bare hostname, such as code.example.com, with an optional :port.", ); } const bucket = fields.source === "s3" ? s3Candidate(fields, null) : await folderCandidate(fields.folder ?? ""); if (typeof bucket === "string") return refuse(400, bucket); const unreachable = bucket.kind === "s3" ? await checkS3Access(bucket) : null; if (unreachable) return refuse(400, unreachable); const config: Config = { domain, dataDir: options.dataDir, adminPasswordHash: await Bun.password.hash(password, "argon2id"), bucket, }; await saveConfig(options.configPath, config); finished = true; kepr.close(); options.done(config); return c.redirect("/sign-in", 303); }); app.all("*", (c) => (finished ? c.notFound() : c.redirect("/setup", 302))); return app; } function setupPage( c: Context, kepr: ReturnType, error: string, fields: Record = {}, ): string { const host = c.req.header("host") ?? "localhost"; const value = (name: string, fallback = "") => fields[name] ?? fallback; const input = (name: string, label: string, fallback = "", type = "text") => html` `; const s3 = fields.source === "s3"; const body = html`

Set up Kepr

${error && html``}

Kepr printed a setup code in the terminal it was started from. Enter it here with the rest; Kepr checks everything, saves it, and takes you to sign in. Every setting can be changed later in the console.

${input("code", "setup code")} ${input("domain", "domain", host)} ${input("password", `admin password (at least ${MIN_PASSWORD} characters)`, "", "password")} ${input("confirm", "admin password again", "", "password")}
where your repos are ${input("endpoint", "endpoint", "https://s3.fr-par.scw.cloud")} ${input("name", "bucket name")} ${input("region", "region")} ${input("access_key_id", "read access key ID")} ${input("secret_access_key", "read secret key", "", "password")}

A write key, a backup bucket and email can be added in the console afterwards.

${input("folder", "folder", "", "text")}
`; return page( { domain: host, kepr, mirrorDir: "" }, { title: "setup", description: "", path: "/setup", body }, ); }