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 }12 13 | { kind: "folder"; path: string };1415export const SMTP_SECURITY = ["tls", "starttls", "none"] as const;161718export type SmtpSettings = {19 host: string;20 port: number;21 22 security: (typeof SMTP_SECURITY)[number];23 username: string;24 password: string;25 from: string;26 to: string;27};282930313233export type BackupBucket = {34 endpoint: string;35 name: string;36 region: string;37 accessKeyId: string;38 secretAccessKey: string;39};4041export type Config = {42 domain: string;43 dataDir: string;44 adminPasswordHash: string;45 bucket: BucketSource;46 smtp?: SmtpSettings;47 backup?: BackupBucket;48};4950export class ConfigError extends Error {}5152const HOSTNAME = new RegExp(53 String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +54 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]))?$`,55 "i",56);575859export function isDomain(domain: string): boolean {60 return HOSTNAME.test(domain);61}6263export function parseConfig(text: string): Config {64 let raw: unknown;65 try {66 raw = Bun.TOML.parse(text);67 } catch (cause) {68 throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);69 }70 const top = asTable(raw, "kepr.toml");71 const bucket = asTable(top.bucket, "[bucket]");72 const adminPasswordHash = requireString(top, "admin_password_hash");73 if (!adminPasswordHash.startsWith("$argon2id$")) {74 throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");75 }76 const domain = requireString(top, "domain");77 78 if (!HOSTNAME.test(domain))79 throw new ConfigError("domain must be a bare hostname, with an optional :port");80 return {81 domain,82 dataDir: requireString(top, "data_dir"),83 adminPasswordHash,84 bucket: parseBucket(bucket),85 ...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),86 ...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),87 };88}8990function parseBackup(backup: Record<string, unknown>): BackupBucket {91 const endpoint = requireString(backup, "endpoint", "[backup] ");92 if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL");93 return {94 endpoint,95 name: requireString(backup, "name", "[backup] "),96 region: requireString(backup, "region", "[backup] "),97 accessKeyId: requireString(backup, "access_key_id", "[backup] "),98 secretAccessKey: requireString(backup, "secret_access_key", "[backup] "),99 };100}101102function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {103 const { port, security } = smtp;104 if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {105 throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");106 }107 const known = SMTP_SECURITY.find((value) => value === security);108 if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);109 const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");110 return {111 host: requireString(smtp, "host", "[smtp] "),112 port,113 security: known,114 username: optional("username"),115 password: optional("password"),116 from: requireString(smtp, "from", "[smtp] "),117 to: requireString(smtp, "to", "[smtp] "),118 };119}120121122export const CONFIG_FILE = "kepr.toml";123124125126127128export async function saveConfig(path: string, config: Config): Promise<void> {129 130 const str = JSON.stringify;131 const { bucket } = config;132 const lines = [133 `domain = ${str(config.domain)}`,134 `data_dir = ${str(config.dataDir)}`,135 `admin_password_hash = ${str(config.adminPasswordHash)}`,136 "",137 "[bucket]",138 ];139 if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);140 else {141 lines.push(142 `endpoint = ${str(bucket.endpoint)}`,143 `name = ${str(bucket.name)}`,144 `region = ${str(bucket.region)}`,145 `access_key_id = ${str(bucket.accessKeyId)}`,146 `secret_access_key = ${str(bucket.secretAccessKey)}`,147 );148 }149 const { smtp } = config;150 if (smtp) {151 lines.push(152 "",153 "[smtp]",154 `host = ${str(smtp.host)}`,155 `port = ${smtp.port}`,156 `security = ${str(smtp.security)}`,157 `username = ${str(smtp.username)}`,158 `password = ${str(smtp.password)}`,159 `from = ${str(smtp.from)}`,160 `to = ${str(smtp.to)}`,161 );162 }163 const { backup } = config;164 if (backup) {165 lines.push(166 "",167 "[backup]",168 `endpoint = ${str(backup.endpoint)}`,169 `name = ${str(backup.name)}`,170 `region = ${str(backup.region)}`,171 `access_key_id = ${str(backup.accessKeyId)}`,172 `secret_access_key = ${str(backup.secretAccessKey)}`,173 );174 }175 const text = `${lines.join("\n")}\n`;176 parseConfig(text);177 const next = `${path}.new`;178 await writeFile(next, text, { mode: 0o600 });179 180 await chmod(next, 0o600);181 await rename(next, path);182}183184export async function loadConfig(path: string): Promise<Config> {185 const file = Bun.file(path);186 if (!(await file.exists())) throw new ConfigError(`${path} not found`);187 return parseConfig(await file.text());188}189190function parseBucket(bucket: Record<string, unknown>): BucketSource {191 if (bucket.write !== undefined) {192 throw new ConfigError(193 "[bucket.write] is no longer used: Kepr reads and writes with the [bucket] key alone",194 );195 }196 if (bucket.folder !== undefined) {197 const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];198 const mixed = s3Keys.filter((key) => bucket[key] !== undefined);199 if (mixed.length > 0) {200 throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);201 }202 return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };203 }204 const endpoint = requireString(bucket, "endpoint", "[bucket] ");205 if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");206 return {207 kind: "s3",208 endpoint,209 name: requireString(bucket, "name", "[bucket] "),210 region: requireString(bucket, "region", "[bucket] "),211 accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),212 secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),213 };214}215216function asTable(value: unknown, name: string): Record<string, unknown> {217 if (typeof value !== "object" || value === null || Array.isArray(value)) {218 throw new ConfigError(`${name} is missing or not a table`);219 }220 return value as Record<string, unknown>;221}222223function requireString(table: Record<string, unknown>, key: string, section = ""): string {224 const value = table[key];225 if (typeof value !== "string" || value.trim() === "") {226 throw new ConfigError(`${section}${key} is required and must be a non-empty string`);227 }228 return value;229}