225 lines
6.3 KiB
JavaScript
225 lines
6.3 KiB
JavaScript
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;
|