improved queries validation in the backend
This commit is contained in:
parent
7778d34925
commit
27170d90ec
@ -8,6 +8,8 @@ const {
|
||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||
const { parse } = require('json2csv');
|
||||
const { logger } = require('../utils/logger');
|
||||
const { validateRequest } = require('../middlewares/validate-request');
|
||||
const { crud: crudSchemas } = require('../validators/request-schemas');
|
||||
|
||||
const DEFAULT_LIST_LIMIT = 50;
|
||||
const MAX_LIST_LIMIT = 1000;
|
||||
@ -54,10 +56,13 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
const router = express.Router();
|
||||
|
||||
const permissionEntity = options.permissionEntity || entityName;
|
||||
const validation = options.validation || {};
|
||||
const schemaFor = (name) => validation[name] || crudSchemas[name];
|
||||
router.use(checkCrudPermissions(permissionEntity));
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest(schemaFor('create')),
|
||||
wrapAsync(async (req, res) => {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
@ -87,6 +92,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
validateRequest(schemaFor('update')),
|
||||
wrapAsync(async (req, res) => {
|
||||
assertRouteIdMatchesBody(req);
|
||||
await Service.update(req.body.data, req.params.id, req.currentUser);
|
||||
@ -96,6 +102,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateRequest(schemaFor('remove')),
|
||||
wrapAsync(async (req, res) => {
|
||||
await Service.remove(req.params.id, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
@ -104,6 +111,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.post(
|
||||
'/deleteByIds',
|
||||
validateRequest(schemaFor('deleteByIds')),
|
||||
wrapAsync(async (req, res) => {
|
||||
await Service.deleteByIds(req.body.data, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
@ -112,6 +120,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
validateRequest(schemaFor('list')),
|
||||
wrapAsync(async (req, res) => {
|
||||
const filetype = req.query.filetype;
|
||||
const currentUser = req.currentUser;
|
||||
@ -144,6 +153,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.get(
|
||||
'/count',
|
||||
validateRequest(schemaFor('count')),
|
||||
wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const runtimeContext = req.runtimeContext;
|
||||
@ -158,6 +168,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.get(
|
||||
'/autocomplete',
|
||||
validateRequest(schemaFor('autocomplete')),
|
||||
wrapAsync(async (req, res) => {
|
||||
const limit = clampLimit(req.query.limit, {
|
||||
defaultLimit: 20,
|
||||
@ -174,11 +185,8 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
validateRequest(schemaFor('findOne')),
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send(`Invalid ${entityName} id`);
|
||||
}
|
||||
|
||||
const runtimeContext = req.runtimeContext;
|
||||
const payload = await DBApi.findBy(
|
||||
{ id: req.params.id },
|
||||
|
||||
@ -13,6 +13,13 @@ module.exports = class Helpers {
|
||||
static commonErrorHandler(error, req, res, _next) {
|
||||
const statusCode = error.code || error.status;
|
||||
|
||||
if (error.isRequestValidation) {
|
||||
return res.status(400).send({
|
||||
error: error.message,
|
||||
details: error.details || [],
|
||||
});
|
||||
}
|
||||
|
||||
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
|
||||
return res.status(statusCode).send(error.message);
|
||||
}
|
||||
|
||||
58
backend/src/middlewares/validate-request.js
Normal file
58
backend/src/middlewares/validate-request.js
Normal file
@ -0,0 +1,58 @@
|
||||
const Joi = require('joi');
|
||||
const ValidationError = require('../services/notifications/errors/validation');
|
||||
|
||||
const VALID_REQUEST_PARTS = ['params', 'query', 'body'];
|
||||
|
||||
function formatDetails(details = [], part) {
|
||||
return details.map((detail) => ({
|
||||
path: [part, ...detail.path].join('.'),
|
||||
message: detail.message,
|
||||
type: detail.type,
|
||||
}));
|
||||
}
|
||||
|
||||
function validateRequest(schemas) {
|
||||
if (!schemas || typeof schemas !== 'object') {
|
||||
throw new Error('validateRequest requires a schema map');
|
||||
}
|
||||
|
||||
for (const part of Object.keys(schemas)) {
|
||||
if (!VALID_REQUEST_PARTS.includes(part)) {
|
||||
throw new Error(`Unsupported request validation part: ${part}`);
|
||||
}
|
||||
|
||||
if (!Joi.isSchema(schemas[part])) {
|
||||
throw new Error(
|
||||
`Request validation schema for ${part} must be a Joi schema`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return function requestValidationMiddleware(req, _res, next) {
|
||||
for (const part of VALID_REQUEST_PARTS) {
|
||||
const schema = schemas[part];
|
||||
if (!schema) continue;
|
||||
|
||||
const { value, error } = schema.validate(req[part], {
|
||||
abortEarly: false,
|
||||
convert: true,
|
||||
stripUnknown: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return next(
|
||||
new ValidationError('Invalid request', {
|
||||
isRequestValidation: true,
|
||||
details: formatDetails(error.details, part),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
req[part] = value;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { validateRequest };
|
||||
@ -11,6 +11,8 @@ const {
|
||||
authLimiter: signinLimiter,
|
||||
passwordResetLimiter,
|
||||
} = require('../middlewares/rateLimiter');
|
||||
const { validateRequest } = require('../middlewares/validate-request');
|
||||
const { auth: authSchemas } = require('../validators/request-schemas');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -108,6 +110,7 @@ function getRequestHost(req) {
|
||||
router.post(
|
||||
'/signin/local',
|
||||
signinLimiter,
|
||||
validateRequest(authSchemas.signinLocal),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.signin(
|
||||
req.body.email,
|
||||
@ -157,6 +160,7 @@ router.get(
|
||||
|
||||
router.put(
|
||||
'/password-reset',
|
||||
validateRequest(authSchemas.passwordReset),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.passwordReset(
|
||||
req.body.token,
|
||||
@ -170,6 +174,7 @@ router.put(
|
||||
router.put(
|
||||
'/password-update',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(authSchemas.passwordUpdate),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.passwordUpdate(
|
||||
req.body.currentPassword,
|
||||
@ -197,6 +202,7 @@ router.post(
|
||||
router.post(
|
||||
'/send-password-reset-email',
|
||||
passwordResetLimiter,
|
||||
validateRequest(authSchemas.sendPasswordResetEmail),
|
||||
wrapAsync(async (req, res) => {
|
||||
const host = getRequestHost(req);
|
||||
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
|
||||
@ -208,6 +214,7 @@ router.post(
|
||||
router.put(
|
||||
'/profile',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(authSchemas.profile),
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!req.currentUser || !req.currentUser.id) {
|
||||
throw new ForbiddenError();
|
||||
@ -221,6 +228,7 @@ router.put(
|
||||
|
||||
router.put(
|
||||
'/verify-email',
|
||||
validateRequest(authSchemas.verifyEmail),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await AuthService.verifyEmail(
|
||||
req.body.token,
|
||||
@ -236,12 +244,16 @@ router.get('/email-configured', (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
router.get('/signin/google', (req, res, next) => {
|
||||
passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
state: req.query.app,
|
||||
})(req, res, next);
|
||||
});
|
||||
router.get(
|
||||
'/signin/google',
|
||||
validateRequest(authSchemas.socialSignin),
|
||||
(req, res, next) => {
|
||||
passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
state: req.query.app,
|
||||
})(req, res, next);
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/signin/google/callback',
|
||||
@ -255,12 +267,16 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/signin/microsoft', (req, res, next) => {
|
||||
passport.authenticate('microsoft', {
|
||||
scope: ['https://graph.microsoft.com/user.read openid'],
|
||||
state: req.query.app,
|
||||
})(req, res, next);
|
||||
});
|
||||
router.get(
|
||||
'/signin/microsoft',
|
||||
validateRequest(authSchemas.socialSignin),
|
||||
(req, res, next) => {
|
||||
passport.authenticate('microsoft', {
|
||||
scope: ['https://graph.microsoft.com/user.read openid'],
|
||||
state: req.query.app,
|
||||
})(req, res, next);
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/signin/microsoft/callback',
|
||||
|
||||
@ -4,6 +4,9 @@ const bodyParser = require('body-parser');
|
||||
const services = require('../services/file/');
|
||||
const { isValidPath, createErrorResponse } = require('../services/file');
|
||||
const { logger } = require('../utils/logger');
|
||||
const { commonErrorHandler } = require('../helpers');
|
||||
const { validateRequest } = require('../middlewares/validate-request');
|
||||
const { file: fileSchemas } = require('../validators/request-schemas');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -22,72 +25,49 @@ router.get('/download', (req, res) => {
|
||||
});
|
||||
|
||||
// POST /api/file/presign - Generate presigned URLs for multiple assets
|
||||
router.post('/presign', jsonParser, async (req, res) => {
|
||||
const log = req.log || logger;
|
||||
const { urls } = req.body || {};
|
||||
router.post(
|
||||
'/presign',
|
||||
jsonParser,
|
||||
validateRequest(fileSchemas.presign),
|
||||
async (req, res) => {
|
||||
const log = req.log || logger;
|
||||
const { urls } = req.body || {};
|
||||
|
||||
if (!Array.isArray(urls) || urls.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(createErrorResponse('urls array required', 'MISSING_URLS'));
|
||||
}
|
||||
|
||||
if (urls.length > 50) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS'),
|
||||
// Validate paths for security (no traversal, no protocols)
|
||||
const unsafeUrls = urls.filter((url) => !isValidPath(url));
|
||||
if (unsafeUrls.length > 0) {
|
||||
log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected');
|
||||
return res.status(400).json(
|
||||
createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
|
||||
invalidPaths: unsafeUrls,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all URLs are non-empty strings
|
||||
const invalidUrls = urls.filter(
|
||||
(url) => typeof url !== 'string' || !url.trim(),
|
||||
);
|
||||
if (invalidUrls.length > 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
createErrorResponse(
|
||||
'All URLs must be non-empty strings',
|
||||
'INVALID_URL_FORMAT',
|
||||
),
|
||||
try {
|
||||
const presignedUrls = await services.generatePresignedUrls(urls);
|
||||
res.json({ presignedUrls });
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ err: error, urlCount: urls.length },
|
||||
'Failed to generate presigned URLs',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate paths for security (no traversal, no protocols)
|
||||
const unsafeUrls = urls.filter((url) => !isValidPath(url));
|
||||
if (unsafeUrls.length > 0) {
|
||||
log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected');
|
||||
return res.status(400).json(
|
||||
createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
|
||||
invalidPaths: unsafeUrls,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const presignedUrls = await services.generatePresignedUrls(urls);
|
||||
res.json({ presignedUrls });
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ err: error, urlCount: urls.length },
|
||||
'Failed to generate presigned URLs',
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json(
|
||||
createErrorResponse(
|
||||
'Failed to generate presigned URLs',
|
||||
'PRESIGN_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json(
|
||||
createErrorResponse(
|
||||
'Failed to generate presigned URLs',
|
||||
'PRESIGN_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/upload/:table/:field',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(fileSchemas.upload),
|
||||
(req, res) => {
|
||||
const fileName = `${req.params.table}/${req.params.field}`;
|
||||
|
||||
@ -99,6 +79,7 @@ router.post(
|
||||
'/upload-sessions/init',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
jsonParser,
|
||||
validateRequest(fileSchemas.initUploadSession),
|
||||
(req, res) => {
|
||||
services.initUploadSession(req, res);
|
||||
},
|
||||
@ -107,6 +88,7 @@ router.post(
|
||||
router.get(
|
||||
'/upload-sessions/:sessionId',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(fileSchemas.session),
|
||||
(req, res) => {
|
||||
services.getUploadSession(req, res);
|
||||
},
|
||||
@ -116,6 +98,7 @@ router.get(
|
||||
router.put(
|
||||
'/upload-sessions/:sessionId/chunks/:chunkIndex',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(fileSchemas.chunk),
|
||||
(req, res) => {
|
||||
services.uploadChunk(req, res);
|
||||
},
|
||||
@ -125,9 +108,12 @@ router.put(
|
||||
router.post(
|
||||
'/upload-sessions/:sessionId/finalize',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
validateRequest(fileSchemas.session),
|
||||
(req, res) => {
|
||||
services.finalizeUploadSession(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
router.use('/', commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
const { createEntityRouter, isUuidV4 } = require('../factories/router.factory');
|
||||
const { createEntityRouter } = require('../factories/router.factory');
|
||||
const ProjectsService = require('../services/projects');
|
||||
const ProjectsDBApi = require('../db/api/projects');
|
||||
const { wrapAsync } = require('../helpers');
|
||||
const { wrapAsync, commonErrorHandler } = require('../helpers');
|
||||
const { validateRequest } = require('../middlewares/validate-request');
|
||||
const { projects: projectSchemas } = require('../validators/request-schemas');
|
||||
|
||||
// Create base router with factory (includes all standard CRUD endpoints)
|
||||
const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, {
|
||||
@ -15,16 +17,17 @@ const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, {
|
||||
'favicon_url',
|
||||
'og_image_url',
|
||||
],
|
||||
validation: {
|
||||
create: projectSchemas.create,
|
||||
update: projectSchemas.update,
|
||||
},
|
||||
});
|
||||
|
||||
// Custom endpoint: Clone project
|
||||
router.post(
|
||||
'/:id/clone',
|
||||
validateRequest(projectSchemas.clone),
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send('Invalid project id');
|
||||
}
|
||||
|
||||
const payload = await ProjectsService.cloneFromProject(
|
||||
req.params.id,
|
||||
req.currentUser,
|
||||
@ -33,4 +36,6 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.use('/', commonErrorHandler);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -2,6 +2,8 @@ const express = require('express');
|
||||
const PublishService = require('../services/publish');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||
const { validateRequest } = require('../middlewares/validate-request');
|
||||
const { publish: publishSchemas } = require('../validators/request-schemas');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -18,7 +20,7 @@ const publishHandler = wrapAsync(async (req, res) => {
|
||||
res.status(200).send(result);
|
||||
});
|
||||
|
||||
router.post('/', publishHandler);
|
||||
router.post('/', validateRequest(publishSchemas.publish), publishHandler);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@ -49,6 +51,7 @@ router.post('/', publishHandler);
|
||||
*/
|
||||
router.post(
|
||||
'/save-to-stage',
|
||||
validateRequest(publishSchemas.saveToStage),
|
||||
wrapAsync(async (req, res) => {
|
||||
const { projectId } = req.body;
|
||||
const result = await PublishService.saveToStage(projectId, req.currentUser);
|
||||
|
||||
@ -4,12 +4,16 @@ const Tour_pagesDBApi = require('../db/api/tour_pages');
|
||||
const {
|
||||
wrapAsync,
|
||||
commonErrorHandler,
|
||||
isUuidV4,
|
||||
assertRouteIdMatchesBody,
|
||||
} = require('../helpers');
|
||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||
const { parse } = require('json2csv');
|
||||
const { logger } = require('../utils/logger');
|
||||
const { validateRequest } = require('../middlewares/validate-request');
|
||||
const {
|
||||
crud: crudSchemas,
|
||||
tourPages: tourPageSchemas,
|
||||
} = require('../validators/request-schemas');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@ -165,6 +169,7 @@ router.use(checkCrudPermissions('tour_pages'));
|
||||
// POST - Create
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest(tourPageSchemas.create),
|
||||
wrapAsync(async (req, res) => {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
@ -196,6 +201,7 @@ router.post(
|
||||
// POST - Reorder pages within a project/environment
|
||||
router.post(
|
||||
'/reorder',
|
||||
validateRequest(tourPageSchemas.reorder),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await Tour_pagesService.reorder(
|
||||
req.body.data,
|
||||
@ -208,11 +214,8 @@ router.post(
|
||||
// POST - Duplicate a dev page within a project
|
||||
router.post(
|
||||
'/:id/duplicate',
|
||||
validateRequest(tourPageSchemas.duplicate),
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send('Invalid tour_pages id');
|
||||
}
|
||||
|
||||
const payload = await Tour_pagesService.duplicatePage(
|
||||
req.params.id,
|
||||
req.body.data,
|
||||
@ -225,6 +228,7 @@ router.post(
|
||||
// PUT - Update
|
||||
router.put(
|
||||
'/:id',
|
||||
validateRequest(tourPageSchemas.update),
|
||||
wrapAsync(async (req, res) => {
|
||||
assertRouteIdMatchesBody(req);
|
||||
await Tour_pagesService.update(
|
||||
@ -239,6 +243,7 @@ router.put(
|
||||
// DELETE - Remove
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateRequest(crudSchemas.remove),
|
||||
wrapAsync(async (req, res) => {
|
||||
await Tour_pagesService.remove(req.params.id, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
@ -248,6 +253,7 @@ router.delete(
|
||||
// POST - Delete by IDs
|
||||
router.post(
|
||||
'/deleteByIds',
|
||||
validateRequest(crudSchemas.deleteByIds),
|
||||
wrapAsync(async (req, res) => {
|
||||
await Tour_pagesService.deleteByIds(req.body.data, req.currentUser);
|
||||
res.status(200).send(true);
|
||||
@ -257,6 +263,7 @@ router.post(
|
||||
// GET - List all (with reverseVideoUrl population)
|
||||
router.get(
|
||||
'/',
|
||||
validateRequest(crudSchemas.list),
|
||||
wrapAsync(async (req, res) => {
|
||||
const filetype = req.query.filetype;
|
||||
const currentUser = req.currentUser;
|
||||
@ -297,13 +304,10 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/reverse-video-status',
|
||||
validateRequest(tourPageSchemas.reverseVideoStatus),
|
||||
wrapAsync(async (req, res) => {
|
||||
const { storageKeys } = req.body;
|
||||
|
||||
if (!Array.isArray(storageKeys)) {
|
||||
return res.status(400).send({ error: 'storageKeys must be an array' });
|
||||
}
|
||||
|
||||
const status = await Tour_pagesService.checkReverseVideoStatus(storageKeys);
|
||||
res.status(200).send(status);
|
||||
}),
|
||||
@ -312,6 +316,7 @@ router.post(
|
||||
// GET - Count
|
||||
router.get(
|
||||
'/count',
|
||||
validateRequest(crudSchemas.count),
|
||||
wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const runtimeContext = req.runtimeContext;
|
||||
@ -327,6 +332,7 @@ router.get(
|
||||
// GET - Autocomplete
|
||||
router.get(
|
||||
'/autocomplete',
|
||||
validateRequest(crudSchemas.autocomplete),
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await Tour_pagesDBApi.findAllAutocomplete(
|
||||
req.query.query,
|
||||
@ -340,11 +346,8 @@ router.get(
|
||||
// GET - Single item (with reverseVideoUrl population)
|
||||
router.get(
|
||||
'/:id',
|
||||
validateRequest(crudSchemas.findOne),
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send('Invalid tour_pages id');
|
||||
}
|
||||
|
||||
const runtimeContext = req.runtimeContext;
|
||||
let payload = await Tour_pagesDBApi.findBy(
|
||||
{ id: req.params.id },
|
||||
|
||||
@ -2,11 +2,16 @@ const { createEntityRouter } = require('../factories/router.factory');
|
||||
const UsersService = require('../services/users');
|
||||
const UsersDBApi = require('../db/api/users');
|
||||
const { wrapAsync } = require('../helpers');
|
||||
const { users: userSchemas } = require('../validators/request-schemas');
|
||||
|
||||
// Create base router with factory (includes all standard CRUD endpoints)
|
||||
const router = createEntityRouter('users', UsersService, UsersDBApi, {
|
||||
permissionEntity: 'users',
|
||||
csvFields: ['id', 'firstName', 'lastName', 'phoneNumber', 'email'],
|
||||
validation: {
|
||||
create: userSchemas.create,
|
||||
update: userSchemas.update,
|
||||
},
|
||||
});
|
||||
|
||||
// Override GET /:id to remove password from response
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const { getNotification } = require('../helpers');
|
||||
|
||||
module.exports = class ValidationError extends Error {
|
||||
constructor(messageCode) {
|
||||
constructor(messageCode, options = {}) {
|
||||
// getNotification returns the translated message if key exists,
|
||||
// or the key itself as the message if not found
|
||||
// This allows both notification keys and plain string messages
|
||||
@ -11,5 +11,7 @@ module.exports = class ValidationError extends Error {
|
||||
|
||||
super(message);
|
||||
this.code = 400;
|
||||
this.details = options.details;
|
||||
this.isRequestValidation = Boolean(options.isRequestValidation);
|
||||
}
|
||||
};
|
||||
|
||||
357
backend/src/validators/request-schemas.js
Normal file
357
backend/src/validators/request-schemas.js
Normal file
@ -0,0 +1,357 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const uuid = Joi.string().guid({ version: ['uuidv4'] });
|
||||
const idParam = Joi.object({ id: uuid.required() }).required();
|
||||
const emptyQuery = Joi.object().max(0);
|
||||
const entityId = Joi.alternatives().try(
|
||||
uuid,
|
||||
Joi.object({
|
||||
id: uuid.optional(),
|
||||
value: uuid.optional(),
|
||||
})
|
||||
.or('id', 'value')
|
||||
.unknown(true),
|
||||
);
|
||||
|
||||
const sortDirection = Joi.string().valid('ASC', 'DESC', 'asc', 'desc');
|
||||
|
||||
const listQuery = Joi.object({
|
||||
limit: Joi.number().integer().min(1).max(1000),
|
||||
page: Joi.number().integer().min(1),
|
||||
field: Joi.string()
|
||||
.trim()
|
||||
.max(128)
|
||||
.pattern(/^[A-Za-z0-9_.$]+$/),
|
||||
sort: sortDirection,
|
||||
filetype: Joi.string().valid('csv'),
|
||||
})
|
||||
.unknown(true)
|
||||
.required();
|
||||
|
||||
const countQuery = listQuery;
|
||||
|
||||
const autocompleteQuery = Joi.object({
|
||||
query: Joi.string().allow('').max(255),
|
||||
limit: Joi.number().integer().min(1).max(50),
|
||||
offset: Joi.number().integer().min(0),
|
||||
})
|
||||
.unknown(false)
|
||||
.required();
|
||||
|
||||
const dataEnvelope = Joi.object({
|
||||
data: Joi.object().required().unknown(true),
|
||||
})
|
||||
.unknown(false)
|
||||
.required();
|
||||
|
||||
const updateDataEnvelope = Joi.object({
|
||||
id: uuid.optional(),
|
||||
data: Joi.object({
|
||||
id: uuid.optional(),
|
||||
})
|
||||
.required()
|
||||
.unknown(true),
|
||||
})
|
||||
.unknown(false)
|
||||
.required();
|
||||
|
||||
const deleteByIdsBody = Joi.object({
|
||||
data: Joi.array().items(uuid.required()).min(1).required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required();
|
||||
|
||||
const optionalSlug = Joi.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.pattern(/^[a-z0-9_-]+$/i);
|
||||
|
||||
const fileUrl = Joi.string().trim().min(1).max(4096);
|
||||
|
||||
const userData = Joi.object({
|
||||
id: uuid.optional(),
|
||||
firstName: Joi.string().allow('', null).max(255),
|
||||
lastName: Joi.string().allow('', null).max(255),
|
||||
phoneNumber: Joi.string().allow('', null).max(255),
|
||||
email: Joi.string().trim().email().allow(null),
|
||||
disabled: Joi.boolean(),
|
||||
password: Joi.string().allow('', null).max(1024),
|
||||
emailVerified: Joi.boolean(),
|
||||
emailVerificationToken: Joi.string().allow('', null).max(1024),
|
||||
emailVerificationTokenExpiresAt: Joi.date().allow(null),
|
||||
passwordResetToken: Joi.string().allow('', null).max(1024),
|
||||
passwordResetTokenExpiresAt: Joi.date().allow(null),
|
||||
provider: Joi.string().allow('', null).max(255),
|
||||
importHash: Joi.string().allow('', null).max(255),
|
||||
app_role: entityId.allow(null),
|
||||
custom_permissions: Joi.array().items(entityId),
|
||||
allowed_private_production_project_ids: Joi.array().items(entityId),
|
||||
avatar: Joi.any(),
|
||||
}).unknown(true);
|
||||
|
||||
const userCreateData = userData.keys({
|
||||
email: Joi.string().trim().email().required(),
|
||||
});
|
||||
|
||||
const projectData = Joi.object({
|
||||
id: uuid.optional(),
|
||||
name: Joi.string().trim().min(1).max(255),
|
||||
slug: optionalSlug,
|
||||
description: Joi.string().allow('', null),
|
||||
logo_url: Joi.string().allow('', null).max(4096),
|
||||
favicon_url: Joi.string().allow('', null).max(4096),
|
||||
og_image_url: Joi.string().allow('', null).max(4096),
|
||||
design_width: Joi.number().integer().min(1).max(20000).allow(null),
|
||||
design_height: Joi.number().integer().min(1).max(20000).allow(null),
|
||||
production_presentation_visibility: Joi.string().valid('public', 'private'),
|
||||
importHash: Joi.string().allow('', null).max(255),
|
||||
}).unknown(true);
|
||||
|
||||
const projectCreateData = projectData.keys({
|
||||
name: Joi.string().trim().min(1).max(255).required(),
|
||||
slug: optionalSlug.required(),
|
||||
});
|
||||
|
||||
const uiSchema = Joi.alternatives().try(
|
||||
Joi.object({ elements: Joi.array().default([]) }).unknown(true),
|
||||
Joi.string().allow('', null),
|
||||
Joi.allow(null),
|
||||
);
|
||||
|
||||
const tourPageData = Joi.object({
|
||||
id: uuid.optional(),
|
||||
project: uuid.optional(),
|
||||
projectId: uuid.optional(),
|
||||
project_id: uuid.optional(),
|
||||
environment: Joi.string().valid('dev', 'stage', 'production'),
|
||||
source_key: Joi.string().allow('', null).max(4096),
|
||||
name: Joi.string().trim().min(1).max(255),
|
||||
slug: optionalSlug,
|
||||
sort_order: Joi.number().integer().min(0),
|
||||
background_image_url: Joi.string().allow('', null).max(4096),
|
||||
background_video_url: Joi.string().allow('', null).max(4096),
|
||||
background_embed_url: Joi.string().allow('', null).max(8192),
|
||||
background_audio_url: Joi.string().allow('', null).max(4096),
|
||||
background_audio_autoplay: Joi.boolean(),
|
||||
background_audio_loop: Joi.boolean(),
|
||||
background_audio_start_time: Joi.number().min(0).allow(null),
|
||||
background_audio_end_time: Joi.number().min(0).allow(null),
|
||||
background_loop: Joi.boolean(),
|
||||
background_video_autoplay: Joi.boolean(),
|
||||
background_video_loop: Joi.boolean(),
|
||||
background_video_muted: Joi.boolean(),
|
||||
background_video_start_time: Joi.number().min(0).allow(null),
|
||||
background_video_end_time: Joi.number().min(0).allow(null),
|
||||
design_width: Joi.number().integer().min(1).max(20000).allow(null),
|
||||
design_height: Joi.number().integer().min(1).max(20000).allow(null),
|
||||
requires_auth: Joi.boolean(),
|
||||
ui_schema_json: uiSchema,
|
||||
global_ui_controls_settings_json: Joi.alternatives().try(
|
||||
Joi.object().unknown(true),
|
||||
Joi.string().allow('', null),
|
||||
Joi.allow(null),
|
||||
),
|
||||
importHash: Joi.string().allow('', null).max(255),
|
||||
}).unknown(true);
|
||||
|
||||
const tourPageCreateData = tourPageData
|
||||
.keys({
|
||||
name: Joi.string().trim().min(1).max(255).required(),
|
||||
slug: optionalSlug.required(),
|
||||
})
|
||||
.or('project', 'projectId', 'project_id');
|
||||
|
||||
function envelopeForData(dataSchema, { requireData = true } = {}) {
|
||||
return Joi.object({
|
||||
id: uuid.optional(),
|
||||
data: requireData ? dataSchema.required() : dataSchema,
|
||||
})
|
||||
.unknown(false)
|
||||
.required();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auth: {
|
||||
signinLocal: {
|
||||
body: Joi.object({
|
||||
email: Joi.string().trim().email().required(),
|
||||
password: Joi.string().required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
passwordReset: {
|
||||
body: Joi.object({
|
||||
token: Joi.string().trim().required(),
|
||||
password: Joi.string().required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
passwordUpdate: {
|
||||
body: Joi.object({
|
||||
currentPassword: Joi.string().required(),
|
||||
newPassword: Joi.string().required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
sendPasswordResetEmail: {
|
||||
body: Joi.object({
|
||||
email: Joi.string().trim().email().required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
profile: {
|
||||
body: Joi.object({
|
||||
profile: Joi.object().required().unknown(true),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
verifyEmail: {
|
||||
body: Joi.object({
|
||||
token: Joi.string().trim().required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
socialSignin: {
|
||||
query: Joi.object({
|
||||
app: Joi.string().allow('').max(255),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
},
|
||||
crud: {
|
||||
create: { body: dataEnvelope },
|
||||
update: { params: idParam, body: updateDataEnvelope },
|
||||
remove: { params: idParam },
|
||||
deleteByIds: { body: deleteByIdsBody },
|
||||
list: { query: listQuery },
|
||||
count: { query: countQuery },
|
||||
autocomplete: { query: autocompleteQuery },
|
||||
findOne: { params: idParam },
|
||||
},
|
||||
file: {
|
||||
presign: {
|
||||
body: Joi.object({
|
||||
urls: Joi.array().items(fileUrl.required()).min(1).max(50).required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
upload: {
|
||||
params: Joi.object({
|
||||
table: Joi.string()
|
||||
.trim()
|
||||
.max(128)
|
||||
.pattern(/^[A-Za-z0-9_-]+$/)
|
||||
.required(),
|
||||
field: Joi.string()
|
||||
.trim()
|
||||
.max(128)
|
||||
.pattern(/^[A-Za-z0-9_-]+$/)
|
||||
.required(),
|
||||
}).required(),
|
||||
},
|
||||
initUploadSession: {
|
||||
body: Joi.object({
|
||||
folder: Joi.string().trim().min(1).max(512).required(),
|
||||
filename: Joi.string().trim().min(1).max(255).required(),
|
||||
totalChunks: Joi.number().integer().min(1).max(10000).required(),
|
||||
size: Joi.number().min(0).required(),
|
||||
contentType: Joi.string().trim().allow('').max(255),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
session: {
|
||||
params: Joi.object({
|
||||
sessionId: uuid.required(),
|
||||
}).required(),
|
||||
},
|
||||
chunk: {
|
||||
params: Joi.object({
|
||||
sessionId: uuid.required(),
|
||||
chunkIndex: Joi.number().integer().min(0).max(9999).required(),
|
||||
}).required(),
|
||||
},
|
||||
},
|
||||
publish: {
|
||||
publish: {
|
||||
body: Joi.object({
|
||||
projectId: uuid.required(),
|
||||
title: Joi.string().allow('', null).max(255),
|
||||
description: Joi.string().allow('', null).max(2000),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
saveToStage: {
|
||||
body: Joi.object({
|
||||
projectId: uuid.required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
create: { body: envelopeForData(projectCreateData) },
|
||||
update: { params: idParam, body: envelopeForData(projectData) },
|
||||
clone: { params: idParam },
|
||||
},
|
||||
tourPages: {
|
||||
create: { body: envelopeForData(tourPageCreateData) },
|
||||
update: { params: idParam, body: envelopeForData(tourPageData) },
|
||||
reorder: {
|
||||
body: Joi.object({
|
||||
data: Joi.object({
|
||||
project: uuid.optional(),
|
||||
projectId: uuid.optional(),
|
||||
environment: Joi.string().valid('dev').default('dev'),
|
||||
orderedPageIds: Joi.array().items(uuid.required()).min(1).required(),
|
||||
})
|
||||
.or('project', 'projectId')
|
||||
.required()
|
||||
.unknown(false),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
duplicate: {
|
||||
params: idParam,
|
||||
body: Joi.object({
|
||||
data: Joi.object({
|
||||
project: uuid.optional(),
|
||||
projectId: uuid.optional(),
|
||||
environment: Joi.string().valid('dev').default('dev'),
|
||||
name: Joi.string().trim().allow('').max(255),
|
||||
slug: optionalSlug,
|
||||
})
|
||||
.unknown(false)
|
||||
.default({}),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
reverseVideoStatus: {
|
||||
body: Joi.object({
|
||||
storageKeys: Joi.array()
|
||||
.items(fileUrl.required())
|
||||
.min(1)
|
||||
.max(200)
|
||||
.required(),
|
||||
})
|
||||
.unknown(false)
|
||||
.required(),
|
||||
},
|
||||
},
|
||||
users: {
|
||||
create: { body: envelopeForData(userCreateData) },
|
||||
update: { params: idParam, body: envelopeForData(userData) },
|
||||
},
|
||||
emptyQuery,
|
||||
};
|
||||
129
backend/tests/request-validation.test.js
Normal file
129
backend/tests/request-validation.test.js
Normal file
@ -0,0 +1,129 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Joi = require('joi');
|
||||
|
||||
const { validateRequest } = require('../src/middlewares/validate-request');
|
||||
const { commonErrorHandler } = require('../src/helpers');
|
||||
const {
|
||||
crud,
|
||||
projects,
|
||||
tourPages,
|
||||
users,
|
||||
} = require('../src/validators/request-schemas');
|
||||
|
||||
function runMiddleware(middleware, req) {
|
||||
return new Promise((resolve) => {
|
||||
middleware(req, {}, (error) => resolve(error || null));
|
||||
});
|
||||
}
|
||||
|
||||
test('validateRequest applies converted sanitized values to request parts', async () => {
|
||||
const req = {
|
||||
query: {
|
||||
limit: '25',
|
||||
page: '2',
|
||||
field: 'createdAt',
|
||||
sort: 'DESC',
|
||||
name: 'Lobby',
|
||||
},
|
||||
};
|
||||
|
||||
const error = await runMiddleware(validateRequest(crud.list), req);
|
||||
|
||||
assert.equal(error, null);
|
||||
assert.equal(req.query.limit, 25);
|
||||
assert.equal(req.query.page, 2);
|
||||
assert.equal(req.query.name, 'Lobby');
|
||||
});
|
||||
|
||||
test('validateRequest returns structured request validation error details', async () => {
|
||||
const req = {
|
||||
body: {
|
||||
data: ['not-object'],
|
||||
},
|
||||
};
|
||||
|
||||
const error = await runMiddleware(validateRequest(crud.create), req);
|
||||
|
||||
assert.equal(error.code, 400);
|
||||
assert.equal(error.isRequestValidation, true);
|
||||
assert.equal(error.details[0].path, 'body.data');
|
||||
});
|
||||
|
||||
test('commonErrorHandler sends request validation errors as JSON', () => {
|
||||
const error = {
|
||||
code: 400,
|
||||
message: 'Invalid request',
|
||||
isRequestValidation: true,
|
||||
details: [{ path: 'body.email', message: 'email is required' }],
|
||||
};
|
||||
const res = {
|
||||
statusCode: null,
|
||||
payload: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
send(payload) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
commonErrorHandler(error, {}, res, () => {});
|
||||
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.deepEqual(res.payload, {
|
||||
error: 'Invalid request',
|
||||
details: [{ path: 'body.email', message: 'email is required' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('user update schema preserves omitted permissions fields', async () => {
|
||||
const req = {
|
||||
params: { id: '094121b9-c567-469a-b256-ba221b7fd5d6' },
|
||||
body: {
|
||||
data: {
|
||||
firstName: 'Admin',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const error = await runMiddleware(validateRequest(users.update), req);
|
||||
|
||||
assert.equal(error, null);
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(req.body.data, 'custom_permissions'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
req.body.data,
|
||||
'allowed_private_production_project_ids',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('create schemas reject missing fields required before service layer', async () => {
|
||||
const userError = await runMiddleware(validateRequest(users.create), {
|
||||
body: { data: { firstName: 'No Email' } },
|
||||
});
|
||||
const projectError = await runMiddleware(validateRequest(projects.create), {
|
||||
body: { data: { name: 'No Slug' } },
|
||||
});
|
||||
const tourPageError = await runMiddleware(validateRequest(tourPages.create), {
|
||||
body: { data: { name: 'No Project', slug: 'no-project' } },
|
||||
});
|
||||
|
||||
assert.equal(userError.isRequestValidation, true);
|
||||
assert.equal(projectError.isRequestValidation, true);
|
||||
assert.equal(tourPageError.isRequestValidation, true);
|
||||
});
|
||||
|
||||
test('validateRequest rejects invalid schema maps during route setup', () => {
|
||||
assert.throws(
|
||||
() => validateRequest({ body: Joi.object(), cookies: Joi.object() }),
|
||||
/Unsupported request validation part/,
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user