DC-059: claim for Hermes
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Joi-based request body validation middleware.
|
||||
*
|
||||
* Usage:
|
||||
* const { validateBody, schemas } = require('../utilities/validate');
|
||||
*
|
||||
* router.post('/schedule', validateBody(schemas.backupSchedule), 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(
|
||||
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'),
|
||||
);
|
||||
|
||||
// ─── 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(),
|
||||
resources: Joi.object({
|
||||
memory: Joi.number().min(32).max(65536).optional(),
|
||||
cpus: Joi.number().min(0.1).max(64).optional(),
|
||||
}).optional(),
|
||||
}).required(),
|
||||
}),
|
||||
|
||||
/** POST /apps/:appId/restore */
|
||||
appRestore: Joi.object({}).max(0),
|
||||
|
||||
/** 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().max(10 * 1024 * 1024).optional(),
|
||||
dataDark: Joi.string().max(10 * 1024 * 1024).optional(),
|
||||
dataLight: Joi.string().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 };
|
||||
Reference in New Issue
Block a user