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

@@ -315,6 +315,14 @@ function decryptWithKey(encryptedData, key) {
// Initialize key on module load // Initialize key on module load
loadOrCreateKey(); loadOrCreateKey();
/**
* Clear the cached encryption key so it reloads from file on next use.
* Called after restoring an encryption key from backup.
*/
function clearCachedKey() {
encryptionKey = null;
}
module.exports = { module.exports = {
encrypt, encrypt,
decrypt, decrypt,
@@ -327,5 +335,6 @@ module.exports = {
loadOrCreateKey, loadOrCreateKey,
deriveKey, deriveKey,
rotateKey, rotateKey,
decryptWithKey decryptWithKey,
clearCachedKey
}; };

View File

@@ -1,4 +1,5 @@
const fsp = require('fs').promises; const fsp = require('fs').promises;
const fs = require('fs');
const path = require('path'); const path = require('path');
const { CADDY } = require('../../constants'); const { CADDY } = require('../../constants');
const { exists } = require('../../fs-helpers'); const { exists } = require('../../fs-helpers');
@@ -7,20 +8,36 @@ module.exports = function(ctx) {
const express = require('express'); const express = require('express');
const router = 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 ===== // ===== 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 // Export all configuration as a downloadable JSON bundle
router.get('/backup/export', ctx.asyncHandler(async (req, res) => { router.get('/backup/export', ctx.asyncHandler(async (req, res) => {
const backup = { const backup = {
version: '1.1', version: '2.0',
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
dashcaddyVersion: '1.0.0', dashcaddyVersion: '1.0.0',
files: {}, files: {},
themes: {},
assets: {} 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 ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
const filesToBackup = [ const filesToBackup = [
{ key: 'services', path: ctx.SERVICES_FILE, required: true }, { 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: 'config', path: ctx.CONFIG_FILE, required: false },
{ key: 'dnsCredentials', path: ctx.dns.credentialsFile, 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: '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: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false },
{ key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false }, { key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false },
{ key: 'notifications', path: ctx.NOTIFICATIONS_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 }); 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 // Set headers for file download
const backupFilename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; const backupFilename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
@@ -156,6 +180,16 @@ module.exports = function(ctx) {
preview.serviceCount = services.length; 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 }); res.json({ success: true, preview });
}, 'backup-preview')); }, 'backup-preview'));
@@ -192,13 +226,14 @@ module.exports = function(ctx) {
errors: [] 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 = { const fileMapping = {
services: ctx.SERVICES_FILE, services: ctx.SERVICES_FILE,
caddyfile: ctx.caddy.filePath, caddyfile: ctx.caddy.filePath,
config: ctx.CONFIG_FILE, config: ctx.CONFIG_FILE,
dnsCredentials: ctx.dns.credentialsFile, dnsCredentials: ctx.dns.credentialsFile,
credentials: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'), credentials: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'),
encryptionKey: ENCRYPTION_KEY_FILE,
totpConfig: ctx.TOTP_CONFIG_FILE, totpConfig: ctx.TOTP_CONFIG_FILE,
tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE, tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE,
notifications: ctx.NOTIFICATIONS_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; const success = results.restored.length > 0 && results.errors.length === 0;
res.json({ res.json({

View File

@@ -140,8 +140,6 @@
</button> </button>
<div class="tools-section-items"> <div class="tools-section-items">
<button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button> <button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button>
<button id="export-dashboard" aria-label="Export dashboard configuration">📤 Export</button>
<button id="import-dashboard" aria-label="Import dashboard configuration">📥 Import</button>
<button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button> <button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button>
<button id="license-btn" aria-label="License management" onclick="window.openLicenseModal && window.openLicenseModal()">🔑 License</button> <button id="license-btn" aria-label="License management" onclick="window.openLicenseModal && window.openLicenseModal()">🔑 License</button>
<button id="api-docs-btn" aria-label="API documentation" onclick="window.open('/api/docs', '_blank')">📖 API</button> <button id="api-docs-btn" aria-label="API documentation" onclick="window.open('/api/docs', '_blank')">📖 API</button>

View File

@@ -1,11 +1,94 @@
// ========== BACKUP/RESTORE (Enhanced) ========== // ========== UNIFIED BACKUP/RESTORE v2.0 ==========
// Single file captures everything: server config + browser state + themes + encryption key
(function() { (function() {
// All DashCaddy localStorage keys to include in backup
var BROWSER_STATE_KEYS = [
'dashcaddy_site_config', 'dashcaddy_onboarding', 'dashcaddy-encryption-key',
'dashcaddy-setup', 'dashcaddy-config',
'theme', 'user-themes', 'custom-theme',
'custom-apps', 'custom-services', 'toolbar-sections',
'weather-location', 'weather-zip', 'weather-geo', 'weather-unit',
'clock-style', 'clock-chimes', 'clock-chime-volume'
];
// Collect all DashCaddy browser state from localStorage
function collectBrowserState() {
var state = {};
// Grab all whitelisted keys
for (var i = 0; i < BROWSER_STATE_KEYS.length; i++) {
var key = BROWSER_STATE_KEYS[i];
var val = safeGet(key);
if (val !== null && val !== undefined) state[key] = val;
}
// Grab dynamic widget-*-enabled keys
try {
for (var j = 0; j < localStorage.length; j++) {
var k = localStorage.key(j);
if (/^widget-.+-enabled$/.test(k)) {
state[k] = localStorage.getItem(k);
}
}
} catch (e) { /* private browsing */ }
return state;
}
// Restore browser state from backup into localStorage
function restoreBrowserState(browserState) {
if (!browserState || typeof browserState !== 'object') return 0;
var count = 0;
for (var key in browserState) {
if (!browserState.hasOwnProperty(key)) continue;
safeSet(key, browserState[key]);
count++;
}
return count;
}
// Handle legacy v1.0.0 import-export format (pre-backup modal)
function isLegacyFormat(data) {
return data.version && !data.files && data.services;
}
function restoreLegacyFormat(data) {
// Map the old flat keys into browserState for localStorage restore
var browserState = {};
if (data.customServices) browserState['custom-services'] = JSON.stringify(data.customServices);
if (data.customApps) browserState['custom-apps'] = JSON.stringify(data.customApps);
if (data.weatherZip) browserState['weather-zip'] = data.weatherZip;
if (data.theme) browserState['theme'] = data.theme;
if (data.userThemes && Object.keys(data.userThemes).length) browserState['user-themes'] = JSON.stringify(data.userThemes);
restoreBrowserState(browserState);
// Push services to server if available
if (data.services && Array.isArray(data.services)) {
secureFetch('/api/v1/services', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data.services)
}).catch(function() {});
}
// Push themes to server if available
if (data.userThemes) {
Object.keys(data.userThemes).forEach(function(slug) {
var t = data.userThemes[slug];
var colors = {};
(window.THEME_PROPS || []).forEach(function(p) { if (t[p]) colors[p] = t[p]; });
secureFetch('/api/v1/themes/' + slug, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: t.name || slug, colors: colors })
}).catch(function() {});
});
}
}
// Inject modal HTML // Inject modal HTML
injectModal('backup-modal', `<div id="backup-modal" class="weather-modal"> injectModal('backup-modal', `<div id="backup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>💾 Backup & Restore</h3> <h3>💾 Backup & Restore</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
Manual and automated backups for your DashCaddy configuration. Full backup of your entire DashCaddy setup — server config, credentials, themes, and browser preferences in one file.
</p> </p>
<!-- Tab bar --> <!-- Tab bar -->
@@ -15,16 +98,16 @@
<button class="panel-tab" data-panel="backup-history-tab">History</button> <button class="panel-tab" data-panel="backup-history-tab">History</button>
</div> </div>
<!-- Tab: Manual (existing export/import) --> <!-- Tab: Manual -->
<div id="backup-manual" class="panel-section active"> <div id="backup-manual" class="panel-section active">
<!-- Export Section --> <!-- Export Section -->
<div style="margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;"> <div style="margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;">
<h4 style="margin: 0 0 8px; color: #2ecc71; font-size: 0.95rem;">📤 Export Backup</h4> <h4 style="margin: 0 0 8px; color: #2ecc71; font-size: 0.95rem;">📤 Export Backup</h4>
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;"> <p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
Download all your settings: services, Caddyfile, DNS credentials, notifications. Downloads everything services, Caddyfile, credentials, encryption key, themes, and all browser preferences.
</p> </p>
<button id="backup-export-btn" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;"> <button id="backup-export-btn" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
⬇️ Download Backup ⬇️ Download Full Backup
</button> </button>
</div> </div>
@@ -32,7 +115,7 @@
<div style="padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #3498db 15%, transparent), color-mix(in srgb, #2980b9 10%, transparent)); border-radius: 10px; border: 1px solid #3498db;"> <div style="padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #3498db 15%, transparent), color-mix(in srgb, #2980b9 10%, transparent)); border-radius: 10px; border: 1px solid #3498db;">
<h4 style="margin: 0 0 8px; color: #3498db; font-size: 0.95rem;">📥 Restore Backup</h4> <h4 style="margin: 0 0 8px; color: #3498db; font-size: 0.95rem;">📥 Restore Backup</h4>
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;"> <p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
Upload a backup file to restore your configuration. Upload a backup file to restore your entire configuration — drag and drop ready.
</p> </p>
<input type="file" id="backup-file-input" accept=".json" style="display: none;" /> <input type="file" id="backup-file-input" accept=".json" style="display: none;" />
<button id="backup-select-file" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;"> <button id="backup-select-file" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
@@ -52,7 +135,7 @@
</label> </label>
</div> </div>
<button id="backup-do-restore-btn" style="width: 100%; margin-top: 12px; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;"> <button id="backup-do-restore-btn" style="width: 100%; margin-top: 12px; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
⚡ Restore Configuration ⚡ Restore Everything
</button> </button>
</div> </div>
@@ -87,24 +170,24 @@
</div> </div>
</div>`); </div>`);
const modal = document.getElementById('backup-modal'); var modal = document.getElementById('backup-modal');
const openBtn = document.getElementById('backup-restore-btn'); var openBtn = document.getElementById('backup-restore-btn');
const cancelBtn = document.getElementById('backup-cancel'); var cancelBtn = document.getElementById('backup-cancel');
const exportBtn = document.getElementById('backup-export-btn'); var exportBtn = document.getElementById('backup-export-btn');
const selectFileBtn = document.getElementById('backup-select-file'); var selectFileBtn = document.getElementById('backup-select-file');
const fileInput = document.getElementById('backup-file-input'); var fileInput = document.getElementById('backup-file-input');
const fileNameDiv = document.getElementById('backup-file-name'); var fileNameDiv = document.getElementById('backup-file-name');
const previewDiv = document.getElementById('backup-preview'); var previewDiv = document.getElementById('backup-preview');
const previewContent = document.getElementById('backup-preview-content'); var previewContent = document.getElementById('backup-preview-content');
const restoreBtn = document.getElementById('backup-do-restore-btn'); var restoreBtn = document.getElementById('backup-do-restore-btn');
const resultDiv = document.getElementById('backup-result'); var resultDiv = document.getElementById('backup-result');
const scheduleContainer = document.getElementById('backup-schedule-container'); var scheduleContainer = document.getElementById('backup-schedule-container');
const historyContainer = document.getElementById('backup-history-container'); var historyContainer = document.getElementById('backup-history-container');
let selectedBackup = null; var selectedBackup = null;
// Open modal // Open modal
openBtn?.addEventListener('click', () => { openBtn?.addEventListener('click', function() {
modal.classList.add('show'); modal.classList.add('show');
if (resultDiv) resultDiv.style.display = 'none'; if (resultDiv) resultDiv.style.display = 'none';
if (previewDiv) previewDiv.style.display = 'none'; if (previewDiv) previewDiv.style.display = 'none';
@@ -114,79 +197,122 @@
wireModal(modal, cancelBtn); wireModal(modal, cancelBtn);
// Export backup // === EXPORT: Server backup + browser state in one file ===
exportBtn?.addEventListener('click', async () => { exportBtn?.addEventListener('click', async function() {
exportBtn.disabled = true; exportBtn.disabled = true;
exportBtn.innerHTML = '<span class="brand-spinner"></span> Exporting...'; exportBtn.innerHTML = '<span class="brand-spinner"></span> Exporting...';
try { try {
const response = await fetch('/api/v1/backup/export'); // Fetch server-side backup (config, services, caddyfile, credentials, encryption key, themes, etc.)
const data = await response.json(); var response = await fetch('/api/v1/backup/export');
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); var data = await response.json();
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); // Add all browser localStorage state
data.browserState = collectBrowserState();
// Download unified backup
var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.href = url;
a.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; a.download = 'dashcaddy-backup-' + new Date().toISOString().split('T')[0] + '.json';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
resultDiv.innerHTML = '✅ Backup downloaded successfully!';
var stateCount = Object.keys(data.browserState).length;
var themeCount = data.themes ? Object.keys(data.themes).length : 0;
resultDiv.innerHTML = '✅ Full backup downloaded — server config + ' + stateCount + ' browser settings' + (themeCount ? ' + ' + themeCount + ' themes' : '');
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--ok-fg)'; resultDiv.style.border = '1px solid var(--ok-fg)';
} catch (e) { } catch (e) {
resultDiv.innerHTML = `❌ Export failed: ${escapeHtml(e.message)}`; resultDiv.innerHTML = '❌ Export failed: ' + escapeHtml(e.message);
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--bad-fg)'; resultDiv.style.border = '1px solid var(--bad-fg)';
} }
exportBtn.disabled = false; exportBtn.disabled = false;
exportBtn.innerHTML = '⬇️ Download Backup'; exportBtn.innerHTML = '⬇️ Download Full Backup';
}); });
// Select file button // Select file button
selectFileBtn?.addEventListener('click', () => fileInput.click()); selectFileBtn?.addEventListener('click', function() { fileInput.click(); });
// File selected // === FILE SELECTED: Preview contents ===
fileInput?.addEventListener('change', async (e) => { fileInput?.addEventListener('change', async function(e) {
const file = e.target.files[0]; var file = e.target.files[0];
if (!file) return; if (!file) return;
fileNameDiv.textContent = `📄 ${file.name}`; fileNameDiv.textContent = '📄 ' + file.name;
fileNameDiv.style.display = 'block'; fileNameDiv.style.display = 'block';
resultDiv.style.display = 'none'; resultDiv.style.display = 'none';
try { try {
const text = await file.text(); var text = await file.text();
const backup = JSON.parse(text); var backup = JSON.parse(text);
const response = await secureFetch('/api/v1/backup/preview', {
// Handle legacy v1.0.0 format (from old import-export.js)
if (isLegacyFormat(backup)) {
selectedBackup = backup;
var html = '<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v' + escapeHtml(backup.version) + ')</div>';
html += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
if (backup.services?.length) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">📋 ' + backup.services.length + ' services</span>';
if (backup.customApps?.length) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">📦 ' + backup.customApps.length + ' custom apps</span>';
if (backup.theme) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">🎨 Theme: ' + escapeHtml(backup.theme) + '</span>';
if (backup.userThemes) html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">🎨 ' + Object.keys(backup.userThemes).length + ' custom themes</span>';
html += '</div>';
previewContent.innerHTML = html;
previewDiv.style.display = 'block';
return;
}
// v1.1+ / v2.0 format — send to server for preview
var response = await secureFetch('/api/v1/backup/preview', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(backup) body: JSON.stringify(backup)
}); });
const preview = await response.json(); var preview = await response.json();
if (preview.success) { if (preview.success) {
selectedBackup = backup; selectedBackup = backup;
let html = `<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;"> var html = '<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: ' + new Date(backup.exportedAt).toLocaleString() + ' (v' + escapeHtml(backup.version) + ')</div>';
Exported: ${new Date(backup.exportedAt).toLocaleString()}</div>`;
html += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">'; // Server files
for (const [key, info] of Object.entries(preview.preview.files)) { html += '<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>';
const icon = info.action === 'create' ? '🆕' : '📝'; html += '<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';
html += `<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">${icon} ${info.description}</span>`; for (var key in preview.preview.files) {
var info = preview.preview.files[key];
var icon = info.action === 'create' ? '🆕' : '📝';
html += '<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">' + icon + ' ' + escapeHtml(info.description) + '</span>';
} }
html += '</div>'; html += '</div>';
// Services count
if (preview.preview.serviceCount) { if (preview.preview.serviceCount) {
html += `<div style="margin-top: 8px; font-size: 0.8rem; color: var(--accent);">${preview.preview.serviceCount} services in backup</div>`; html += '<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">' + preview.preview.serviceCount + ' services</div>';
} }
// Themes
if (preview.preview.themeCount) {
html += '<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">🎨 ' + preview.preview.themeCount + ' custom themes</div>';
}
// Browser state
if (preview.preview.browserStateCount) {
html += '<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>';
html += '<div style="font-size: 0.8rem; color: var(--accent);">🖥️ ' + preview.preview.browserStateCount + ' saved settings (theme, weather, clock, widgets, etc.)</div>';
}
previewContent.innerHTML = html; previewContent.innerHTML = html;
previewDiv.style.display = 'block'; previewDiv.style.display = 'block';
} else { } else {
resultDiv.innerHTML = `⚠️ Invalid backup file: ${escapeHtml(preview.error)}`; resultDiv.innerHTML = '⚠️ Invalid backup file: ' + escapeHtml(preview.error);
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
resultDiv.style.border = '1px solid #f39c12'; resultDiv.style.border = '1px solid #f39c12';
previewDiv.style.display = 'none'; previewDiv.style.display = 'none';
} }
} catch (e) { } catch (e) {
resultDiv.innerHTML = `❌ Could not read file: ${escapeHtml(e.message)}`; resultDiv.innerHTML = '❌ Could not read file: ' + escapeHtml(e.message);
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--bad-fg)'; resultDiv.style.border = '1px solid var(--bad-fg)';
@@ -194,103 +320,126 @@
} }
}); });
// Restore from file backup // === RESTORE: Server + browser state ===
restoreBtn?.addEventListener('click', async () => { restoreBtn?.addEventListener('click', async function() {
if (!selectedBackup) return; if (!selectedBackup) return;
if (!confirm('This will overwrite your current configuration. Continue?')) return; if (!confirm('This will overwrite your current configuration and browser preferences. Continue?')) return;
restoreBtn.disabled = true; restoreBtn.disabled = true;
restoreBtn.innerHTML = '<span class="brand-spinner"></span> Restoring...'; restoreBtn.innerHTML = '<span class="brand-spinner"></span> Restoring...';
try { try {
const reloadCaddy = document.getElementById('backup-reload-caddy')?.checked ?? true; // Handle legacy format
const response = await secureFetch('/api/v1/backup/restore', { if (isLegacyFormat(selectedBackup)) {
restoreLegacyFormat(selectedBackup);
resultDiv.innerHTML = '✅ Legacy backup restored — browser settings and services imported.';
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--ok-fg)';
resultDiv.style.display = 'block';
setTimeout(function() { location.reload(); }, 2000);
restoreBtn.disabled = false;
restoreBtn.innerHTML = '⚡ Restore Everything';
return;
}
// v1.1+ / v2.0 — restore server-side first
var reloadCaddy = document.getElementById('backup-reload-caddy')?.checked ?? true;
var response = await secureFetch('/api/v1/backup/restore', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backup: selectedBackup, options: { reloadCaddy } }) body: JSON.stringify({ backup: selectedBackup, options: { reloadCaddy: reloadCaddy } })
}); });
const data = await response.json(); var data = await response.json();
// Then restore browser state
var browserCount = 0;
if (selectedBackup.browserState) {
browserCount = restoreBrowserState(selectedBackup.browserState);
}
if (data.success) { if (data.success) {
let msg = `${data.message}`; var msg = '✅ ' + data.message;
if (browserCount > 0) msg += '<br><small style="color: var(--muted);">' + browserCount + ' browser settings restored</small>';
if (data.results.caddyReloaded) msg += '<br><small style="color: var(--muted);">Caddy configuration reloaded</small>'; if (data.results.caddyReloaded) msg += '<br><small style="color: var(--muted);">Caddy configuration reloaded</small>';
resultDiv.innerHTML = msg; resultDiv.innerHTML = msg;
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--ok-fg)'; resultDiv.style.border = '1px solid var(--ok-fg)';
setTimeout(() => location.reload(), 2000); setTimeout(function() { location.reload(); }, 2000);
} else { } else {
resultDiv.innerHTML = `⚠️ ${escapeHtml(data.message)}`; resultDiv.innerHTML = '⚠️ ' + escapeHtml(data.message);
if (browserCount > 0) resultDiv.innerHTML += '<br><small style="color: var(--muted);">' + browserCount + ' browser settings were restored</small>';
if (data.results?.errors?.length > 0) { if (data.results?.errors?.length > 0) {
resultDiv.innerHTML += '<br><small>' + data.results.errors.map(e => `${escapeHtml(e.file)}: ${escapeHtml(e.error)}`).join(', ') + '</small>'; resultDiv.innerHTML += '<br><small>' + data.results.errors.map(function(e) { return escapeHtml(e.file) + ': ' + escapeHtml(e.error); }).join(', ') + '</small>';
} }
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
resultDiv.style.border = '1px solid #f39c12'; resultDiv.style.border = '1px solid #f39c12';
} }
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
} catch (e) { } catch (e) {
resultDiv.innerHTML = `❌ Restore failed: ${escapeHtml(e.message)}`; resultDiv.innerHTML = '❌ Restore failed: ' + escapeHtml(e.message);
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultDiv.style.border = '1px solid var(--bad-fg)'; resultDiv.style.border = '1px solid var(--bad-fg)';
} }
restoreBtn.disabled = false; restoreBtn.disabled = false;
restoreBtn.innerHTML = '⚡ Restore Configuration'; restoreBtn.innerHTML = '⚡ Restore Everything';
}); });
// === Automated Backups Tab === // === Automated Backups Tab ===
async function loadBackupSchedule() { async function loadBackupSchedule() {
if (!scheduleContainer) return; if (!scheduleContainer) return;
try { try {
const res = await fetch('/api/v1/backups/config'); var res = await fetch('/api/v1/backups/config');
const data = await res.json(); var data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to load config'); if (!data.success) throw new Error(data.error || 'Failed to load config');
const cfg = data.config?.backups || {}; var cfg = data.config?.backups || {};
const autoKey = Object.keys(cfg)[0]; var autoKey = Object.keys(cfg)[0];
const auto = autoKey ? cfg[autoKey] : null; var auto = autoKey ? cfg[autoKey] : null;
let html = `<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">`; var html = '<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">';
html += `<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">⏰ Backup Schedule</h4>`; html += '<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">⏰ Backup Schedule</h4>';
html += `<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">`; html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';
html += `<div><label style="font-size: 0.8rem; color: var(--muted);">Schedule:</label> html += '<div><label style="font-size: 0.8rem; color: var(--muted);">Schedule:</label>';
<select id="backup-schedule-select" style="width: 100%;"> html += ' <select id="backup-schedule-select" style="width: 100%;">';
<option value="disabled" ${!auto?.enabled ? 'selected' : ''}>Disabled</option> html += ' <option value="disabled"' + (!auto?.enabled ? ' selected' : '') + '>Disabled</option>';
<option value="hourly" ${auto?.schedule === 'hourly' ? 'selected' : ''}>Hourly</option> html += ' <option value="hourly"' + (auto?.schedule === 'hourly' ? ' selected' : '') + '>Hourly</option>';
<option value="daily" ${auto?.schedule === 'daily' ? 'selected' : ''}>Daily</option> html += ' <option value="daily"' + (auto?.schedule === 'daily' ? ' selected' : '') + '>Daily</option>';
<option value="weekly" ${auto?.schedule === 'weekly' ? 'selected' : ''}>Weekly</option> html += ' <option value="weekly"' + (auto?.schedule === 'weekly' ? ' selected' : '') + '>Weekly</option>';
<option value="monthly" ${auto?.schedule === 'monthly' ? 'selected' : ''}>Monthly</option> html += ' <option value="monthly"' + (auto?.schedule === 'monthly' ? ' selected' : '') + '>Monthly</option>';
</select></div>`; html += ' </select></div>';
html += `<div><label style="font-size: 0.8rem; color: var(--muted);">Keep last:</label> html += '<div><label style="font-size: 0.8rem; color: var(--muted);">Keep last:</label>';
<select id="backup-retention-select" style="width: 100%;"> html += ' <select id="backup-retention-select" style="width: 100%;">';
<option value="3" ${auto?.retention?.keep === 3 ? 'selected' : ''}>3 backups</option> html += ' <option value="3"' + (auto?.retention?.keep === 3 ? ' selected' : '') + '>3 backups</option>';
<option value="5" ${!auto?.retention || auto?.retention?.keep === 5 ? 'selected' : ''}>5 backups</option> html += ' <option value="5"' + (!auto?.retention || auto?.retention?.keep === 5 ? ' selected' : '') + '>5 backups</option>';
<option value="10" ${auto?.retention?.keep === 10 ? 'selected' : ''}>10 backups</option> html += ' <option value="10"' + (auto?.retention?.keep === 10 ? ' selected' : '') + '>10 backups</option>';
<option value="30" ${auto?.retention?.keep === 30 ? 'selected' : ''}>30 backups</option> html += ' <option value="30"' + (auto?.retention?.keep === 30 ? ' selected' : '') + '>30 backups</option>';
</select></div>`; html += ' </select></div>';
html += `</div>`; html += '</div>';
html += `<div style="margin-top: 12px;"> html += '<div style="margin-top: 12px;">';
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;"> html += ' <label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">';
<input type="checkbox" id="backup-encrypt-toggle" ${auto?.encrypt !== false ? 'checked' : ''} /> html += ' <input type="checkbox" id="backup-encrypt-toggle"' + (auto?.encrypt !== false ? ' checked' : '') + ' />';
Encrypt backups html += ' Encrypt backups';
</label></div>`; html += ' </label></div>';
html += `<div style="display: flex; gap: 8px; margin-top: 12px;"> html += '<div style="display: flex; gap: 8px; margin-top: 12px;">';
<button id="backup-save-schedule" style="padding: 8px 16px; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer; font-weight: 500;">Save Schedule</button> html += ' <button id="backup-save-schedule" style="padding: 8px 16px; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer; font-weight: 500;">Save Schedule</button>';
<button id="backup-run-now" style="padding: 8px 16px; border-radius: 6px; cursor: pointer;">▶️ Run Backup Now</button> html += ' <button id="backup-run-now" style="padding: 8px 16px; border-radius: 6px; cursor: pointer;">▶️ Run Backup Now</button>';
</div>`; html += '</div>';
html += `</div>`; html += '</div>';
html += `<div id="backup-schedule-result" style="display: none; margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 0.85rem;"></div>`; html += '<div id="backup-schedule-result" style="display: none; margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 0.85rem;"></div>';
scheduleContainer.innerHTML = html; scheduleContainer.innerHTML = html;
document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule); document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule);
document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow); document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow);
} catch (e) { } catch (e) {
scheduleContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: ${escapeHtml(e.message)}</div>`; scheduleContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: ' + escapeHtml(e.message) + '</div>';
} }
} }
async function saveSchedule() { async function saveSchedule() {
const schedule = document.getElementById('backup-schedule-select')?.value; var schedule = document.getElementById('backup-schedule-select')?.value;
const retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5; var retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5;
const encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true; var encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true;
const resultEl = document.getElementById('backup-schedule-result'); var resultEl = document.getElementById('backup-schedule-result');
try { try {
const res = await secureFetch('/api/v1/backups/config', { var res = await secureFetch('/api/v1/backups/config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -299,7 +448,7 @@
enabled: schedule !== 'disabled', enabled: schedule !== 'disabled',
schedule: schedule === 'disabled' ? 'daily' : schedule, schedule: schedule === 'disabled' ? 'daily' : schedule,
include: ['all'], include: ['all'],
encrypt, encrypt: encrypt,
verify: true, verify: true,
retention: { keep: retention }, retention: { keep: retention },
destinations: [{ type: 'local' }] destinations: [{ type: 'local' }]
@@ -307,17 +456,17 @@
} }
}) })
}); });
const data = await res.json(); var data = await res.json();
if (resultEl) { if (resultEl) {
resultEl.innerHTML = data.success ? '✅ Schedule saved' : `⚠️ ${escapeHtml(data.error)}`; resultEl.innerHTML = data.success ? '✅ Schedule saved' : '⚠️ ' + escapeHtml(data.error);
resultEl.style.display = 'block'; resultEl.style.display = 'block';
resultEl.style.background = data.success ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultEl.style.background = data.success ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = data.success ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)'; resultEl.style.border = data.success ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)';
setTimeout(() => { if (resultEl) resultEl.style.display = 'none'; }, 3000); setTimeout(function() { if (resultEl) resultEl.style.display = 'none'; }, 3000);
} }
} catch (e) { } catch (e) {
if (resultEl) { if (resultEl) {
resultEl.innerHTML = `${escapeHtml(e.message)}`; resultEl.innerHTML = '❌ ' + escapeHtml(e.message);
resultEl.style.display = 'block'; resultEl.style.display = 'block';
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)'; resultEl.style.border = '1px solid var(--bad-fg)';
@@ -326,24 +475,24 @@
} }
async function runBackupNow() { async function runBackupNow() {
const btn = document.getElementById('backup-run-now'); var btn = document.getElementById('backup-run-now');
const resultEl = document.getElementById('backup-schedule-result'); var resultEl = document.getElementById('backup-schedule-result');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="brand-spinner"></span> Running...'; } if (btn) { btn.disabled = true; btn.innerHTML = '<span class="brand-spinner"></span> Running...'; }
try { try {
const res = await secureFetch('/api/v1/backups/execute', { var res = await secureFetch('/api/v1/backups/execute', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ include: ['all'], destinations: [{ type: 'local' }] }) body: JSON.stringify({ include: ['all'], destinations: [{ type: 'local' }] })
}); });
const data = await res.json(); var data = await res.json();
if (resultEl) { if (resultEl) {
if (data.success) { if (data.success) {
const sizeMB = data.backup?.size ? (data.backup.size / 1024 / 1024).toFixed(2) : '?'; var sizeMB = data.backup?.size ? (data.backup.size / 1024 / 1024).toFixed(2) : '?';
resultEl.innerHTML = `✅ Backup complete (${sizeMB} MB)`; resultEl.innerHTML = '✅ Backup complete (' + sizeMB + ' MB)';
resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)'; resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--ok-fg)'; resultEl.style.border = '1px solid var(--ok-fg)';
} else { } else {
resultEl.innerHTML = `⚠️ ${escapeHtml(data.error)}`; resultEl.innerHTML = '⚠️ ' + escapeHtml(data.error);
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)'; resultEl.style.border = '1px solid var(--bad-fg)';
} }
@@ -352,7 +501,7 @@
loadBackupHistory(); loadBackupHistory();
} catch (e) { } catch (e) {
if (resultEl) { if (resultEl) {
resultEl.innerHTML = `${escapeHtml(e.message)}`; resultEl.innerHTML = '❌ ' + escapeHtml(e.message);
resultEl.style.display = 'block'; resultEl.style.display = 'block';
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)'; resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
resultEl.style.border = '1px solid var(--bad-fg)'; resultEl.style.border = '1px solid var(--bad-fg)';
@@ -366,50 +515,49 @@
if (!historyContainer) return; if (!historyContainer) return;
historyContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>'; historyContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
try { try {
const res = await fetch('/api/v1/backups/history?limit=50'); var res = await fetch('/api/v1/backups/history?limit=50');
const data = await res.json(); var data = await res.json();
if (!data.success || !data.history?.length) { if (!data.success || !data.history?.length) {
historyContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📋</span> No backup history yet</div>'; historyContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📋</span> No backup history yet</div>';
return; return;
} }
let html = '<div style="display: flex; flex-direction: column; gap: 6px;">'; var html = '<div style="display: flex; flex-direction: column; gap: 6px;">';
for (const bk of data.history) { for (var i = 0; i < data.history.length; i++) {
const statusColor = bk.status === 'success' ? '#2ecc71' : '#e74c3c'; var bk = data.history[i];
const sizeMB = bk.size ? (bk.size / 1024 / 1024).toFixed(2) : '?'; var sizeMB = bk.size ? (bk.size / 1024 / 1024).toFixed(2) : '?';
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;"> html += '<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;">';
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;"> html += ' <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">';
<span style="font-weight: 500;">${escapeHtml(bk.name || 'backup')}</span> html += ' <span style="font-weight: 500;">' + escapeHtml(bk.name || 'backup') + '</span>';
<div style="display: flex; align-items: center; gap: 8px;"> html += ' <div style="display: flex; align-items: center; gap: 8px;">';
<span class="status-badge ${bk.status === 'success' ? 'success' : 'down'}">${escapeHtml(bk.status)}</span> html += ' <span class="status-badge ' + (bk.status === 'success' ? 'success' : 'down') + '">' + escapeHtml(bk.status) + '</span>';
${bk.status === 'success' ? `<button class="backup-restore-btn" data-backup-id="${escapeHtml(bk.id)}" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>` : ''} if (bk.status === 'success') html += ' <button class="backup-restore-btn" data-backup-id="' + escapeHtml(bk.id) + '" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>';
</div> html += ' </div>';
</div> html += ' </div>';
<div style="font-size: 0.75rem; color: var(--muted);"> html += ' <div style="font-size: 0.75rem; color: var(--muted);">';
${new Date(bk.timestamp).toLocaleString()} | ${sizeMB} MB | ${bk.duration ? (bk.duration / 1000).toFixed(1) + 's' : '--'} html += ' ' + new Date(bk.timestamp).toLocaleString() + ' | ' + sizeMB + ' MB | ' + (bk.duration ? (bk.duration / 1000).toFixed(1) + 's' : '--');
${bk.encrypted ? ' | 🔒' : ''} if (bk.encrypted) html += ' | 🔒';
</div> html += ' </div>';
</div>`; html += '</div>';
} }
html += '</div>'; html += '</div>';
historyContainer.innerHTML = html; historyContainer.innerHTML = html;
// Wire restore buttons with addEventListener (not inline onclick — HTML entity decode bypass) historyContainer.querySelectorAll('.backup-restore-btn').forEach(function(btn) {
historyContainer.querySelectorAll('.backup-restore-btn').forEach(btn => { btn.addEventListener('click', function() { window.__restoreServerBackup(btn.dataset.backupId); });
btn.addEventListener('click', () => window.__restoreServerBackup(btn.dataset.backupId));
}); });
} catch (e) { } catch (e) {
historyContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`; historyContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
} }
} }
window.__restoreServerBackup = async function(backupId) { window.__restoreServerBackup = async function(backupId) {
if (!confirm('Restore from this server backup? This will overwrite current configuration.')) return; if (!confirm('Restore from this server backup? This will overwrite current configuration.')) return;
try { try {
const res = await secureFetch(`/api/v1/backups/restore/${backupId}`, { var res = await secureFetch('/api/v1/backups/restore/' + backupId, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ restoreServices: true, restoreConfig: true }) body: JSON.stringify({ restoreServices: true, restoreConfig: true })
}); });
const data = await res.json(); var data = await res.json();
if (data.success) { if (data.success) {
showNotification('Restore completed successfully!', 'success'); showNotification('Restore completed successfully!', 'success');
location.reload(); location.reload();

View File

@@ -1,137 +1,5 @@
// ========== IMPORT/EXPORT FUNCTIONALITY ========== // ========== CADDY RELOAD BUTTON ==========
(function() { (function() {
// Export dashboard configuration
async function exportDashboard() {
// Fetch themes from server (source of truth) with localStorage fallback
let userThemes = safeGetJSON('user-themes', {});
try {
const res = await secureFetch('/api/v1/themes');
const data = await res.json();
if (data.success && data.themes) userThemes = data.themes;
} catch (e) {}
const exportData = {
version: '1.0.0',
exportDate: new Date().toISOString(),
services: window.APPS || [],
customServices: safeGetJSON('custom-services', []),
customApps: safeGetJSON('custom-apps', []),
weatherZip: safeGet('weather-zip') || '',
theme: safeGet('theme') || 'dark',
userThemes: userThemes,
// Note: API tokens are intentionally NOT exported for security
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showNotification('Dashboard exported successfully! Note: API tokens are not included for security reasons.', 'success');
}
// Import dashboard configuration
function importDashboard() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const importData = JSON.parse(text);
// Validate import data
if (!importData.version || !importData.services) {
throw new Error('Invalid dashboard backup file');
}
// Confirm import
const confirmed = confirm(
`Import dashboard configuration?\n\n` +
`Export Date: ${new Date(importData.exportDate).toLocaleString()}\n` +
`Services: ${importData.services.length}\n` +
`Custom Apps: ${(importData.customApps || []).length}\n\n` +
`⚠️ This will replace your current dashboard configuration.\n` +
`API tokens will need to be reconfigured.`
);
if (!confirmed) return;
// Import services to API
try {
const response = await secureFetch('/api/v1/services', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(importData.services)
});
if (!response.ok) {
console.warn('Could not save services to API, saving locally only');
}
} catch (err) {
console.warn('API not available, saving locally only:', err);
}
// Import to localStorage
if (importData.customServices) {
safeSet('custom-services', JSON.stringify(importData.customServices));
}
if (importData.customApps) {
safeSet('custom-apps', JSON.stringify(importData.customApps));
}
if (importData.weatherZip) {
safeSet('weather-zip', importData.weatherZip);
}
if (importData.theme) {
safeSet('theme', importData.theme);
}
if (importData.userThemes && Object.keys(importData.userThemes).length) {
safeSet('user-themes', JSON.stringify(importData.userThemes));
// Push imported themes to server
Object.keys(importData.userThemes).forEach(function (slug) {
var t = importData.userThemes[slug];
var colors = {};
(window.THEME_PROPS || []).forEach(function (p) { if (t[p]) colors[p] = t[p]; });
secureFetch('/api/v1/themes/' + slug, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: t.name || slug, colors: colors })
}).catch(function () {});
});
}
// Update APPS array
window.APPS = importData.services;
showNotification('Dashboard imported successfully! The page will now reload.', 'success');
// Reload page to apply changes
window.location.reload();
} catch (err) {
showNotification(`Import failed: ${err.message}. Please check the file and try again.`, 'error');
console.error('Import error:', err);
}
};
input.click();
}
// Add event listeners for import/export buttons
document.getElementById('export-dashboard')?.addEventListener('click', exportDashboard);
document.getElementById('import-dashboard')?.addEventListener('click', importDashboard);
// Reload Caddy button handler // Reload Caddy button handler
document.getElementById('reload-caddy-top')?.addEventListener('click', async () => { document.getElementById('reload-caddy-top')?.addEventListener('click', async () => {
const button = document.getElementById('reload-caddy-top'); const button = document.getElementById('reload-caddy-top');