/** * 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 };