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 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 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}555657export const CONFIG_FILE = "kepr.toml";585960616263export async function saveConfig(path: string, config: Config): Promise<void> {64 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 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}