feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager

This commit is contained in:
Hermes
2026-05-27 23:39:46 -07:00
parent 6ce0a18f98
commit 11823a1466
19 changed files with 3686 additions and 407 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ module.exports = function(ctx) {
try { router.use('/apps', initTemplates(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
try { router.use('/restore', initRestore(subCtx)); }
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
try { router.use('/compose', initCompose(subCtx)); }
+187
View File
@@ -1,6 +1,10 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const { DOCKER } = require('../../constants');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
/**
* Apps restore routes factory
* @param {Object} deps - Explicit dependencies
@@ -122,6 +126,180 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
res.json({ success: true, services: status });
}, 'apps-restore-status'));
// ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ====================
// Get available backup files for a specific app
router.get('/:appId/backup-points', 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(),
modified: stats.mtime.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));
res.json({
success: true,
appId,
isBackupFile: true,
files,
total: files.length
});
}, 'apps-backup-points'));
// Revert a specific app to a backup file (point-in-time restore)
router.post('/:appId/revert/:filename', asyncHandler(async (req, res) => {
const { appId, filename } = req.params;
const { encryptionKey, restartContainers } = req.body || {};
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ success: false, error: 'Invalid filename' });
}
const filepath = path.join(DEFAULT_BACKUP_DIR, filename);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: `Backup file not found: ${filename}` });
}
try {
// Read the backup file
let fileData = fs.readFileSync(filepath);
// Decrypt if needed
if (encryptionKey) {
try {
fileData = await backupManager.decryptBackup(fileData, encryptionKey);
} catch (err) {
return res.status(400).json({ success: false, 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-revert-${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);
}
// Read manifest if present
let manifest = null;
const manifestPath = path.join(tempDir, 'manifest.json');
if (fs.existsSync(manifestPath)) {
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (_) {}
}
// Read app-specific data
const appServicesPath = path.join(tempDir, 'services.json');
const appConfigPath = path.join(tempDir, 'config.json');
const appCredsPath = path.join(tempDir, 'credentials.json');
let restoreData = { services: null, config: null, credentials: null };
if (fs.existsSync(appServicesPath)) {
try { restoreData.services = JSON.parse(fs.readFileSync(appServicesPath, 'utf8')); } catch (_) {}
}
if (fs.existsSync(appConfigPath)) {
try { restoreData.config = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')); } catch (_) {}
}
if (fs.existsSync(appCredsPath)) {
try { restoreData.credentials = JSON.parse(fs.readFileSync(appCredsPath, '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);
// Cleanup temp dir
fs.rmSync(tempDir, { recursive: true, force: true });
res.json({
success: true,
isBackupFile: true,
restored: {
services: !!restoreData.services,
config: !!restoreData.config,
credentials: !!restoreData.credentials
},
message: `${appId} reverted to backup successfully`
});
} else {
// Preview mode
fs.rmSync(tempDir, { recursive: true, force: true });
res.json({
success: true,
isBackupFile: true,
preview: true,
filename,
appId,
manifest,
restoreData: {
hasServices: !!restoreData.services,
hasConfig: !!restoreData.config,
hasCredentials: !!restoreData.credentials
}
});
}
} catch (err) {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
throw err;
}
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}, 'apps-revert'));
/**
* Core restore logic for a single service.
*/
@@ -309,3 +487,12 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
return router;
};
// Helper: format bytes to human readable
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];
}