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:
2026-03-17 21:06:56 -07:00
parent 6d098fd96f
commit 75e2d7853e
5 changed files with 375 additions and 287 deletions

View File

@@ -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({