1import { chmod, rename, writeFile } from "node:fs/promises";2import { isSecret } from "./totp.ts";34export type BucketSource =5 | {6 kind: "s3";7 endpoint: string;8 name: string;9 region: string;10 accessKeyId: string;11 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 47 totpSecret?: string;48 bucket: BucketSource;49 smtp?: SmtpSettings;50 backup?: BackupBucket;51};5253export class ConfigError extends Error {}5455const HOSTNAME = new RegExp(56 String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +57 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]))?$`,58 "i",59);606162export function isDomain(domain: string): boolean {63 return HOSTNAME.test(domain);64}6566export function parseConfig(text: string): Config {67 let raw: unknown;68 try {69 raw = Bun.TOML.parse(text);70 } catch (cause) {71 throw new ConfigError(`kepr.toml is not valid TOML: ${(cause as Error).message}`);72 }73 const top = asTable(raw, "kepr.toml");74 const bucket = asTable(top.bucket, "[bucket]");75 const adminPasswordHash = requireString(top, "admin_password_hash");76 if (!adminPasswordHash.startsWith("$argon2id$")) {77 throw new ConfigError("admin_password_hash must be an Argon2id hash ($argon2id$...)");78 }79 const domain = requireString(top, "domain");80 81 if (!HOSTNAME.test(domain))82 throw new ConfigError("domain must be a bare hostname, with an optional :port");83 84 const totpSecret = typeof top.totp_secret === "string" ? top.totp_secret.trim() : "";85 if (totpSecret !== "" && !isSecret(totpSecret)) {86 throw new ConfigError("totp_secret must be a base32 secret of at least 16 bytes");87 }88 return {89 domain,90 dataDir: requireString(top, "data_dir"),91 adminPasswordHash,92 ...(totpSecret === "" ? {} : { totpSecret }),93 bucket: parseBucket(bucket),94 ...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),95 ...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),96 };97}9899function parseBackup(backup: Record<string, unknown>): BackupBucket {100 const endpoint = requireString(backup, "endpoint", "[backup] ");101 if (!URL.canParse(endpoint)) throw new ConfigError("[backup] endpoint must be a URL");102 return {103 endpoint,104 name: requireString(backup, "name", "[backup] "),105 region: requireString(backup, "region", "[backup] "),106 accessKeyId: requireString(backup, "access_key_id", "[backup] "),107 secretAccessKey: requireString(backup, "secret_access_key", "[backup] "),108 };109}110111function parseSmtp(smtp: Record<string, unknown>): SmtpSettings {112 const { port, security } = smtp;113 if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {114 throw new ConfigError("[smtp] port must be a whole number from 1 to 65535");115 }116 const known = SMTP_SECURITY.find((value) => value === security);117 if (!known) throw new ConfigError(`[smtp] security must be one of ${SMTP_SECURITY.join(", ")}`);118 const optional = (key: string) => (typeof smtp[key] === "string" ? (smtp[key] as string) : "");119 return {120 host: requireString(smtp, "host", "[smtp] "),121 port,122 security: known,123 username: optional("username"),124 password: optional("password"),125 from: requireString(smtp, "from", "[smtp] "),126 to: requireString(smtp, "to", "[smtp] "),127 };128}129130131export const CONFIG_FILE = "kepr.toml";132133134135136137export async function saveConfig(path: string, config: Config): Promise<void> {138 139 const str = JSON.stringify;140 const { bucket } = config;141 const lines = [142 `domain = ${str(config.domain)}`,143 `data_dir = ${str(config.dataDir)}`,144 `admin_password_hash = ${str(config.adminPasswordHash)}`,145 ...(config.totpSecret ? [`totp_secret = ${str(config.totpSecret)}`] : []),146 "",147 "[bucket]",148 ];149 if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);150 else {151 lines.push(152 `endpoint = ${str(bucket.endpoint)}`,153 `name = ${str(bucket.name)}`,154 `region = ${str(bucket.region)}`,155 `access_key_id = ${str(bucket.accessKeyId)}`,156 `secret_access_key = ${str(bucket.secretAccessKey)}`,157 );158 }159 const { smtp } = config;160 if (smtp) {161 lines.push(162 "",163 "[smtp]",164 `host = ${str(smtp.host)}`,165 `port = ${smtp.port}`,166 `security = ${str(smtp.security)}`,167 `username = ${str(smtp.username)}`,168 `password = ${str(smtp.password)}`,169 `from = ${str(smtp.from)}`,170 `to = ${str(smtp.to)}`,171 );172 }173 const { backup } = config;174 if (backup) {175 lines.push(176 "",177 "[backup]",178 `endpoint = ${str(backup.endpoint)}`,179 `name = ${str(backup.name)}`,180 `region = ${str(backup.region)}`,181 `access_key_id = ${str(backup.accessKeyId)}`,182 `secret_access_key = ${str(backup.secretAccessKey)}`,183 );184 }185 const text = `${lines.join("\n")}\n`;186 parseConfig(text);187 const next = `${path}.new`;188 await writeFile(next, text, { mode: 0o600 });189 190 await chmod(next, 0o600);191 await rename(next, path);192}193194export async function loadConfig(path: string): Promise<Config> {195 const file = Bun.file(path);196 if (!(await file.exists())) throw new ConfigError(`${path} not found`);197 return parseConfig(await file.text());198}199200function parseBucket(bucket: Record<string, unknown>): BucketSource {201 if (bucket.write !== undefined) {202 throw new ConfigError(203 "[bucket.write] is no longer used: Kepr reads and writes with the [bucket] key alone",204 );205 }206 if (bucket.folder !== undefined) {207 const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];208 const mixed = s3Keys.filter((key) => bucket[key] !== undefined);209 if (mixed.length > 0) {210 throw new ConfigError(`[bucket] folder cannot be combined with ${mixed.join(", ")}`);211 }212 return { kind: "folder", path: requireString(bucket, "folder", "[bucket] ") };213 }214 const endpoint = requireString(bucket, "endpoint", "[bucket] ");215 if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");216 return {217 kind: "s3",218 endpoint,219 name: requireString(bucket, "name", "[bucket] "),220 region: requireString(bucket, "region", "[bucket] "),221 accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),222 secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),223 };224}225226function asTable(value: unknown, name: string): Record<string, unknown> {227 if (typeof value !== "object" || value === null || Array.isArray(value)) {228 throw new ConfigError(`${name} is missing or not a table`);229 }230 return value as Record<string, unknown>;231}232233function requireString(table: Record<string, unknown>, key: string, section = ""): string {234 const value = table[key];235 if (typeof value !== "string" || value.trim() === "") {236 throw new ConfigError(`${section}${key} is required and must be a non-empty string`);237 }238 return value;239}