@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
213 lines (212 loc) • 7.74 kB
JavaScript
import { useEnv } from '@directus/env';
import { InvalidQueryError } from '@directus/errors';
import Joi from 'joi';
import { isPlainObject, uniq } from 'lodash-es';
import { stringify } from 'wellknown';
import { calculateFieldDepth } from './calculate-field-depth.js';
const env = useEnv();
const querySchema = Joi.object({
fields: Joi.array().items(Joi.string()),
group: Joi.array().items(Joi.string()),
sort: Joi.array().items(Joi.string()),
filter: Joi.object({}).unknown(),
limit: 'QUERY_LIMIT_MAX' in env && env['QUERY_LIMIT_MAX'] !== -1
? Joi.number()
.integer()
.min(-1)
.max(env['QUERY_LIMIT_MAX']) // min should be 0
: Joi.number().integer().min(-1),
offset: Joi.number().integer().min(0),
page: Joi.number().integer().min(0),
meta: Joi.array().items(Joi.string().valid('total_count', 'filter_count')),
search: Joi.string(),
export: Joi.string().valid('csv', 'csv_utf8', 'json', 'xml', 'yaml'),
version: Joi.string(),
versionRaw: Joi.boolean(),
aggregate: Joi.object(),
deep: Joi.object(),
alias: Joi.object(),
backlink: Joi.boolean(),
}).id('query');
export function validateQuery(query) {
const { error } = querySchema.validate(query);
if (query.filter && Object.keys(query.filter).length > 0) {
validateFilter(query.filter);
}
if (query.alias) {
validateAlias(query.alias);
}
validateRelationalDepth(query);
if (error) {
throw new InvalidQueryError({ reason: error.message });
}
return query;
}
function validateFilter(filter) {
for (const [key, nested] of Object.entries(filter)) {
if (key === '_and' || key === '_or') {
nested.forEach(validateFilter);
}
else if (key.startsWith('_')) {
const value = nested;
switch (key) {
case '_in':
case '_nin':
case '_between':
case '_nbetween':
validateList(value, key);
break;
case '_null':
case '_nnull':
case '_empty':
case '_nempty':
validateBoolean(value, key);
break;
case '_intersects':
case '_nintersects':
case '_intersects_bbox':
case '_nintersects_bbox':
validateGeometry(value, key);
break;
case '_none':
case '_some':
validateFilter(nested);
break;
case '_eq':
case '_neq':
case '_contains':
case '_ncontains':
case '_starts_with':
case '_nstarts_with':
case '_istarts_with':
case '_nistarts_with':
case '_ends_with':
case '_nends_with':
case '_iends_with':
case '_niends_with':
case '_gt':
case '_gte':
case '_lt':
case '_lte':
default:
validateFilterPrimitive(value, key);
break;
}
}
else if (isPlainObject(nested)) {
validateFilter(nested);
}
else if (Array.isArray(nested) === false) {
validateFilterPrimitive(nested, '_eq');
}
else {
// @ts-ignore TODO Check which case this is supposed to cover
validateFilter(nested);
}
}
}
function validateFilterPrimitive(value, key) {
if (value === null)
return true;
if ((typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value instanceof Date) ===
false) {
throw new InvalidQueryError({ reason: `The filter value for "${key}" has to be a string, number, or boolean` });
}
if (typeof value === 'number' && (Number.isNaN(value) || value > Number.MAX_SAFE_INTEGER)) {
throw new InvalidQueryError({ reason: `The filter value for "${key}" is not a valid number` });
}
if (typeof value === 'string' && value.length === 0) {
throw new InvalidQueryError({
reason: `You can't filter for an empty string in "${key}". Use "_empty" or "_nempty" instead`,
});
}
return true;
}
function validateList(value, key) {
if (Array.isArray(value) === false || value.length === 0) {
throw new InvalidQueryError({ reason: `"${key}" has to be an array of values` });
}
return true;
}
export function validateBoolean(value, key) {
if (value === null || value === '')
return true;
if (typeof value !== 'boolean') {
throw new InvalidQueryError({ reason: `"${key}" has to be a boolean` });
}
return true;
}
export function validateGeometry(value, key) {
if (value === null || value === '')
return true;
try {
stringify(value);
}
catch {
throw new InvalidQueryError({ reason: `"${key}" has to be a valid GeoJSON object` });
}
return true;
}
function validateAlias(alias) {
if (isPlainObject(alias) === false) {
throw new InvalidQueryError({ reason: `"alias" has to be an object` });
}
for (const [key, value] of Object.entries(alias)) {
if (typeof key !== 'string') {
throw new InvalidQueryError({ reason: `"alias" key has to be a string. "${typeof key}" given` });
}
if (typeof value !== 'string') {
throw new InvalidQueryError({ reason: `"alias" value has to be a string. "${typeof key}" given` });
}
if (key.includes('.') || value.includes('.')) {
throw new InvalidQueryError({ reason: `"alias" key/value can't contain a period character \`.\`` });
}
}
}
function validateRelationalDepth(query) {
const maxRelationalDepth = Number(env['MAX_RELATIONAL_DEPTH']) > 2 ? Number(env['MAX_RELATIONAL_DEPTH']) : 2;
// Process the fields in the same way as api/src/utils/get-ast-from-query.ts
let fields = ['*'];
if (query.fields) {
fields = query.fields;
}
/**
* When using aggregate functions, you can't have any other regular fields
* selected. This makes sure you never end up in a non-aggregate fields selection error
*/
if (Object.keys(query.aggregate || {}).length > 0) {
fields = [];
}
/**
* Similarly, when grouping on a specific field, you can't have other non-aggregated fields.
* The group query will override the fields query
*/
if (query.group) {
fields = query.group;
}
fields = uniq(fields);
for (const field of fields) {
if (field.split('.').length > maxRelationalDepth) {
throw new InvalidQueryError({ reason: 'Max relational depth exceeded' });
}
}
if (query.filter) {
const filterRelationalDepth = calculateFieldDepth(query.filter);
if (filterRelationalDepth > maxRelationalDepth) {
throw new InvalidQueryError({ reason: 'Max relational depth exceeded' });
}
}
if (query.sort) {
for (const sort of query.sort) {
if (sort.split('.').length > maxRelationalDepth) {
throw new InvalidQueryError({ reason: 'Max relational depth exceeded' });
}
}
}
if (query.deep) {
const deepRelationalDepth = calculateFieldDepth(query.deep, ['_sort']);
if (deepRelationalDepth > maxRelationalDepth) {
throw new InvalidQueryError({ reason: 'Max relational depth exceeded' });
}
}
}