config.ts

6.7 KB · raw

1import { chmod, rename, writeFile } from "node:fs/promises";23export type BucketSource =4	| {5			kind: "s3";6			endpoint: string;7			name: string;8			region: string;9			accessKeyId: string;10			secretAccessKey: string;11			write?: { accessKeyId: string; secretAccessKey: string };12	  }13	// A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests.14	| { kind: "folder"; path: string };1516export const SMTP_SECURITY = ["tls", "starttls", "none"] as const;1718/** Where the operator's email goes out (§24); the password lives here and nowhere else (§25). */19export type SmtpSettings = {20	host: string;21	port: number;22	/** Implicit TLS, STARTTLS, or none for a relay on the same machine. */23	security: (typeof SMTP_SECURITY)[number];24	username: string;25	password: string;26	from: string;27	to: string;28};2930export type Config = {31	domain: string;32	dataDir: string;33	adminPasswordHash: string;34	bucket: BucketSource;35	smtp?: SmtpSettings;36};3738export class ConfigError extends Error {}3940const HOSTNAME = new RegExp(41	String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +42		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]))?$`,43	"i",44);4546export function parseConfig(text: string): Config {47	let raw: unknown;48	try {49		raw = Bun.TOML.parse(text);50	} catch (cause) {51		throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);52	}53	const top = asTable(raw, "kepr.toml");54	const bucket = asTable(top.bucket, "[bucket]");55	const adminPasswordHash = requireString(top, "admin_password_hash");56	if (!adminPasswordHash.startsWith("$argon2id$")) {57		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");58	}59	const domain = requireString(top, "domain");60	// Pages, feeds and install.sh put it in URLs and a shell script as is.61	if (!HOSTNAME.test(domain))62		throw new ConfigError("domain must be a bare hostname, with an optional :port");63	return {64		domain,65		dataDir: requireString(top, "data_dir"),66		adminPasswordHash,67		bucket: parseBucket(bucket),68		...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),69	};70}7172function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {73	const { port, security } = smtp;74	if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {75		throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");76	}77	const known = SMTP_SECURITY.find((value) => value === security);78	if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);79	const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");80	return {81		host: requireString(smtp, "host", "[smtp] "),82		port,83		security: known,84		username: optional("username"),85		password: optional("password"),86		from: requireString(smtp, "from", "[smtp] "),87		to: requireString(smtp, "to", "[smtp] "),88	};89}9091/** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */92export const CONFIG_FILE = "kepr.toml";9394/**95 * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19).96 * Comments in the old file are not kept. What would not parse back is never written.97 */98export async function saveConfig(path: string, config: Config): Promise<void> {99	// A JSON string is a valid TOML basic string for everything the parse-back below accepts.100	const str = JSON.stringify;101	const { bucket } = config;102	const lines = [103		`domain = ${str(config.domain)}`,104		`data_dir = ${str(config.dataDir)}`,105		`admin_password_hash = ${str(config.adminPasswordHash)}`,106		"",107		"[bucket]",108	];109	if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);110	else {111		lines.push(112			`endpoint = ${str(bucket.endpoint)}`,113			`name = ${str(bucket.name)}`,114			`region = ${str(bucket.region)}`,115			`access_key_id = ${str(bucket.accessKeyId)}`,116			`secret_access_key = ${str(bucket.secretAccessKey)}`,117		);118		if (bucket.write) {119			lines.push(120				"",121				"[bucket.write]",122				`access_key_id = ${str(bucket.write.accessKeyId)}`,123				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,124			);125		}126	}127	const { smtp } = config;128	if (smtp) {129		lines.push(130			"",131			"[smtp]",132			`host = ${str(smtp.host)}`,133			`port = ${smtp.port}`,134			`security = ${str(smtp.security)}`,135			`username = ${str(smtp.username)}`,136			`password = ${str(smtp.password)}`,137			`from = ${str(smtp.from)}`,138			`to = ${str(smtp.to)}`,139		);140	}141	const text = `${lines.join("\n")}\n`;142	parseConfig(text);143	const next = `${path}.new`;144	await writeFile(next, text, { mode: 0o600 });145	// `mode` applies only to a new file, and a crash may have left an older one behind.146	await chmod(next, 0o600);147	await rename(next, path);148}149150export async function loadConfig(path: string): Promise<Config> {151	const file = Bun.file(path);152	if (!(await file.exists())) throw new ConfigError(`${path} not found`);153	return parseConfig(await file.text());154}155156function parseBucket(bucket: Record<string, unknown>): BucketSource {157	if (bucket.folder !== undefined) {158		if (bucket.write !== undefined)159			throw new ConfigError("[bucket] write cannot be combined with folder");160		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];161		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);162		if (mixed.length > 0) {163			throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);164		}165		return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };166	}167	const endpoint = requireString(bucket, "endpoint", "[bucket] ");168	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");169	const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");170	return {171		kind: "s3",172		endpoint,173		name: requireString(bucket, "name", "[bucket] "),174		region: requireString(bucket, "region", "[bucket] "),175		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),176		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),177		...(write178			? {179					write: {180						accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),181						secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),182					},183				}184			: {}),185	};186}187188function asTable(value: unknown, name: string): Record<string, unknown> {189	if (typeof value !== "object" || value === null || Array.isArray(value)) {190		throw new ConfigError(`${name} is missing or not a table`);191	}192	return value as Record<string, unknown>;193}194195function requireString(table: Record<string, unknown>, key: string, section = ""): string {196	const value = table[key];197	if (typeof value !== "string" || value.trim() === "") {198		throw new ConfigError(`${section}${key} is required and must be a non-empty string`);199	}200	return value;201}