Files
dashcaddy/dashcaddy-api/routes/config/backup.js
Krystie 77ae8171b8 Refactor config routes: explicit dependency injection
- Updated all config route modules to use destructured dependencies
- Added JSDoc comments for factory functions
- Replaced ctx. references with direct parameter access
- All files pass syntax validation

Files refactored:
- routes/config/assets.js
- routes/config/backup.js
- routes/config/settings.js
- routes/config/index.js (orchestrator)
2026-03-29 21:43:29 -07:00

397 lines
15 KiB
JavaScript

const fsp = require('fs').promises;
const fs = require('fs');
const path = require('path');
const { CADDY } = require('../../constants');
const { exists } = require('../../fs-helpers');
const { ValidationError, AuthenticationError } = require('../errors');
module.exports = function({ configStateManager, servicesStateManager, asyncHandler, log }) {
const express = require('express');
/**
* Config backup routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.configStateManager - Config state manager
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
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 =====
// 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', asyncHandler(async (req, res) => {
const backup = {
version: '2.0',
exportedAt: new Date().toISOString(),
dashcaddyVersion: '1.0.0',
files: {},
themes: {},
assets: {}
};
// 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 },
{ key: 'caddyfile', path: ctx.caddy.filePath, required: true },
{ 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 },
{ 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 }
];
for (const file of filesToBackup) {
try {
if (await exists(file.path)) {
const content = await fsp.readFile(file.path, 'utf8');
// Try to parse as JSON, otherwise store as raw string
try {
backup.files[file.key] = {
type: 'json',
data: JSON.parse(content)
};
} catch {
backup.files[file.key] = {
type: 'text',
data: content
};
}
} else if (file.required) {
backup.files[file.key] = { type: 'missing', data: null };
}
} catch (e) {
log.warn('backup', `Could not backup ${file.key}`, { error: e.message });
}
}
// Include TOTP QR code for authenticator app recovery
if (ctx.totpConfig.isSetUp) {
try {
const secret = await ctx.credentialManager.retrieve('totp.secret');
if (secret) {
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2,
color: { dark: '#000000', light: '#ffffff' }
});
backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' };
}
} catch (e) {
log.warn('backup', 'Could not include TOTP QR in backup', { error: e.message });
}
}
// Include custom assets (logo, favicon) as base64
try {
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
const configData = backup.files.config?.data || {};
const assetFiles = [configData.customLogo, configData.customFavicon]
.filter(Boolean)
.map(p => p.replace(/^\/assets\//, ''));
for (const assetName of assetFiles) {
const assetPath = path.join(assetsDir, assetName);
if (await exists(assetPath)) {
const data = await fsp.readFile(assetPath);
backup.assets[assetName] = data.toString('base64');
}
}
} catch (e) {
log.warn('backup', 'Could not include assets in backup', { error: e.message });
}
// Include user-created themes
try {
backup.themes = readAllThemes();
} catch (e) {
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');
res.setHeader('Content-Disposition', `attachment; filename="${backupFilename}"`);
res.json(backup);
log.info('backup', 'Backup exported successfully');
}, 'backup-export'));
// Preview what will be restored (without making changes)
router.post('/backup/preview', asyncHandler(async (req, res) => {
const backup = req.body;
if (!backup || !backup.version || !backup.files) {
throw new ValidationError('Invalid backup file format');
}
const preview = {
valid: true,
version: backup.version,
exportedAt: backup.exportedAt,
files: {}
};
// Check each file in the backup
const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
const fileMapping = {
services: { path: ctx.SERVICES_FILE, description: 'Services list' },
caddyfile: { path: ctx.caddy.filePath, description: 'Caddy configuration' },
config: { path: ctx.CONFIG_FILE, description: 'DashCaddy settings' },
dnsCredentials: { path: ctx.dns.credentialsFile, description: 'DNS credentials (legacy)' },
credentials: { path: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'), description: 'Encrypted credentials' },
encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' },
totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' },
tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' },
notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' }
};
for (const [key, value] of Object.entries(backup.files)) {
if (value && value.type !== 'missing') {
const mapping = fileMapping[key];
const currentExists = mapping ? await exists(mapping.path) : false;
preview.files[key] = {
description: mapping?.description || key,
inBackup: true,
currentExists,
action: currentExists ? 'overwrite' : 'create',
type: value.type
};
}
}
// Count services if present
if (backup.files.services?.data) {
const services = Array.isArray(backup.files.services.data)
? backup.files.services.data
: backup.files.services.data.services || [];
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'));
// Restore configuration from backup
router.post('/backup/restore', asyncHandler(async (req, res) => {
const { backup, options = {}, totpCode } = req.body;
if (!backup || !backup.version || !backup.files) {
throw new ValidationError('Invalid backup file format');
}
// Require TOTP verification for restores that include security-sensitive files
const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey'];
const restoresSensitive = sensitiveKeys.some(key =>
backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key)
);
if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
if (!totpCode || !/^\d{6}$/.test(totpCode)) {
throw new ValidationError('TOTP code required for restoring security-sensitive files');
}
const { authenticator } = require('otplib');
const secret = await ctx.credentialManager.retrieve('totp.secret');
if (secret) {
authenticator.options = { window: 1 };
if (!authenticator.verify({ token: totpCode, secret })) {
throw new AuthenticationError('[DC-111] Invalid TOTP code');
}
}
}
const results = {
restored: [],
skipped: [],
errors: []
};
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
};
// Restore each file
for (const [key, value] of Object.entries(backup.files)) {
if (!value || value.type === 'missing') {
continue;
}
// Skip if user chose to skip certain files
if (options.skip && options.skip.includes(key)) {
results.skipped.push(key);
continue;
}
const filePath = fileMapping[key];
if (!filePath) {
results.errors.push({ file: key, error: 'Unknown file type' });
continue;
}
try {
let content;
if (value.type === 'json') {
content = JSON.stringify(value.data, null, 2);
} else {
content = value.data;
}
// Create backup of existing file before overwriting
if (await exists(filePath) && options.createBackup !== false) {
const backupPath = `${filePath}.bak`;
await fsp.copyFile(filePath, backupPath);
}
await fsp.writeFile(filePath, content, 'utf8');
results.restored.push(key);
log.info('backup', `Restored: ${key}`, { path: filePath });
} catch (e) {
results.errors.push({ file: key, error: e.message });
}
}
// Reload Caddy if Caddyfile was restored
if (results.restored.includes('caddyfile') && options.reloadCaddy !== false) {
try {
const caddyContent = await ctx.caddy.read();
const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyContent
});
if (loadResponse.ok) {
results.caddyReloaded = true;
} else {
results.caddyReloadError = await loadResponse.text();
}
} catch (e) {
results.caddyReloadError = e.message;
}
}
// Reload DNS credentials if restored
if (results.restored.includes('dnsCredentials')) {
try {
ctx.loadDnsCredentials();
results.dnsReloaded = true;
} catch (e) {
results.dnsReloadError = e.message;
}
}
// Reload notification config if restored
if (results.restored.includes('notifications')) {
try {
await ctx.loadNotificationConfig();
results.notificationsReloaded = true;
} catch (e) {
results.notificationsReloadError = e.message;
}
}
// Reload site config if restored
if (results.restored.includes('config')) {
ctx.loadSiteConfig();
results.configReloaded = true;
}
// Restore custom assets from base64
if (backup.assets && typeof backup.assets === 'object') {
const assetsDir = process.env.ASSETS_DIR || '/app/assets';
for (const [name, b64] of Object.entries(backup.assets)) {
try {
const safeName = path.basename(name); // prevent path traversal
await fsp.writeFile(path.join(assetsDir, safeName), Buffer.from(b64, 'base64'));
results.restored.push(`asset:${safeName}`);
} catch (e) {
results.errors.push({ file: `asset:${name}`, error: e.message });
}
}
}
// 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}`);
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) {
log.warn('backup', 'Could not reload encryption key', { error: e.message });
}
}
const success = results.restored.length > 0 && results.errors.length === 0;
res.json({
success,
message: success
? `Restored ${results.restored.length} file(s) successfully`
: `Restore completed with ${results.errors.length} error(s)`,
results
});
log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
}, 'backup-restore'));
return router;
};