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 { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
const { logger } = require('../utils/logger');
|
const { logger } = require('../utils/logger');
|
||||||
|
const { validateRequest } = require('../middlewares/validate-request');
|
||||||
|
const { crud: crudSchemas } = require('../validators/request-schemas');
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT = 50;
|
const DEFAULT_LIST_LIMIT = 50;
|
||||||
const MAX_LIST_LIMIT = 1000;
|
const MAX_LIST_LIMIT = 1000;
|
||||||
@ -54,10 +56,13 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const permissionEntity = options.permissionEntity || entityName;
|
const permissionEntity = options.permissionEntity || entityName;
|
||||||
|
const validation = options.validation || {};
|
||||||
|
const schemaFor = (name) => validation[name] || crudSchemas[name];
|
||||||
router.use(checkCrudPermissions(permissionEntity));
|
router.use(checkCrudPermissions(permissionEntity));
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
validateRequest(schemaFor('create')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const referer =
|
const referer =
|
||||||
req.headers.referer ||
|
req.headers.referer ||
|
||||||
@ -87,6 +92,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
validateRequest(schemaFor('update')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
assertRouteIdMatchesBody(req);
|
assertRouteIdMatchesBody(req);
|
||||||
await Service.update(req.body.data, req.params.id, req.currentUser);
|
await Service.update(req.body.data, req.params.id, req.currentUser);
|
||||||
@ -96,6 +102,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
validateRequest(schemaFor('remove')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Service.remove(req.params.id, req.currentUser);
|
await Service.remove(req.params.id, req.currentUser);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
@ -104,6 +111,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/deleteByIds',
|
'/deleteByIds',
|
||||||
|
validateRequest(schemaFor('deleteByIds')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Service.deleteByIds(req.body.data, req.currentUser);
|
await Service.deleteByIds(req.body.data, req.currentUser);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
@ -112,6 +120,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
validateRequest(schemaFor('list')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const filetype = req.query.filetype;
|
const filetype = req.query.filetype;
|
||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
@ -144,6 +153,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/count',
|
'/count',
|
||||||
|
validateRequest(schemaFor('count')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
const runtimeContext = req.runtimeContext;
|
const runtimeContext = req.runtimeContext;
|
||||||
@ -158,6 +168,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/autocomplete',
|
'/autocomplete',
|
||||||
|
validateRequest(schemaFor('autocomplete')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const limit = clampLimit(req.query.limit, {
|
const limit = clampLimit(req.query.limit, {
|
||||||
defaultLimit: 20,
|
defaultLimit: 20,
|
||||||
@ -174,11 +185,8 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
validateRequest(schemaFor('findOne')),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isUuidV4(req.params.id)) {
|
|
||||||
return res.status(400).send(`Invalid ${entityName} id`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeContext = req.runtimeContext;
|
const runtimeContext = req.runtimeContext;
|
||||||
const payload = await DBApi.findBy(
|
const payload = await DBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ id: req.params.id },
|
||||||
|
|||||||
@ -13,6 +13,13 @@ module.exports = class Helpers {
|
|||||||
static commonErrorHandler(error, req, res, _next) {
|
static commonErrorHandler(error, req, res, _next) {
|
||||||
const statusCode = error.code || error.status;
|
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)) {
|
if ([400, 401, 403, 404, 409, 422].includes(statusCode)) {
|
||||||
return res.status(statusCode).send(error.message);
|
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,
|
authLimiter: signinLimiter,
|
||||||
passwordResetLimiter,
|
passwordResetLimiter,
|
||||||
} = require('../middlewares/rateLimiter');
|
} = require('../middlewares/rateLimiter');
|
||||||
|
const { validateRequest } = require('../middlewares/validate-request');
|
||||||
|
const { auth: authSchemas } = require('../validators/request-schemas');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -108,6 +110,7 @@ function getRequestHost(req) {
|
|||||||
router.post(
|
router.post(
|
||||||
'/signin/local',
|
'/signin/local',
|
||||||
signinLimiter,
|
signinLimiter,
|
||||||
|
validateRequest(authSchemas.signinLocal),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await AuthService.signin(
|
const payload = await AuthService.signin(
|
||||||
req.body.email,
|
req.body.email,
|
||||||
@ -157,6 +160,7 @@ router.get(
|
|||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/password-reset',
|
'/password-reset',
|
||||||
|
validateRequest(authSchemas.passwordReset),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await AuthService.passwordReset(
|
const payload = await AuthService.passwordReset(
|
||||||
req.body.token,
|
req.body.token,
|
||||||
@ -170,6 +174,7 @@ router.put(
|
|||||||
router.put(
|
router.put(
|
||||||
'/password-update',
|
'/password-update',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
validateRequest(authSchemas.passwordUpdate),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await AuthService.passwordUpdate(
|
const payload = await AuthService.passwordUpdate(
|
||||||
req.body.currentPassword,
|
req.body.currentPassword,
|
||||||
@ -197,6 +202,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
'/send-password-reset-email',
|
'/send-password-reset-email',
|
||||||
passwordResetLimiter,
|
passwordResetLimiter,
|
||||||
|
validateRequest(authSchemas.sendPasswordResetEmail),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const host = getRequestHost(req);
|
const host = getRequestHost(req);
|
||||||
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
|
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
|
||||||
@ -208,6 +214,7 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
'/profile',
|
'/profile',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
validateRequest(authSchemas.profile),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!req.currentUser || !req.currentUser.id) {
|
if (!req.currentUser || !req.currentUser.id) {
|
||||||
throw new ForbiddenError();
|
throw new ForbiddenError();
|
||||||
@ -221,6 +228,7 @@ router.put(
|
|||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/verify-email',
|
'/verify-email',
|
||||||
|
validateRequest(authSchemas.verifyEmail),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await AuthService.verifyEmail(
|
const payload = await AuthService.verifyEmail(
|
||||||
req.body.token,
|
req.body.token,
|
||||||
@ -236,12 +244,16 @@ router.get('/email-configured', (req, res) => {
|
|||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/signin/google', (req, res, next) => {
|
router.get(
|
||||||
passport.authenticate('google', {
|
'/signin/google',
|
||||||
scope: ['profile', 'email'],
|
validateRequest(authSchemas.socialSignin),
|
||||||
state: req.query.app,
|
(req, res, next) => {
|
||||||
})(req, res, next);
|
passport.authenticate('google', {
|
||||||
});
|
scope: ['profile', 'email'],
|
||||||
|
state: req.query.app,
|
||||||
|
})(req, res, next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/signin/google/callback',
|
'/signin/google/callback',
|
||||||
@ -255,12 +267,16 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get('/signin/microsoft', (req, res, next) => {
|
router.get(
|
||||||
passport.authenticate('microsoft', {
|
'/signin/microsoft',
|
||||||
scope: ['https://graph.microsoft.com/user.read openid'],
|
validateRequest(authSchemas.socialSignin),
|
||||||
state: req.query.app,
|
(req, res, next) => {
|
||||||
})(req, res, next);
|
passport.authenticate('microsoft', {
|
||||||
});
|
scope: ['https://graph.microsoft.com/user.read openid'],
|
||||||
|
state: req.query.app,
|
||||||
|
})(req, res, next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/signin/microsoft/callback',
|
'/signin/microsoft/callback',
|
||||||
|
|||||||
@ -4,6 +4,9 @@ const bodyParser = require('body-parser');
|
|||||||
const services = require('../services/file/');
|
const services = require('../services/file/');
|
||||||
const { isValidPath, createErrorResponse } = require('../services/file');
|
const { isValidPath, createErrorResponse } = require('../services/file');
|
||||||
const { logger } = require('../utils/logger');
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -22,72 +25,49 @@ router.get('/download', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/file/presign - Generate presigned URLs for multiple assets
|
// POST /api/file/presign - Generate presigned URLs for multiple assets
|
||||||
router.post('/presign', jsonParser, async (req, res) => {
|
router.post(
|
||||||
const log = req.log || logger;
|
'/presign',
|
||||||
const { urls } = req.body || {};
|
jsonParser,
|
||||||
|
validateRequest(fileSchemas.presign),
|
||||||
|
async (req, res) => {
|
||||||
|
const log = req.log || logger;
|
||||||
|
const { urls } = req.body || {};
|
||||||
|
|
||||||
if (!Array.isArray(urls) || urls.length === 0) {
|
// Validate paths for security (no traversal, no protocols)
|
||||||
return res
|
const unsafeUrls = urls.filter((url) => !isValidPath(url));
|
||||||
.status(400)
|
if (unsafeUrls.length > 0) {
|
||||||
.json(createErrorResponse('urls array required', 'MISSING_URLS'));
|
log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected');
|
||||||
}
|
return res.status(400).json(
|
||||||
|
createErrorResponse('Invalid file paths detected', 'INVALID_PATH', {
|
||||||
if (urls.length > 50) {
|
invalidPaths: unsafeUrls,
|
||||||
return res
|
}),
|
||||||
.status(400)
|
|
||||||
.json(
|
|
||||||
createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that all URLs are non-empty strings
|
try {
|
||||||
const invalidUrls = urls.filter(
|
const presignedUrls = await services.generatePresignedUrls(urls);
|
||||||
(url) => typeof url !== 'string' || !url.trim(),
|
res.json({ presignedUrls });
|
||||||
);
|
} catch (error) {
|
||||||
if (invalidUrls.length > 0) {
|
log.error(
|
||||||
return res
|
{ err: error, urlCount: urls.length },
|
||||||
.status(400)
|
'Failed to generate presigned URLs',
|
||||||
.json(
|
|
||||||
createErrorResponse(
|
|
||||||
'All URLs must be non-empty strings',
|
|
||||||
'INVALID_URL_FORMAT',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
res
|
||||||
|
.status(500)
|
||||||
// Validate paths for security (no traversal, no protocols)
|
.json(
|
||||||
const unsafeUrls = urls.filter((url) => !isValidPath(url));
|
createErrorResponse(
|
||||||
if (unsafeUrls.length > 0) {
|
'Failed to generate presigned URLs',
|
||||||
log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected');
|
'PRESIGN_ERROR',
|
||||||
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',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/upload/:table/:field',
|
'/upload/:table/:field',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
validateRequest(fileSchemas.upload),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
const fileName = `${req.params.table}/${req.params.field}`;
|
const fileName = `${req.params.table}/${req.params.field}`;
|
||||||
|
|
||||||
@ -99,6 +79,7 @@ router.post(
|
|||||||
'/upload-sessions/init',
|
'/upload-sessions/init',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
jsonParser,
|
jsonParser,
|
||||||
|
validateRequest(fileSchemas.initUploadSession),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
services.initUploadSession(req, res);
|
services.initUploadSession(req, res);
|
||||||
},
|
},
|
||||||
@ -107,6 +88,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
'/upload-sessions/:sessionId',
|
'/upload-sessions/:sessionId',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
validateRequest(fileSchemas.session),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
services.getUploadSession(req, res);
|
services.getUploadSession(req, res);
|
||||||
},
|
},
|
||||||
@ -116,6 +98,7 @@ router.get(
|
|||||||
router.put(
|
router.put(
|
||||||
'/upload-sessions/:sessionId/chunks/:chunkIndex',
|
'/upload-sessions/:sessionId/chunks/:chunkIndex',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
validateRequest(fileSchemas.chunk),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
services.uploadChunk(req, res);
|
services.uploadChunk(req, res);
|
||||||
},
|
},
|
||||||
@ -125,9 +108,12 @@ router.put(
|
|||||||
router.post(
|
router.post(
|
||||||
'/upload-sessions/:sessionId/finalize',
|
'/upload-sessions/:sessionId/finalize',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
validateRequest(fileSchemas.session),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
services.finalizeUploadSession(req, res);
|
services.finalizeUploadSession(req, res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.use('/', commonErrorHandler);
|
||||||
|
|
||||||
module.exports = router;
|
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 ProjectsService = require('../services/projects');
|
||||||
const ProjectsDBApi = require('../db/api/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)
|
// Create base router with factory (includes all standard CRUD endpoints)
|
||||||
const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, {
|
const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, {
|
||||||
@ -15,16 +17,17 @@ const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, {
|
|||||||
'favicon_url',
|
'favicon_url',
|
||||||
'og_image_url',
|
'og_image_url',
|
||||||
],
|
],
|
||||||
|
validation: {
|
||||||
|
create: projectSchemas.create,
|
||||||
|
update: projectSchemas.update,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom endpoint: Clone project
|
// Custom endpoint: Clone project
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/clone',
|
'/:id/clone',
|
||||||
|
validateRequest(projectSchemas.clone),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isUuidV4(req.params.id)) {
|
|
||||||
return res.status(400).send('Invalid project id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await ProjectsService.cloneFromProject(
|
const payload = await ProjectsService.cloneFromProject(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.currentUser,
|
req.currentUser,
|
||||||
@ -33,4 +36,6 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.use('/', commonErrorHandler);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -2,6 +2,8 @@ const express = require('express');
|
|||||||
const PublishService = require('../services/publish');
|
const PublishService = require('../services/publish');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
const { validateRequest } = require('../middlewares/validate-request');
|
||||||
|
const { publish: publishSchemas } = require('../validators/request-schemas');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -18,7 +20,7 @@ const publishHandler = wrapAsync(async (req, res) => {
|
|||||||
res.status(200).send(result);
|
res.status(200).send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', publishHandler);
|
router.post('/', validateRequest(publishSchemas.publish), publishHandler);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@ -49,6 +51,7 @@ router.post('/', publishHandler);
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/save-to-stage',
|
'/save-to-stage',
|
||||||
|
validateRequest(publishSchemas.saveToStage),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId } = req.body;
|
const { projectId } = req.body;
|
||||||
const result = await PublishService.saveToStage(projectId, req.currentUser);
|
const result = await PublishService.saveToStage(projectId, req.currentUser);
|
||||||
|
|||||||
@ -4,12 +4,16 @@ const Tour_pagesDBApi = require('../db/api/tour_pages');
|
|||||||
const {
|
const {
|
||||||
wrapAsync,
|
wrapAsync,
|
||||||
commonErrorHandler,
|
commonErrorHandler,
|
||||||
isUuidV4,
|
|
||||||
assertRouteIdMatchesBody,
|
assertRouteIdMatchesBody,
|
||||||
} = require('../helpers');
|
} = require('../helpers');
|
||||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
const { logger } = require('../utils/logger');
|
const { logger } = require('../utils/logger');
|
||||||
|
const { validateRequest } = require('../middlewares/validate-request');
|
||||||
|
const {
|
||||||
|
crud: crudSchemas,
|
||||||
|
tourPages: tourPageSchemas,
|
||||||
|
} = require('../validators/request-schemas');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@ -165,6 +169,7 @@ router.use(checkCrudPermissions('tour_pages'));
|
|||||||
// POST - Create
|
// POST - Create
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
validateRequest(tourPageSchemas.create),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const referer =
|
const referer =
|
||||||
req.headers.referer ||
|
req.headers.referer ||
|
||||||
@ -196,6 +201,7 @@ router.post(
|
|||||||
// POST - Reorder pages within a project/environment
|
// POST - Reorder pages within a project/environment
|
||||||
router.post(
|
router.post(
|
||||||
'/reorder',
|
'/reorder',
|
||||||
|
validateRequest(tourPageSchemas.reorder),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await Tour_pagesService.reorder(
|
const payload = await Tour_pagesService.reorder(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
@ -208,11 +214,8 @@ router.post(
|
|||||||
// POST - Duplicate a dev page within a project
|
// POST - Duplicate a dev page within a project
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/duplicate',
|
'/:id/duplicate',
|
||||||
|
validateRequest(tourPageSchemas.duplicate),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isUuidV4(req.params.id)) {
|
|
||||||
return res.status(400).send('Invalid tour_pages id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await Tour_pagesService.duplicatePage(
|
const payload = await Tour_pagesService.duplicatePage(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.body.data,
|
req.body.data,
|
||||||
@ -225,6 +228,7 @@ router.post(
|
|||||||
// PUT - Update
|
// PUT - Update
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
validateRequest(tourPageSchemas.update),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
assertRouteIdMatchesBody(req);
|
assertRouteIdMatchesBody(req);
|
||||||
await Tour_pagesService.update(
|
await Tour_pagesService.update(
|
||||||
@ -239,6 +243,7 @@ router.put(
|
|||||||
// DELETE - Remove
|
// DELETE - Remove
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
validateRequest(crudSchemas.remove),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Tour_pagesService.remove(req.params.id, req.currentUser);
|
await Tour_pagesService.remove(req.params.id, req.currentUser);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
@ -248,6 +253,7 @@ router.delete(
|
|||||||
// POST - Delete by IDs
|
// POST - Delete by IDs
|
||||||
router.post(
|
router.post(
|
||||||
'/deleteByIds',
|
'/deleteByIds',
|
||||||
|
validateRequest(crudSchemas.deleteByIds),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Tour_pagesService.deleteByIds(req.body.data, req.currentUser);
|
await Tour_pagesService.deleteByIds(req.body.data, req.currentUser);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
@ -257,6 +263,7 @@ router.post(
|
|||||||
// GET - List all (with reverseVideoUrl population)
|
// GET - List all (with reverseVideoUrl population)
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
validateRequest(crudSchemas.list),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const filetype = req.query.filetype;
|
const filetype = req.query.filetype;
|
||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
@ -297,13 +304,10 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/reverse-video-status',
|
'/reverse-video-status',
|
||||||
|
validateRequest(tourPageSchemas.reverseVideoStatus),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { storageKeys } = req.body;
|
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);
|
const status = await Tour_pagesService.checkReverseVideoStatus(storageKeys);
|
||||||
res.status(200).send(status);
|
res.status(200).send(status);
|
||||||
}),
|
}),
|
||||||
@ -312,6 +316,7 @@ router.post(
|
|||||||
// GET - Count
|
// GET - Count
|
||||||
router.get(
|
router.get(
|
||||||
'/count',
|
'/count',
|
||||||
|
validateRequest(crudSchemas.count),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
const runtimeContext = req.runtimeContext;
|
const runtimeContext = req.runtimeContext;
|
||||||
@ -327,6 +332,7 @@ router.get(
|
|||||||
// GET - Autocomplete
|
// GET - Autocomplete
|
||||||
router.get(
|
router.get(
|
||||||
'/autocomplete',
|
'/autocomplete',
|
||||||
|
validateRequest(crudSchemas.autocomplete),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await Tour_pagesDBApi.findAllAutocomplete(
|
const payload = await Tour_pagesDBApi.findAllAutocomplete(
|
||||||
req.query.query,
|
req.query.query,
|
||||||
@ -340,11 +346,8 @@ router.get(
|
|||||||
// GET - Single item (with reverseVideoUrl population)
|
// GET - Single item (with reverseVideoUrl population)
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
|
validateRequest(crudSchemas.findOne),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isUuidV4(req.params.id)) {
|
|
||||||
return res.status(400).send('Invalid tour_pages id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeContext = req.runtimeContext;
|
const runtimeContext = req.runtimeContext;
|
||||||
let payload = await Tour_pagesDBApi.findBy(
|
let payload = await Tour_pagesDBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ id: req.params.id },
|
||||||
|
|||||||
@ -2,11 +2,16 @@ const { createEntityRouter } = require('../factories/router.factory');
|
|||||||
const UsersService = require('../services/users');
|
const UsersService = require('../services/users');
|
||||||
const UsersDBApi = require('../db/api/users');
|
const UsersDBApi = require('../db/api/users');
|
||||||
const { wrapAsync } = require('../helpers');
|
const { wrapAsync } = require('../helpers');
|
||||||
|
const { users: userSchemas } = require('../validators/request-schemas');
|
||||||
|
|
||||||
// Create base router with factory (includes all standard CRUD endpoints)
|
// Create base router with factory (includes all standard CRUD endpoints)
|
||||||
const router = createEntityRouter('users', UsersService, UsersDBApi, {
|
const router = createEntityRouter('users', UsersService, UsersDBApi, {
|
||||||
permissionEntity: 'users',
|
permissionEntity: 'users',
|
||||||
csvFields: ['id', 'firstName', 'lastName', 'phoneNumber', 'email'],
|
csvFields: ['id', 'firstName', 'lastName', 'phoneNumber', 'email'],
|
||||||
|
validation: {
|
||||||
|
create: userSchemas.create,
|
||||||
|
update: userSchemas.update,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override GET /:id to remove password from response
|
// Override GET /:id to remove password from response
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const { getNotification } = require('../helpers');
|
const { getNotification } = require('../helpers');
|
||||||
|
|
||||||
module.exports = class ValidationError extends Error {
|
module.exports = class ValidationError extends Error {
|
||||||
constructor(messageCode) {
|
constructor(messageCode, options = {}) {
|
||||||
// getNotification returns the translated message if key exists,
|
// getNotification returns the translated message if key exists,
|
||||||
// or the key itself as the message if not found
|
// or the key itself as the message if not found
|
||||||
// This allows both notification keys and plain string messages
|
// This allows both notification keys and plain string messages
|
||||||
@ -11,5 +11,7 @@ module.exports = class ValidationError extends Error {
|
|||||||
|
|
||||||
super(message);
|
super(message);
|
||||||
this.code = 400;
|
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