config.ts

3.0 KB · raw

1export type BucketSource =2	| {3			kind: "s3";4			endpoint: string;5			name: string;6			region: string;7			accessKeyId: string;8			secretAccessKey: string;9	  }10	// A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests.11	| { kind: "folder"; path: string };1213export type Config = {14	domain: string;15	dataDir: string;16	adminPasswordHash: string;17	bucket: BucketSource;18};1920export class ConfigError extends Error {}2122const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/i;2324export function parseConfig(text: string): Config {25	let raw: unknown;26	try {27		raw = Bun.TOML.parse(text);28	} catch (cause) {29		throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);30	}31	const top = asTable(raw, "kepr.toml");32	const bucket = asTable(top.bucket, "[bucket]");33	const adminPasswordHash = requireString(top, "admin_password_hash");34	if (!adminPasswordHash.startsWith("$argon2id$")) {35		throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");36	}37	const domain = requireString(top, "domain");38	// Pages, feeds and install.sh put it in URLs and a shell script as is.39	if (!HOSTNAME.test(domain))40		throw new ConfigError("domain must be a bare hostname, with an optional :port");41	return {42		domain,43		dataDir: requireString(top, "data_dir"),44		adminPasswordHash,45		bucket: parseBucket(bucket),46	};47}4849export async function loadConfig(path: string): Promise<Config> {50	const file = Bun.file(path);51	if (!(await file.exists())) throw new ConfigError(`${path} not found`);52	return parseConfig(await file.text());53}5455function parseBucket(bucket: Record<string, unknown>): BucketSource {56	if (bucket.folder !== undefined) {57		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];58		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);59		if (mixed.length > 0) {60			throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);61		}62		return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };63	}64	const endpoint = requireString(bucket, "endpoint", "[bucket] ");65	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");66	return {67		kind: "s3",68		endpoint,69		name: requireString(bucket, "name", "[bucket] "),70		region: requireString(bucket, "region", "[bucket] "),71		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),72		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),73	};74}7576function asTable(value: unknown, name: string): Record<string, unknown> {77	if (typeof value !== "object" || value === null || Array.isArray(value)) {78		throw new ConfigError(`${name} is missing or not a table`);79	}80	return value as Record<string, unknown>;81}8283function requireString(table: Record<string, unknown>, key: string, section = ""): string {84	const value = table[key];85	if (typeof value !== "string" || value.trim() === "") {86		throw new ConfigError(`${section}${key} is required and must be a non-empty string`);87	}88	return value;89}