const express = require('express'); const { success } = require('../src/utils/responses'); const fs = require('fs'); const path = require('path'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups'); /** * Backups routes factory * @param {Object} deps - Explicit dependencies * @param {Object} deps.backupManager - Backup management module * @param {Object} deps.licenseManager - License manager for premium gating * @param {Function} deps.asyncHandler - Async route handler wrapper * @returns {express.Router} */ module.exports = function({ backupManager, licenseManager, asyncHandler }) { const router = express.Router(); // ==================== SCHEDULE ENDPOINTS (PREMIUM) ==================== // Apply premium gating to schedule-related routes const premiumGating = licenseManager.requirePremium('auto-backup'); // Get all scheduled backup configs router.get('/backups/schedule', premiumGating, asyncHandler(async (req, res) => { const config = backupManager.getConfig(); const backups = config.backups || {}; // Calculate next run times based on schedule and last history entry const history = backupManager.getHistory(1000); const schedules = Object.entries(backups).map(([appId, backup]) => { const appHistory = history.filter(h => h.name === appId && h.status === 'success'); const lastRun = appHistory.length > 0 ? new Date(appHistory[0].timestamp) : null; const nextRun = calculateNextRun(lastRun, backup.schedule); return { appId, enabled: backup.enabled || false, schedule: backup.schedule || 'daily', retention: backup.retention || { keep: 7, olderThan: null }, runImmediately: backup.runImmediately || false, destination: backup.destination || 'local', destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR, lastRun: lastRun ? lastRun.toISOString() : null, nextRun: nextRun ? nextRun.toISOString() : null, lastBackupId: appHistory.length > 0 ? appHistory[0].id : null }; }); success(res, { schedules }); }, 'backups-schedule-list')); // Create or update a scheduled backup for an app router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => { const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body; if (!appId) { const { ValidationError } = require('../errors'); throw new ValidationError('appId is required'); } const config = backupManager.getConfig(); if (!config.backups) config.backups = {}; // Build the backup config for this app const backupConfig = { enabled: enabled !== undefined ? enabled : true, schedule: schedule || 'daily', retention: retention || { keep: 7, olderThan: null }, runImmediately: runImmediately || false, destination: destination || 'local', destinationPath: destinationPath || DEFAULT_BACKUP_DIR, include: ['all'], destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }] }; config.backups[appId] = backupConfig; backupManager.updateConfig(config); success(res, { message: `Backup schedule ${enabled === false ? 'disabled' : 'updated'} for ${appId}`, schedule: { appId, ...backupConfig, retention: backupConfig.retention } }); }, 'backups-schedule-update')); // Remove scheduled backup for an app router.delete('/backups/schedule/:appId', premiumGating, asyncHandler(async (req, res) => { const { appId } = req.params; const config = backupManager.getConfig(); if (!config.backups || !config.backups[appId]) { const { NotFoundError } = require('../errors'); throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404'); } delete config.backups[appId]; backupManager.updateConfig(config); success(res, { message: `Backup schedule removed for ${appId}` }); }, 'backups-schedule-delete')); // List backup files on disk router.get('/backups/files', asyncHandler(async (req, res) => { const backupDir = DEFAULT_BACKUP_DIR; const files = []; try { if (fs.existsSync(backupDir)) { const entries = fs.readdirSync(backupDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.backup')) { try { const filepath = path.join(backupDir, entry.name); const stats = fs.statSync(filepath); const nameWithoutExt = entry.name.replace('.backup', ''); const parts = nameWithoutExt.split('-'); const appId = parts[0]; const timestamp = parts.length > 1 ? parseInt(parts[parts.length - 1]) : stats.mtimeMs; files.push({ name: entry.name, appId, size: stats.size, sizeFormatted: formatBytes(stats.size), timestamp: new Date(timestamp).toISOString(), path: filepath }); } catch (err) { // Skip malformed filenames } } } } } catch (err) { // Directory might not exist yet } // Sort by timestamp descending (newest first) files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); success(res, { files, total: files.length }); }, 'backups-files-list')); // Trigger immediate backup for an app router.post('/backups/backup/:appId', asyncHandler(async (req, res) => { const { appId } = req.params; const config = backupManager.getConfig(); const backupConfig = config.backups && config.backups[appId]; if (!backupConfig) { const { NotFoundError } = require('../errors'); throw new NotFoundError(`No backup schedule found for app: ${appId}`, 'DC-404'); } const backup = await backupManager.executeBackup(appId, { ...backupConfig, destinations: backupConfig.destinations || [{ type: backupConfig.destination || 'local', path: backupConfig.destinationPath || DEFAULT_BACKUP_DIR }] }); success(res, { message: `Backup started for ${appId}`, backup: { id: backup.id, name: backup.name, timestamp: backup.timestamp, size: backup.size, status: backup.status } }); }, 'backups-backup-trigger')); // List backup files for a specific app router.get('/backups/files/:appId', asyncHandler(async (req, res) => { const { appId } = req.params; const backupDir = DEFAULT_BACKUP_DIR; const files = []; try { if (fs.existsSync(backupDir)) { const entries = fs.readdirSync(backupDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.backup')) { try { const nameWithoutExt = entry.name.replace('.backup', ''); const parts = nameWithoutExt.split('-'); const fileAppId = parts[0]; // Only include files for the requested app if (fileAppId !== appId) continue; const filepath = path.join(backupDir, entry.name); const stats = fs.statSync(filepath); const timestamp = parts.length > 1 ? parseInt(parts[parts.length - 1]) : stats.mtimeMs; files.push({ name: entry.name, appId: fileAppId, size: stats.size, sizeFormatted: formatBytes(stats.size), timestamp: new Date(timestamp).toISOString(), path: filepath }); } catch (err) { // Skip malformed filenames } } } } } catch (err) { // Directory might not exist yet } // Sort by timestamp descending (newest first) files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); success(res, { files, total: files.length }); }, 'backups-files-app')); // Restore from a specific backup file on disk router.post('/backups/restore-file/:filename', asyncHandler(async (req, res) => { const { filename } = req.params; const { encryptionKey, restartContainers } = req.body || {}; // Security: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { const { ValidationError } = require('../errors'); throw new ValidationError('Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { const { NotFoundError } = require('../errors'); throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404'); } // Read the backup file let fileData = fs.readFileSync(filepath); // Decrypt if needed (format: iv:authTag:encrypted base64) if (encryptionKey) { try { fileData = await backupManager.decryptBackup(fileData, encryptionKey); } catch (err) { throw new Error('Failed to decrypt backup: ' + err.message); } } // Decompress const backupData = await backupManager.decompressBackup(fileData); // Extract to temp directory for inspection/restoration const os = require('os'); const crypto = require('crypto'); const tempDir = path.join(os.tmpdir(), `dashcaddy-restore-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`); fs.mkdirSync(tempDir, { recursive: true }); try { // Write the decompressed JSON as a tar.gz to extract const tarPath = path.join(tempDir, 'backup.tar.gz'); fs.writeFileSync(tarPath, backupData); // Extract tar.gz const { execSync } = require('child_process'); try { execSync(`cd "${tempDir}" && tar -xzf backup.tar.gz`, { stdio: 'pipe' }); } catch (tarErr) { throw new Error('Failed to extract backup archive: ' + tarErr.message); } // Read manifest if present const manifestPath = path.join(tempDir, 'manifest.json'); let manifest = null; if (fs.existsSync(manifestPath)) { try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (_) { /* ignore malformed manifest */ } } // Read extracted data files const restoreData = { services: null, config: null, credentials: null, volumes: null }; const servicesPath = path.join(tempDir, 'services.json'); if (fs.existsSync(servicesPath)) { try { restoreData.services = JSON.parse(fs.readFileSync(servicesPath, 'utf8')); } catch (_) {} } const configPath = path.join(tempDir, 'config.json'); if (fs.existsSync(configPath)) { try { restoreData.config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (_) {} } const credsPath = path.join(tempDir, 'credentials.json'); if (fs.existsSync(credsPath)) { try { restoreData.credentials = JSON.parse(fs.readFileSync(credsPath, 'utf8')); } catch (_) {} } const volumesPath = path.join(tempDir, 'volumes.json'); if (fs.existsSync(volumesPath)) { try { restoreData.volumes = JSON.parse(fs.readFileSync(volumesPath, 'utf8')); } catch (_) {} } // If restartContainers is true, actually perform the restore if (restartContainers) { if (restoreData.services) { backupManager.restoreServices(restoreData.services); } if (restoreData.config) { backupManager.restoreConfig(restoreData.config); } if (restoreData.credentials) { backupManager.restoreCredentials(restoreData.credentials); } if (restoreData.volumes) { await backupManager.restoreVolumes(restoreData.volumes); } // Cleanup temp dir fs.rmSync(tempDir, { recursive: true, force: true }); success(res, { restored: { services: !!restoreData.services, config: !!restoreData.config, credentials: !!restoreData.credentials, volumes: !!restoreData.volumes }, message: 'Backup restored successfully' }); } else { // Preview mode: return what would be restored fs.rmSync(tempDir, { recursive: true, force: true }); success(res, { preview: true, filename, size: fs.statSync(filepath).size, sizeFormatted: formatBytes(fs.statSync(filepath).size), manifest, restoreData: { hasServices: !!restoreData.services, hasConfig: !!restoreData.config, hasCredentials: !!restoreData.credentials, hasVolumes: !!restoreData.volumes } }); } } catch (err) { // Cleanup on error try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {} throw err; } }, 'backups-restore-file')); // Compare a backup file against current state router.post('/backups/compare/:filename', asyncHandler(async (req, res) => { const { filename } = req.params; const { encryptionKey } = req.body || {}; // Security: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { const { ValidationError } = require('../errors'); throw new ValidationError('Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { const { NotFoundError } = require('../errors'); throw new NotFoundError(`Backup file not found: ${filename}`, 'DC-404'); } // Read the backup file let fileData = fs.readFileSync(filepath); // Decrypt if needed if (encryptionKey) { try { fileData = await backupManager.decryptBackup(fileData, encryptionKey); } catch (err) { throw new Error('Failed to decrypt backup: ' + err.message); } } // Decompress const backupData = await backupManager.decompressBackup(fileData); // Extract to temp directory const os = require('os'); const crypto = require('crypto'); const tempDir = path.join(os.tmpdir(), `dashcaddy-compare-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`); fs.mkdirSync(tempDir, { recursive: true }); try { const tarPath = path.join(tempDir, 'backup.tar.gz'); fs.writeFileSync(tarPath, backupData); const { execSync } = require('child_process'); try { execSync(`cd "${tempDir}" && tar -xzf backup.tar.gz`, { stdio: 'pipe' }); } catch (tarErr) { throw new Error('Failed to extract backup archive: ' + tarErr.message); } // Build diff const diff = { filename, timestamp: fs.statSync(filepath).mtime.toISOString(), size: fs.statSync(filepath).size, sizeFormatted: formatBytes(fs.statSync(filepath).size), services: null, config: null }; // Compare services.json const servicesPath = path.join(tempDir, 'services.json'); if (fs.existsSync(servicesPath)) { try { const backupServices = JSON.parse(fs.readFileSync(servicesPath, 'utf8')); const currentServicesPath = process.env.SERVICES_FILE || path.join(__dirname, 'services.json'); let currentServices = null; if (fs.existsSync(currentServicesPath)) { currentServices = JSON.parse(fs.readFileSync(currentServicesPath, 'utf8')); } diff.services = { backup: backupServices, current: currentServices, hasChanges: JSON.stringify(backupServices) !== JSON.stringify(currentServices), backupCount: Array.isArray(backupServices) ? backupServices.length : 0, currentCount: Array.isArray(currentServices) ? currentServices.length : 0 }; } catch (_) {} } // Compare config.json const configPath = path.join(tempDir, 'config.json'); if (fs.existsSync(configPath)) { try { const backupConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); const currentConfigPath = process.env.CONFIG_FILE || path.join(__dirname, 'config.json'); let currentConfig = null; if (fs.existsSync(currentConfigPath)) { currentConfig = JSON.parse(fs.readFileSync(currentConfigPath, 'utf8')); } diff.config = { backup: backupConfig, current: currentConfig, hasChanges: JSON.stringify(backupConfig) !== JSON.stringify(currentConfig) }; } catch (_) {} } fs.rmSync(tempDir, { recursive: true, force: true }); success(res, { diff }); } catch (err) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {} throw err; } }, 'backups-compare')); // ==================== EXISTING ENDPOINTS ==================== // Get backup configuration router.get('/backups/config', asyncHandler(async (req, res) => { const config = backupManager.getConfig(); success(res, { config }); }, 'backups-config-get')); // Update backup configuration router.post('/backups/config', asyncHandler(async (req, res) => { backupManager.updateConfig(req.body); success(res, { message: 'Backup configuration updated' }); }, 'backups-config-update')); // Execute manual backup router.post('/backups/execute', asyncHandler(async (req, res) => { const backup = await backupManager.executeBackup('manual', req.body); success(res, { backup }); }, 'backups-execute')); // Get backup history router.get('/backups/history', asyncHandler(async (req, res) => { const limit = parseInt(req.query.limit) || 50; const history = backupManager.getHistory(limit); success(res, { history }); }, 'backups-history')); // Restore from backup router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { const result = await backupManager.restoreBackup(req.params.backupId, req.body); success(res, { result }); }, 'backups-restore')); // ==================== CLOUD DESTINATIONS ==================== // Test a destination (write+read+delete probe) router.post('/backups/test-destination', asyncHandler(async (req, res) => { const destination = req.body; if (!destination || !destination.type) { const { ValidationError } = require('../errors'); throw new ValidationError('destination.type is required'); } const result = await backupManager.testDestination(destination); success(res, result); }, 'backups-test-destination')); // Get cloud credentials (masked) for a provider // Provider: dropbox | webdav | sftp router.get('/backups/credentials/:provider', asyncHandler(async (req, res) => { const credentialManager = require('../credential-manager'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { const { ValidationError } = require('../errors'); throw new ValidationError('Invalid provider'); } const mask = (val) => val ? '***' : null; let creds = {}; if (provider === 'dropbox') { const token = await credentialManager.retrieve('backup.dropbox.token'); creds = { token: mask(token) }; } else if (provider === 'webdav') { creds = { url: (await credentialManager.retrieve('backup.webdav.url')) || null, username: (await credentialManager.retrieve('backup.webdav.username')) || null, password: mask(await credentialManager.retrieve('backup.webdav.password')) }; } else if (provider === 'sftp') { creds = { host: (await credentialManager.retrieve('backup.sftp.host')) || null, port: (await credentialManager.retrieve('backup.sftp.port')) || '22', username: (await credentialManager.retrieve('backup.sftp.username')) || null, password: mask(await credentialManager.retrieve('backup.sftp.password')), privateKey: mask(await credentialManager.retrieve('backup.sftp.privateKey')) }; } success(res, { provider, credentials: creds }); }, 'backups-credentials-get')); // Save cloud credentials for a provider router.post('/backups/credentials/:provider', asyncHandler(async (req, res) => { const credentialManager = require('../credential-manager'); const { ValidationError } = require('../errors'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { throw new ValidationError('Invalid provider'); } const body = req.body || {}; const storeIfPresent = async (key, val) => { if (val !== undefined && val !== null && val !== '' && val !== '***') { await credentialManager.store(key, String(val)); } }; if (provider === 'dropbox') { if (!body.token || body.token === '***') { const existing = await credentialManager.retrieve('backup.dropbox.token'); if (!existing) { throw new ValidationError('Dropbox token required'); } } else { await credentialManager.store('backup.dropbox.token', body.token); } } else if (provider === 'webdav') { await storeIfPresent('backup.webdav.url', body.url); await storeIfPresent('backup.webdav.username', body.username); await storeIfPresent('backup.webdav.password', body.password); } else if (provider === 'sftp') { await storeIfPresent('backup.sftp.host', body.host); await storeIfPresent('backup.sftp.port', body.port); await storeIfPresent('backup.sftp.username', body.username); await storeIfPresent('backup.sftp.password', body.password); await storeIfPresent('backup.sftp.privateKey', body.privateKey); } success(res, { message: `${provider} credentials saved` }); }, 'backups-credentials-set')); // Delete cloud credentials for a provider router.delete('/backups/credentials/:provider', asyncHandler(async (req, res) => { const credentialManager = require('../credential-manager'); const { ValidationError } = require('../errors'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { throw new ValidationError('Invalid provider'); } const keys = { dropbox: ['backup.dropbox.token'], webdav: ['backup.webdav.url', 'backup.webdav.username', 'backup.webdav.password'], sftp: ['backup.sftp.host', 'backup.sftp.port', 'backup.sftp.username', 'backup.sftp.password', 'backup.sftp.privateKey'] }; for (const k of keys[provider]) { try { await credentialManager.delete(k); } catch (_) { /* ignore */ } } success(res, { message: `${provider} credentials deleted` }); }, 'backups-credentials-delete')); return router; }; // Helper functions /** * Calculate next run time based on schedule and last run */ function calculateNextRun(lastRun, schedule) { if (!lastRun) return null; const intervals = { 'hourly': 60 * 60 * 1000, 'daily': 24 * 60 * 60 * 1000, 'weekly': 7 * 24 * 60 * 60 * 1000, 'monthly': 30 * 24 * 60 * 60 * 1000 }; const baseInterval = intervals[schedule]; if (baseInterval) { return new Date(lastRun.getTime() + baseInterval); } // Custom interval (e.g., "6h", "30m", "6" for 6 minutes) const match = schedule.match(/^(\d+)([mh])?$/); if (match) { const value = parseInt(match[1]); const unit = match[2] || 'm'; // default to minutes const ms = unit === 'h' ? value * 60 * 60 * 1000 : value * 60 * 1000; return new Date(lastRun.getTime() + ms); } // Default to daily return new Date(lastRun.getTime() + intervals.daily); } /** * Format bytes to human readable string */ function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }