mirror of
https://github.com/strapi/strapi.git
synced 2025-12-29 08:04:51 +00:00
add utils.validate and replace sanitize usage
This commit is contained in:
parent
2fd0ed49fd
commit
995473d959
@ -4,8 +4,9 @@ const _ = require('lodash');
|
||||
const { cloneDeep, isPlainObject } = require('lodash/fp');
|
||||
const { subject: asSubject } = require('@casl/ability');
|
||||
const createSanitizeHelpers = require('./sanitize');
|
||||
const createValidateHelpers = require('./validate');
|
||||
|
||||
const { buildStrapiQuery, buildCaslQuery } = require('./query-builers');
|
||||
const { buildStrapiQuery, buildCaslQuery } = require('./query-builders');
|
||||
|
||||
module.exports = ({ ability, action, model }) => ({
|
||||
ability,
|
||||
@ -48,4 +49,5 @@ module.exports = ({ ability, action, model }) => ({
|
||||
},
|
||||
|
||||
...createSanitizeHelpers({ action, ability, model }),
|
||||
...createValidateHelpers({ action, ability, model }),
|
||||
});
|
||||
|
||||
@ -19,7 +19,7 @@ const {
|
||||
cloneDeep,
|
||||
} = require('lodash/fp');
|
||||
|
||||
const { contentTypes, traverseEntity, sanitize, pipeAsync, traverse } = require('@strapi/utils');
|
||||
const { contentTypes, traverseEntity, sanitize, pipeAsync } = require('@strapi/utils');
|
||||
const { removePassword } = require('@strapi/utils').sanitize.visitors;
|
||||
const { ADMIN_USER_ALLOWED_FIELDS } = require('../../../domain/user');
|
||||
|
||||
@ -45,7 +45,10 @@ const STATIC_FIELDS = [ID_ATTRIBUTE];
|
||||
module.exports = ({ action, ability, model }) => {
|
||||
const schema = strapi.getModel(model);
|
||||
|
||||
const { sanitizePasswords } = sanitize.sanitizers;
|
||||
const { allowedFields } = sanitize.visitors;
|
||||
const { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate, traverseQueryFields } =
|
||||
sanitize.traversals;
|
||||
|
||||
const createSanitizeQuery = (options = {}) => {
|
||||
const { fields } = options;
|
||||
@ -54,10 +57,10 @@ module.exports = ({ action, ability, model }) => {
|
||||
const permittedFields = fields.shouldIncludeAll ? null : getQueryFields(fields.permitted);
|
||||
|
||||
const sanitizeFilters = pipeAsync(
|
||||
traverse.traverseQueryFilters(allowedFields(permittedFields), { schema }),
|
||||
traverse.traverseQueryFilters(omitDisallowedAdminUserFields, { schema }),
|
||||
traverse.traverseQueryFilters(removePassword, { schema }),
|
||||
traverse.traverseQueryFilters(
|
||||
traverseQueryFilters(allowedFields(permittedFields), { schema }),
|
||||
traverseQueryFilters(omitDisallowedAdminUserFields, { schema }),
|
||||
traverseQueryFilters(removePassword, { schema }),
|
||||
traverseQueryFilters(
|
||||
({ key, value }, { remove }) => {
|
||||
if (isObject(value) && isEmpty(value)) {
|
||||
remove(key);
|
||||
@ -68,10 +71,10 @@ module.exports = ({ action, ability, model }) => {
|
||||
);
|
||||
|
||||
const sanitizeSort = pipeAsync(
|
||||
traverse.traverseQuerySort(allowedFields(permittedFields), { schema }),
|
||||
traverse.traverseQuerySort(omitDisallowedAdminUserFields, { schema }),
|
||||
traverse.traverseQuerySort(removePassword, { schema }),
|
||||
traverse.traverseQuerySort(
|
||||
traverseQuerySort(allowedFields(permittedFields), { schema }),
|
||||
traverseQuerySort(omitDisallowedAdminUserFields, { schema }),
|
||||
traverseQuerySort(removePassword, { schema }),
|
||||
traverseQuerySort(
|
||||
({ key, attribute, value }, { remove }) => {
|
||||
if (!isScalarAttribute(attribute) && isEmpty(value)) {
|
||||
remove(key);
|
||||
@ -82,14 +85,14 @@ module.exports = ({ action, ability, model }) => {
|
||||
);
|
||||
|
||||
const sanitizePopulate = pipeAsync(
|
||||
traverse.traverseQueryPopulate(allowedFields(permittedFields), { schema }),
|
||||
traverse.traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }),
|
||||
traverse.traverseQueryPopulate(removePassword, { schema })
|
||||
traverseQueryPopulate(allowedFields(permittedFields), { schema }),
|
||||
traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }),
|
||||
traverseQueryPopulate(removePassword, { schema })
|
||||
);
|
||||
|
||||
const sanitizeFields = pipeAsync(
|
||||
traverse.traverseQueryFields(allowedFields(permittedFields), { schema }),
|
||||
traverse.traverseQueryFields(removePassword, { schema })
|
||||
traverseQueryFields(allowedFields(permittedFields), { schema }),
|
||||
traverseQueryFields(removePassword, { schema })
|
||||
);
|
||||
|
||||
return async (query) => {
|
||||
@ -128,7 +131,7 @@ module.exports = ({ action, ability, model }) => {
|
||||
// Remove not allowed fields (RBAC)
|
||||
traverseEntity(allowedFields(permittedFields), { schema }),
|
||||
// Remove all fields of type 'password'
|
||||
sanitize.sanitizers.sanitizePasswords(schema)
|
||||
sanitizePasswords(schema)
|
||||
);
|
||||
};
|
||||
|
||||
@ -270,5 +273,6 @@ module.exports = ({ action, ability, model }) => {
|
||||
sanitizeOutput: wrapSanitize(createSanitizeOutput),
|
||||
sanitizeInput: wrapSanitize(createSanitizeInput),
|
||||
sanitizeQuery: wrapSanitize(createSanitizeQuery),
|
||||
validateQuery: wrapSanitize(createSanitizeQuery),
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
const { subject: asSubject, detectSubjectType } = require('@casl/ability');
|
||||
const { permittedFieldsOf } = require('@casl/ability/extra');
|
||||
const {
|
||||
defaults,
|
||||
omit,
|
||||
isArray,
|
||||
isEmpty,
|
||||
isNil,
|
||||
flatMap,
|
||||
some,
|
||||
prop,
|
||||
uniq,
|
||||
intersection,
|
||||
getOr,
|
||||
isObject,
|
||||
cloneDeep,
|
||||
} = require('lodash/fp');
|
||||
|
||||
const { contentTypes, traverseEntity, validate, pipeAsync } = require('@strapi/utils');
|
||||
const { removePassword } = require('@strapi/utils').validate.visitors;
|
||||
const { ADMIN_USER_ALLOWED_FIELDS } = require('../../../domain/user');
|
||||
|
||||
const { constants, isScalarAttribute, getNonVisibleAttributes, getWritableAttributes } =
|
||||
contentTypes;
|
||||
const {
|
||||
ID_ATTRIBUTE,
|
||||
CREATED_AT_ATTRIBUTE,
|
||||
UPDATED_AT_ATTRIBUTE,
|
||||
PUBLISHED_AT_ATTRIBUTE,
|
||||
CREATED_BY_ATTRIBUTE,
|
||||
UPDATED_BY_ATTRIBUTE,
|
||||
} = constants;
|
||||
|
||||
const COMPONENT_FIELDS = ['__component'];
|
||||
const STATIC_FIELDS = [ID_ATTRIBUTE];
|
||||
|
||||
module.exports = ({ action, ability, model }) => {
|
||||
const schema = strapi.getModel(model);
|
||||
|
||||
const { allowedFields } = validate.visitors;
|
||||
const { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate, traverseQueryFields } =
|
||||
validate.traversals;
|
||||
|
||||
const createValidateQuery = (options = {}) => {
|
||||
const { fields } = options;
|
||||
|
||||
// TODO: sanitize relations to admin users in all sanitizers
|
||||
const permittedFields = fields.shouldIncludeAll ? null : getQueryFields(fields.permitted);
|
||||
|
||||
const validateFilters = pipeAsync(
|
||||
traverseQueryFilters(allowedFields(permittedFields), { schema }),
|
||||
traverseQueryFilters(omitDisallowedAdminUserFields, { schema }),
|
||||
traverseQueryFilters(removePassword, { schema }),
|
||||
traverseQueryFilters(
|
||||
({ key, value }, { remove }) => {
|
||||
if (isObject(value) && isEmpty(value)) {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
)
|
||||
);
|
||||
|
||||
const validateSort = pipeAsync(
|
||||
traverseQuerySort(allowedFields(permittedFields), { schema }),
|
||||
traverseQuerySort(omitDisallowedAdminUserFields, { schema }),
|
||||
traverseQuerySort(removePassword, { schema }),
|
||||
traverseQuerySort(
|
||||
({ key, attribute, value }, { remove }) => {
|
||||
if (!isScalarAttribute(attribute) && isEmpty(value)) {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
)
|
||||
);
|
||||
|
||||
const validatePopulate = pipeAsync(
|
||||
traverseQueryPopulate(allowedFields(permittedFields), { schema }),
|
||||
traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }),
|
||||
traverseQueryPopulate(removePassword, { schema })
|
||||
);
|
||||
|
||||
const validateFields = pipeAsync(
|
||||
traverseQueryFields(allowedFields(permittedFields), { schema }),
|
||||
traverseQueryFields(removePassword, { schema })
|
||||
);
|
||||
|
||||
return async (query) => {
|
||||
const validatedQuery = cloneDeep(query);
|
||||
|
||||
if (query.filters) {
|
||||
Object.assign(validatedQuery, { filters: await validateFilters(query.filters) });
|
||||
}
|
||||
|
||||
if (query.sort) {
|
||||
Object.assign(validatedQuery, { sort: await validateSort(query.sort) });
|
||||
}
|
||||
|
||||
if (query.populate) {
|
||||
Object.assign(validatedQuery, { populate: await validatePopulate(query.populate) });
|
||||
}
|
||||
|
||||
if (query.fields) {
|
||||
Object.assign(validatedQuery, { fields: await validateFields(query.fields) });
|
||||
}
|
||||
|
||||
return validatedQuery;
|
||||
};
|
||||
};
|
||||
|
||||
const createValidateInput = (options = {}) => {
|
||||
const { fields } = options;
|
||||
|
||||
const permittedFields = fields.shouldIncludeAll ? null : getInputFields(fields.permitted);
|
||||
|
||||
return pipeAsync(
|
||||
// Remove fields hidden from the admin
|
||||
traverseEntity(omitHiddenFields, { schema }),
|
||||
// Remove not allowed fields (RBAC)
|
||||
traverseEntity(allowedFields(permittedFields), { schema }),
|
||||
// Remove roles from createdBy & updateBy fields
|
||||
omitCreatorRoles
|
||||
);
|
||||
};
|
||||
|
||||
const wrapValidate = (createSanitizeFunction) => {
|
||||
const wrappedValidate = async (data, options = {}) => {
|
||||
if (isArray(data)) {
|
||||
return Promise.all(data.map((entity) => wrappedValidate(entity, options)));
|
||||
}
|
||||
|
||||
const { subject, action: actionOverride } = getDefaultOptions(data, options);
|
||||
|
||||
const permittedFields = permittedFieldsOf(ability, actionOverride, subject, {
|
||||
fieldsFrom: (rule) => rule.fields || [],
|
||||
});
|
||||
|
||||
const hasAtLeastOneRegistered = some(
|
||||
(fields) => !isNil(fields),
|
||||
flatMap(prop('fields'), ability.rulesFor(actionOverride, detectSubjectType(subject)))
|
||||
);
|
||||
const shouldIncludeAllFields = isEmpty(permittedFields) && !hasAtLeastOneRegistered;
|
||||
|
||||
const sanitizeOptions = {
|
||||
...options,
|
||||
fields: {
|
||||
shouldIncludeAll: shouldIncludeAllFields,
|
||||
permitted: permittedFields,
|
||||
hasAtLeastOneRegistered,
|
||||
},
|
||||
};
|
||||
|
||||
const sanitizeFunction = createSanitizeFunction(sanitizeOptions);
|
||||
|
||||
return sanitizeFunction(data);
|
||||
};
|
||||
|
||||
return wrappedValidate;
|
||||
};
|
||||
|
||||
const getDefaultOptions = (data, options) => {
|
||||
return defaults({ subject: asSubject(model, data), action }, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Omit creator fields' (createdBy & updatedBy) roles from the admin API responses
|
||||
*/
|
||||
const omitCreatorRoles = omit([`${CREATED_BY_ATTRIBUTE}.roles`, `${UPDATED_BY_ATTRIBUTE}.roles`]);
|
||||
|
||||
/**
|
||||
* Visitor used to remove hidden fields from the admin API responses
|
||||
*/
|
||||
const omitHiddenFields = ({ key, schema }, { remove }) => {
|
||||
const isHidden = getOr(false, ['config', 'attributes', key, 'hidden'], schema);
|
||||
|
||||
if (isHidden) {
|
||||
remove(key);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Visitor used to omit disallowed fields from the admin users entities & avoid leaking sensitive information
|
||||
*/
|
||||
const omitDisallowedAdminUserFields = ({ key, attribute, schema }, { remove }) => {
|
||||
if (schema.uid === 'admin::user' && attribute && !ADMIN_USER_ALLOWED_FIELDS.includes(key)) {
|
||||
remove(key);
|
||||
}
|
||||
};
|
||||
|
||||
const getInputFields = (fields = []) => {
|
||||
const nonVisibleAttributes = getNonVisibleAttributes(schema);
|
||||
const writableAttributes = getWritableAttributes(schema);
|
||||
|
||||
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
|
||||
|
||||
return uniq([
|
||||
...fields,
|
||||
...STATIC_FIELDS,
|
||||
...COMPONENT_FIELDS,
|
||||
...nonVisibleWritableAttributes,
|
||||
]);
|
||||
};
|
||||
|
||||
const getQueryFields = (fields = []) => {
|
||||
return uniq([
|
||||
...fields,
|
||||
...STATIC_FIELDS,
|
||||
...COMPONENT_FIELDS,
|
||||
CREATED_AT_ATTRIBUTE,
|
||||
UPDATED_AT_ATTRIBUTE,
|
||||
PUBLISHED_AT_ATTRIBUTE,
|
||||
]);
|
||||
};
|
||||
|
||||
return {
|
||||
validateQuery: wrapValidate(createValidateQuery),
|
||||
validateInput: wrapValidate(createValidateInput),
|
||||
};
|
||||
};
|
||||
@ -44,6 +44,17 @@ const createPermissionChecker =
|
||||
});
|
||||
};
|
||||
|
||||
const validateQuery = (query, { action = ACTIONS.read } = {}) => {
|
||||
return permissionsManager.validateQuery(query, { subject: model, action });
|
||||
};
|
||||
|
||||
const validateInput = (action, data, entity) => {
|
||||
return permissionsManager.validateInput(data, {
|
||||
subject: entity ? toSubject(entity) : model,
|
||||
action,
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeCreateInput = (data) => sanitizeInput(ACTIONS.create, data);
|
||||
const sanitizeUpdateInput = (entity) => (data) => sanitizeInput(ACTIONS.update, data, entity);
|
||||
|
||||
@ -82,6 +93,9 @@ const createPermissionChecker =
|
||||
sanitizeQuery,
|
||||
sanitizeCreateInput,
|
||||
sanitizeUpdateInput,
|
||||
// Validators
|
||||
validateQuery,
|
||||
validateInput,
|
||||
// Queries Builder
|
||||
sanitizedQuery,
|
||||
};
|
||||
|
||||
@ -223,7 +223,7 @@ const getDeepPopulateDraftCount = (uid) => {
|
||||
const getQueryPopulate = async (uid, query) => {
|
||||
let populateQuery = {};
|
||||
|
||||
await strapiUtils.traverse.traverseQueryFilters(
|
||||
await strapiUtils.sanitize.traversals.traverseQueryFilters(
|
||||
/**
|
||||
*
|
||||
* @param {Object} param0
|
||||
|
||||
@ -19,7 +19,7 @@ const createCollectionTypeController = ({ contentType }) => {
|
||||
* @return {Object|Array}
|
||||
*/
|
||||
async find(ctx) {
|
||||
const sanitizedQuery = await this.sanitizeQuery(ctx);
|
||||
const sanitizedQuery = await this.validateQuery(ctx);
|
||||
const { results, pagination } = await strapi.service(uid).find(sanitizedQuery);
|
||||
const sanitizedResults = await this.sanitizeOutput(results, ctx);
|
||||
return this.transformResponse(sanitizedResults, { pagination });
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
const { getOr } = require('lodash/fp');
|
||||
|
||||
const { contentTypes, sanitize } = require('@strapi/utils');
|
||||
const { contentTypes, sanitize, validate } = require('@strapi/utils');
|
||||
|
||||
const { transformResponse } = require('./transform');
|
||||
const createSingleTypeController = require('./single-type');
|
||||
@ -35,6 +35,18 @@ const createController = ({ contentType }) => {
|
||||
|
||||
return sanitize.contentAPI.query(ctx.query, contentType, { auth });
|
||||
},
|
||||
|
||||
validateQuery(ctx) {
|
||||
const auth = getAuthFromKoaContext(ctx);
|
||||
|
||||
return validate.contentAPI.query(ctx.query, contentType, { auth });
|
||||
},
|
||||
|
||||
validateInput(data, ctx) {
|
||||
const auth = getAuthFromKoaContext(ctx);
|
||||
|
||||
return validate.contentAPI.input(data, contentType, { auth });
|
||||
},
|
||||
};
|
||||
|
||||
let ctrl;
|
||||
|
||||
@ -18,7 +18,7 @@ const createSingleTypeController = ({ contentType }) => {
|
||||
* @return {Object|Array}
|
||||
*/
|
||||
async find(ctx) {
|
||||
const sanitizedQuery = await this.sanitizeQuery(ctx);
|
||||
const sanitizedQuery = await this.validateQuery(ctx);
|
||||
const entity = await strapi.service(uid).find(sanitizedQuery);
|
||||
|
||||
const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
|
||||
|
||||
@ -12,6 +12,8 @@ export interface Base {
|
||||
sanitizeOutput<TData>(data: TData, ctx: ExtendableContext): Promise<TData>;
|
||||
sanitizeInput<TData>(data: TData, ctx: ExtendableContext): Promise<TData>;
|
||||
sanitizeQuery<TData>(data: TData, ctx: ExtendableContext): Promise<TData>;
|
||||
validateQuery<TData>(data: TData, ctx: ExtendableContext): Promise<TData>;
|
||||
validateInput<TData>(data: TData, ctx: ExtendableContext): Promise<TData>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -25,7 +25,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
const pmQuery = pm.addPermissionsQueryTo(merge(defaultQuery, ctx.query));
|
||||
const query = await pm.sanitizeQuery(pmQuery);
|
||||
const query = await pm.validateQuery(pmQuery);
|
||||
|
||||
const { results: files, pagination } = await getService('upload').findPage(query);
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ module.exports = {
|
||||
model: FOLDER_MODEL_UID,
|
||||
});
|
||||
|
||||
const query = await permissionsManager.sanitizeQuery(ctx.query);
|
||||
const query = await permissionsManager.validateQuery(ctx.query);
|
||||
const { results } = await strapi.entityService.findWithRelationCountsPage(FOLDER_MODEL_UID, {
|
||||
...defaultsDeep(
|
||||
{
|
||||
@ -49,7 +49,7 @@ module.exports = {
|
||||
model: FOLDER_MODEL_UID,
|
||||
});
|
||||
|
||||
const query = await permissionsManager.sanitizeQuery(ctx.query);
|
||||
const query = await permissionsManager.validateQuery(ctx.query);
|
||||
const results = await strapi.entityService.findWithRelationCounts(FOLDER_MODEL_UID, {
|
||||
...defaultsDeep(
|
||||
{
|
||||
|
||||
@ -6,7 +6,7 @@ const { getService } = require('../utils');
|
||||
const { FILE_MODEL_UID } = require('../constants');
|
||||
const validateUploadBody = require('./validation/content-api/upload');
|
||||
|
||||
const { sanitize } = utils;
|
||||
const { sanitize, validate } = utils;
|
||||
const { ValidationError } = utils.errors;
|
||||
|
||||
const sanitizeOutput = (data, ctx) => {
|
||||
@ -15,16 +15,16 @@ const sanitizeOutput = (data, ctx) => {
|
||||
|
||||
return sanitize.contentAPI.output(data, schema, { auth });
|
||||
};
|
||||
const sanitizeQuery = (data, ctx) => {
|
||||
const validateQuery = (data, ctx) => {
|
||||
const schema = strapi.getModel(FILE_MODEL_UID);
|
||||
const { auth } = ctx.state;
|
||||
|
||||
return sanitize.contentAPI.query(data, schema, { auth });
|
||||
return validate.contentAPI.query(data, schema, { auth });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
async find(ctx) {
|
||||
const sanitizedParams = await sanitizeQuery(ctx.query, ctx);
|
||||
const sanitizedParams = await validateQuery(ctx.query, ctx);
|
||||
|
||||
const files = await getService('upload').findMany(sanitizedParams);
|
||||
|
||||
@ -36,7 +36,7 @@ module.exports = {
|
||||
params: { id },
|
||||
} = ctx;
|
||||
|
||||
const sanitizedParams = await sanitizeQuery(ctx.query, ctx);
|
||||
const sanitizedParams = await validateQuery(ctx.query, ctx);
|
||||
const file = await getService('upload').findOne(id, sanitizedParams.populate);
|
||||
|
||||
if (!file) {
|
||||
|
||||
@ -32,6 +32,7 @@ import * as hooks from './hooks';
|
||||
import providerFactory from './provider-factory';
|
||||
import * as pagination from './pagination';
|
||||
import sanitize from './sanitize';
|
||||
import validate from './validate';
|
||||
import traverseEntity from './traverse-entity';
|
||||
import { pipeAsync, mapAsync, reduceAsync, forEachAsync } from './async';
|
||||
import convertQueryParams from './convert-query-params';
|
||||
@ -78,6 +79,7 @@ export = {
|
||||
providerFactory,
|
||||
pagination,
|
||||
sanitize,
|
||||
validate,
|
||||
traverseEntity,
|
||||
pipeAsync,
|
||||
mapAsync,
|
||||
|
||||
@ -4,11 +4,12 @@ import { isArray, cloneDeep } from 'lodash/fp';
|
||||
import { getNonWritableAttributes } from '../content-types';
|
||||
import { pipeAsync } from '../async';
|
||||
|
||||
import * as visitors from './visitors';
|
||||
import * as visitors from '../traverse/visitors';
|
||||
import * as sanitizers from './sanitizers';
|
||||
import traverseEntity, { Data } from '../traverse-entity';
|
||||
|
||||
import { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate } from '../traverse';
|
||||
import * as traversals from './traversals';
|
||||
import { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate } from './traversals';
|
||||
import { Model } from '../types';
|
||||
|
||||
export interface Options {
|
||||
@ -156,4 +157,5 @@ export default {
|
||||
contentAPI,
|
||||
sanitizers,
|
||||
visitors,
|
||||
traversals,
|
||||
};
|
||||
|
||||
@ -9,14 +9,14 @@ import {
|
||||
traverseQuerySort,
|
||||
traverseQueryPopulate,
|
||||
traverseQueryFields,
|
||||
} from '../traverse';
|
||||
} from './traversals';
|
||||
|
||||
import {
|
||||
removePassword,
|
||||
removePrivate,
|
||||
removeDynamicZones,
|
||||
removeMorphToRelations,
|
||||
} from './visitors';
|
||||
} from '../traverse/visitors';
|
||||
import { isOperator } from '../operators';
|
||||
|
||||
import type { Model } from '../types';
|
||||
|
||||
4
packages/core/utils/src/sanitize/traversals/index.ts
Normal file
4
packages/core/utils/src/sanitize/traversals/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as traverseQueryFilters } from './query-filters';
|
||||
export { default as traverseQuerySort } from './query-sort';
|
||||
export { default as traverseQueryPopulate } from './query-populate';
|
||||
export { default as traverseQueryFields } from './query-fields';
|
||||
@ -1,6 +1,6 @@
|
||||
import { curry, isArray, isString, eq, trim, constant } from 'lodash/fp';
|
||||
|
||||
import traverseFactory from './factory';
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
isArray(value) && value.every(isString);
|
||||
@ -1,6 +1,6 @@
|
||||
import { curry, isObject, isEmpty, isArray, isNil, cloneDeep, omit } from 'lodash/fp';
|
||||
|
||||
import traverseFactory from './factory';
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
|
||||
const isObj = (value: unknown): value is Record<string, unknown> => isObject(value);
|
||||
|
||||
@ -13,9 +13,9 @@ import {
|
||||
omit,
|
||||
} from 'lodash/fp';
|
||||
|
||||
import traverseFactory from './factory';
|
||||
import { Attribute } from '../types';
|
||||
import { isMorphToRelationalAttribute } from '../content-types';
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
import { Attribute } from '../../types';
|
||||
import { isMorphToRelationalAttribute } from '../../content-types';
|
||||
|
||||
const isKeyword = (keyword: string) => {
|
||||
return ({ key, attribute }: { key: string; attribute: Attribute }) => {
|
||||
@ -13,7 +13,7 @@ import {
|
||||
cloneDeep,
|
||||
} from 'lodash/fp';
|
||||
|
||||
import traverseFactory from './factory';
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
|
||||
const ORDERS = { asc: 'asc', desc: 'desc' };
|
||||
const ORDER_VALUES = Object.values(ORDERS);
|
||||
@ -73,7 +73,7 @@ interface CommonHandler<AttributeType = Attribute> {
|
||||
handler(ctx: Context<AttributeType>, opts: Pick<TransformUtils, 'set' | 'recurse'>): void;
|
||||
}
|
||||
|
||||
interface TransformUtils {
|
||||
export interface TransformUtils {
|
||||
remove(key: string): void;
|
||||
set(key: string, valeu: unknown): void;
|
||||
recurse: Traverse;
|
||||
|
||||
@ -1,5 +1 @@
|
||||
export { default as factory } from './factory';
|
||||
export { default as traverseQueryFilters } from './query-filters';
|
||||
export { default as traverseQuerySort } from './query-sort';
|
||||
export { default as traverseQueryPopulate } from './query-populate';
|
||||
export { default as traverseQueryFields } from './query-fields';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { isArray, isNil, toPath } from 'lodash/fp';
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import type { Visitor } from '../factory';
|
||||
|
||||
export default (allowedFields: string[] | null = null): Visitor =>
|
||||
({ key, path: { attribute: path } }, { remove }) => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { isDynamicZoneAttribute } from '../../content-types';
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import { Visitor } from '../factory';
|
||||
|
||||
const visitor: Visitor = ({ key, attribute }, { remove }) => {
|
||||
if (isDynamicZoneAttribute(attribute)) {
|
||||
@ -1,5 +1,5 @@
|
||||
import { isMorphToRelationalAttribute } from '../../content-types';
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import type { Visitor } from '../factory';
|
||||
|
||||
const visitor: Visitor = ({ key, attribute }, { remove }) => {
|
||||
if (isMorphToRelationalAttribute(attribute)) {
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import type { Visitor } from '../factory';
|
||||
|
||||
const visitor: Visitor = ({ key, attribute }, { remove }) => {
|
||||
if (attribute?.type === 'password') {
|
||||
@ -1,5 +1,5 @@
|
||||
import { isPrivateAttribute } from '../../content-types';
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import type { Visitor } from '../factory';
|
||||
|
||||
const visitor: Visitor = ({ schema, key, attribute }, { remove }) => {
|
||||
if (!attribute) {
|
||||
@ -1,5 +1,5 @@
|
||||
import * as contentTypeUtils from '../../content-types';
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import type { Visitor } from '../factory';
|
||||
|
||||
const ACTIONS_TO_VERIFY = ['find'];
|
||||
const { CREATED_BY_ATTRIBUTE, UPDATED_BY_ATTRIBUTE } = contentTypeUtils.constants;
|
||||
@ -1,5 +1,5 @@
|
||||
import { isArray } from 'lodash/fp';
|
||||
import type { Visitor } from '../../traverse/factory';
|
||||
import type { Visitor } from '../factory';
|
||||
|
||||
export default (restrictedFields: string[] | null = null): Visitor =>
|
||||
({ key, path: { attribute: path } }, { remove }) => {
|
||||
138
packages/core/utils/src/validate/index.ts
Normal file
138
packages/core/utils/src/validate/index.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { CurriedFunction1 } from 'lodash';
|
||||
import { isArray, cloneDeep } from 'lodash/fp';
|
||||
|
||||
import { getNonWritableAttributes } from '../content-types';
|
||||
import { pipeAsync } from '../async';
|
||||
|
||||
import * as visitors from '../traverse/visitors';
|
||||
import * as validators from './validators';
|
||||
import traverseEntity, { Data } from '../traverse-entity';
|
||||
|
||||
import * as traversals from './traversals';
|
||||
import { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate } from './traversals';
|
||||
|
||||
import { Model } from '../types';
|
||||
|
||||
export interface Options {
|
||||
auth?: unknown;
|
||||
}
|
||||
|
||||
interface Validator {
|
||||
(schema: Model): CurriedFunction1<Data, Promise<Data>>;
|
||||
}
|
||||
export interface ValidateFunc {
|
||||
(data: unknown, schema: Model, options?: Options): Promise<unknown>;
|
||||
}
|
||||
|
||||
const createContentAPIValidators = () => {
|
||||
const validateInput: ValidateFunc = (data: unknown, schema: Model, { auth } = {}) => {
|
||||
if (isArray(data)) {
|
||||
return Promise.all(data.map((entry) => validateInput(entry, schema, { auth })));
|
||||
}
|
||||
|
||||
const nonWritableAttributes = getNonWritableAttributes(schema);
|
||||
|
||||
const transforms = [
|
||||
// Remove non writable attributes
|
||||
traverseEntity(visitors.restrictedFields(nonWritableAttributes), { schema }),
|
||||
];
|
||||
|
||||
if (auth) {
|
||||
// Remove restricted relations
|
||||
transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema }));
|
||||
}
|
||||
|
||||
// Apply sanitizers from registry if exists
|
||||
strapi.validators
|
||||
.get('content-api.input')
|
||||
.forEach((validator: Validator) => transforms.push(validator(schema)));
|
||||
|
||||
return pipeAsync(...transforms)(data as Data);
|
||||
};
|
||||
|
||||
const validateQuery = async (
|
||||
query: Record<string, unknown>,
|
||||
schema: Model,
|
||||
{ auth }: Options = {}
|
||||
) => {
|
||||
const { filters, sort, fields, populate } = query;
|
||||
|
||||
const validatedQuery = cloneDeep(query);
|
||||
|
||||
if (filters) {
|
||||
Object.assign(validatedQuery, { filters: await validateFilters(filters, schema, { auth }) });
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
Object.assign(validatedQuery, { sort: await validateSort(sort, schema, { auth }) });
|
||||
}
|
||||
|
||||
if (fields) {
|
||||
Object.assign(validatedQuery, { fields: await validateFields(fields, schema) });
|
||||
}
|
||||
|
||||
if (populate) {
|
||||
Object.assign(validatedQuery, { populate: await validatePopulate(populate, schema) });
|
||||
}
|
||||
|
||||
return validatedQuery;
|
||||
};
|
||||
|
||||
const validateFilters: ValidateFunc = (filters, schema: Model, { auth } = {}) => {
|
||||
if (isArray(filters)) {
|
||||
return Promise.all(filters.map((filter) => validateFilters(filter, schema, { auth })));
|
||||
}
|
||||
|
||||
const transforms = [validators.defaultSanitizeFilters(schema)];
|
||||
|
||||
if (auth) {
|
||||
transforms.push(traverseQueryFilters(visitors.removeRestrictedRelations(auth), { schema }));
|
||||
}
|
||||
|
||||
return pipeAsync(...transforms)(filters);
|
||||
};
|
||||
|
||||
const validateSort: ValidateFunc = (sort, schema: Model, { auth } = {}) => {
|
||||
const transforms = [validators.defaultSanitizeSort(schema)];
|
||||
|
||||
if (auth) {
|
||||
transforms.push(traverseQuerySort(visitors.removeRestrictedRelations(auth), { schema }));
|
||||
}
|
||||
|
||||
return pipeAsync(...transforms)(sort);
|
||||
};
|
||||
|
||||
const validateFields: ValidateFunc = (fields, schema: Model) => {
|
||||
const transforms = [validators.defaultSanitizeFields(schema)];
|
||||
|
||||
return pipeAsync(...transforms)(fields);
|
||||
};
|
||||
|
||||
const validatePopulate: ValidateFunc = (populate, schema: Model, { auth } = {}) => {
|
||||
const transforms = [validators.defaultSanitizePopulate(schema)];
|
||||
|
||||
if (auth) {
|
||||
transforms.push(traverseQueryPopulate(visitors.removeRestrictedRelations(auth), { schema }));
|
||||
}
|
||||
|
||||
return pipeAsync(...transforms)(populate);
|
||||
};
|
||||
|
||||
return {
|
||||
input: validateInput,
|
||||
query: validateQuery,
|
||||
filters: validateFilters,
|
||||
sort: validateSort,
|
||||
fields: validateFields,
|
||||
populate: validatePopulate,
|
||||
};
|
||||
};
|
||||
|
||||
const contentAPI = createContentAPIValidators();
|
||||
|
||||
export default {
|
||||
contentAPI,
|
||||
validators,
|
||||
visitors,
|
||||
traversals,
|
||||
};
|
||||
4
packages/core/utils/src/validate/traversals/index.ts
Normal file
4
packages/core/utils/src/validate/traversals/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as traverseQueryFilters } from './query-filters';
|
||||
export { default as traverseQuerySort } from './query-sort';
|
||||
export { default as traverseQueryPopulate } from './query-populate';
|
||||
export { default as traverseQueryFields } from './query-fields';
|
||||
39
packages/core/utils/src/validate/traversals/query-fields.ts
Normal file
39
packages/core/utils/src/validate/traversals/query-fields.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { curry, isArray, isString, eq, trim, constant } from 'lodash/fp';
|
||||
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
import { ValidationError } from '../../errors';
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
isArray(value) && value.every(isString);
|
||||
|
||||
const fields = traverseFactory()
|
||||
// Interecept array of strings
|
||||
.intercept(isStringArray, async (visitor, options, fields, { recurse }) => {
|
||||
return Promise.all(fields.map((field) => recurse(visitor, options, field)));
|
||||
})
|
||||
// Return wildcards as is
|
||||
.intercept((value): value is string => eq('*', value), constant('*'))
|
||||
// Parse string values
|
||||
// Since we're parsing strings only, each value should be an attribute name (and it's value, undefined),
|
||||
// thus it shouldn't be possible to set a new value, and get should return the whole data if key === data
|
||||
.parse(isString, () => ({
|
||||
transform: trim,
|
||||
|
||||
remove(key, data) {
|
||||
throw new ValidationError(`Invalid parameter ${key}`);
|
||||
},
|
||||
|
||||
set(_key, _value, data) {
|
||||
return data;
|
||||
},
|
||||
|
||||
keys(data) {
|
||||
return [data];
|
||||
},
|
||||
|
||||
get(key, data) {
|
||||
return key === data ? data : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
export default curry(fields.traverse);
|
||||
95
packages/core/utils/src/validate/traversals/query-filters.ts
Normal file
95
packages/core/utils/src/validate/traversals/query-filters.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { curry, isObject, isEmpty, isArray, isNil, cloneDeep, omit } from 'lodash/fp';
|
||||
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
import { ValidationError } from '../../errors';
|
||||
|
||||
const isObj = (value: unknown): value is Record<string, unknown> => isObject(value);
|
||||
|
||||
const filters = traverseFactory()
|
||||
.intercept(
|
||||
// Intercept filters arrays and apply the traversal to each one individually
|
||||
isArray,
|
||||
async (visitor, options, filters, { recurse }) => {
|
||||
return Promise.all(
|
||||
filters.map((filter, i) => {
|
||||
// In filters, only operators such as $and, $in, $notIn or $or and implicit operators like [...]
|
||||
// can have a value array, thus we can update the raw path but not the attribute one
|
||||
const newPath = options.path
|
||||
? { ...options.path, raw: `${options.path.raw}[${i}]` }
|
||||
: options.path;
|
||||
|
||||
return recurse(visitor, { ...options, path: newPath }, filter);
|
||||
})
|
||||
// todo: move that to the visitors
|
||||
).then((res) => res.filter((val) => !(isObject(val) && isEmpty(val))));
|
||||
}
|
||||
)
|
||||
.intercept(
|
||||
// Ignore non object filters and return the value as-is
|
||||
(filters): filters is unknown => !isObject(filters),
|
||||
(_, __, filters) => {
|
||||
return filters;
|
||||
}
|
||||
)
|
||||
// Parse object values
|
||||
.parse(isObj, () => ({
|
||||
transform: cloneDeep,
|
||||
|
||||
remove(key, data) {
|
||||
throw new ValidationError(`Invalid parameter ${key}`);
|
||||
},
|
||||
|
||||
set(key, value, data) {
|
||||
return { ...data, [key]: value };
|
||||
},
|
||||
|
||||
keys(data) {
|
||||
return Object.keys(data);
|
||||
},
|
||||
|
||||
get(key, data) {
|
||||
return data[key];
|
||||
},
|
||||
}))
|
||||
// Ignore null or undefined values
|
||||
.ignore(({ value }) => isNil(value))
|
||||
// Recursion on operators (non attributes)
|
||||
.on(
|
||||
({ attribute }) => isNil(attribute),
|
||||
async ({ key, visitor, path, value, schema }, { set, recurse }) => {
|
||||
set(key, await recurse(visitor, { schema, path }, value));
|
||||
}
|
||||
)
|
||||
// Handle relation recursion
|
||||
.onRelation(async ({ key, attribute, visitor, path, value }, { set, recurse }) => {
|
||||
const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
|
||||
|
||||
if (isMorphRelation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSchemaUID = attribute.target;
|
||||
const targetSchema = strapi.getModel(targetSchemaUID);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
.onComponent(async ({ key, attribute, visitor, path, value }, { set, recurse }) => {
|
||||
const targetSchema = strapi.getModel(attribute.component);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
// Handle media recursion
|
||||
.onMedia(async ({ key, visitor, path, value }, { set, recurse }) => {
|
||||
const targetSchemaUID = 'plugin::upload.file';
|
||||
const targetSchema = strapi.getModel(targetSchemaUID);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
});
|
||||
|
||||
export default curry(filters.traverse);
|
||||
230
packages/core/utils/src/validate/traversals/query-populate.ts
Normal file
230
packages/core/utils/src/validate/traversals/query-populate.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import {
|
||||
curry,
|
||||
isString,
|
||||
isArray,
|
||||
isEmpty,
|
||||
split,
|
||||
isObject,
|
||||
trim,
|
||||
isNil,
|
||||
cloneDeep,
|
||||
join,
|
||||
first,
|
||||
omit,
|
||||
} from 'lodash/fp';
|
||||
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
import { Attribute } from '../../types';
|
||||
import { isMorphToRelationalAttribute } from '../../content-types';
|
||||
import { ValidationError } from '../../errors';
|
||||
|
||||
const isKeyword = (keyword: string) => {
|
||||
return ({ key, attribute }: { key: string; attribute: Attribute }) => {
|
||||
return !attribute && keyword === key;
|
||||
};
|
||||
};
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
isArray(value) && value.every(isString);
|
||||
|
||||
const isWildCardConstant = (value: unknown): value is '*' => value === '*';
|
||||
|
||||
const isObj = (value: unknown): value is Record<string, unknown> => isObject(value);
|
||||
|
||||
const populate = traverseFactory()
|
||||
// Array of strings ['foo', 'foo.bar'] => map(recurse), then filter out empty items
|
||||
.intercept(isStringArray, async (visitor, options, populate, { recurse }) => {
|
||||
const visitedPopulate = await Promise.all(
|
||||
populate.map((nestedPopulate) => recurse(visitor, options, nestedPopulate))
|
||||
);
|
||||
|
||||
return visitedPopulate.filter((item) => !isNil(item));
|
||||
})
|
||||
// Transform wildcard populate to an exhaustive list of attributes to populate.
|
||||
.intercept(isWildCardConstant, (visitor, options, _data, { recurse }) => {
|
||||
const attributes = options.schema?.attributes;
|
||||
|
||||
// This should never happen, but adding the check in
|
||||
// case this method is called with wrong parameters
|
||||
if (!attributes) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
const parsedPopulate = Object.entries(attributes)
|
||||
// Get the list of all attributes that can be populated
|
||||
.filter(([, value]) => ['relation', 'component', 'dynamiczone', 'media'].includes(value.type))
|
||||
// Only keep the attributes key
|
||||
.reduce((acc, [key]) => ({ ...acc, [key]: true }), {});
|
||||
|
||||
return recurse(visitor, options, parsedPopulate);
|
||||
})
|
||||
// Parse string values
|
||||
.parse(isString, () => {
|
||||
const tokenize = split('.');
|
||||
const recompose = join('.');
|
||||
|
||||
return {
|
||||
transform: trim,
|
||||
|
||||
remove(key, data) {
|
||||
throw new ValidationError(`Invalid parameter ${key}`);
|
||||
},
|
||||
|
||||
set(key, value, data) {
|
||||
const [root] = tokenize(data);
|
||||
|
||||
if (root !== key) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return isNil(value) || isEmpty(value) ? root : `${root}.${value}`;
|
||||
},
|
||||
|
||||
keys(data) {
|
||||
const v = first(tokenize(data));
|
||||
return v ? [v] : [];
|
||||
},
|
||||
|
||||
get(key, data) {
|
||||
const [root, ...rest] = tokenize(data);
|
||||
|
||||
return key === root ? recompose(rest) : undefined;
|
||||
},
|
||||
};
|
||||
})
|
||||
// Parse object values
|
||||
.parse(isObj, () => ({
|
||||
transform: cloneDeep,
|
||||
|
||||
remove(key, data) {
|
||||
throw new ValidationError(`Invalid parameter ${key}`);
|
||||
},
|
||||
|
||||
set(key, value, data) {
|
||||
return { ...data, [key]: value };
|
||||
},
|
||||
|
||||
keys(data) {
|
||||
return Object.keys(data);
|
||||
},
|
||||
|
||||
get(key, data) {
|
||||
return data[key];
|
||||
},
|
||||
}))
|
||||
.ignore(({ key, attribute }) => {
|
||||
return ['sort', 'filters', 'fields'].includes(key) && !attribute;
|
||||
})
|
||||
.on(
|
||||
// Handle recursion on populate."populate"
|
||||
isKeyword('populate'),
|
||||
async ({ key, visitor, path, value, schema }, { set, recurse }) => {
|
||||
const newValue = await recurse(visitor, { schema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
}
|
||||
)
|
||||
.on(isKeyword('on'), async ({ key, visitor, path, value }, { set, recurse }) => {
|
||||
const newOn: Record<string, unknown> = {};
|
||||
|
||||
if (!isObj(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [uid, subPopulate] of Object.entries(value)) {
|
||||
const model = strapi.getModel(uid);
|
||||
const newPath = { ...path, raw: `${path.raw}[${uid}]` };
|
||||
|
||||
newOn[uid] = await recurse(visitor, { schema: model, path: newPath }, subPopulate);
|
||||
}
|
||||
|
||||
set(key, newOn);
|
||||
})
|
||||
// Handle populate on relation
|
||||
.onRelation(async ({ key, value, attribute, visitor, path, schema }, { set, recurse }) => {
|
||||
if (isNil(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMorphToRelationalAttribute(attribute)) {
|
||||
// Don't traverse values that cannot be parsed
|
||||
if (!isObject(value) || !('on' in value && isObject(value?.on))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is a populate fragment defined, traverse it
|
||||
const newValue = await recurse(visitor, { schema, path }, { on: value?.on });
|
||||
|
||||
set(key, { on: newValue });
|
||||
}
|
||||
|
||||
const targetSchemaUID = attribute.target;
|
||||
const targetSchema = strapi.getModel(targetSchemaUID);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
// Handle populate on media
|
||||
.onMedia(async ({ key, path, visitor, value }, { recurse, set }) => {
|
||||
if (isNil(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSchemaUID = 'plugin::upload.file';
|
||||
const targetSchema = strapi.getModel(targetSchemaUID);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
// Handle populate on components
|
||||
.onComponent(async ({ key, value, visitor, path, attribute }, { recurse, set }) => {
|
||||
if (isNil(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSchema = strapi.getModel(attribute.component);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
// Handle populate on dynamic zones
|
||||
.onDynamicZone(async ({ key, value, attribute, schema, visitor, path }, { set, recurse }) => {
|
||||
if (isNil(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
const { components } = attribute;
|
||||
|
||||
const newValue = {};
|
||||
|
||||
// Handle legacy DZ params
|
||||
let newProperties: unknown = omit('on', value);
|
||||
|
||||
for (const componentUID of components) {
|
||||
const componentSchema = strapi.getModel(componentUID);
|
||||
newProperties = await recurse(visitor, { schema: componentSchema, path }, newProperties);
|
||||
}
|
||||
|
||||
Object.assign(newValue, newProperties);
|
||||
|
||||
// Handle new morph fragment syntax
|
||||
if ('on' in value && value.on) {
|
||||
const newOn = await recurse(visitor, { schema, path }, { on: value.on });
|
||||
|
||||
// Recompose both syntaxes
|
||||
Object.assign(newValue, newOn);
|
||||
}
|
||||
|
||||
set(key, newValue);
|
||||
} else {
|
||||
const newValue = await recurse(visitor, { schema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
}
|
||||
});
|
||||
|
||||
export default curry(populate.traverse);
|
||||
166
packages/core/utils/src/validate/traversals/query-sort.ts
Normal file
166
packages/core/utils/src/validate/traversals/query-sort.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import {
|
||||
curry,
|
||||
isString,
|
||||
isObject,
|
||||
map,
|
||||
trim,
|
||||
split,
|
||||
isEmpty,
|
||||
flatten,
|
||||
pipe,
|
||||
isNil,
|
||||
first,
|
||||
cloneDeep,
|
||||
} from 'lodash/fp';
|
||||
|
||||
import traverseFactory from '../../traverse/factory';
|
||||
import { ValidationError } from '../../errors';
|
||||
|
||||
const ORDERS = { asc: 'asc', desc: 'desc' };
|
||||
const ORDER_VALUES = Object.values(ORDERS);
|
||||
|
||||
const isSortOrder = (value: string) => ORDER_VALUES.includes(value.toLowerCase());
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every(isString);
|
||||
const isObjectArray = (value: unknown): value is object[] =>
|
||||
Array.isArray(value) && value.every(isObject);
|
||||
const isNestedSorts = (value: unknown): value is string =>
|
||||
isString(value) && value.split(',').length > 1;
|
||||
|
||||
const isObj = (value: unknown): value is Record<string, unknown> => isObject(value);
|
||||
|
||||
const sort = traverseFactory()
|
||||
.intercept(
|
||||
// String with chained sorts (foo,bar,foobar) => split, map(recurse), then recompose
|
||||
isNestedSorts,
|
||||
async (visitor, options, sort, { recurse }) => {
|
||||
return Promise.all(
|
||||
sort
|
||||
.split(',')
|
||||
.map(trim)
|
||||
.map((nestedSort) => recurse(visitor, options, nestedSort))
|
||||
).then((res) => res.filter((part) => !isEmpty(part)).join(','));
|
||||
}
|
||||
)
|
||||
.intercept(
|
||||
// Array of strings ['foo', 'foo,bar'] => map(recurse), then filter out empty items
|
||||
isStringArray,
|
||||
async (visitor, options, sort, { recurse }) => {
|
||||
return Promise.all(sort.map((nestedSort) => recurse(visitor, options, nestedSort))).then(
|
||||
(res) => res.filter((nestedSort) => !isEmpty(nestedSort))
|
||||
);
|
||||
}
|
||||
)
|
||||
.intercept(
|
||||
// Array of objects [{ foo: 'asc' }, { bar: 'desc', baz: 'asc' }] => map(recurse), then filter out empty items
|
||||
isObjectArray,
|
||||
async (visitor, options, sort, { recurse }) => {
|
||||
return Promise.all(sort.map((nestedSort) => recurse(visitor, options, nestedSort))).then(
|
||||
(res) => res.filter((nestedSort) => !isEmpty(nestedSort))
|
||||
);
|
||||
}
|
||||
)
|
||||
// Parse string values
|
||||
.parse(isString, () => {
|
||||
const tokenize = pipe(split('.'), map(split(':')), flatten);
|
||||
const recompose = (parts: string[]) => {
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parts.reduce((acc, part) => {
|
||||
if (isEmpty(part)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (acc === '') {
|
||||
return part;
|
||||
}
|
||||
|
||||
return isSortOrder(part) ? `${acc}:${part}` : `${acc}.${part}`;
|
||||
}, '');
|
||||
};
|
||||
|
||||
return {
|
||||
transform: trim,
|
||||
|
||||
remove(key, data) {
|
||||
throw new ValidationError(`Invalid parameter ${key}`);
|
||||
},
|
||||
|
||||
set(key, value, data) {
|
||||
const [root] = tokenize(data);
|
||||
|
||||
if (root !== key) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return isNil(value) ? root : `${root}.${value}`;
|
||||
},
|
||||
|
||||
keys(data) {
|
||||
const v = first(tokenize(data));
|
||||
return v ? [v] : [];
|
||||
},
|
||||
|
||||
get(key, data) {
|
||||
const [root, ...rest] = tokenize(data);
|
||||
|
||||
return key === root ? recompose(rest) : undefined;
|
||||
},
|
||||
};
|
||||
})
|
||||
// Parse object values
|
||||
.parse(isObj, () => ({
|
||||
transform: cloneDeep,
|
||||
|
||||
remove(key, data) {
|
||||
throw new ValidationError(`Invalid parameter ${key}`);
|
||||
},
|
||||
|
||||
set(key, value, data) {
|
||||
return { ...data, [key]: value };
|
||||
},
|
||||
|
||||
keys(data) {
|
||||
return Object.keys(data);
|
||||
},
|
||||
|
||||
get(key, data) {
|
||||
return data[key];
|
||||
},
|
||||
}))
|
||||
// Handle deep sort on relation
|
||||
.onRelation(async ({ key, value, attribute, visitor, path }, { set, recurse }) => {
|
||||
const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
|
||||
|
||||
if (isMorphRelation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSchemaUID = attribute.target;
|
||||
const targetSchema = strapi.getModel(targetSchemaUID);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
// Handle deep sort on media
|
||||
.onMedia(async ({ key, path, visitor, value }, { recurse, set }) => {
|
||||
const targetSchemaUID = 'plugin::upload.file';
|
||||
const targetSchema = strapi.getModel(targetSchemaUID);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
})
|
||||
// Handle deep sort on components
|
||||
.onComponent(async ({ key, value, visitor, path, attribute }, { recurse, set }) => {
|
||||
const targetSchema = strapi.getModel(attribute.component);
|
||||
|
||||
const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
|
||||
|
||||
set(key, newValue);
|
||||
});
|
||||
|
||||
export default curry(sort.traverse);
|
||||
163
packages/core/utils/src/validate/validators.ts
Normal file
163
packages/core/utils/src/validate/validators.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { curry, isEmpty, isNil, isArray, isObject } from 'lodash/fp';
|
||||
|
||||
import { pipeAsync } from '../async';
|
||||
import traverseEntity, { Data } from '../traverse-entity';
|
||||
import { isScalarAttribute } from '../content-types';
|
||||
|
||||
import {
|
||||
traverseQueryFilters,
|
||||
traverseQuerySort,
|
||||
traverseQueryPopulate,
|
||||
traverseQueryFields,
|
||||
} from './traversals';
|
||||
|
||||
import {
|
||||
removePassword,
|
||||
removePrivate,
|
||||
removeDynamicZones,
|
||||
removeMorphToRelations,
|
||||
} from '../traverse/visitors';
|
||||
import { isOperator } from '../operators';
|
||||
|
||||
import type { Model } from '../types';
|
||||
|
||||
const sanitizePasswords = (schema: Model) => async (entity: Data) => {
|
||||
return traverseEntity(removePassword, { schema }, entity);
|
||||
};
|
||||
|
||||
const defaultSanitizeOutput = async (schema: Model, entity: Data) => {
|
||||
return traverseEntity(
|
||||
(...args) => {
|
||||
removePassword(...args);
|
||||
removePrivate(...args);
|
||||
},
|
||||
{ schema },
|
||||
entity
|
||||
);
|
||||
};
|
||||
|
||||
const defaultSanitizeFilters = curry((schema: Model, filters: unknown) => {
|
||||
return pipeAsync(
|
||||
// Remove keys that are not attributes or valid operators
|
||||
traverseQueryFilters(
|
||||
({ key, attribute }, { remove }) => {
|
||||
const isAttribute = !!attribute;
|
||||
|
||||
if (!isAttribute && !isOperator(key) && key !== 'id') {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
),
|
||||
// Remove dynamic zones from filters
|
||||
traverseQueryFilters(removeDynamicZones, { schema }),
|
||||
// Remove morpTo relations from filters
|
||||
traverseQueryFilters(removeMorphToRelations, { schema }),
|
||||
// Remove passwords from filters
|
||||
traverseQueryFilters(removePassword, { schema }),
|
||||
// Remove private from filters
|
||||
traverseQueryFilters(removePrivate, { schema }),
|
||||
// Remove empty objects
|
||||
traverseQueryFilters(
|
||||
({ key, value }, { remove }) => {
|
||||
if (isObject(value) && isEmpty(value)) {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
)
|
||||
)(filters);
|
||||
});
|
||||
|
||||
const defaultSanitizeSort = curry((schema: Model, sort: unknown) => {
|
||||
return pipeAsync(
|
||||
// Remove non attribute keys
|
||||
traverseQuerySort(
|
||||
({ key, attribute }, { remove }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if (key === 'id') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attribute) {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
),
|
||||
// Remove dynamic zones from sort
|
||||
traverseQuerySort(removeDynamicZones, { schema }),
|
||||
// Remove morpTo relations from sort
|
||||
traverseQuerySort(removeMorphToRelations, { schema }),
|
||||
// Remove private from sort
|
||||
traverseQuerySort(removePrivate, { schema }),
|
||||
// Remove passwords from filters
|
||||
traverseQuerySort(removePassword, { schema }),
|
||||
// Remove keys for empty non-scalar values
|
||||
traverseQuerySort(
|
||||
({ key, attribute, value }, { remove }) => {
|
||||
if (!isScalarAttribute(attribute) && isEmpty(value)) {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
)
|
||||
)(sort);
|
||||
});
|
||||
|
||||
const defaultSanitizeFields = curry((schema: Model, fields: unknown) => {
|
||||
return pipeAsync(
|
||||
// Only keep scalar attributes
|
||||
traverseQueryFields(
|
||||
({ key, attribute }, { remove }) => {
|
||||
if (isNil(attribute) || !isScalarAttribute(attribute)) {
|
||||
remove(key);
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
),
|
||||
// Remove private fields
|
||||
traverseQueryFields(removePrivate, { schema }),
|
||||
// Remove password fields
|
||||
traverseQueryFields(removePassword, { schema }),
|
||||
// Remove nil values from fields array
|
||||
(value) => (isArray(value) ? value.filter((field) => !isNil(field)) : value)
|
||||
)(fields);
|
||||
});
|
||||
|
||||
const defaultSanitizePopulate = curry((schema: Model, populate: unknown) => {
|
||||
return pipeAsync(
|
||||
traverseQueryPopulate(
|
||||
async ({ key, value, schema, attribute }, { set }) => {
|
||||
if (attribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'sort') {
|
||||
set(key, await defaultSanitizeSort(schema, value));
|
||||
}
|
||||
|
||||
if (key === 'filters') {
|
||||
set(key, await defaultSanitizeFilters(schema, value));
|
||||
}
|
||||
|
||||
if (key === 'fields') {
|
||||
set(key, await defaultSanitizeFields(schema, value));
|
||||
}
|
||||
},
|
||||
{ schema }
|
||||
),
|
||||
// Remove private fields
|
||||
traverseQueryPopulate(removePrivate, { schema })
|
||||
)(populate);
|
||||
});
|
||||
|
||||
export {
|
||||
sanitizePasswords,
|
||||
defaultSanitizeOutput,
|
||||
defaultSanitizeFilters,
|
||||
defaultSanitizeSort,
|
||||
defaultSanitizeFields,
|
||||
defaultSanitizePopulate,
|
||||
};
|
||||
@ -5,7 +5,7 @@ const { omit, isNil } = require('lodash/fp');
|
||||
|
||||
const utils = require('@strapi/utils');
|
||||
|
||||
const { sanitize } = utils;
|
||||
const { sanitize, validate } = utils;
|
||||
const { NotFoundError } = utils.errors;
|
||||
|
||||
module.exports = ({ strapi }) => {
|
||||
@ -53,7 +53,7 @@ module.exports = ({ strapi }) => {
|
||||
.get('content-api')
|
||||
.buildMutationsResolvers({ contentType });
|
||||
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(
|
||||
const sanitizedQuery = await validate.contentAPI.query(
|
||||
omit(['data', 'files'], transformedArgs),
|
||||
contentType,
|
||||
{
|
||||
@ -91,7 +91,7 @@ module.exports = ({ strapi }) => {
|
||||
.get('content-api')
|
||||
.buildMutationsResolvers({ contentType });
|
||||
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(transformedArgs, contentType, {
|
||||
const sanitizedQuery = await validate.contentAPI.query(transformedArgs, contentType, {
|
||||
auth: ctx?.state?.auth,
|
||||
});
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ const { get } = require('lodash/fp');
|
||||
|
||||
const utils = require('@strapi/utils');
|
||||
|
||||
const { sanitize, pipeAsync } = utils;
|
||||
const { sanitize, validate, pipeAsync } = utils;
|
||||
const { ApplicationError } = utils.errors;
|
||||
|
||||
module.exports = ({ strapi }) => {
|
||||
@ -41,7 +41,7 @@ module.exports = ({ strapi }) => {
|
||||
usePagination: true,
|
||||
});
|
||||
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(transformedArgs, targetContentType, {
|
||||
const sanitizedQuery = await validate.contentAPI.query(transformedArgs, targetContentType, {
|
||||
auth,
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { sanitize } = require('@strapi/utils');
|
||||
const { validate } = require('@strapi/utils');
|
||||
|
||||
module.exports = ({ strapi }) => ({
|
||||
buildComponentResolver({ contentTypeUID, attributeName }) {
|
||||
@ -13,7 +13,7 @@ module.exports = ({ strapi }) => ({
|
||||
const component = strapi.getModel(componentName);
|
||||
|
||||
const transformedArgs = transformArgs(args, { contentType: component, usePagination: true });
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(transformedArgs, contentType, {
|
||||
const sanitizedQuery = await validate.contentAPI.query(transformedArgs, contentType, {
|
||||
auth: ctx?.state?.auth,
|
||||
});
|
||||
return strapi.entityService.load(contentTypeUID, parent, attributeName, sanitizedQuery);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { pick } = require('lodash/fp');
|
||||
const { sanitize } = require('@strapi/utils');
|
||||
const { validate } = require('@strapi/utils');
|
||||
|
||||
const pickCreateArgs = pick(['params', 'data', 'files']);
|
||||
|
||||
@ -25,7 +25,7 @@ module.exports = ({ strapi }) => ({
|
||||
|
||||
async delete(parent, args, ctx) {
|
||||
const { id, ...rest } = args;
|
||||
const sanitizedQuery = sanitize.contentAPI.query(rest, contentType, {
|
||||
const sanitizedQuery = validate.contentAPI.query(rest, contentType, {
|
||||
auth: ctx?.state?.auth,
|
||||
});
|
||||
return strapi.entityService.delete(uid, id, sanitizedQuery);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { omit } = require('lodash/fp');
|
||||
const { sanitize } = require('@strapi/utils');
|
||||
const { validate } = require('@strapi/utils');
|
||||
|
||||
module.exports = ({ strapi }) => ({
|
||||
buildQueriesResolvers({ contentType }) {
|
||||
@ -9,7 +9,7 @@ module.exports = ({ strapi }) => ({
|
||||
|
||||
return {
|
||||
async find(parent, args, ctx) {
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(args, contentType, {
|
||||
const sanitizedQuery = await validate.contentAPI.query(args, contentType, {
|
||||
auth: ctx?.state?.auth,
|
||||
});
|
||||
|
||||
@ -17,7 +17,7 @@ module.exports = ({ strapi }) => ({
|
||||
},
|
||||
|
||||
async findOne(parent, args, ctx) {
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(args, contentType, {
|
||||
const sanitizedQuery = await validate.contentAPI.query(args, contentType, {
|
||||
auth: ctx?.state?.auth,
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { objectType } = require('nexus');
|
||||
const { sanitize } = require('@strapi/utils');
|
||||
const { validate } = require('@strapi/utils');
|
||||
|
||||
module.exports = ({ strapi }) => {
|
||||
const { RESPONSE_COLLECTION_META_TYPE_NAME, PAGINATION_TYPE_NAME } = strapi
|
||||
@ -27,7 +27,7 @@ module.exports = ({ strapi }) => {
|
||||
const safeLimit = Math.max(limit, 1);
|
||||
const contentType = strapi.getModel(resourceUID);
|
||||
|
||||
const sanitizedQuery = await sanitize.contentAPI.query(args, contentType, {
|
||||
const sanitizedQuery = await validate.contentAPI.query(args, contentType, {
|
||||
auth: ctx?.state?.auth,
|
||||
});
|
||||
const total = await strapi.entityService.count(resourceUID, sanitizedQuery);
|
||||
|
||||
@ -11,7 +11,7 @@ const utils = require('@strapi/utils');
|
||||
const { getService } = require('../utils');
|
||||
const { validateCreateUserBody, validateUpdateUserBody } = require('./validation/user');
|
||||
|
||||
const { sanitize } = utils;
|
||||
const { sanitize, validate } = utils;
|
||||
const { ApplicationError, ValidationError, NotFoundError } = utils.errors;
|
||||
|
||||
const sanitizeOutput = async (user, ctx) => {
|
||||
@ -21,11 +21,11 @@ const sanitizeOutput = async (user, ctx) => {
|
||||
return sanitize.contentAPI.output(user, schema, { auth });
|
||||
};
|
||||
|
||||
const sanitizeQuery = async (query, ctx) => {
|
||||
const validateQuery = async (query, ctx) => {
|
||||
const schema = strapi.getModel('plugin::users-permissions.user');
|
||||
const { auth } = ctx.state;
|
||||
|
||||
return sanitize.contentAPI.query(query, schema, { auth });
|
||||
return validate.contentAPI.query(query, schema, { auth });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@ -143,7 +143,7 @@ module.exports = {
|
||||
* @return {Object|Array}
|
||||
*/
|
||||
async find(ctx) {
|
||||
const sanitizedQuery = await sanitizeQuery(ctx.query, ctx);
|
||||
const sanitizedQuery = await validateQuery(ctx.query, ctx);
|
||||
const users = await getService('user').fetchAll(sanitizedQuery);
|
||||
|
||||
ctx.body = await Promise.all(users.map((user) => sanitizeOutput(user, ctx)));
|
||||
@ -155,7 +155,7 @@ module.exports = {
|
||||
*/
|
||||
async findOne(ctx) {
|
||||
const { id } = ctx.params;
|
||||
const sanitizedQuery = await sanitizeQuery(ctx.query, ctx);
|
||||
const sanitizedQuery = await validateQuery(ctx.query, ctx);
|
||||
|
||||
let data = await getService('user').fetch(id, sanitizedQuery);
|
||||
|
||||
@ -171,7 +171,7 @@ module.exports = {
|
||||
* @return {Number}
|
||||
*/
|
||||
async count(ctx) {
|
||||
const sanitizedQuery = await sanitizeQuery(ctx.query, ctx);
|
||||
const sanitizedQuery = await validateQuery(ctx.query, ctx);
|
||||
|
||||
ctx.body = await getService('user').count(sanitizedQuery);
|
||||
},
|
||||
@ -201,7 +201,7 @@ module.exports = {
|
||||
return ctx.unauthorized();
|
||||
}
|
||||
|
||||
const sanitizedQuery = await sanitizeQuery(query, ctx);
|
||||
const sanitizedQuery = await validateQuery(query, ctx);
|
||||
const user = await getService('user').fetch(authUser.id, sanitizedQuery);
|
||||
|
||||
ctx.body = await sanitizeOutput(user, ctx);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user