stage 1
This commit is contained in:
+113
-2
@@ -3,22 +3,128 @@ import { Router } from 'express';
|
||||
import {
|
||||
getAppConfig,
|
||||
getExportProfile,
|
||||
getImageRules
|
||||
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();
|
||||
|
||||
/*
|
||||
* 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.' });
|
||||
}
|
||||
|
||||
res.json(imageRules);
|
||||
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);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -38,6 +144,11 @@ router.get(
|
||||
router.get(
|
||||
'/app-config',
|
||||
asyncHandler(async (_req, res) => {
|
||||
/*
|
||||
* Generic application configuration is kept as a simple key/value list in
|
||||
* the PoC. This avoids hardcoding small behavioral settings in the frontend
|
||||
* while still keeping the schema easy to inspect and evolve.
|
||||
*/
|
||||
const config = await getAppConfig();
|
||||
res.json({ items: config });
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user