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 const SMTP_SECURITY = ["tls", "starttls", "none"] as const;171819export type SmtpSettings = {20 host: string;21 port: number;22 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 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}909192export const CONFIG_FILE = "kepr.toml";939495969798export async function saveConfig(path: string, config: Config): Promise<void> {99 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 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}