DC-059: Joi validation middleware + schemas for destructive routes
[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
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* Usage:
|
||||
* const { validateBody, schemas } = require('../utilities/validate');
|
||||
*
|
||||
* router.post('/schedule', validateBody(schemas.backupSchedule), handler);
|
||||
* router.post('/schedule', validateBody(schemas.backupScheduleCreate), handler);
|
||||
*
|
||||
* The middleware validates req.body against the provided Joi schema.
|
||||
* On success it replaces req.body with the validated/stripped value.
|
||||
@@ -46,22 +46,11 @@ const scheduleSchema = Joi.alternatives().try(
|
||||
).optional();
|
||||
|
||||
const ipOrCidr = Joi.alternatives().try(
|
||||
// Authoritative single-IP validation (Joi's built-in strict IPv4/IPv6 check)
|
||||
Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
|
||||
Joi.string().regex(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/)
|
||||
.custom((value, helpers) => {
|
||||
const [, a, b, c, d, prefix] = value.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
|
||||
for (const octet of [a, b, c, d]) {
|
||||
if (parseInt(octet) > 255) return helpers.error('string.pattern.base', { name: 'IPv4 CIDR' });
|
||||
}
|
||||
if (parseInt(prefix) > 32) return helpers.error('string.pattern.base', { name: 'IPv4 CIDR' });
|
||||
return value;
|
||||
}, 'IPv4 CIDR with valid octets/prefix'),
|
||||
Joi.string().regex(/^[0-9a-fA-F:]+\/(\d{1,3})$/)
|
||||
.custom((value, helpers) => {
|
||||
const prefix = parseInt(value.split('/')[1]);
|
||||
if (prefix > 128) return helpers.error('string.pattern.base', { name: 'IPv6 CIDR' });
|
||||
return value;
|
||||
}, 'IPv6 CIDR with valid prefix'),
|
||||
// Authoritative CIDR validation (Joi's built-in strict CIDR check rejects malformed
|
||||
// addresses like "::::/64" that a permissive hex/colon regex would otherwise accept)
|
||||
Joi.string().ip({ version: ['ipv4', 'ipv6'], cidr: 'required' }),
|
||||
);
|
||||
|
||||
// ─── Schemas for destructive routes ───────────────────────────
|
||||
@@ -147,15 +136,26 @@ const schemas = {
|
||||
containerPath: Joi.string().max(500).required(),
|
||||
}).unknown(false)).optional(),
|
||||
mediaPath: Joi.string().max(500).optional(),
|
||||
// Template-specific config fields preserved from the live frontend
|
||||
sslType: Joi.string().valid('self-signed', 'tailscale', 'letsencrypt', 'none').optional(),
|
||||
dnsType: Joi.string().valid('private', 'public', 'none').optional(),
|
||||
plexClaimToken: Joi.string().max(500).optional(),
|
||||
resources: Joi.object({
|
||||
memory: Joi.number().min(32).max(65536).optional(),
|
||||
cpus: Joi.number().min(0.1).max(64).optional(),
|
||||
}).optional(),
|
||||
}).required(),
|
||||
}).unknown(true), // Forward-compat: templates may accept additional fields
|
||||
}),
|
||||
|
||||
/** POST /apps/:appId/restore */
|
||||
appRestore: Joi.object({}).max(0),
|
||||
/** POST /apps/:appId/restore — empty body, reject any input fields */
|
||||
appRestore: Joi.any().custom((value, helpers) => {
|
||||
if (value !== undefined && value !== null && (typeof value !== 'object' || Object.keys(value).length > 0)) {
|
||||
return helpers.error('object.empty');
|
||||
}
|
||||
return {};
|
||||
}, 'empty-body-guard').messages({
|
||||
'object.empty': '"body" must be empty (this endpoint accepts no input)',
|
||||
}),
|
||||
|
||||
/** POST /apps/:appId/revert/:filename */
|
||||
appRevert: Joi.object({
|
||||
@@ -171,9 +171,9 @@ const schemas = {
|
||||
|
||||
/** POST /assets/logo */
|
||||
logoUpload: Joi.object({
|
||||
data: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
dataDark: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
dataLight: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
data: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
|
||||
dataDark: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
|
||||
dataLight: Joi.string().min(1).max(10 * 1024 * 1024).optional(),
|
||||
position: Joi.string().valid('left', 'center', 'right').optional(),
|
||||
dashboardTitle: Joi.string().max(50).allow('').optional(),
|
||||
}).min(1),
|
||||
|
||||
Reference in New Issue
Block a user