1import { timingSafeEqual } from "node:crypto";2import { join } from "node:path";3import { type Context, Hono } from "hono";4import { bodyLimit } from "hono/body-limit";5import { checkS3Access } from "../bucket/reader.ts";6import { isSafePath } from "../bucket/repo.ts";7import { html } from "../pages/html.ts";8import { page } from "../pages/layout.ts";9import { PUBLIC_DIR, securityHeaders } from "../server/app.ts";10import { type Config, isDomain, saveConfig } from "../server/config.ts";11import { typedCode } from "../server/connect.ts";12import { openKeprDb } from "../server/db.ts";13import { MIN_PASSWORD } from "./account.ts";14import { folderCandidate, s3Candidate } from "./bucket.ts";1516const MAX_WRONG_CODES = 10;1718type SetupOptions = {19 20 code: string;21 configPath: string;22 dataDir: string;23 24 done: (config: Config) => void;25};26272829303132export function createSetupApp(options: SetupOptions): Hono {33 const kepr = openKeprDb(options.dataDir);34 const app = new Hono();35 let wrong = 0;36 let finished = false;37 app.use(securityHeaders());38 app.use(bodyLimit({ maxSize: 64 * 1024 }));39 app.get("/static/:path{.+}", async (c) => {40 const path = c.req.param("path");41 const file = Bun.file(join(PUBLIC_DIR, path));42 if (!isSafePath(path) || path.startsWith("assets/") || !(await file.exists())) {43 return c.notFound();44 }45 return c.body(await file.bytes(), 200, { "Content-Type": file.type });46 });47 app.get("/setup", (c) => c.html(setupPage(c, kepr, "")));48 app.post("/setup", async (c) => {49 if (finished) return c.notFound();50 const refuse = (status: 400 | 403 | 429, error: string) =>51 c.html(setupPage(c, kepr, error, fields), status);52 const fields: Record<string, string> = {};53 54 55 const host = c.req.header("host") ?? "";56 const origin = c.req.header("origin") ?? "";57 if (origin !== `http://${host}` && origin !== `https://${host}`) {58 return refuse(403, "Refused: wrong origin.");59 }60 for (const [name, value] of Object.entries(await c.req.parseBody())) {61 if (typeof value === "string") fields[name] = value;62 }63 if (wrong >= MAX_WRONG_CODES)64 return refuse(429, "Too many wrong codes. Restart Kepr for a new code.");65 const code = typedCode(fields.code ?? "") ?? "";66 const right = Buffer.from(options.code);67 if (code.length !== right.length || !timingSafeEqual(Buffer.from(code), right)) {68 wrong++;69 return refuse(403, "That setup code is not right.");70 }71 const password = fields.password ?? "";72 if (password.length < MIN_PASSWORD) {73 return refuse(400, `The password needs at least ${MIN_PASSWORD} characters.`);74 }75 if (password !== fields.confirm) return refuse(400, "The two passwords differ.");76 const domain = (fields.domain ?? "").trim().toLowerCase();77 if (!isDomain(domain)) {78 return refuse(79 400,80 "The domain is a bare hostname, such as code.example.com, with an optional :port.",81 );82 }83 const bucket =84 fields.source === "s3"85 ? s3Candidate(fields, null)86 : await folderCandidate(fields.folder ?? "");87 if (typeof bucket === "string") return refuse(400, bucket);88 const unreachable = bucket.kind === "s3" ? await checkS3Access(bucket) : null;89 if (unreachable) return refuse(400, unreachable);90 const config: Config = {91 domain,92 dataDir: options.dataDir,93 adminPasswordHash: await Bun.password.hash(password, "argon2id"),94 bucket,95 };96 await saveConfig(options.configPath, config);97 finished = true;98 kepr.close();99 options.done(config);100 return c.redirect("/sign-in", 303);101 });102 app.all("*", (c) => (finished ? c.notFound() : c.redirect("/setup", 302)));103 return app;104}105106function setupPage(107 c: Context,108 kepr: ReturnType<typeof openKeprDb>,109 error: string,110 fields: Record<string, string> = {},111): string {112 const host = c.req.header("host") ?? "localhost";113 const value = (name: string, fallback = "") => fields[name] ?? fallback;114 const input = (name: string, label: string, fallback = "", type = "text") =>115 html`<label class="issues-label" for="setup-${name}">${label}</label>116<input class="issues-input mono" id="setup-${name}" name="${name}" type="${type}"117value="${type === "password" ? "" : value(name, fallback)}" autocomplete="off" spellcheck="false">`;118 const s3 = fields.source === "s3";119 const body = html`<div class="admin-content">120<header class="admin-heading"><span class="section-label mono">first run</span>121<h1>Set up Kepr</h1></header>122${error && html`<p class="issues-error mono" role="alert">${error}</p>`}123<p class="admin-intro">Kepr printed a setup code in the terminal it was started from. Enter it here124with the rest; Kepr checks everything, saves it, and takes you to sign in. Every setting can be125changed later in the console.</p>126<form class="issues-form" method="post" action="/setup">127${input("code", "setup code")}128${input("domain", "domain", host)}129${input("password", `admin password (at least ${MIN_PASSWORD} characters)`, "", "password")}130${input("confirm", "admin password again", "", "password")}131<fieldset class="issues-form"><legend class="issues-label">where your repos are</legend>132<label class="issues-label"><input type="radio" name="source" value="s3"${s3 ? html` checked` : ""}>133an S3 bucket Koh offers to</label>134${input("endpoint", "endpoint", "https://s3.fr-par.scw.cloud")}135${input("name", "bucket name")}136${input("region", "region")}137${input("access_key_id", "read access key ID")}138${input("secret_access_key", "read secret key", "", "password")}139<p class="muted">A write key, a backup bucket and email can be added in the console afterwards.</p>140<label class="issues-label"><input type="radio" name="source" value="folder"${s3 ? "" : html` checked`}>141a Koh folder remote on this server</label>142${input("folder", "folder", "", "text")}143</fieldset>144<button class="issues-submit" type="submit">save and continue</button>145</form></div>`;146 return page(147 { domain: host, kepr, mirrorDir: "" },148 { title: "setup", description: "", path: "/setup", body },149 );150}