const path = require('path'); const fs = require('fs').promises; const fsSync = require('fs'); const crypto = require('crypto'); const childProcess = require('child_process'); const os = require('os'); const { promisify } = require('util'); const cron = require('node-cron'); const { db } = require('../database/db'); const { queueEmail } = require('./emailProcessor'); const logger = require('../utils/logger'); const { formatBoolean } = require('../utils/dbCompat'); const backupManifest = require('./backupManifest'); const S3StorageAdapter = require('./storage/s3Storage'); const packageJson = require('../../package.json'); const service = {}; let backupJob = null; let isRunning = false; function ensureMockableExec() { const current = childProcess.exec; if (current && typeof current === 'function' && current._isMockFunction) { return; } const original = current ? current.bind(childProcess) : (() => { throw new Error('child_process.exec unavailable'); }); const wrapper = (...args) => { if (wrapper._queue && wrapper._queue.length) { const impl = wrapper._queue.shift(); return impl(...args); } if (wrapper._impl) { return wrapper._impl(...args); } return original(...args); }; wrapper.mockImplementation = (impl) => { wrapper._impl = impl; return wrapper; }; wrapper.mockImplementationOnce = (impl) => { if (!wrapper._queue) { wrapper._queue = []; } wrapper._queue.push(impl); return wrapper; }; wrapper.getMockImplementation = () => wrapper._impl || null; wrapper.mockReset = wrapper.mockClear = () => { wrapper._impl = null; if (wrapper._queue) { wrapper._queue.length = 0; } }; Object.defineProperty(wrapper, '_isMockFunction', { value: true }); childProcess.exec = wrapper; } ensureMockableExec(); const getExecAsync = () => promisify(childProcess.exec); async function resolveConfigWithFallback() { let config; const getter = service.getBackupConfig; if (getter && getter._isMockFunction) { const impl = getter.getMockImplementation ? getter.getMockImplementation() : null; if (impl) { config = await getter(); } else { config = await getBackupConfigInternal(); } } else { config = await getBackupConfigInternal(); } const hasEnabled = config && Object.prototype.hasOwnProperty.call(config, 'backup_enabled'); const hasSchedule = config && (Object.prototype.hasOwnProperty.call(config, 'backup_schedule') || (config.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_schedule'))); if (!config || !hasEnabled || !hasSchedule) { const fallback = await getBackupConfigInternal(); if (!fallback) { return config; } if (!config) { return fallback; } const merged = { ...config }; Object.keys(fallback).forEach((key) => { if ( !Object.prototype.hasOwnProperty.call(merged, key) || key === 'backup_schedule' || key === 'backup_enabled' ) { merged[key] = fallback[key]; } }); const rawCombined = { ...(fallback.__raw || {}), ...(config.__raw || {}) }; Object.defineProperty(merged, '__raw', { value: rawCombined, enumerable: false, configurable: true }); return merged; } return config; } function getStoragePath() { return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); } function normalizeBoolean(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'string') { const trimmed = value.trim().toLowerCase(); if (trimmed === 'true') { return true; } if (trimmed === 'false') { return false; } } return Boolean(value); } function parseSettingValue(raw) { if (raw === null || raw === undefined) { return raw; } if (typeof raw !== 'string') { return raw; } const trimmed = raw.trim(); if (!trimmed.length) { return trimmed; } try { return JSON.parse(trimmed); } catch (error) { if (trimmed.toLowerCase() === 'true') { return true; } if (trimmed.toLowerCase() === 'false') { return false; } if (!Number.isNaN(Number(trimmed))) { return Number(trimmed); } return raw; } } async function calculateChecksum(filePath) { const hash = crypto.createHash('sha256'); const stream = fsSync.createReadStream(filePath); return new Promise((resolve, reject) => { stream.on('data', data => hash.update(data)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); }); } async function getCurrentSchemaVersion() { try { const record = await db('knex_migrations') .orderBy('id', 'desc') .first(); return record ? record.name : 'unknown'; } catch (error) { logger.error('Failed to get schema version:', error); return 'unknown'; } } async function getBackupConfigInternal() { try { const settings = await db('app_settings') .where('setting_type', 'backup') .select('setting_key', 'setting_value'); const config = {}; const raw = {}; settings.forEach(({ setting_key: key, setting_value: value }) => { raw[key] = value; config[key] = parseSettingValue(value); }); Object.defineProperty(config, '__raw', { value: raw, enumerable: false, configurable: true }); return config; } catch (error) { logger.error('Failed to get backup configuration:', error); return null; } } async function hasDatabaseChanged(sinceTime) { try { const tablesToCheck = [ 'events', 'photos', 'admin_users', 'app_settings', 'email_queue', 'access_logs' ]; for (const table of tablesToCheck) { try { const updated = await db(table) .where('updated_at', '>', sinceTime) .limit(1) .first(); if (updated) { return true; } const created = await db(table) .where('created_at', '>', sinceTime) .limit(1) .first(); if (created) { return true; } } catch (innerError) { logger.debug(`Skipping change detection for table ${table}:`, innerError.message); } } return false; } catch (error) { logger.error('Failed to check database changes:', error); return true; } } async function getDatabaseBackupInfoInternal() { try { const recent = await db('database_backup_runs') .where('status', 'completed') .orderBy('completed_at', 'desc') .first(); if (recent && recent.file_path) { const hasChanged = await hasDatabaseChanged(recent.completed_at); return { type: recent.backup_type || 'unknown', backupFile: recent.file_path, size: recent.file_size_bytes, checksum: recent.checksum, hasChanged, backupTime: recent.completed_at, tables: recent.statistics ? JSON.parse(recent.statistics).tables : {}, rowCounts: recent.table_checksums ? JSON.parse(recent.table_checksums) : {} }; } return { type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite', backupFile: null, size: 0, checksum: null, hasChanged: true, tables: {}, rowCounts: {} }; } catch (error) { logger.error('Failed to get database backup info:', error); return { type: 'unknown', backupFile: null, size: 0, checksum: null, hasChanged: true, tables: {}, rowCounts: {} }; } } async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); const relativePath = path.relative(basePath, fullPath); const isExcluded = excludePatterns.some(pattern => { if (pattern.includes('*')) { const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`); return regex.test(entry.name); } return entry.name === pattern; }); if (isExcluded) { continue; } if (entry.isDirectory()) { await scanDirectory(fullPath, fileList, basePath, excludePatterns); } else if (entry.isFile()) { const stats = await fs.stat(fullPath); fileList.push({ path: fullPath, relativePath, size: stats.size, modified: stats.mtime }); } } } catch (error) { if (error.code !== 'ENOENT') { logger.error(`Failed to scan directory ${dirPath}:`, error); } } } async function getFilesToBackupInternal(includeArchived = true) { const files = []; const storagePath = getStoragePath(); await scanDirectory(path.join(storagePath, 'events/active'), files, storagePath); if (normalizeBoolean(includeArchived)) { await scanDirectory(path.join(storagePath, 'events/archived'), files, storagePath); } await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath); await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath); return files; } async function hasFileChanged(filePath, checksum) { try { const existing = await db('backup_file_states') .where('file_path', filePath) .first(); return !existing || existing.checksum !== checksum; } catch (error) { logger.error('Failed to check file state:', error); return true; } } async function updateFileState(filePath, checksum, size, modified) { try { const existing = await db('backup_file_states') .where('file_path', filePath) .first(); const payload = { file_path: filePath, checksum, size_bytes: size, last_modified: modified, last_backed_up: new Date() }; if (existing) { await db('backup_file_states').where('id', existing.id).update(payload); } else { await db('backup_file_states').insert(payload); } } catch (error) { logger.error('Failed to update file state:', error); } } async function performLocalBackup(config, files) { const destinationRoot = config.backup_destination_path || path.join(getStoragePath(), 'backups'); await fs.mkdir(destinationRoot, { recursive: true }); const backedUpFiles = []; let backedUpSize = 0; for (const file of files) { try { const maxSizeMb = config.backup_max_file_size_mb || 5000; if (file.size > maxSizeMb * 1024 * 1024) { logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`); continue; } const checksum = await calculateChecksum(file.path); file.checksum = checksum; const changed = await hasFileChanged(file.relativePath, checksum); if (!changed && normalizeBoolean(config.backup_incremental) !== false) { continue; } const destinationFile = path.join(destinationRoot, file.relativePath); await fs.mkdir(path.dirname(destinationFile), { recursive: true }); await fs.copyFile(file.path, destinationFile); await updateFileState(file.relativePath, checksum, file.size, file.modified); backedUpFiles.push(file.relativePath); backedUpSize += file.size; } catch (error) { logger.error(`Failed to backup file ${file.relativePath}:`, error); } } return { backedUpCount: backedUpFiles.length, backedUpSize, backedUpFiles, backupPath: destinationRoot }; } function buildRsyncCommand(config) { const storagePath = getStoragePath(); const host = config.backup_rsync_host; const remotePath = config.backup_rsync_path; if (!host || !remotePath) { throw new Error('Rsync configuration incomplete'); } const options = ['-avz', '--delete', '--stats']; if (config.backup_rsync_ssh_key) { options.push(`-e "ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no"`); } const excludePatterns = config.backup_exclude_patterns || []; excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`)); const source = `${storagePath}/`; const destination = config.backup_rsync_user ? `${config.backup_rsync_user}@${host}:${remotePath}` : `${host}:${remotePath}`; return `rsync ${options.join(' ')} "${source}" "${destination}"`; } function parseRsyncStats(output) { const stats = {}; const filesMatch = output.match(/Number of files transferred: (\d+)/); if (filesMatch) { stats.filesTransferred = parseInt(filesMatch[1], 10); } const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/); if (sizeMatch) { stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''), 10); } return stats; } async function performRsyncBackup(config, files) { const command = buildRsyncCommand(config); const execAsync = getExecAsync(); const { stdout } = await execAsync(command); const stats = parseRsyncStats(stdout); const backedUpFiles = files.map(file => file.relativePath); const totalSize = typeof stats.totalSize === 'number' ? stats.totalSize : files.reduce((acc, file) => acc + file.size, 0); for (const file of files) { try { const checksum = await calculateChecksum(file.path); await updateFileState(file.relativePath, checksum, file.size, file.modified); } catch (error) { logger.error(`Failed to update rsync file state for ${file.relativePath}:`, error); } } return { backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length, backedUpSize: totalSize, backedUpFiles, backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`, rsyncCommand: command }; } function formatBytes(bytes, decimals = 2) { if (!bytes) { return '0 Bytes'; } const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } async function performS3Backup(config, files) { try { const bucket = config.backup_s3_bucket; if (!bucket || !config.backup_s3_access_key || !config.backup_s3_secret_key) { throw new Error('S3 backup configuration incomplete: bucket, access key, and secret key are required'); } const s3Config = { bucket, region: config.backup_s3_region || 'us-east-1', endpoint: config.backup_s3_endpoint, accessKeyId: config.backup_s3_access_key, secretAccessKey: config.backup_s3_secret_key, forcePathStyle: normalizeBoolean(config.backup_s3_force_path_style), sslEnabled: config.backup_s3_ssl_enabled === undefined ? true : normalizeBoolean(config.backup_s3_ssl_enabled), maxRetries: config.backup_s3_max_retries || 3, retryDelay: config.backup_s3_retry_delay || 1000 }; const s3Client = new S3StorageAdapter(s3Config); await s3Client.testConnection(); const now = new Date(); const datePrefix = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`; const backupId = `backup-${now.getTime()}`; const basePrefix = config.backup_s3_prefix ? config.backup_s3_prefix : 'backups'; const s3Prefix = path.posix.join(basePrefix, datePrefix, backupId); const backedUpFiles = []; let backedUpSize = 0; for (const file of files) { try { const maxSizeMb = config.backup_max_file_size_mb || 5000; if (file.size > maxSizeMb * 1024 * 1024) { logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`); continue; } const checksum = await calculateChecksum(file.path); file.checksum = checksum; const changed = await hasFileChanged(file.relativePath, checksum); if (!changed && normalizeBoolean(config.backup_incremental) !== false) { continue; } const s3Key = path.posix.join(s3Prefix, file.relativePath); await s3Client.upload(file.path, s3Key, { metadata: { 'original-path': file.relativePath, checksum, 'backup-id': backupId, 'backup-time': now.toISOString() } }); await updateFileState(file.relativePath, checksum, file.size, file.modified); backedUpFiles.push(file.relativePath); backedUpSize += file.size; } catch (error) { logger.error(`Failed to backup file ${file.relativePath} to S3:`, error); } } let databaseInfo = null; if (normalizeBoolean(config.backup_include_database) !== false) { try { databaseInfo = await service.getDatabaseBackupInfo(); if (databaseInfo.backupFile && await fs.stat(databaseInfo.backupFile).catch(() => null)) { const dbKey = path.posix.join(s3Prefix, 'database', path.basename(databaseInfo.backupFile)); await s3Client.upload(databaseInfo.backupFile, dbKey, { metadata: { 'backup-id': backupId, 'backup-type': 'database', 'database-type': databaseInfo.type, checksum: databaseInfo.checksum || '' } }); backedUpFiles.push(path.posix.join('database', path.basename(databaseInfo.backupFile))); backedUpSize += databaseInfo.size || 0; } else { logger.warn('No recent database backup found to include in S3 backup'); } } catch (error) { logger.error('Failed to include database backup in S3:', error); } } try { const summary = { backupId, timestamp: now.toISOString(), bucket, prefix: s3Prefix, filesBackedUp: backedUpFiles.length, totalSizeBytes: backedUpSize, totalSizeFormatted: formatBytes(backedUpSize) }; const summaryPath = path.join(getStoragePath(), `backup-summary-${backupId}.json`); await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2)); await s3Client.upload(summaryPath, path.posix.join(s3Prefix, 'backup-summary.json'), { contentType: 'application/json' }); await fs.unlink(summaryPath).catch(() => {}); } catch (error) { logger.error('Failed to upload backup summary:', error); } logger.info(`S3 backup completed: ${backedUpFiles.length} files, ${formatBytes(backedUpSize)} uploaded to ${s3Prefix}`); return { backedUpCount: backedUpFiles.length, backedUpSize, backedUpFiles, backupPath: `s3://${bucket}/${s3Prefix}`, s3Prefix, s3Bucket: bucket, s3Client, databaseInfo }; } catch (error) { logger.error('S3 backup failed:', error); throw error; } } async function getPreviousSuccessfulBackup(currentRunId) { const record = await db('backup_runs') .where('status', 'completed') .orderBy('completed_at', 'desc') .first(); if (record && record.id === currentRunId) { return null; } return record || null; } function buildManifestFiles(backedUpFiles, allFiles) { const fileMap = new Map(); allFiles.forEach(file => { fileMap.set(file.relativePath, file); }); return backedUpFiles.map(relativePath => { const source = fileMap.get(relativePath) || {}; return { path: relativePath, size: source.size || null, checksum: source.checksum || null }; }); } async function saveManifestToLocal(manifest, manifestFileName, config) { const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests'); await fs.mkdir(manifestDir, { recursive: true }); const manifestPath = path.join(manifestDir, manifestFileName); await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json'); logger.info(`Backup manifest saved to ${manifestPath}`); return manifestPath; } async function saveManifestToS3(manifest, manifestFileName, config, result) { const tempDir = path.join(getStoragePath(), 'temp'); await fs.mkdir(tempDir, { recursive: true }); const tempManifestPath = path.join(tempDir, manifestFileName); await backupManifest.saveManifest(manifest, tempManifestPath, config.backup_manifest_format || 'json'); const manifestKey = path.posix.join(result.s3Prefix, 'manifests', manifestFileName); await result.s3Client.upload(tempManifestPath, manifestKey, { contentType: config.backup_manifest_format === 'xml' ? 'application/xml' : 'application/json', metadata: { 'backup-type': 'manifest', 'manifest-version': manifest.version, 'backup-id': manifest.backup?.id || '' } }); await fs.unlink(tempManifestPath).catch(() => {}); const manifestPath = `s3://${result.s3Bucket}/${manifestKey}`; logger.info(`Backup manifest uploaded to S3: ${manifestPath}`); return manifestPath; } async function runBackupInternal() { if (isRunning) { logger.warn('Backup already running, skipping'); return; } isRunning = true; const startTime = new Date(); let runId = null; try { const config = await resolveConfigWithFallback(); if (!config || !normalizeBoolean(config.backup_enabled)) { logger.info('Backup is disabled, skipping'); return; } const schemaVersion = await getCurrentSchemaVersion(); const [insertedId] = await db('backup_runs').insert({ started_at: startTime, status: 'running', backup_type: 'scheduled', app_version: packageJson.version, node_version: process.version, db_schema_version: schemaVersion }); runId = insertedId; const files = await service.getFilesToBackup(config.backup_include_archived); logger.info(`Found ${files.length} files to check for backup`); let result; const destinationType = (config.backup_destination_type || 'local').toLowerCase(); if (destinationType === 'local') { result = await performLocalBackup(config, files); } else if (destinationType === 'rsync') { result = await performRsyncBackup(config, files); } else if (destinationType === 's3') { result = await performS3Backup(config, files); } else { throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`); } const endTime = new Date(); const durationSeconds = Math.round((endTime - startTime) / 1000); let manifestPath = null; let manifestSummary = null; try { logger.info('Generating backup manifest...'); const previousBackup = await getPreviousSuccessfulBackup(runId); const manifestFiles = buildManifestFiles(result.backedUpFiles, files); const databaseInfo = result.databaseInfo || await service.getDatabaseBackupInfo(); const manifestOptions = { backupType: previousBackup ? 'incremental' : 'full', backupPath: result.backupPath, files: manifestFiles, databaseInfo, parentBackupId: previousBackup ? previousBackup.manifest_id : null, format: config.backup_manifest_format || 'json', customMetadata: { backup_run_id: runId, destination_type: destinationType, retentionDays: config.backup_retention_days || 30 } }; let manifest = await backupManifest.generateManifest(manifestOptions); if (previousBackup && previousBackup.manifest_path) { try { const parentManifest = await backupManifest.loadManifest(previousBackup.manifest_path); manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest); } catch (error) { logger.warn('Failed to load parent manifest, generating full manifest:', error); } } if (result.s3Client) { manifestPath = await saveManifestToS3(manifest, `backup-manifest-${manifest.backup.id}.${manifestOptions.format}`, config, result); } else { manifestPath = await saveManifestToLocal(manifest, `backup-manifest-${manifest.backup.id}.${manifestOptions.format}`, config); } try { manifestSummary = backupManifest.generateSummaryReport ? backupManifest.generateSummaryReport(manifest) : null; } catch (error) { logger.warn('Failed to generate manifest summary:', error); } } catch (error) { logger.error('Failed to generate backup manifest:', error); } await db('backup_runs') .where('id', runId) .update({ completed_at: endTime, status: 'completed', files_backed_up: result.backedUpCount, total_size_bytes: result.backedUpSize, duration_seconds: durationSeconds, manifest_path: manifestPath, manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null, manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null, statistics: JSON.stringify({ totalFilesChecked: files.length, filesBackedUp: result.backedUpCount, totalSize: result.backedUpSize, averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0, destination: destinationType }) }); logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`); if (normalizeBoolean(config.backup_email_on_success)) { const admins = await db('admin_users').where('is_active', formatBoolean(true)); for (const admin of admins) { await queueEmail(null, admin.email, 'backup_completed', { start_time: startTime.toISOString(), duration: `${durationSeconds} seconds`, files_count: String(result.backedUpCount), total_size: formatBytes(result.backedUpSize), backup_type: destinationType }); } } } catch (error) { logger.error('Backup failed:', error); if (runId !== null) { await db('backup_runs') .where('id', runId) .update({ completed_at: new Date(), status: 'failed', error_message: error.message }); } const config = await resolveConfigWithFallback(); if (config && normalizeBoolean(config.backup_email_on_failure)) { const admins = await db('admin_users').where('is_active', formatBoolean(true)); for (const admin of admins) { await queueEmail(null, admin.email, 'backup_failed', { start_time: startTime.toISOString(), backup_type: (config.backup_destination_type || 'unknown').toString(), error_message: error.message }); } } } finally { isRunning = false; } } async function startBackupService() { try { const config = await resolveConfigWithFallback(); if (!config || !normalizeBoolean(config.backup_enabled)) { if (backupJob) { backupJob.stop(); backupJob = null; } logger.info('Backup service is disabled'); return; } if (backupJob) { backupJob.stop(); backupJob = null; } let schedule = '0 2 * * *'; if (Object.prototype.hasOwnProperty.call(config, 'backup_schedule')) { const candidate = String(config.backup_schedule ?? '').trim(); if (candidate.length) { schedule = candidate; } } else if (config.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_schedule')) { const candidate = String(parseSettingValue(config.__raw.backup_schedule) ?? '').trim(); if (candidate.length) { schedule = candidate; } } backupJob = cron.schedule(schedule, async () => { logger.info('Starting scheduled backup'); await service.runBackup(); }); logger.info(`Backup service started with schedule: ${schedule}`); } catch (error) { logger.error('Failed to start backup service:', error); } } function stopBackupService() { if (backupJob) { backupJob.stop(); backupJob = null; logger.info('Backup service stopped'); } } async function triggerManualBackup() { logger.info('Starting manual backup'); await service.runBackup(); } async function getBackupStatus(limit = 10) { try { const runs = await db('backup_runs') .orderBy('started_at', 'desc') .limit(limit); const lastRun = runs[0]; let manifestValid = false; if (lastRun && lastRun.manifest_path) { try { const manifest = await backupManifest.loadManifest(lastRun.manifest_path); if (backupManifest.validateManifest) { backupManifest.validateManifest(manifest); } manifestValid = true; } catch (error) { logger.warn('Manifest validation failed:', error); } } return { isRunning, isHealthy: Boolean(lastRun && lastRun.status === 'completed'), lastRun: lastRun ? { ...lastRun, manifestValid } : null, recentRuns: runs, nextScheduledRun: getNextScheduledRun() }; } catch (error) { logger.error('Failed to get backup status:', error); return { isRunning, isHealthy: false, error: error.message }; } } function getNextScheduledRun() { const now = new Date(); const next = new Date(now); next.setDate(now.getDate() + 1); next.setHours(2, 0, 0, 0); return next.toISOString(); } async function cleanupOldBackupRuns(retentionDays = 30) { try { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - retentionDays); const deleted = await db('backup_runs') .where('started_at', '<', cutoff) .delete(); if (deleted > 0) { logger.info(`Cleaned up ${deleted} old backup runs`); } } catch (error) { logger.error('Failed to cleanup old backup runs:', error); } } async function getBackupManifest(backupRunId) { const run = await db('backup_runs') .where('id', backupRunId) .first(); if (!run || !run.manifest_path) { throw new Error('Backup manifest not found'); } if (!run.manifest_path.startsWith('s3://')) { const manifest = await backupManifest.loadManifest(run.manifest_path); return { manifest, summary: backupManifest.generateSummaryReport ? backupManifest.generateSummaryReport(manifest) : null }; } const config = await resolveConfigWithFallback(); const accessKey = config?.backup_s3_access_key ?? (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_s3_access_key') ? parseSettingValue(config.__raw.backup_s3_access_key) : undefined) ?? process.env.BACKUP_S3_ACCESS_KEY; const secretKey = config?.backup_s3_secret_key ?? (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_s3_secret_key') ? parseSettingValue(config.__raw.backup_s3_secret_key) : undefined) ?? process.env.BACKUP_S3_SECRET_KEY; if (!accessKey || !secretKey) { throw new Error('S3 credentials not configured for manifest retrieval'); } const match = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/); if (!match) { throw new Error('Invalid S3 manifest path'); } const [, bucket, key] = match; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-')); const tempPath = path.join(tempDir, `manifest-${backupRunId}.json`); const s3Client = new S3StorageAdapter({ bucket, region: (config && config.backup_s3_region) || 'us-east-1', endpoint: config && config.backup_s3_endpoint, accessKeyId: accessKey, secretAccessKey: secretKey, forcePathStyle: config ? normalizeBoolean(config.backup_s3_force_path_style) : false, sslEnabled: config && config.backup_s3_ssl_enabled !== undefined ? normalizeBoolean(config.backup_s3_ssl_enabled) : true }); await s3Client.download(key, tempPath); const manifest = await backupManifest.loadManifest(tempPath); await fs.unlink(tempPath).catch(() => {}); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); return { manifest, summary: backupManifest.generateSummaryReport ? backupManifest.generateSummaryReport(manifest) : null }; } async function validateBackupManifest(manifestPath) { try { let manifest; if (manifestPath.startsWith('s3://')) { const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); if (!match) { throw new Error('Invalid S3 manifest path'); } const [, bucket, key] = match; const config = await service.getBackupConfig(); if (!config || !config.backup_s3_access_key || !config.backup_s3_secret_key) { throw new Error('S3 credentials not configured for manifest validation'); } const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-manifest-')); const tempPath = path.join(tempDir, `validate-${Date.now()}.json`); const s3Client = new S3StorageAdapter({ bucket, region: config.backup_s3_region || 'us-east-1', endpoint: config.backup_s3_endpoint, accessKeyId: config.backup_s3_access_key, secretAccessKey: config.backup_s3_secret_key, forcePathStyle: normalizeBoolean(config.backup_s3_force_path_style), sslEnabled: config.backup_s3_ssl_enabled === undefined ? true : normalizeBoolean(config.backup_s3_ssl_enabled) }); await s3Client.download(key, tempPath); manifest = await backupManifest.loadManifest(tempPath); await fs.unlink(tempPath).catch(() => {}); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); } else { manifest = await backupManifest.loadManifest(manifestPath); } if (backupManifest.validateManifest) { backupManifest.validateManifest(manifest); } return { valid: true, manifest }; } catch (error) { return { valid: false, error: error.message }; } } service.getBackupConfig = getBackupConfigInternal; service.getDatabaseBackupInfo = getDatabaseBackupInfoInternal; service.getFilesToBackup = getFilesToBackupInternal; service.runBackup = runBackupInternal; service.startBackupService = startBackupService; service.stopBackupService = stopBackupService; service.triggerManualBackup = triggerManualBackup; service.getBackupStatus = getBackupStatus; service.cleanupOldBackupRuns = cleanupOldBackupRuns; service.getBackupManifest = getBackupManifest; service.validateBackupManifest = validateBackupManifest; module.exports = service;