361 lines
9.5 KiB
JavaScript
361 lines
9.5 KiB
JavaScript
|
|
/**
|
||
|
|
* Validation Middleware
|
||
|
|
* Provides reusable validation middleware for common request patterns
|
||
|
|
*/
|
||
|
|
|
||
|
|
const {
|
||
|
|
validateUsername,
|
||
|
|
validateEmail,
|
||
|
|
validateUrl,
|
||
|
|
validatePlaylistName,
|
||
|
|
validateChannelName,
|
||
|
|
validateDescription,
|
||
|
|
validateFilename,
|
||
|
|
validateSettingKey,
|
||
|
|
validateInteger,
|
||
|
|
validateBoolean,
|
||
|
|
validateJSON,
|
||
|
|
sanitizeObject
|
||
|
|
} = require('../utils/inputValidator');
|
||
|
|
const logger = require('../utils/logger');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generic validation middleware factory
|
||
|
|
*/
|
||
|
|
function createValidationMiddleware(validators) {
|
||
|
|
return (req, res, next) => {
|
||
|
|
const errors = {};
|
||
|
|
const sanitized = {};
|
||
|
|
let hasErrors = false;
|
||
|
|
|
||
|
|
// Validate body parameters
|
||
|
|
if (validators.body) {
|
||
|
|
for (const [field, validator] of Object.entries(validators.body)) {
|
||
|
|
const value = req.body[field];
|
||
|
|
const result = validator(value);
|
||
|
|
|
||
|
|
if (!result.valid) {
|
||
|
|
errors[field] = result.errors;
|
||
|
|
hasErrors = true;
|
||
|
|
} else if (result.sanitized !== undefined) {
|
||
|
|
sanitized[field] = result.sanitized;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate query parameters
|
||
|
|
if (validators.query) {
|
||
|
|
for (const [field, validator] of Object.entries(validators.query)) {
|
||
|
|
const value = req.query[field];
|
||
|
|
const result = validator(value);
|
||
|
|
|
||
|
|
if (!result.valid) {
|
||
|
|
errors[`query.${field}`] = result.errors;
|
||
|
|
hasErrors = true;
|
||
|
|
} else if (result.sanitized !== undefined) {
|
||
|
|
if (!req.sanitizedQuery) req.sanitizedQuery = {};
|
||
|
|
req.sanitizedQuery[field] = result.sanitized;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate params
|
||
|
|
if (validators.params) {
|
||
|
|
for (const [field, validator] of Object.entries(validators.params)) {
|
||
|
|
const value = req.params[field];
|
||
|
|
const result = validator(value);
|
||
|
|
|
||
|
|
if (!result.valid) {
|
||
|
|
errors[`params.${field}`] = result.errors;
|
||
|
|
hasErrors = true;
|
||
|
|
} else if (result.sanitized !== undefined) {
|
||
|
|
if (!req.sanitizedParams) req.sanitizedParams = {};
|
||
|
|
req.sanitizedParams[field] = result.sanitized;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hasErrors) {
|
||
|
|
logger.warn('Validation failed:', { errors, path: req.path, ip: req.ip });
|
||
|
|
return res.status(400).json({
|
||
|
|
error: 'Validation failed',
|
||
|
|
details: errors
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Replace request data with sanitized versions
|
||
|
|
if (Object.keys(sanitized).length > 0) {
|
||
|
|
req.body = { ...req.body, ...sanitized };
|
||
|
|
}
|
||
|
|
|
||
|
|
next();
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate playlist creation/update
|
||
|
|
*/
|
||
|
|
const validatePlaylist = createValidationMiddleware({
|
||
|
|
body: {
|
||
|
|
name: validatePlaylistName,
|
||
|
|
url: (value) => validateUrl(value, false),
|
||
|
|
category: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: null };
|
||
|
|
return validateDescription(value);
|
||
|
|
},
|
||
|
|
type: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: 'live' };
|
||
|
|
const allowed = ['live', 'vod', 'series', 'radio'];
|
||
|
|
if (!allowed.includes(value)) {
|
||
|
|
return { valid: false, errors: ['Invalid playlist type'], sanitized: null };
|
||
|
|
}
|
||
|
|
return { valid: true, errors: [], sanitized: value };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate channel update
|
||
|
|
*/
|
||
|
|
const validateChannelUpdate = createValidationMiddleware({
|
||
|
|
body: {
|
||
|
|
name: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateChannelName(value);
|
||
|
|
},
|
||
|
|
group_name: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateDescription(value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate settings
|
||
|
|
*/
|
||
|
|
const validateSettings = createValidationMiddleware({
|
||
|
|
params: {
|
||
|
|
key: validateSettingKey
|
||
|
|
},
|
||
|
|
body: {
|
||
|
|
value: (value) => {
|
||
|
|
// Settings can be strings, numbers, booleans, or JSON objects
|
||
|
|
if (value === undefined || value === null) {
|
||
|
|
return { valid: false, errors: ['Value is required'], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
// If it's an object, validate as JSON
|
||
|
|
if (typeof value === 'object') {
|
||
|
|
return validateJSON(value, 100000);
|
||
|
|
}
|
||
|
|
|
||
|
|
return { valid: true, errors: [], sanitized: value };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate ID parameter
|
||
|
|
*/
|
||
|
|
const validateIdParam = createValidationMiddleware({
|
||
|
|
params: {
|
||
|
|
id: (value) => validateInteger(value, 1, Number.MAX_SAFE_INTEGER)
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate channelId parameter
|
||
|
|
*/
|
||
|
|
const validateChannelIdParam = createValidationMiddleware({
|
||
|
|
params: {
|
||
|
|
channelId: (value) => validateInteger(value, 1, Number.MAX_SAFE_INTEGER)
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate pagination parameters
|
||
|
|
*/
|
||
|
|
const validatePagination = createValidationMiddleware({
|
||
|
|
query: {
|
||
|
|
limit: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: 100 };
|
||
|
|
return validateInteger(value, 1, 1000);
|
||
|
|
},
|
||
|
|
offset: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: 0 };
|
||
|
|
return validateInteger(value, 0, Number.MAX_SAFE_INTEGER);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate search query
|
||
|
|
*/
|
||
|
|
const validateSearch = createValidationMiddleware({
|
||
|
|
query: {
|
||
|
|
search: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateDescription(value);
|
||
|
|
},
|
||
|
|
q: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateDescription(value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate user creation
|
||
|
|
*/
|
||
|
|
const validateUserCreation = createValidationMiddleware({
|
||
|
|
body: {
|
||
|
|
username: validateUsername,
|
||
|
|
email: validateEmail,
|
||
|
|
password: (value) => {
|
||
|
|
if (!value || typeof value !== 'string') {
|
||
|
|
return { valid: false, errors: ['Password is required'], sanitized: null };
|
||
|
|
}
|
||
|
|
if (value.length < 8) {
|
||
|
|
return { valid: false, errors: ['Password must be at least 8 characters'], sanitized: null };
|
||
|
|
}
|
||
|
|
if (value.length > 128) {
|
||
|
|
return { valid: false, errors: ['Password must not exceed 128 characters'], sanitized: null };
|
||
|
|
}
|
||
|
|
return { valid: true, errors: [], sanitized: value };
|
||
|
|
},
|
||
|
|
role: (value) => {
|
||
|
|
const allowed = ['user', 'admin'];
|
||
|
|
if (!allowed.includes(value)) {
|
||
|
|
return { valid: false, errors: ['Invalid role'], sanitized: null };
|
||
|
|
}
|
||
|
|
return { valid: true, errors: [], sanitized: value };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate user update
|
||
|
|
*/
|
||
|
|
const validateUserUpdate = createValidationMiddleware({
|
||
|
|
params: {
|
||
|
|
id: (value) => validateInteger(value, 1, Number.MAX_SAFE_INTEGER)
|
||
|
|
},
|
||
|
|
body: {
|
||
|
|
username: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateUsername(value);
|
||
|
|
},
|
||
|
|
email: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateEmail(value);
|
||
|
|
},
|
||
|
|
role: (value) => {
|
||
|
|
if (!value) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
const allowed = ['user', 'admin'];
|
||
|
|
if (!allowed.includes(value)) {
|
||
|
|
return { valid: false, errors: ['Invalid role'], sanitized: null };
|
||
|
|
}
|
||
|
|
return { valid: true, errors: [], sanitized: value };
|
||
|
|
},
|
||
|
|
is_active: (value) => {
|
||
|
|
if (value === undefined) return { valid: true, errors: [], sanitized: undefined };
|
||
|
|
return validateBoolean(value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate filename for file operations
|
||
|
|
*/
|
||
|
|
const validateFileOperation = createValidationMiddleware({
|
||
|
|
params: {
|
||
|
|
filename: validateFilename
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate bulk delete operations
|
||
|
|
*/
|
||
|
|
const validateBulkDelete = createValidationMiddleware({
|
||
|
|
body: {
|
||
|
|
ids: (value) => {
|
||
|
|
if (!Array.isArray(value)) {
|
||
|
|
return { valid: false, errors: ['IDs must be an array'], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (value.length === 0) {
|
||
|
|
return { valid: false, errors: ['At least one ID required'], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (value.length > 1000) {
|
||
|
|
return { valid: false, errors: ['Cannot delete more than 1000 items at once'], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
const errors = [];
|
||
|
|
const sanitized = [];
|
||
|
|
|
||
|
|
for (let i = 0; i < value.length; i++) {
|
||
|
|
const result = validateInteger(value[i], 1, Number.MAX_SAFE_INTEGER);
|
||
|
|
if (!result.valid) {
|
||
|
|
errors.push(`Invalid ID at index ${i}`);
|
||
|
|
} else {
|
||
|
|
sanitized.push(result.sanitized);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (errors.length > 0) {
|
||
|
|
return { valid: false, errors, sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { valid: true, errors: [], sanitized };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generic text field validator
|
||
|
|
*/
|
||
|
|
function validateTextField(maxLength = 1000, required = true) {
|
||
|
|
return (value) => {
|
||
|
|
if (!value || value === '') {
|
||
|
|
if (required) {
|
||
|
|
return { valid: false, errors: ['This field is required'], sanitized: null };
|
||
|
|
}
|
||
|
|
return { valid: true, errors: [], sanitized: '' };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof value !== 'string') {
|
||
|
|
return { valid: false, errors: ['Must be a string'], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
const trimmed = value.trim();
|
||
|
|
|
||
|
|
if (required && trimmed.length === 0) {
|
||
|
|
return { valid: false, errors: ['This field is required'], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (trimmed.length > maxLength) {
|
||
|
|
return { valid: false, errors: [`Must not exceed ${maxLength} characters`], sanitized: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = validateDescription(trimmed);
|
||
|
|
return result;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
createValidationMiddleware,
|
||
|
|
validatePlaylist,
|
||
|
|
validateChannelUpdate,
|
||
|
|
validateSettings,
|
||
|
|
validateIdParam,
|
||
|
|
validateChannelIdParam,
|
||
|
|
validatePagination,
|
||
|
|
validateSearch,
|
||
|
|
validateUserCreation,
|
||
|
|
validateUserUpdate,
|
||
|
|
validateFileOperation,
|
||
|
|
validateBulkDelete,
|
||
|
|
validateTextField
|
||
|
|
};
|