import { readFile, writeFile, mkdir, access, constants } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto'; import type { NocoDBSettings, SMTPConfig } from '~/utils/types'; interface AdminConfiguration { nocodb: NocoDBSettings; recaptcha?: { siteKey: string; secretKey: string; // Will be encrypted }; registration?: { membershipFee: number; iban: string; accountHolder: string; }; smtp?: { host: string; port: number; secure: boolean; username: string; password: string; // Will be encrypted fromAddress: string; fromName: string; }; lastUpdated: string; updatedBy: string; } interface EffectiveNocoDB { url: string; token: string; baseId: string; tables: { [tableName: string]: string }; } // Support both Docker (/app/data) and local development (./data) paths const getConfigDir = () => { // Check if running in Docker container if (process.env.NODE_ENV === 'production' || existsSync('/app/data')) { return '/app/data'; } // Local development return './data'; }; const CONFIG_DIR = getConfigDir(); const CONFIG_FILE = join(CONFIG_DIR, 'admin-config.json'); const BACKUP_FILE = join(CONFIG_DIR, 'admin-config.backup.json'); const LOG_FILE = join(CONFIG_DIR, 'admin-config.log'); // In-memory cache for runtime configuration let configCache: AdminConfiguration | null = null; /** * Ensure configuration directory exists */ async function ensureConfigDir(): Promise { try { await access(CONFIG_DIR, constants.F_OK); } catch { await mkdir(CONFIG_DIR, { recursive: true }); console.log(`[admin-config] Created config directory: ${CONFIG_DIR}`); } } /** * Encrypt sensitive data using the app's encryption key */ function encryptSensitiveData(data: string): string { const runtimeConfig = useRuntimeConfig(); const encryptionKey = runtimeConfig.encryptionKey; if (!encryptionKey || encryptionKey.length < 16) { console.warn('[admin-config] No encryption key found or key too short, storing data as-is'); return data; } try { // Use AES-256-GCM for modern encryption const algorithm = 'aes-256-gcm'; const iv = randomBytes(16); const salt = randomBytes(16); // Derive key from the encryption key const key = pbkdf2Sync(encryptionKey, salt, 100000, 32, 'sha256'); const cipher = createCipheriv(algorithm, key, iv); let encrypted = cipher.update(data, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); // Combine salt, iv, authTag, and encrypted data const combined = salt.toString('hex') + ':' + iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; return combined; } catch (error) { console.error('[admin-config] Encryption failed:', error); return data; } } /** * Decrypt sensitive data using the app's encryption key */ function decryptSensitiveData(encryptedData: string): string { const runtimeConfig = useRuntimeConfig(); const encryptionKey = runtimeConfig.encryptionKey; if (!encryptionKey || encryptionKey.length < 16) { return encryptedData; } try { // Check if data contains our modern format with colons if (!encryptedData.includes(':')) { // Legacy data, return as-is console.warn('[admin-config] Legacy encrypted data format detected, returning as-is'); return encryptedData; } const parts = encryptedData.split(':'); if (parts.length !== 4) { throw new Error('Invalid encrypted data format'); } const [saltHex, ivHex, authTagHex, encrypted] = parts; const salt = Buffer.from(saltHex, 'hex'); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); // Derive key from the encryption key const key = pbkdf2Sync(encryptionKey, salt, 100000, 32, 'sha256'); const algorithm = 'aes-256-gcm'; const decipher = createDecipheriv(algorithm, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { console.error('[admin-config] Decryption failed:', error); return encryptedData; } } /** * Log configuration changes for audit trail */ async function logConfigChange(action: string, user: string, details?: any): Promise { const logEntry = { timestamp: new Date().toISOString(), action, user, details: details || {} }; try { await ensureConfigDir(); const logLine = JSON.stringify(logEntry) + '\n'; await writeFile(LOG_FILE, logLine, { flag: 'a' }); } catch (error) { console.error('[admin-config] Failed to write log:', error); } } /** * Create backup of current configuration before changes */ async function createBackup(): Promise { try { await access(CONFIG_FILE, constants.F_OK); const currentConfig = await readFile(CONFIG_FILE, 'utf-8'); await writeFile(BACKUP_FILE, currentConfig, 'utf-8'); console.log('[admin-config] Configuration backup created'); } catch (error) { // No existing config to backup console.log('[admin-config] No existing config to backup'); } } /** * Load configuration from file */ export async function loadAdminConfig(): Promise { try { await ensureConfigDir(); const configData = await readFile(CONFIG_FILE, 'utf-8'); const config: AdminConfiguration = JSON.parse(configData); // Decrypt sensitive data if (config.nocodb.apiKey) { config.nocodb.apiKey = decryptSensitiveData(config.nocodb.apiKey); } if (config.recaptcha?.secretKey) { config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey); } if (config.smtp?.password) { config.smtp.password = decryptSensitiveData(config.smtp.password); } console.log('[admin-config] Configuration loaded from file'); configCache = config; // Update global nocodb configuration try { const { setGlobalNocoDBConfig } = await import('./nocodb'); setGlobalNocoDBConfig(getEffectiveNocoDBConfig()); } catch (error) { console.error('[admin-config] Failed to update global configuration:', error); } return config; } catch (error) { console.log('[admin-config] No configuration file found, using defaults'); configCache = null; return null; } } /** * Save configuration to file */ export async function saveAdminConfig(config: NocoDBSettings, updatedBy: string): Promise { try { await ensureConfigDir(); await createBackup(); // Prepare configuration with encrypted sensitive data const adminConfig: AdminConfiguration = { nocodb: { ...config, apiKey: config.apiKey ? encryptSensitiveData(config.apiKey) : '' }, lastUpdated: new Date().toISOString(), updatedBy }; const configJson = JSON.stringify(adminConfig, null, 2); await writeFile(CONFIG_FILE, configJson, 'utf-8'); // Update cache with unencrypted data for runtime use configCache = { ...adminConfig, nocodb: { ...config } // Keep original unencrypted data in cache }; console.log('[admin-config] Configuration saved to file'); await logConfigChange('CONFIG_SAVED', updatedBy, { url: config.url, baseId: config.baseId }); // Update global nocodb configuration immediately try { const { setGlobalNocoDBConfig } = await import('./nocodb'); setGlobalNocoDBConfig(getEffectiveNocoDBConfig()); console.log('[admin-config] Global configuration updated immediately'); } catch (error) { console.error('[admin-config] Failed to update global configuration after save:', error); } } catch (error) { console.error('[admin-config] Failed to save configuration:', error); await logConfigChange('CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Get effective NocoDB configuration (file config merged with env defaults) */ export function getEffectiveNocoDBConfig(): EffectiveNocoDB { const runtimeConfig = useRuntimeConfig(); // Start with environment variables as defaults const envConfig = { url: runtimeConfig.nocodb?.url || 'https://database.monacousa.org', token: runtimeConfig.nocodb?.token || '', baseId: runtimeConfig.nocodb?.baseId || '', tables: { members: 'members-table-id' } // Default table mapping }; // Override with file configuration if available if (configCache?.nocodb) { return { url: configCache.nocodb.url || envConfig.url, token: configCache.nocodb.apiKey || envConfig.token, baseId: configCache.nocodb.baseId || envConfig.baseId, tables: configCache.nocodb.tables || envConfig.tables }; } return envConfig; } /** * Get current configuration for display (with masked sensitive data) */ export async function getCurrentConfig(): Promise { const config = configCache || await loadAdminConfig(); const runtimeConfig = useRuntimeConfig(); if (config?.nocodb) { return { url: config.nocodb.url || runtimeConfig.nocodb?.url || 'https://database.monacousa.org', apiKey: config.nocodb.apiKey ? '••••••••••••••••' : '', baseId: config.nocodb.baseId || runtimeConfig.nocodb?.baseId || '', tables: config.nocodb.tables || { members: 'members-table-id' } }; } // Return defaults from environment if no file config return { url: runtimeConfig.nocodb?.url || 'https://database.monacousa.org', apiKey: runtimeConfig.nocodb?.token ? '••••••••••••••••' : '', baseId: runtimeConfig.nocodb?.baseId || '', tables: { members: 'members-table-id' } }; } /** * Save reCAPTCHA configuration */ export async function saveRecaptchaConfig(config: { siteKey: string; secretKey: string }, updatedBy: string): Promise { try { await ensureConfigDir(); await createBackup(); const currentConfig = configCache || await loadAdminConfig() || { nocodb: { url: '', apiKey: '', baseId: '', tables: {} }, lastUpdated: new Date().toISOString(), updatedBy: 'system' }; const updatedConfig: AdminConfiguration = { ...currentConfig, recaptcha: { siteKey: config.siteKey, secretKey: encryptSensitiveData(config.secretKey) }, lastUpdated: new Date().toISOString(), updatedBy }; const configJson = JSON.stringify(updatedConfig, null, 2); await writeFile(CONFIG_FILE, configJson, 'utf-8'); // Update cache with unencrypted data configCache = { ...updatedConfig, recaptcha: { siteKey: config.siteKey, secretKey: config.secretKey } }; console.log('[admin-config] reCAPTCHA configuration saved'); await logConfigChange('RECAPTCHA_CONFIG_SAVED', updatedBy, { siteKey: config.siteKey }); } catch (error) { console.error('[admin-config] Failed to save reCAPTCHA configuration:', error); await logConfigChange('RECAPTCHA_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Save registration configuration */ export async function saveRegistrationConfig(config: { membershipFee: number; iban: string; accountHolder: string }, updatedBy: string): Promise { try { await ensureConfigDir(); await createBackup(); const currentConfig = configCache || await loadAdminConfig() || { nocodb: { url: '', apiKey: '', baseId: '', tables: {} }, lastUpdated: new Date().toISOString(), updatedBy: 'system' }; const updatedConfig: AdminConfiguration = { ...currentConfig, registration: config, lastUpdated: new Date().toISOString(), updatedBy }; const configJson = JSON.stringify(updatedConfig, null, 2); await writeFile(CONFIG_FILE, configJson, 'utf-8'); configCache = updatedConfig; console.log('[admin-config] Registration configuration saved'); await logConfigChange('REGISTRATION_CONFIG_SAVED', updatedBy, { membershipFee: config.membershipFee }); } catch (error) { console.error('[admin-config] Failed to save registration configuration:', error); await logConfigChange('REGISTRATION_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Get reCAPTCHA configuration */ export function getRecaptchaConfig(): { siteKey: string; secretKey: string } { const config = configCache?.recaptcha || { siteKey: '', secretKey: '' }; return config; } /** * Get registration configuration */ export function getRegistrationConfig(): { membershipFee: number; iban: string; accountHolder: string } { const config = configCache?.registration || { membershipFee: 50, iban: '', accountHolder: '' }; return config; } /** * Save SMTP configuration */ export async function saveSMTPConfig(config: SMTPConfig, updatedBy: string): Promise { try { await ensureConfigDir(); await createBackup(); const currentConfig = configCache || await loadAdminConfig() || { nocodb: { url: '', apiKey: '', baseId: '', tables: {} }, lastUpdated: new Date().toISOString(), updatedBy: 'system' }; const updatedConfig: AdminConfiguration = { ...currentConfig, smtp: { ...config, password: encryptSensitiveData(config.password) }, lastUpdated: new Date().toISOString(), updatedBy }; const configJson = JSON.stringify(updatedConfig, null, 2); await writeFile(CONFIG_FILE, configJson, 'utf-8'); // Update cache with unencrypted data configCache = { ...updatedConfig, smtp: { ...config } // Keep original unencrypted data in cache }; console.log('[admin-config] SMTP configuration saved'); await logConfigChange('SMTP_CONFIG_SAVED', updatedBy, { host: config.host, fromAddress: config.fromAddress }); } catch (error) { console.error('[admin-config] Failed to save SMTP configuration:', error); await logConfigChange('SMTP_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Get SMTP configuration */ export function getSMTPConfig(): SMTPConfig { const config = configCache?.smtp || { host: '', port: 587, secure: false, username: '', password: '', fromAddress: '', fromName: 'MonacoUSA Portal' }; return config; } /** * Initialize configuration system on server startup */ export async function initAdminConfig(): Promise { console.log('[admin-config] Initializing admin configuration system...'); try { await ensureConfigDir(); await loadAdminConfig(); console.log('[admin-config] ✅ Admin configuration system initialized'); } catch (error) { console.error('[admin-config] ❌ Failed to initialize admin configuration:', error); } }