const express = require('express'); const fsp = require('fs').promises; const fs = require('fs'); const path = require('path'); const { success } = require('../src/utils/responses'); const { validateBody, schemas } = require('../src/utilities/validate'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES ? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10) : 0; /** * 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, maxStorageBytes: backup.maxStorageBytes || null, 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, validateBody(schemas.backupScheduleCreate), asyncHandler(async (req, res) => { // appId is guaranteed present by the Joi schema (backupScheduleCreate requires it) const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body; const config = backupManager.getConfig(); if (!config.backups) config.backups = {}; // Parse maxStorageBytes if provided as string (e.g. "10GB") const parsedMaxStorage = maxStorageBytes ? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes) : null; // 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 }], maxStorageBytes: parsedMaxStorage }; 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('../src/utilities/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('../src/utilities/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', validateBody(schemas.backupRestoreFile), 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('../src/utilities/errors'); throw new ValidationError('Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { const { NotFoundError } = require('../src/utilities/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('../src/utilities/errors'); throw new ValidationError('Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { const { NotFoundError } = require('../src/utilities/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', validateBody(schemas.backupConfigUpdate), asyncHandler(async (req, res) => { // P0-3 fix: was `backupManager.updateConfig(req.body)` which allowed // arbitrary keys from HTTP request body to be merged into persisted config. // Now destructure only the two known top-level fields. const { backups, defaultRetention } = req.body || {}; const patch = {}; if (backups !== undefined) patch.backups = backups; if (defaultRetention !== undefined) patch.defaultRetention = defaultRetention; backupManager.updateConfig(patch); 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')); // Get storage info for backups destination router.get('/backups/storage-info', asyncHandler(async (req, res) => { const storageInfo = await getStorageInfo(); success(res, storageInfo); }, 'backups-storage-info')); // Schedule a backup // LEGACY: this is a duplicate registration of POST /backups/schedule (see also line 60, // which uses the appId-keyed schema and is the route the frontend actually calls). // Express only matches the first registered handler per METHOD+PATH, so this handler // is unreachable. It is preserved for now to avoid removing a route any unknown // integration might still POST to. TODO: audit + remove in a dedicated cleanup PR. router.post('/backups/schedule', asyncHandler(async (req, res) => { const { name, schedule, maxStorageBytes, ...backupConfig } = req.body; if (!name || !schedule) { return res.status(400).json({ error: 'name and schedule are required' }); } const config = backupManager.getConfig(); // Store maxStorageBytes in the backup config (converted to bytes) const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0 ? maxStorageBytes : (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0); config.backups[name] = { ...backupConfig, enabled: true, schedule, maxStorageBytes: maxBytes, destinations: backupConfig.destinations || [{ type: 'local' }] }; backupManager.updateConfig(config); success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes }); }, 'backups-schedule-legacy')); // Restore from backup router.post('/backups/restore/:backupId', validateBody(schemas.backupRestore), 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('../src/utilities/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('../src/managers/credential-manager'); const provider = req.params.provider; if (!['dropbox', 'webdav', 'sftp'].includes(provider)) { const { ValidationError } = require('../src/utilities/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('../src/managers/credential-manager'); const { ValidationError } = require('../src/utilities/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('../src/managers/credential-manager'); const { ValidationError } = require('../src/utilities/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]; } /** * Get storage information for the backup directory */ async function getStorageInfo() { const result = { destination: DEFAULT_BACKUP_DIR, maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES, usedBytes: 0, availableBytes: 0, usagePercent: 0, backupCount: 0, oldestBackup: null, newestBackup: null }; try { // Get disk space info const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR); result.availableBytes = diskSpace.available; // Scan for backup files if (DEFAULT_MAX_STORAGE_BYTES > 0) { result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES; } else { result.maxStorageBytes = diskSpace.total || 0; } let totalSize = 0; let oldestTime = null; let newestTime = null; try { const entries = await fsp.readdir(DEFAULT_BACKUP_DIR); for (const entry of entries) { if (entry.endsWith('.backup')) { const filePath = path.join(DEFAULT_BACKUP_DIR, entry); try { const stats = await fsp.stat(filePath); totalSize += stats.size; result.backupCount++; const fileTime = new Date(stats.mtime); if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime; if (!newestTime || fileTime > newestTime) newestTime = fileTime; } catch (e) { // Skip files we can't stat } } } } catch (e) { // Backup directory might not exist yet } result.usedBytes = totalSize; result.oldestBackup = oldestTime ? oldestTime.toISOString() : null; result.newestBackup = newestTime ? newestTime.toISOString() : null; // Calculate available (total limit - used), or from disk space if no limit set if (result.maxStorageBytes > 0) { result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize); result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2)); } else if (diskSpace.total) { result.availableBytes = diskSpace.available; result.usagePercent = diskSpace.total > 0 ? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2)) : 0; } } catch (error) { process.stderr.write(`[BackupsRouter] Error getting storage info: ${error.message}\n`); } return result; } /** * Get disk space info (filesystem-agnostic) */ async function getDiskSpaceInfo(dirPath) { try { const diskInfo = await fsp.statfs(dirPath); return { total: diskInfo.blocks * diskInfo.bsize, available: diskInfo.bfree * diskInfo.bsize, used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize }; } catch (error) { // Directory might not exist or be accessible return { total: 0, available: 0, used: 0 }; } } /** * Parse storage size string like "10GB" to bytes */ function parseStorageSize(sizeStr) { if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0; const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i); if (!match) return 0; const value = parseFloat(match[1]); const unit = (match[2] || 'B').toUpperCase(); const multipliers = { 'B': 1, 'K': 1024, 'KB': 1024, 'M': 1024 * 1024, 'MB': 1024 * 1024, 'G': 1024 * 1024 * 1024, 'GB': 1024 * 1024 * 1024, 'T': 1024 * 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024 }; return Math.floor(value * (multipliers[unit] || 1)); }