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};293031323334export type BackupBucket = {35 endpoint: string;36 name: string;37 region: string;38 accessKeyId: string;39 secretAccessKey: string;40};4142export type Config = {43 domain: string;44 dataDir: string;45 adminPasswordHash: string;46 bucket: BucketSource;47 smtp?: SmtpSettings;48 backup?: BackupBucket;49};5051export class ConfigError extends Error {}5253const HOSTNAME = new RegExp(54 String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +55 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]))?$`,56 "i",57);585960export function isDomain(domain: string): boolean {61 return HOSTNAME.test(domain);62}6364export function parseConfig(text: string): Config {65 let raw: unknown;66 try {67 raw = Bun.TOML.parse(text);68 } catch (cause) {69 throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);70 }71 const top = asTable(raw, "kepr.toml");72 const bucket = asTable(top.bucket, "[bucket]");73 const adminPasswordHash = requireString(top, "admin_password_hash");74 if (!adminPasswordHash.startsWith("$argon2id$")) {75 throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");76 }77 const domain = requireString(top, "domain");78 79 if (!HOSTNAME.test(domain))80 throw new ConfigError("domain must be a bare hostname, with an optional :port");81 return {82 domain,83 dataDir: requireString(top, "data_dir"),84 adminPasswordHash,85 bucket: parseBucket(bucket),86 ...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),87 ...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),88 };89}9091function parseBackup(backup: Record<string, unknown>): BackupBucket {92 const endpoint = requireString(backup, "endpoint", "[backup] ");93 if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL");94 return {95 endpoint,96 name: requireString(backup, "name", "[backup] "),97 region: requireString(backup, "region", "[backup] "),98 accessKeyId: requireString(backup, "access_key_id", "[backup] "),99 secretAccessKey: requireString(backup, "secret_access_key", "[backup] "),100 };101}102103function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {104 const { port, security } = smtp;105 if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {106 throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");107 }108 const known = SMTP_SECURITY.find((value) => value === security);109 if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);110 const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");111 return {112 host: requireString(smtp, "host", "[smtp] "),113 port,114 security: known,115 username: optional("username"),116 password: optional("password"),117 from: requireString(smtp, "from", "[smtp] "),118 to: requireString(smtp, "to", "[smtp] "),119 };120}121122123export const CONFIG_FILE = "kepr.toml";124125126127128129export async function saveConfig(path: string, config: Config): Promise<void> {130 131 const str = JSON.stringify;132 const { bucket } = config;133 const lines = [134 `domain = ${str(config.domain)}`,135 `data_dir = ${str(config.dataDir)}`,136 `admin_password_hash = ${str(config.adminPasswordHash)}`,137 "",138 "[bucket]",139 ];140 if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);141 else {142 lines.push(143 `endpoint = ${str(bucket.endpoint)}`,144 `name = ${str(bucket.name)}`,145 `region = ${str(bucket.region)}`,146 `access_key_id = ${str(bucket.accessKeyId)}`,147 `secret_access_key = ${str(bucket.secretAccessKey)}`,148 );149 if (bucket.write) {150 lines.push(151 "",152 "[bucket.write]",153 `access_key_id = ${str(bucket.write.accessKeyId)}`,154 `secret_access_key = ${str(bucket.write.secretAccessKey)}`,155 );156 }157 }158 const { smtp } = config;159 if (smtp) {160 lines.push(161 "",162 "[smtp]",163 `host = ${str(smtp.host)}`,164 `port = ${smtp.port}`,165 `security = ${str(smtp.security)}`,166 `username = ${str(smtp.username)}`,167 `password = ${str(smtp.password)}`,168 `from = ${str(smtp.from)}`,169 `to = ${str(smtp.to)}`,170 );171 }172 const { backup } = config;173 if (backup) {174 lines.push(175 "",176 "[backup]",177 `endpoint = ${str(backup.endpoint)}`,178 `name = ${str(backup.name)}`,179 `region = ${str(backup.region)}`,180 `access_key_id = ${str(backup.accessKeyId)}`,181 `secret_access_key = ${str(backup.secretAccessKey)}`,182 );183 }184 const text = `${lines.join("\n")}\n`;185 parseConfig(text);186 const next = `${path}.new`;187 await writeFile(next, text, { mode: 0o600 });188 189 await chmod(next, 0o600);190 await rename(next, path);191}192193export async function loadConfig(path: string): Promise<Config> {194 const file = Bun.file(path);195 if (!(await file.exists())) throw new ConfigError(`${path} not found`);196 return parseConfig(await file.text());197}198199function parseBucket(bucket: Record<string, unknown>): BucketSource {200 if (bucket.folder !== undefined) {201 if (bucket.write !== undefined)202 throw new ConfigError("[bucket] write cannot be combined with folder");203 const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];204 const mixed = s3Keys.filter((key) => bucket[key] !== undefined);205 if (mixed.length > 0) {206 throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);207 }208 return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };209 }210 const endpoint = requireString(bucket, "endpoint", "[bucket] ");211 if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");212 const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");213 return {214 kind: "s3",215 endpoint,216 name: requireString(bucket, "name", "[bucket] "),217 region: requireString(bucket, "region", "[bucket] "),218 accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),219 secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),220 ...(write221 ? {222 write: {223 accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),224 secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),225 },226 }227 : {}),228 };229}230231function asTable(value: unknown, name: string): Record<string, unknown> {232 if (typeof value !== "object" || value === null || Array.isArray(value)) {233 throw new ConfigError(`${name} is missing or not a table`);234 }235 return value as Record<string, unknown>;236}237238function requireString(table: Record<string, unknown>, key: string, section = ""): string {239 const value = table[key];240 if (typeof value !== "string" || value.trim() === "") {241 throw new ConfigError(`${section}${key} is required and must be a non-empty string`);242 }243 return value;244}