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