Unify backup/restore into single v2.0 file with full state capture
Server export now includes encryption key, themes, and all config files. Client export bundles all DashCaddy localStorage keys (19 named + dynamic widget keys) as browserState. Restore handles both server and browser state in one operation. Legacy v1.0 import format still supported. Removed redundant Export/Import toolbar buttons — Backup modal is now the single entry point. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const fsp = require('fs').promises;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { CADDY } = require('../../constants');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
@@ -7,20 +8,36 @@ module.exports = function(ctx) {
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(ctx.SERVICES_FILE), 'themes');
|
||||
|
||||
function readAllThemes() {
|
||||
const themes = {};
|
||||
try {
|
||||
if (!fs.existsSync(THEMES_DIR)) return themes;
|
||||
const files = fs.readdirSync(THEMES_DIR).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const slug = path.basename(file, '.json');
|
||||
themes[slug] = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, file), 'utf8'));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return themes;
|
||||
}
|
||||
|
||||
// ===== BACKUP/RESTORE ENDPOINTS =====
|
||||
// Export and import DashCaddy configuration
|
||||
// Unified v2.0 backup — server config + encryption key + themes (browser state added client-side)
|
||||
|
||||
// Export all configuration as a downloadable JSON bundle
|
||||
router.get('/backup/export', ctx.asyncHandler(async (req, res) => {
|
||||
const backup = {
|
||||
version: '1.1',
|
||||
version: '2.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
dashcaddyVersion: '1.0.0',
|
||||
files: {},
|
||||
themes: {},
|
||||
assets: {}
|
||||
};
|
||||
|
||||
// Collect all configuration files
|
||||
// Collect all configuration files (encryption key now included for self-contained restore)
|
||||
const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
|
||||
const filesToBackup = [
|
||||
{ key: 'services', path: ctx.SERVICES_FILE, required: true },
|
||||
@@ -28,7 +45,7 @@ module.exports = function(ctx) {
|
||||
{ key: 'config', path: ctx.CONFIG_FILE, required: false },
|
||||
{ key: 'dnsCredentials', path: ctx.dns.credentialsFile, required: false },
|
||||
{ key: 'credentials', path: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'), required: false },
|
||||
// NOTE: encryptionKey deliberately excluded — bundling it with encrypted data defeats the encryption
|
||||
{ key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false },
|
||||
{ key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false },
|
||||
{ key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false },
|
||||
{ key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false }
|
||||
@@ -95,6 +112,13 @@ module.exports = function(ctx) {
|
||||
ctx.log.warn('backup', 'Could not include assets in backup', { error: e.message });
|
||||
}
|
||||
|
||||
// Include user-created themes
|
||||
try {
|
||||
backup.themes = readAllThemes();
|
||||
} catch (e) {
|
||||
ctx.log.warn('backup', 'Could not include themes in backup', { error: e.message });
|
||||
}
|
||||
|
||||
// Set headers for file download
|
||||
const backupFilename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -156,6 +180,16 @@ module.exports = function(ctx) {
|
||||
preview.serviceCount = services.length;
|
||||
}
|
||||
|
||||
// Count themes if present
|
||||
if (backup.themes && typeof backup.themes === 'object') {
|
||||
preview.themeCount = Object.keys(backup.themes).length;
|
||||
}
|
||||
|
||||
// Count browser state items if present
|
||||
if (backup.browserState && typeof backup.browserState === 'object') {
|
||||
preview.browserStateCount = Object.keys(backup.browserState).length;
|
||||
}
|
||||
|
||||
res.json({ success: true, preview });
|
||||
}, 'backup-preview'));
|
||||
|
||||
@@ -192,13 +226,14 @@ module.exports = function(ctx) {
|
||||
errors: []
|
||||
};
|
||||
|
||||
// File mapping (encryptionKey excluded — must never be overwritten from backup)
|
||||
const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
|
||||
const fileMapping = {
|
||||
services: ctx.SERVICES_FILE,
|
||||
caddyfile: ctx.caddy.filePath,
|
||||
config: ctx.CONFIG_FILE,
|
||||
dnsCredentials: ctx.dns.credentialsFile,
|
||||
credentials: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'),
|
||||
encryptionKey: ENCRYPTION_KEY_FILE,
|
||||
totpConfig: ctx.TOTP_CONFIG_FILE,
|
||||
tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE,
|
||||
notifications: ctx.NOTIFICATIONS_FILE
|
||||
@@ -304,6 +339,36 @@ module.exports = function(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore themes
|
||||
if (backup.themes && typeof backup.themes === 'object' && Object.keys(backup.themes).length) {
|
||||
try {
|
||||
if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true });
|
||||
for (const [slug, data] of Object.entries(backup.themes)) {
|
||||
if (/^[a-z0-9-]+$/.test(slug)) {
|
||||
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
results.restored.push(`themes:${Object.keys(backup.themes).length}`);
|
||||
ctx.log.info('backup', `Restored ${Object.keys(backup.themes).length} themes`);
|
||||
} catch (e) {
|
||||
results.errors.push({ file: 'themes', error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Reload encryption key in memory if it was restored
|
||||
if (results.restored.includes('encryptionKey')) {
|
||||
try {
|
||||
// Clear the cached key so crypto-utils reloads from the new file on next use
|
||||
const cryptoUtils = require('../../crypto-utils');
|
||||
if (typeof cryptoUtils.clearCachedKey === 'function') {
|
||||
cryptoUtils.clearCachedKey();
|
||||
}
|
||||
results.encryptionKeyReloaded = true;
|
||||
} catch (e) {
|
||||
ctx.log.warn('backup', 'Could not reload encryption key', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
const success = results.restored.length > 0 && results.errors.length === 0;
|
||||
|
||||
res.json({
|
||||
|
||||
Reference in New Issue
Block a user