feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager
This commit is contained in:
@@ -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)); }
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user