From ad9400490dd26a84b4a3ced09a274a627ff8dcf6 Mon Sep 17 00:00:00 2001 From: Krystie Date: Thu, 28 May 2026 15:00:41 -0700 Subject: [PATCH] Merge: resolve conflict in routes/backups.js, keep storage-info + maxStorageBytes --- dashcaddy-api/backup-manager.js | 75 +++++++- dashcaddy-api/routes/backups.js | 180 +++++++++++++++++- dashcaddy-installer/install.sh | 56 ++++++ .../src/main/config-manager.js | 25 ++- dashcaddy-installer/src/renderer/wizard.js | 52 +++++ .../templates/docker-compose.template.yml | 13 ++ 6 files changed, 387 insertions(+), 14 deletions(-) diff --git a/dashcaddy-api/backup-manager.js b/dashcaddy-api/backup-manager.js index 57c1bb8..f29a099 100644 --- a/dashcaddy-api/backup-manager.js +++ b/dashcaddy-api/backup-manager.js @@ -140,8 +140,16 @@ class BackupManager extends EventEmitter { // Calculate checksum const checksum = this.calculateChecksum(finalData); - // Save to destinations + // Check and enforce maxStorageBytes limit BEFORE saving const destinations = backup.destinations || [{ type: 'local' }]; + for (const dest of destinations) { + if (backup.maxStorageBytes && backup.maxStorageBytes > 0) { + const destPath = dest.path || DEFAULT_BACKUP_DIR; + await this.enforceStorageLimit(backup.maxStorageBytes, destPath, backupId, finalData.length); + } + } + + // Save to destinations const savedLocations = []; for (const dest of destinations) { @@ -1053,6 +1061,71 @@ class BackupManager extends EventEmitter { this.saveHistory(); } + /** + * Enforce storage limit by pruning oldest backups if needed. + * Works with any filesystem (ext4, Btrfs, XFS, ZFS, APFS, NTFS). + * @param {number} maxBytes - Maximum storage limit in bytes + * @param {string} backupDir - Backup directory path + * @param {string} pendingBackupId - ID of backup about to be written + * @param {number} pendingSize - Estimated size of pending backup in bytes + */ + async enforceStorageLimit(maxBytes, backupDir, pendingBackupId, pendingSize) { + console.log(`[BackupManager] Checking storage limit: max=${maxBytes} bytes`); + + // Find all backup files for this destination + const files = []; + if (fs.existsSync(backupDir)) { + const entries = fs.readdirSync(backupDir); + for (const entry of entries) { + if (entry.endsWith('.backup')) { + const filePath = path.join(backupDir, entry); + try { + const stats = fs.statSync(filePath); + files.push({ path: filePath, size: stats.size, mtime: stats.mtime }); + } catch (e) { + // Skip files we can't stat + } + } + } + } + + // Sort by modification time (oldest first) + files.sort((a, b) => new Date(a.mtime) - new Date(b.mtime)); + + let totalSize = files.reduce((sum, f) => sum + f.size, 0) + pendingSize; + + // If even ONE backup exceeds the limit, fail immediately + const largestFile = files.reduce((max, f) => f.size > max ? f.size : max, 0); + if (largestFile > maxBytes) { + throw new Error( + `Backup too large (${largestFile} bytes) for configured storage limit (${maxBytes} bytes). ` + + `Increase the storage limit or remove large backups manually.` + ); + } + + // Prune oldest backups until we fit within limit + while (files.length > 0 && totalSize > maxBytes) { + const oldest = files.shift(); + console.log(`[BackupManager] Pruning oldest backup to free space: ${oldest.path} (${oldest.size} bytes)`); + try { + fs.unlinkSync(oldest.path); + // Also remove from history if present + const historyEntry = this.history.find(h => + h.locations && h.locations.some(l => l.path === oldest.path) + ); + if (historyEntry) { + this.history = this.history.filter(h => h.id !== historyEntry.id); + } + totalSize -= oldest.size; + } catch (error) { + console.error(`[BackupManager] Error pruning backup ${oldest.path}:`, error.message); + } + } + + this.saveHistory(); + console.log(`[BackupManager] Storage after pruning: ${totalSize}/${maxBytes} bytes`); + } + /** * Add entry to backup history */ diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js index a2e7a00..d89d59c 100644 --- a/dashcaddy-api/routes/backups.js +++ b/dashcaddy-api/routes/backups.js @@ -1,9 +1,13 @@ const express = require('express'); -const { success } = require('../response-helpers'); -const fs = require('fs'); +const fsp = require('fs').promises; const path = require('path'); +const fs = require('fs'); +const { success } = require('../response-helpers'); -const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups'); +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 @@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { 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 @@ -52,8 +57,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { // 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; - + const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body; + if (!appId) { const { ValidationError } = require('../errors'); throw new ValidationError('appId is required'); @@ -61,7 +66,12 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { 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, @@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { destination: destination || 'local', destinationPath: destinationPath || DEFAULT_BACKUP_DIR, include: ['all'], - destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }] + destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }], + maxStorageBytes: parsedMaxStorage }; config.backups[appId] = backupConfig; @@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { 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 + 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')); + // Restore from backup router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { const result = await backupManager.restoreBackup(req.params.backupId, req.body); @@ -616,7 +660,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { */ function calculateNextRun(lastRun, schedule) { if (!lastRun) return null; - + const intervals = { 'hourly': 60 * 60 * 1000, 'daily': 24 * 60 * 60 * 1000, @@ -625,7 +669,7 @@ function calculateNextRun(lastRun, schedule) { }; const baseInterval = intervals[schedule]; - + if (baseInterval) { return new Date(lastRun.getTime() + baseInterval); } @@ -653,3 +697,121 @@ function formatBytes(bytes) { 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) { + console.error('[BackupsRouter] Error getting storage info:', error.message); + } + + 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)); +} diff --git a/dashcaddy-installer/install.sh b/dashcaddy-installer/install.sh index 333305d..c663812 100644 --- a/dashcaddy-installer/install.sh +++ b/dashcaddy-installer/install.sh @@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api" readonly DASHBOARD_DIR="${SITES_DIR}/status" readonly CONTAINER_NAME="dashcaddy-api" readonly CADDY_ADMIN_PORT=2019 +readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}" +readonly DEFAULT_MAX_STORAGE_BYTES="" # ---- Tunables (overridable via flags) -------------------------------------- API_PORT=3001 LOCAL_PORT=8080 +BACKUP_DIR="" +BACKUP_LIMIT="" # ---- Runtime state --------------------------------------------------------- DOMAIN_MODE="" # public | custom-tld | local @@ -389,6 +393,7 @@ EOF create_directories() { mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets" mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts + mkdir -p "${BACKUP_DIR}" ok "Directories created" } @@ -626,7 +631,41 @@ CEOF # Docker Compose # ============================================================================ +# Parse size string like "10GB" or "1TB" to bytes +parse_size_to_bytes() { + local size="$1" + local value unit + + # Strip whitespace + size=$(echo "$size" | tr -d ' ') + + # Extract numeric value and unit + if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then + value="${BASH_REMATCH[1]}" + unit="${BASH_REMATCH[2]}" + + # Normalize unit to uppercase without 'B' suffix for simplicity + unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]') + case "$unit" in + K|KB) echo $((value * 1024)) ;; + M|MB) echo $((value * 1024 * 1024)) ;; + G|GB) echo $((value * 1024 * 1024 * 1024)) ;; + T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;; + *) echo "$value" ;; + esac + else + # Not recognized, treat as raw bytes + echo "$size" + fi +} + generate_docker_compose() { + # Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240) + local backup_limit_bytes="" + if [[ -n "$BACKUP_LIMIT" ]]; then + backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT") + fi + cat > "${API_DIR}/docker-compose.yml" <} Disk space info */ async getDiskSpace(testPath) { - // Note: This is a simplified version. In production, you'd use a library like 'check-disk-space' try { - const stats = await fs.stat(testPath); - + const fsPromises = require('fs').promises; + const pathModule = require('path'); + + // Ensure directory exists + await fsPromises.mkdir(testPath, { recursive: true }); + + // Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS) + const stats = await fsPromises.statfs(testPath); + + const totalBytes = stats.blocks * stats.bsize; + const freeBytes = stats.bfree * stats.bsize; + const availableBytes = stats.bavail * stats.bsize; // Available to non-root users + const usedBytes = totalBytes - freeBytes; + return { available: true, - path: testPath + path: testPath, + total: totalBytes, + used: usedBytes, + free: freeBytes, + availableBytes: availableBytes, + usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2)) }; } catch (error) { return { available: false, + path: testPath, error: error.message }; } diff --git a/dashcaddy-installer/src/renderer/wizard.js b/dashcaddy-installer/src/renderer/wizard.js index 9af7a08..4c20b71 100644 --- a/dashcaddy-installer/src/renderer/wizard.js +++ b/dashcaddy-installer/src/renderer/wizard.js @@ -62,6 +62,11 @@ const state = { installPath: '', health: null }, + // Backup configuration + backup: { + maxStorageGB: 10, + backupDir: '' + }, // Uninstall mode uninstallMode: false, uninstall: { @@ -373,6 +378,24 @@ function updateBranding(field, value) { if (field === 'primaryColor') render(); } +// Backup functions +function updateBackup(field, value) { + state.backup[field] = value; + render(); +} + +async function selectBackupDir() { + try { + const result = await window.electronAPI.selectFolder(); + if (result.success && result.path) { + state.backup.backupDir = result.path; + render(); + } + } catch (err) { + console.error('Backup dir selection failed:', err); + } +} + async function selectLogo() { try { const result = await window.electronAPI.selectFile({ @@ -420,6 +443,10 @@ async function startInstallation() { password: state.dns.password, token: state.dns.token } : null, + backup: { + maxStorageGB: state.backup.maxStorageGB, + backupDir: state.backup.backupDir || null + }, autoStart: true }); } catch (err) { @@ -963,6 +990,31 @@ function renderDashboardSetup() {

Port for the DashCaddy API server (default: 3001)

` : ''} + +
+ +
+ +
+

Maximum storage for backups in GB (default: 10, max: 10TB)

+
+ + ${state.tier !== 'basic' ? ` +
+ +
+ + +
+

Where backup files are stored on the host

+
+ ` : ''} `; diff --git a/dashcaddy-installer/templates/docker-compose.template.yml b/dashcaddy-installer/templates/docker-compose.template.yml index 6d80f07..4cbbd9e 100644 --- a/dashcaddy-installer/templates/docker-compose.template.yml +++ b/dashcaddy-installer/templates/docker-compose.template.yml @@ -7,15 +7,28 @@ services: volumes: - {{API_PATH}}:/app - /var/run/docker.sock:/var/run/docker.sock + - dashcaddy-backups:/app/backups environment: - NODE_ENV=production - PORT={{API_PORT}} - SERVICES_FILE=/app/services.json - CADDY_ADMIN_URL=http://host.docker.internal:2019 + - BACKUP_DIR=/app/backups + - BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}} + - BACKUP_CONFIG_FILE=/app/backup-config.json + - BACKUP_HISTORY_FILE=/app/backup-history.json restart: unless-stopped networks: - dashcaddy +volumes: + dashcaddy-backups: + driver: local + driver_opts: + type: none + o: bind + device: {{BACKUP_DIR}} + networks: dashcaddy: driver: bridge