[grade=B] - New src/utilities/validate.js: validateBody(schema) middleware + 9 schemas (backupConfigUpdate, backupScheduleCreate, backupRestore, backupRestoreFile, appDeploy, appRestore, appRevert, assetUpload, logoUpload) - Uses Joi's authoritative CIDR validator (rejects malformed IPv6 like ::::/64 that the previous hex/colon regex would have accepted) - appDeploy.config uses .unknown(true) for forward-compat with template-specific fields (sslType, dnsType, plexClaimToken, etc.) — preserves fields the live frontend posts, prevents a behavioural regression - appRestore uses Joi.any().custom() so the empty-body semantics hold under middleware stripUnknown (default) — body with extra keys now rejected - Wired into 8 destructive routes: backups schedule/restore/config, apps deploy/restore/revert, assets upload/logo - Duplicate legacy POST /backups/schedule handler (line 519) marked LEGACY with TODO removal note (Express only matches first registration; this handler is unreachable under normal routing) - Removed redundant manual appId check in /backups/schedule (Joi schema enforces it) - Removed unused 'mime' destructure in /assets/favicon (decodeImageData validates MIME internally) - 41 unit tests covering every exported schema + middleware integration - 1539/1539 Jest tests pass, zero new ESLint warnings
302 lines
11 KiB
JavaScript
302 lines
11 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');
|
|
const { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
|
|
/**
|
|
* 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}
|
|
*/
|
|
|
|
// P0-4: image upload guard rails.
|
|
// We allow a small whitelist of image MIME types and cap decoded bytes at 5 MB.
|
|
const ALLOWED_IMAGE_TYPES = new Set(['png', 'jpeg', 'jpg', 'svg+xml', 'webp', 'ico', 'x-icon']);
|
|
const MAX_ASSET_BYTES = 5 * 1024 * 1024;
|
|
|
|
function decodeImageData(data) {
|
|
if (typeof data !== 'string') {
|
|
throw new ValidationError('Invalid image data format');
|
|
}
|
|
const matches = data.match(/^data:image\/([a-zA-Z0-9+.-]+);base64,(.+)$/);
|
|
if (!matches) {
|
|
throw new ValidationError('Invalid image data format');
|
|
}
|
|
const mime = matches[1].toLowerCase();
|
|
if (!ALLOWED_IMAGE_TYPES.has(mime)) {
|
|
throw new ValidationError(`Unsupported image type: ${mime}`);
|
|
}
|
|
const buffer = Buffer.from(matches[2], 'base64');
|
|
if (buffer.length > MAX_ASSET_BYTES) {
|
|
throw new ValidationError(`File too large (max ${MAX_ASSET_BYTES / 1024 / 1024}MB)`);
|
|
}
|
|
return { mime, buffer };
|
|
}
|
|
|
|
// 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 }), validateBody(valSchemas.assetUpload), asyncHandler(async (req, res) => {
|
|
const { filename, data } = req.body;
|
|
|
|
// 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');
|
|
}
|
|
|
|
// P0-4 fix: use the helper that validates MIME type (whitelist), caps decoded bytes
|
|
// at 5 MB, and rejects anything that isn't a string. The old inline regex allowed
|
|
// `image/<anything>;base64,...` without a size cap and without MIME restriction.
|
|
const { buffer } = decodeImageData(data);
|
|
|
|
// 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 }), validateBody(valSchemas.logoUpload), asyncHandler(async (req, res) => {
|
|
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
|
|
|
|
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');
|
|
}
|
|
|
|
// P0-4: validate MIME type + enforce 5MB buffer size cap (mime validated inside decodeImageData)
|
|
const { buffer } = decodeImageData(data);
|
|
|
|
// Determine assets path (mounted volume)
|
|
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;
|
|
};
|