import { chmod, rename, writeFile } from "node:fs/promises"; export type BucketSource = | { kind: "s3"; endpoint: string; name: string; region: string; accessKeyId: string; secretAccessKey: string; write?: { accessKeyId: string; secretAccessKey: string }; } // A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests. | { kind: "folder"; path: string }; export type Config = { domain: string; dataDir: string; adminPasswordHash: string; bucket: BucketSource; }; export class ConfigError extends Error {} const HOSTNAME = new RegExp( String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` + String.raw`(:([1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?$`, "i", ); export function parseConfig(text: string): Config { let raw: unknown; try { raw = Bun.TOML.parse(text); } catch (cause) { throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`); } const top = asTable(raw, "kepr.toml"); const bucket = asTable(top.bucket, "[bucket]"); const adminPasswordHash = requireString(top, "admin_password_hash"); if (!adminPasswordHash.startsWith("$argon2id$")) { throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)"); } const domain = requireString(top, "domain"); // Pages, feeds and install.sh put it in URLs and a shell script as is. if (!HOSTNAME.test(domain)) throw new ConfigError("domain must be a bare hostname, with an optional :port"); return { domain, dataDir: requireString(top, "data_dir"), adminPasswordHash, bucket: parseBucket(bucket), }; } /** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */ export const CONFIG_FILE = "kepr.toml"; /** * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19). * Comments in the old file are not kept. What would not parse back is never written. */ export async function saveConfig(path: string, config: Config): Promise { // A JSON string is a valid TOML basic string for everything the parse-back below accepts. const str = JSON.stringify; const { bucket } = config; const lines = [ `domain = ${str(config.domain)}`, `data_dir = ${str(config.dataDir)}`, `admin_password_hash = ${str(config.adminPasswordHash)}`, "", "[bucket]", ]; if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`); else { lines.push( `endpoint = ${str(bucket.endpoint)}`, `name = ${str(bucket.name)}`, `region = ${str(bucket.region)}`, `access_key_id = ${str(bucket.accessKeyId)}`, `secret_access_key = ${str(bucket.secretAccessKey)}`, ); if (bucket.write) { lines.push( "", "[bucket.write]", `access_key_id = ${str(bucket.write.accessKeyId)}`, `secret_access_key = ${str(bucket.write.secretAccessKey)}`, ); } } const text = `${lines.join("\n")}\n`; parseConfig(text); const next = `${path}.new`; await writeFile(next, text, { mode: 0o600 }); // `mode` applies only to a new file, and a crash may have left an older one behind. await chmod(next, 0o600); await rename(next, path); } export async function loadConfig(path: string): Promise { const file = Bun.file(path); if (!(await file.exists())) throw new ConfigError(`${path} not found`); return parseConfig(await file.text()); } function parseBucket(bucket: Record): BucketSource { if (bucket.folder !== undefined) { if (bucket.write !== undefined) throw new ConfigError("[bucket] write cannot be combined with folder"); const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"]; const mixed = s3Keys.filter((key) => bucket[key] !== undefined); if (mixed.length > 0) { throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`); } return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") }; } const endpoint = requireString(bucket, "endpoint", "[bucket] "); if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL"); const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]"); return { kind: "s3", endpoint, name: requireString(bucket, "name", "[bucket] "), region: requireString(bucket, "region", "[bucket] "), accessKeyId: requireString(bucket, "access_key_id", "[bucket] "), secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "), ...(write ? { write: { accessKeyId: requireString(write, "access_key_id", "[bucket.write] "), secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "), }, } : {}), }; } function asTable(value: unknown, name: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new ConfigError(`${name} is missing or not a table`); } return value as Record; } function requireString(table: Record, key: string, section = ""): string { const value = table[key]; if (typeof value !== "string" || value.trim() === "") { throw new ConfigError(`${section}${key} is required and must be a non-empty string`); } return value; }