setup.ts

6.6 KB · raw

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	/** The one-time code printed in the terminal, as `oneTimeCode` makes it. */20	code: string;21	configPath: string;22	dataDir: string;23	/** Called once with the saved config, so the caller can start the real app. */24	done: (config: Config) => void;25};2627/**28 * First-run setup (June, 2026-09-30): with no kepr.toml, Kepr serves only this. Whoever holds the29 * code from the terminal sets the admin password, the domain and the bucket; Kepr checks them, writes30 * kepr.toml itself, and hands over. Ten wrong codes lock it until a restart makes a new code.31 */32export 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	// As for /admin: with no-referrer, browsers post the form with `Origin: null`.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		// No session yet to tie a form token to: the code is the credential, and the origin must59		// be this host, so another site can't post a guess on the operator's behalf.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", "read access key ID")}143${input("secret_access_key", "read secret key", "", "password")}144<p class="muted">A write key, a backup bucket and email can be added in the console afterwards.</p>145<label class="issues-label"><input type="radio" name="source" value="folder"${s3 ? "" : html` checked`}>146a Koh folder remote on this server</label>147${input("folder", "folder", "", "text")}148</fieldset>149<button class="issues-submit" type="submit">save and continue</button>150</form></div>`;151	return page(152		{ domain: host, kepr, mirrorDir: "" },153		{ title: "setup", description: "", path: "/setup", body },154	);155}