Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
293
dashcaddy-api/routes/config/assets.js
Normal file
293
dashcaddy-api/routes/config/assets.js
Normal file
@@ -0,0 +1,293 @@
|
||||
const express = require('express');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { LIMITS } = require('../../constants');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
|
||||
// Image processing for favicon conversion (optional)
|
||||
let sharp, pngToIco;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
pngToIco = require('png-to-ico');
|
||||
} catch (e) {
|
||||
// Image processing libraries not available — favicon conversion disabled
|
||||
}
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
|
||||
// ===== ASSET UPLOAD =====
|
||||
|
||||
router.post('/assets/upload', express.json({ limit: LIMITS.BODY_UPLOAD }), ctx.asyncHandler(async (req, res) => {
|
||||
const { filename, data } = req.body;
|
||||
|
||||
if (!filename || !data) {
|
||||
return ctx.errorResponse(res, 400, 'filename and data are required');
|
||||
}
|
||||
|
||||
// Validate filename to prevent directory traversal
|
||||
const safeFilename = path.basename(filename);
|
||||
if (safeFilename !== filename || filename.includes('..')) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid filename - must not contain path separators');
|
||||
}
|
||||
|
||||
// Extract base64 data
|
||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid image data format');
|
||||
}
|
||||
|
||||
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
||||
const base64Data = matches[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Determine assets path (mounted volume)
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
|
||||
// Ensure directory exists
|
||||
if (!await exists(assetsPath)) {
|
||||
await fsp.mkdir(assetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Save file
|
||||
const filePath = path.join(assetsPath, safeFilename);
|
||||
await fsp.writeFile(filePath, buffer);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: `/assets/${safeFilename}`,
|
||||
message: `Logo saved to ${filePath}`
|
||||
});
|
||||
}, 'assets-upload'));
|
||||
|
||||
// ===== CUSTOM LOGO ENDPOINTS =====
|
||||
// Manage custom dashboard logo
|
||||
|
||||
// Get current logo path, position, and title
|
||||
router.get('/logo', ctx.asyncHandler(async (req, res) => {
|
||||
const config = await ctx.readConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
// Dark/light variants (new)
|
||||
customLogoDark: config.customLogoDark || null,
|
||||
customLogoLight: config.customLogoLight || null,
|
||||
// Legacy single-logo fallback
|
||||
customLogo: config.customLogo || config.customLogoDark || null,
|
||||
position: config.logoPosition || 'left',
|
||||
dashboardTitle: config.dashboardTitle || 'DashCaddy',
|
||||
isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo
|
||||
});
|
||||
}, 'logo-get'));
|
||||
|
||||
// Helper: save a base64 image to assets, return { filename, webPath }
|
||||
async function saveLogoFile(data, suffix) {
|
||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||
if (!matches) return null;
|
||||
|
||||
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
|
||||
const buffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
if (!await exists(assetsPath)) {
|
||||
await fsp.mkdir(assetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `custom-logo-${suffix}.${extension}`;
|
||||
await fsp.writeFile(`${assetsPath}/${filename}`, buffer);
|
||||
return `/assets/${filename}`;
|
||||
}
|
||||
|
||||
// Upload custom logo(s) and/or update position and title
|
||||
// Supports: dataDark/dataLight (separate variants) or data (single logo for both)
|
||||
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), ctx.asyncHandler(async (req, res) => {
|
||||
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
|
||||
|
||||
if (!data && !dataDark && !dataLight && !position && !dashboardTitle) {
|
||||
return ctx.errorResponse(res, 400, 'Image data, position, or title is required');
|
||||
}
|
||||
|
||||
const config = await ctx.readConfig();
|
||||
let pathDark = null, pathLight = null;
|
||||
|
||||
// New dual-variant upload
|
||||
if (dataDark) {
|
||||
pathDark = await saveLogoFile(dataDark, 'dark');
|
||||
if (!pathDark) return ctx.errorResponse(res, 400, 'Invalid dark logo data format');
|
||||
config.customLogoDark = pathDark;
|
||||
}
|
||||
if (dataLight) {
|
||||
pathLight = await saveLogoFile(dataLight, 'light');
|
||||
if (!pathLight) return ctx.errorResponse(res, 400, 'Invalid light logo data format');
|
||||
config.customLogoLight = pathLight;
|
||||
}
|
||||
|
||||
// Legacy single-logo: save as both variants
|
||||
if (data && !dataDark && !dataLight) {
|
||||
const singlePath = await saveLogoFile(data, 'dark');
|
||||
if (!singlePath) return ctx.errorResponse(res, 400, 'Invalid image data format');
|
||||
config.customLogoDark = singlePath;
|
||||
config.customLogoLight = singlePath;
|
||||
// Also set legacy field for backward compat
|
||||
config.customLogo = singlePath;
|
||||
pathDark = singlePath;
|
||||
pathLight = singlePath;
|
||||
}
|
||||
|
||||
if (position && ['left', 'center', 'right'].includes(position)) {
|
||||
config.logoPosition = position;
|
||||
}
|
||||
|
||||
if (dashboardTitle !== undefined) {
|
||||
const sanitizedTitle = String(dashboardTitle).trim().substring(0, 50);
|
||||
config.dashboardTitle = sanitizedTitle || 'DashCaddy';
|
||||
}
|
||||
|
||||
config.updatedAt = new Date().toISOString();
|
||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
pathDark: pathDark,
|
||||
pathLight: pathLight,
|
||||
// Legacy compat
|
||||
path: pathDark || pathLight,
|
||||
position: config.logoPosition || 'left',
|
||||
dashboardTitle: config.dashboardTitle || 'DashCaddy',
|
||||
message: 'Branding settings saved'
|
||||
});
|
||||
}, 'logo-upload'));
|
||||
|
||||
// Reset all branding to defaults
|
||||
router.delete('/logo', ctx.asyncHandler(async (req, res) => {
|
||||
const config = await ctx.readConfig();
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
|
||||
// Delete all custom logo files
|
||||
const logoPaths = [config.customLogo, config.customLogoDark, config.customLogoLight].filter(Boolean);
|
||||
const seen = new Set();
|
||||
for (const logoPath of logoPaths) {
|
||||
const filename = logoPath.replace('/assets/', '');
|
||||
if (seen.has(filename)) continue;
|
||||
seen.add(filename);
|
||||
const filePath = `${assetsPath}/${filename}`;
|
||||
if (await exists(filePath)) {
|
||||
await fsp.unlink(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all branding settings to defaults
|
||||
delete config.customLogo;
|
||||
delete config.customLogoDark;
|
||||
delete config.customLogoLight;
|
||||
delete config.dashboardTitle;
|
||||
delete config.logoPosition;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Branding reset to defaults'
|
||||
});
|
||||
}, 'logo-delete'));
|
||||
|
||||
// ===== FAVICON ENDPOINTS =====
|
||||
// Upload and convert favicon (PNG/SVG to ICO)
|
||||
|
||||
// Get current favicon
|
||||
router.get('/favicon', ctx.asyncHandler(async (req, res) => {
|
||||
const config = await ctx.readConfig();
|
||||
res.json({
|
||||
success: true,
|
||||
customFavicon: config.customFavicon || null,
|
||||
isDefault: !config.customFavicon
|
||||
});
|
||||
}, 'favicon-get'));
|
||||
|
||||
// Upload and convert favicon
|
||||
router.post('/favicon', ctx.asyncHandler(async (req, res) => {
|
||||
const { data } = req.body;
|
||||
|
||||
if (!data) {
|
||||
return ctx.errorResponse(res, 400, 'Image data is required');
|
||||
}
|
||||
|
||||
if (!sharp || !pngToIco) {
|
||||
return ctx.errorResponse(res, 500, 'Image processing not available');
|
||||
}
|
||||
|
||||
// Extract base64 data
|
||||
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid image data format');
|
||||
}
|
||||
|
||||
const imageType = matches[1];
|
||||
const base64Data = matches[2];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
if (!await exists(assetsPath)) {
|
||||
await fsp.mkdir(assetsPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Convert to PNG at multiple sizes for ICO
|
||||
const sizes = [16, 32, 48];
|
||||
const pngBuffers = await Promise.all(
|
||||
sizes.map(size =>
|
||||
sharp(buffer)
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer()
|
||||
)
|
||||
);
|
||||
|
||||
// Convert to ICO
|
||||
const icoBuffer = await pngToIco(pngBuffers);
|
||||
|
||||
// Save ICO file
|
||||
const icoPath = `${assetsPath}/favicon.ico`;
|
||||
await fsp.writeFile(icoPath, icoBuffer);
|
||||
|
||||
// Also save a PNG version for modern browsers
|
||||
const png32 = await sharp(buffer)
|
||||
.resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
await fsp.writeFile(`${assetsPath}/favicon.png`, png32);
|
||||
|
||||
// Update config
|
||||
await ctx.saveConfig({ customFavicon: '/assets/favicon.ico', updatedAt: new Date().toISOString() });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: '/assets/favicon.ico',
|
||||
message: 'Favicon created successfully'
|
||||
});
|
||||
}, 'favicon'));
|
||||
|
||||
// Reset favicon to default
|
||||
router.delete('/favicon', ctx.asyncHandler(async (req, res) => {
|
||||
const config = await ctx.readConfig();
|
||||
|
||||
// Delete custom favicon files
|
||||
const assetsPath = process.env.ASSETS_PATH || '/app/assets';
|
||||
const filesToDelete = ['favicon.ico', 'favicon.png'];
|
||||
for (const file of filesToDelete) {
|
||||
const filePath = `${assetsPath}/${file}`;
|
||||
if (await exists(filePath)) {
|
||||
await fsp.unlink(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
delete config.customFavicon;
|
||||
config.updatedAt = new Date().toISOString();
|
||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Favicon reset to default'
|
||||
});
|
||||
}, 'favicon-delete'));
|
||||
|
||||
return router;
|
||||
};
|
||||
304
dashcaddy-api/routes/config/backup.js
Normal file
304
dashcaddy-api/routes/config/backup.js
Normal file
@@ -0,0 +1,304 @@
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { CADDY } = require('../../constants');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// ===== BACKUP/RESTORE ENDPOINTS =====
|
||||
// Export and import DashCaddy configuration
|
||||
|
||||
// Export all configuration as a downloadable JSON bundle
|
||||
router.get('/backup/export', ctx.asyncHandler(async (req, res) => {
|
||||
const backup = {
|
||||
version: '1.1',
|
||||
exportedAt: new Date().toISOString(),
|
||||
dashcaddyVersion: '1.0.0',
|
||||
files: {},
|
||||
assets: {}
|
||||
};
|
||||
|
||||
// Collect all configuration files
|
||||
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) {
|
||||
ctx.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, manualKey: secret, issuer: 'DashCaddy' };
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.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) {
|
||||
ctx.log.warn('backup', 'Could not include assets 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);
|
||||
ctx.log.info('backup', 'Backup exported successfully');
|
||||
}, 'backup-export'));
|
||||
|
||||
// Preview what will be restored (without making changes)
|
||||
router.post('/backup/preview', ctx.asyncHandler(async (req, res) => {
|
||||
const backup = req.body;
|
||||
|
||||
if (!backup || !backup.version || !backup.files) {
|
||||
return ctx.errorResponse(res, 400, '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;
|
||||
}
|
||||
|
||||
res.json({ success: true, preview });
|
||||
}, 'backup-preview'));
|
||||
|
||||
// Restore configuration from backup
|
||||
router.post('/backup/restore', ctx.asyncHandler(async (req, res) => {
|
||||
const { backup, options = {} } = req.body;
|
||||
|
||||
if (!backup || !backup.version || !backup.files) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid backup file format');
|
||||
}
|
||||
|
||||
const results = {
|
||||
restored: [],
|
||||
skipped: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// File mapping
|
||||
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);
|
||||
ctx.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
|
||||
}, 'backup-restore'));
|
||||
|
||||
return router;
|
||||
};
|
||||
9
dashcaddy-api/routes/config/index.js
Normal file
9
dashcaddy-api/routes/config/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const router = express.Router();
|
||||
router.use(require('./settings')(ctx));
|
||||
router.use(require('./assets')(ctx));
|
||||
router.use(require('./backup')(ctx));
|
||||
return router;
|
||||
};
|
||||
70
dashcaddy-api/routes/config/settings.js
Normal file
70
dashcaddy-api/routes/config/settings.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const fsp = require('fs').promises;
|
||||
const { validateConfig } = require('../../config-schema');
|
||||
const { exists } = require('../../fs-helpers');
|
||||
|
||||
module.exports = function(ctx) {
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// ===== DASHCADDY CONFIG ENDPOINTS =====
|
||||
// Server-side config storage for setup wizard (shared across all browsers/machines)
|
||||
|
||||
router.get('/config', ctx.asyncHandler(async (req, res) => {
|
||||
if (!await exists(ctx.CONFIG_FILE)) {
|
||||
return res.json({ setupComplete: false });
|
||||
}
|
||||
const data = await fsp.readFile(ctx.CONFIG_FILE, 'utf8');
|
||||
const config = JSON.parse(data);
|
||||
res.json(config);
|
||||
}, 'config-get'));
|
||||
|
||||
router.post('/config', ctx.asyncHandler(async (req, res) => {
|
||||
const incoming = req.body;
|
||||
|
||||
if (!incoming || typeof incoming !== 'object') {
|
||||
return ctx.errorResponse(res, 400, 'Invalid config object');
|
||||
}
|
||||
|
||||
// Merge with existing config so partial saves don't wipe fields
|
||||
let existing = {};
|
||||
if (await exists(ctx.CONFIG_FILE)) {
|
||||
try {
|
||||
existing = JSON.parse(await fsp.readFile(ctx.CONFIG_FILE, 'utf8'));
|
||||
} catch (_) { /* start fresh if file is corrupt */ }
|
||||
}
|
||||
const config = { ...existing, ...incoming };
|
||||
|
||||
// Merge nested dns object so partial dns updates don't wipe dns fields
|
||||
if (existing.dns && incoming.dns) {
|
||||
config.dns = { ...existing.dns, ...incoming.dns };
|
||||
}
|
||||
// Merge nested dnsServers object
|
||||
if (existing.dnsServers && incoming.dnsServers) {
|
||||
config.dnsServers = { ...existing.dnsServers, ...incoming.dnsServers };
|
||||
}
|
||||
|
||||
// Validate merged config against schema
|
||||
const { valid, errors, warnings } = validateConfig(config);
|
||||
if (!valid) {
|
||||
return ctx.errorResponse(res, 400, 'Config validation failed', { errors });
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
config.updatedAt = new Date().toISOString();
|
||||
|
||||
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
ctx.loadSiteConfig(); // Refresh in-memory config
|
||||
ctx.log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
|
||||
|
||||
res.json({ success: true, message: 'Configuration saved', config, warnings });
|
||||
}, 'config-save'));
|
||||
|
||||
router.delete('/config', ctx.asyncHandler(async (req, res) => {
|
||||
if (await exists(ctx.CONFIG_FILE)) {
|
||||
await fsp.unlink(ctx.CONFIG_FILE);
|
||||
}
|
||||
res.json({ success: true, message: 'Configuration reset' });
|
||||
}, 'config-delete'));
|
||||
|
||||
return router;
|
||||
};
|
||||
Reference in New Issue
Block a user