import dotenv from 'dotenv'; import { readFile, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; dotenv.config(); const envPath = path.resolve(process.cwd(), '.env'); const minApiTokenLength = 32; export const requiredEnv = [ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'RESTIC_REPOSITORY', 'RESTIC_PASSWORD', 'ZFS_POOL_NAME', ]; export const config = { port: Number(process.env.PORT || 3000), apiToken: process.env.API_TOKEN || '', allowedManagementIps: (process.env.ALLOWED_MANAGEMENT_IPS || '') .split(',') .map((value) => value.trim()) .filter(Boolean), zfsPoolName: process.env.ZFS_POOL_NAME || '', resticEnv: { AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID || '', AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY || '', RESTIC_REPOSITORY: process.env.RESTIC_REPOSITORY || '', RESTIC_PASSWORD: process.env.RESTIC_PASSWORD || '', }, retention: { keepHourly: Number(process.env.RESTIC_KEEP_HOURLY || 0), keepDaily: Number(process.env.RESTIC_KEEP_DAILY || 7), keepWeekly: Number(process.env.RESTIC_KEEP_WEEKLY || 0), keepMonthly: Number(process.env.RESTIC_KEEP_MONTHLY || 0), }, }; if (!config.apiToken || config.apiToken.length < minApiTokenLength) { throw new Error(`API_TOKEN is required and must be at least ${minApiTokenLength} characters long.`); } export const editableEnv = [ { key: 'AWS_ACCESS_KEY_ID', label: 'AWS access key ID', required: true, secret: true }, { key: 'AWS_SECRET_ACCESS_KEY', label: 'AWS secret access key', required: true, secret: true }, { key: 'RESTIC_REPOSITORY', label: 'Restic repository', required: true, secret: false }, { key: 'RESTIC_PASSWORD', label: 'Restic password', required: true, secret: true }, { key: 'ZFS_POOL_NAME', label: 'ZFS pool name', required: true, secret: false }, { key: 'RESTIC_KEEP_HOURLY', label: 'Keep hourly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_DAILY', label: 'Keep daily snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_WEEKLY', label: 'Keep weekly snapshots', required: false, secret: false }, { key: 'RESTIC_KEEP_MONTHLY', label: 'Keep monthly snapshots', required: false, secret: false }, { key: 'PORT', label: 'API port', required: false, secret: false }, { key: 'API_TOKEN', label: 'API token', required: true, secret: true }, { key: 'ALLOWED_MANAGEMENT_IPS', label: 'Allowed management IPs', required: false, secret: false }, ]; export function missingEnvVars() { return requiredEnv.filter((key) => !process.env[key]); } export function resticProcessEnv() { return { ...process.env, ...config.resticEnv, }; } export async function readEnvSettings() { const fileValues = await readEnvFile(); return editableEnv.map((field) => ({ ...field, value: fileValues[field.key] ?? process.env[field.key] ?? '', })); } export async function writeEnvSettings(values) { const allowedKeys = new Set(editableEnv.map((field) => field.key)); const currentValues = await readEnvFile(); const nextValues = { ...currentValues }; for (const [key, value] of Object.entries(values || {})) { if (!allowedKeys.has(key)) continue; if (key === 'API_TOKEN' && String(value || '').length < minApiTokenLength) { const error = new Error(`API_TOKEN must be at least ${minApiTokenLength} characters long.`); error.status = 400; throw error; } nextValues[key] = String(value ?? ''); } const body = editableEnv .map(({ key }) => `${key}=${quoteEnvValue(nextValues[key] || '')}`) .join('\n'); const tempPath = `${envPath}.tmp`; await writeFile(tempPath, `${body}\n`, { mode: 0o600 }); await rename(tempPath, envPath); applyRuntimeEnv(nextValues); return readEnvSettings(); } async function readEnvFile() { try { const content = await readFile(envPath, 'utf8'); return dotenv.parse(content); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; } } function quoteEnvValue(value) { const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `"${escaped}"`; } function applyRuntimeEnv(values) { for (const [key, value] of Object.entries(values)) { process.env[key] = value; } config.port = Number(process.env.PORT || 3000); config.apiToken = process.env.API_TOKEN || ''; config.allowedManagementIps = (process.env.ALLOWED_MANAGEMENT_IPS || '') .split(',') .map((value) => value.trim()) .filter(Boolean); config.zfsPoolName = process.env.ZFS_POOL_NAME || ''; config.resticEnv.AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; config.resticEnv.AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; config.resticEnv.RESTIC_REPOSITORY = process.env.RESTIC_REPOSITORY || ''; config.resticEnv.RESTIC_PASSWORD = process.env.RESTIC_PASSWORD || ''; config.retention.keepHourly = Number(process.env.RESTIC_KEEP_HOURLY || 0); config.retention.keepDaily = Number(process.env.RESTIC_KEEP_DAILY || 7); config.retention.keepWeekly = Number(process.env.RESTIC_KEEP_WEEKLY || 0); config.retention.keepMonthly = Number(process.env.RESTIC_KEEP_MONTHLY || 0); }