[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
183 lines
7.4 KiB
JavaScript
183 lines
7.4 KiB
JavaScript
/**
|
|
* Joi-based request body validation middleware.
|
|
*
|
|
* Usage:
|
|
* const { validateBody, schemas } = require('../utilities/validate');
|
|
*
|
|
* 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.
|
|
* On failure it throws a ValidationError (caught by asyncHandler → 400).
|
|
*
|
|
* Schemas for destructive routes live in the `schemas` export so they
|
|
* can be unit-tested without spinning up Express.
|
|
*/
|
|
|
|
const Joi = require('joi');
|
|
const { ValidationError } = require('./errors');
|
|
|
|
/**
|
|
* Factory: returns an Express middleware that validates req.body.
|
|
* @param {Joi.Schema} schema
|
|
* @param {{ stripUnknown?: boolean, abortEarly?: boolean }} [opts]
|
|
*/
|
|
function validateBody(schema, opts = {}) {
|
|
return (req, _res, next) => {
|
|
const { error, value } = schema.validate(req.body, {
|
|
stripUnknown: opts.stripUnknown ?? true,
|
|
abortEarly: opts.abortEarly ?? false,
|
|
convert: true,
|
|
});
|
|
if (error) {
|
|
const msg = error.details.map(d => d.message).join('; ');
|
|
throw new ValidationError(msg);
|
|
}
|
|
req.body = value;
|
|
next();
|
|
};
|
|
}
|
|
|
|
// ─── Reusable schema fragments ───────────────────────────────
|
|
|
|
const scheduleSchema = Joi.alternatives().try(
|
|
Joi.string().valid('hourly', 'daily', 'weekly', 'monthly'),
|
|
Joi.string().pattern(/^\d+[mh]?$/, 'custom-interval (e.g. "30m", "6h")'),
|
|
).optional();
|
|
|
|
const ipOrCidr = Joi.alternatives().try(
|
|
// Authoritative single-IP validation (Joi's built-in strict IPv4/IPv6 check)
|
|
Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
|
|
// 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 ───────────────────────────
|
|
|
|
const schemas = {
|
|
/** POST /backups/config — update backup configuration */
|
|
backupConfigUpdate: Joi.object({
|
|
backups: Joi.object().pattern(
|
|
Joi.string().max(100), // appId key
|
|
Joi.object({ // per-app backup config
|
|
enabled: Joi.boolean().optional(),
|
|
schedule: scheduleSchema,
|
|
retention: Joi.object({
|
|
keep: Joi.number().integer().min(1).max(365).optional(),
|
|
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
|
}).optional(),
|
|
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
|
destinationPath: Joi.string().max(512).optional(),
|
|
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
|
|
runImmediately: Joi.boolean().optional(),
|
|
include: Joi.array().items(Joi.string().max(50)).optional(),
|
|
destinations: Joi.array().items(Joi.object({
|
|
type: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
|
path: Joi.string().max(512).optional(),
|
|
}).unknown(false)).optional(),
|
|
}).unknown(false)
|
|
).optional(),
|
|
defaultRetention: Joi.object({
|
|
keep: Joi.number().integer().min(1).max(365).optional(),
|
|
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
|
}).optional(),
|
|
}),
|
|
|
|
/** POST /backups/schedule — create or update a scheduled backup */
|
|
backupScheduleCreate: Joi.object({
|
|
appId: Joi.string().min(1).max(100).required(),
|
|
enabled: Joi.boolean().optional(),
|
|
schedule: scheduleSchema,
|
|
retention: Joi.object({
|
|
keep: Joi.number().integer().min(1).max(365).optional(),
|
|
olderThan: [Joi.string().max(20).optional(), Joi.number().optional()],
|
|
}).optional(),
|
|
runImmediately: Joi.boolean().optional(),
|
|
destination: Joi.string().valid('local', 'dropbox', 'webdav', 'sftp').optional(),
|
|
destinationPath: Joi.string().max(512).optional(),
|
|
maxStorageBytes: [Joi.number().integer().min(0).optional(), Joi.string().max(20).optional()],
|
|
// Legacy schedule route fields
|
|
name: Joi.string().max(100).optional(),
|
|
}),
|
|
|
|
/** POST /backups/restore/:backupId — passes through to backupManager.restoreBackup */
|
|
backupRestore: Joi.object({
|
|
encryptionKey: Joi.string().max(512).optional(),
|
|
restartContainers: Joi.boolean().optional(),
|
|
// restoreBackup reads options from body; allow known control flags only
|
|
services: Joi.boolean().optional(),
|
|
config: Joi.boolean().optional(),
|
|
credentials: Joi.boolean().optional(),
|
|
volumes: Joi.boolean().optional(),
|
|
}),
|
|
|
|
/** POST /backups/restore-file/:filename */
|
|
backupRestoreFile: Joi.object({
|
|
encryptionKey: Joi.string().max(512).optional(),
|
|
restartContainers: Joi.boolean().optional(),
|
|
}),
|
|
|
|
/** POST /apps/deploy */
|
|
appDeploy: Joi.object({
|
|
appId: Joi.string().min(1).max(100).required(),
|
|
config: Joi.object({
|
|
subdomain: Joi.string().min(1).max(63).required(),
|
|
port: Joi.number().integer().min(1).max(65535).optional(),
|
|
ip: Joi.string().max(45).optional(),
|
|
useExisting: Joi.boolean().optional(),
|
|
existingContainerId: Joi.string().max(200).optional(),
|
|
existingPort: Joi.number().integer().min(1).max(65535).optional(),
|
|
createDns: Joi.boolean().optional(),
|
|
tailscaleOnly: Joi.boolean().optional(),
|
|
allowedIPs: Joi.array().items(ipOrCidr).optional(),
|
|
customVolumes: Joi.array().items(Joi.object({
|
|
hostPath: Joi.string().max(500).required(),
|
|
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(),
|
|
}).unknown(true), // Forward-compat: templates may accept additional fields
|
|
}),
|
|
|
|
/** 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({
|
|
encryptionKey: Joi.string().max(512).optional(),
|
|
restartContainers: Joi.boolean().optional(),
|
|
}),
|
|
|
|
/** POST /assets/upload */
|
|
assetUpload: Joi.object({
|
|
filename: Joi.string().min(1).max(255).required(),
|
|
data: Joi.string().min(1).max(10 * 1024 * 1024).required(), // 10MB base64 cap
|
|
}),
|
|
|
|
/** POST /assets/logo */
|
|
logoUpload: Joi.object({
|
|
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),
|
|
};
|
|
|
|
module.exports = { validateBody, schemas };
|