Files
dashcaddy/dashcaddy-api/routes/config/assets.js
T
Hermes c39c80b3ad
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Fix DC-005 depth-2 route path bugs: 67 broken requires across 21 files
The DC-005 src/ refactor left depth-2 route files (routes/auth/*,
routes/recipes/*, routes/apps/*, routes/arr/*, routes/config/*) with
broken require() paths. A filesystem-resolving scanner found 67 broken
requires across 21 files — three distinct bug classes:

  A) '../../../src/...' (3 levels up, above package root) — Bug 7, ~49 occurrences
  B) '../src/utils/...' (1 level up, resolves to nonexistent routes/src/) — ~15 occurrences
  C) routes/apps/restore.js:5 used utilities/responses (wrong dir) — should be utils/responses

All fixed to '../../src/...' (or '../../src/utils/responses' for class C).
routes/auth/totp.js was already fixed in the DC-006 commit.

Post-fix: 922/922 tests pass, zero new ESLint warnings. No logic changes —
purely mechanical require() path corrections.
2026-06-25 16:55:06 -07:00

294 lines
10 KiB
JavaScript

const express = require('express');
const fsp = require('fs').promises;
const path = require('path');
const { LIMITS } = require('../../src/utilities/constants');
const { exists } = require('../../src/utilities/fs-helpers');
const { ValidationError } = require('../../src/utilities/errors');
const platformPaths = require('../../platform-paths');
const { ok, successMessage } = require('../../src/utils/responses');
/**
* Config assets routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
// 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({ servicesStateManager: _servicesStateManager, asyncHandler, log: _log, CONFIG_FILE, readConfig, saveConfig, errorResponse }) {
const router = express.Router();
const ctx = { CONFIG_FILE, readConfig, saveConfig, errorResponse };
// ===== ASSET UPLOAD =====
router.post('/assets/upload', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
const { filename, data } = req.body;
if (!filename || !data) {
throw new ValidationError('filename and data are required');
}
// Validate filename to prevent directory traversal
const safeFilename = path.basename(filename);
if (safeFilename !== filename || filename.includes('..')) {
throw new ValidationError('Invalid filename - must not contain path separators');
}
// Extract base64 data
const matches = data.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/);
if (!matches) {
throw new ValidationError('Invalid image data format');
}
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
// Determine assets path (mounted volume)
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
// 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);
ok(res, {
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', asyncHandler(async (req, res) => {
const config = await ctx.readConfig();
ok(res, {
// 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 = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
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)
// eslint-disable-next-line complexity
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
if (!data && !dataDark && !dataLight && !position && !dashboardTitle) {
throw new ValidationError('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) throw new ValidationError('Invalid dark logo data format');
config.customLogoDark = pathDark;
}
if (dataLight) {
pathLight = await saveLogoFile(dataLight, 'light');
if (!pathLight) throw new ValidationError('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) throw new ValidationError('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');
ok(res, {
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', asyncHandler(async (req, res) => {
const config = await ctx.readConfig();
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
// 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');
successMessage(res, 'Branding reset to defaults');
}, 'logo-delete'));
// ===== FAVICON ENDPOINTS =====
// Upload and convert favicon (PNG/SVG to ICO)
// Get current favicon
router.get('/favicon', asyncHandler(async (req, res) => {
const config = await ctx.readConfig();
ok(res, {
customFavicon: config.customFavicon || null,
isDefault: !config.customFavicon
});
}, 'favicon-get'));
// Upload and convert favicon
router.post('/favicon', asyncHandler(async (req, res) => {
const { data } = req.body;
if (!data) {
throw new ValidationError('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) {
throw new ValidationError('Invalid image data format');
}
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
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() });
ok(res, {
path: '/assets/favicon.ico',
message: 'Favicon created successfully'
});
}, 'favicon'));
// Reset favicon to default
router.delete('/favicon', asyncHandler(async (req, res) => {
const config = await ctx.readConfig();
// Delete custom favicon files
const assetsPath = platformPaths.resolveAssetsPath(process.env.ASSETS_PATH);
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');
successMessage(res, 'Favicon reset to default');
}, 'favicon-delete'));
return router;
};