config.ts

5.2 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 type Config = {17	domain: string;18	dataDir: string;19	adminPasswordHash: string;20	bucket: BucketSource;21};2223export class ConfigError extends Error {}2425const HOSTNAME = new RegExp(26	String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +27		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]))?$`,28	"i",29);3031export function parseConfig(text: string): Config {32	let raw: unknown;33	try {34		raw = Bun.TOML.parse(text);35	} catch (cause) {36		throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);37	}38	const top = asTable(raw, "kepr.toml");39	const bucket = asTable(top.bucket, "[bucket]");40	const adminPasswordHash = requireString(top, "admin_password_hash");41	if (!adminPasswordHash.startsWith("$argon2id$")) {42		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");43	}44	const domain = requireString(top, "domain");45	// Pages, feeds and install.sh put it in URLs and a shell script as is.46	if (!HOSTNAME.test(domain))47		throw new ConfigError("domain must be a bare hostname, with an optional :port");48	return {49		domain,50		dataDir: requireString(top, "data_dir"),51		adminPasswordHash,52		bucket: parseBucket(bucket),53	};54}5556/** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */57export const CONFIG_FILE = "kepr.toml";5859/**60 * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19).61 * Comments in the old file are not kept. What would not parse back is never written.62 */63export async function saveConfig(path: string, config: Config): Promise<void> {64	// A JSON string is a valid TOML basic string for everything the parse-back below accepts.65	const str = JSON.stringify;66	const { bucket } = config;67	const lines = [68		`domain = ${str(config.domain)}`,69		`data_dir = ${str(config.dataDir)}`,70		`admin_password_hash = ${str(config.adminPasswordHash)}`,71		"",72		"[bucket]",73	];74	if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);75	else {76		lines.push(77			`endpoint = ${str(bucket.endpoint)}`,78			`name = ${str(bucket.name)}`,79			`region = ${str(bucket.region)}`,80			`access_key_id = ${str(bucket.accessKeyId)}`,81			`secret_access_key = ${str(bucket.secretAccessKey)}`,82		);83		if (bucket.write) {84			lines.push(85				"",86				"[bucket.write]",87				`access_key_id = ${str(bucket.write.accessKeyId)}`,88				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,89			);90		}91	}92	const text = `${lines.join("\n")}\n`;93	parseConfig(text);94	const next = `${path}.new`;95	await writeFile(next, text, { mode: 0o600 });96	// `mode` applies only to a new file, and a crash may have left an older one behind.97	await chmod(next, 0o600);98	await rename(next, path);99}100101export async function loadConfig(path: string): Promise<Config> {102	const file = Bun.file(path);103	if (!(await file.exists())) throw new ConfigError(`${path} not found`);104	return parseConfig(await file.text());105}106107function parseBucket(bucket: Record<string, unknown>): BucketSource {108	if (bucket.folder !== undefined) {109		if (bucket.write !== undefined)110			throw new ConfigError("[bucket] write cannot be combined with folder");111		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];112		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);113		if (mixed.length > 0) {114			throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);115		}116		return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };117	}118	const endpoint = requireString(bucket, "endpoint", "[bucket] ");119	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");120	const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");121	return {122		kind: "s3",123		endpoint,124		name: requireString(bucket, "name", "[bucket] "),125		region: requireString(bucket, "region", "[bucket] "),126		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),127		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),128		...(write129			? {130					write: {131						accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),132						secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),133					},134				}135			: {}),136	};137}138139function asTable(value: unknown, name: string): Record<string, unknown> {140	if (typeof value !== "object" || value === null || Array.isArray(value)) {141		throw new ConfigError(`${name} is missing or not a table`);142	}143	return value as Record<string, unknown>;144}145146function requireString(table: Record<string, unknown>, key: string, section = ""): string {147	const value = table[key];148	if (typeof value !== "string" || value.trim() === "") {149		throw new ConfigError(`${section}${key} is required and must be a non-empty string`);150	}151	return value;152}