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 = path.basename(logoPath); if (!filename || seen.has(filename)) continue; seen.add(filename); const filePath = path.join(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; };