import { Router } from 'express'; import { getExportProfile, getImageRules, updateImageRules } from '../services/configService.js'; import { logAuditEvent } from '../services/auditService.js'; import { asyncHandler } from '../utils/asyncHandler.js'; import { configCache } from '../services/cacheService.js'; const router = Router(); /** * @openapi * /api/v1/config/image-rules: * get: * summary: Get image validation rules * tags: * - Configuration * security: * - bearerAuth: [] * - cookieAuth: [] * responses: * 200: * description: Image rules configuration * 404: * description: Image rules not found * put: * summary: Update image validation rules * tags: * - Configuration * security: * - bearerAuth: [] * - cookieAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * - allowedMimeTypes * - maxFileSizeBytes * - maxWidthPx * - maxHeightPx * - jpegQuality * - oversizeBehavior * - maxAttachmentsPerField * properties: * name: * type: string * allowedMimeTypes: * type: array * items: * type: string * maxFileSizeBytes: * type: integer * maxWidthPx: * type: integer * maxHeightPx: * type: integer * jpegQuality: * type: integer * minimum: 1 * maximum: 100 * oversizeBehavior: * type: string * enum: [auto_optimize, warn_then_optimize, block] * maxAttachmentsPerField: * type: integer * responses: * 200: * description: Image rules updated * 400: * description: Invalid input * 404: * description: Image rules not found * /api/v1/config/export: * get: * summary: Get export profile configuration * tags: * - Configuration * security: * - bearerAuth: [] * - cookieAuth: [] * responses: * 200: * description: Export profile configuration * 404: * description: Export profile not found */ /* * Image-rules validation is shared between server and admin frontend. The server * acts as the final authority while the client validates proactively to give the * administrator immediate feedback before the round-trip. */ function validateImageRulesPayload(payload) { const oversizeBehaviors = ['auto_optimize', 'warn_then_optimize', 'block']; const allowedMimeTypes = Array.isArray(payload.allowedMimeTypes) ? payload.allowedMimeTypes.filter((value) => typeof value === 'string' && value.trim()) : []; if (!payload.name || typeof payload.name !== 'string') { return 'Image policy name is required.'; } if (!allowedMimeTypes.length) { return 'At least one allowed MIME type is required.'; } if (!Number.isInteger(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) { return 'Maximum file size must be a positive integer.'; } if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) { return 'Maximum width must be a positive integer.'; } if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) { return 'Maximum height must be a positive integer.'; } if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) { return 'JPEG quality must be an integer between 1 and 100.'; } if (!oversizeBehaviors.includes(payload.oversizeBehavior)) { return 'Oversize behavior is invalid.'; } if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) { return 'Maximum attachments per field must be a positive integer.'; } return null; } router.get( '/image-rules', asyncHandler(async (_req, res) => { const cached = configCache.get('image-rules'); if (cached) { return res.json(cached); } const imageRules = await getImageRules(); if (!imageRules) { return res.status(404).json({ message: 'Image rules not found.' }); } configCache.set('image-rules', imageRules); return res.json(imageRules); }) ); router.put( '/image-rules', asyncHandler(async (req, res) => { /* * Normalize incoming values before validation so the API can accept the * browser form payload in a predictable shape. The frontend sends numbers as * form values, but they still arrive over HTTP as strings until coerced. */ const payload = { name: req.body?.name?.trim(), allowedMimeTypes: Array.isArray(req.body?.allowedMimeTypes) ? req.body.allowedMimeTypes.map((value) => String(value).trim()).filter(Boolean) : [], maxFileSizeBytes: Number(req.body?.maxFileSizeBytes), maxWidthPx: Number(req.body?.maxWidthPx), maxHeightPx: Number(req.body?.maxHeightPx), jpegQuality: Number(req.body?.jpegQuality), oversizeBehavior: req.body?.oversizeBehavior, maxAttachmentsPerField: Number(req.body?.maxAttachmentsPerField) }; const validationMessage = validateImageRulesPayload(payload); if (validationMessage) { return res.status(400).json({ message: validationMessage }); } /* Capture the current value before mutation for the audit trail. */ const previousRules = await getImageRules(); const imageRules = await updateImageRules(payload); if (!imageRules) { return res.status(404).json({ message: 'Image rules not found.' }); } configCache.invalidate('image-rules'); await logAuditEvent({ entityType: 'image_rules', entityCode: imageRules.code, action: 'update', oldValue: previousRules, newValue: imageRules }); return res.json(imageRules); }) ); router.get( '/export', asyncHandler(async (_req, res) => { const exportProfile = await getExportProfile(); if (!exportProfile) { return res.status(404).json({ message: 'Export profile not found.' }); } res.json(exportProfile); }) ); export default router;