@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
180 lines (178 loc) • 7.94 kB
JavaScript
import { parseJsonFunction, parseJsonPath } from "../database/helpers/fn/json/parse-function.js";
import { extractFunctionName } from "./extract-function-name.js";
import { calculateFieldDepth } from "./calculate-field-depth.js";
import { getFieldRelationalDepth } from "./get-field-relational-depth.js";
import { useEnv } from "@directus/env";
import { InvalidQueryError } from "@directus/errors";
import { isPlainObject, uniq } from "lodash-es";
import { stringify } from "wellknown";
import Joi from "joi";
//#region src/utils/validate-query.ts
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"]) : 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");
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);
if (query.sort) validateSort(query.sort);
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 "_json":
validateJsonFilter(value);
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 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;
}
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;
}
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 validateJsonFilter(value) {
if (!isPlainObject(value)) throw new InvalidQueryError({ reason: `"_json" filter value must be an object` });
for (const [path, innerFilter] of Object.entries(value)) {
if (path.length === 0) throw new InvalidQueryError({ reason: `"_json" path key must be a non-empty string` });
if (path === "_or" || path === "_and") {
if (!Array.isArray(innerFilter)) throw new InvalidQueryError({ reason: `"_json" logical operator "${path}" must be an array` });
for (const subFilter of innerFilter) validateJsonFilter(subFilter);
continue;
}
parseJsonPath(path);
if (!isPlainObject(innerFilter)) throw new InvalidQueryError({ reason: `"_json" inner filter for path "${path}" must be an object` });
const nestedPathKey = Object.keys(innerFilter).find((k) => !k.startsWith("_"));
if (nestedPathKey) throw new InvalidQueryError({ reason: `"_json" path "${path}" cannot contain a nested path "${nestedPathKey}"; use a single flat path like "${path}.${nestedPathKey}"` });
validateFilter(innerFilter);
}
}
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(".")) throw new InvalidQueryError({ reason: `"alias" key can't contain a period character \`.\`` });
if (extractFunctionName(value) === "json") parseJsonFunction(value);
else if (value.includes(".")) throw new InvalidQueryError({ reason: `"alias" value can't contain a period character \`.\`` });
}
}
function validateSort(sort) {
for (const sortField of sort) {
const field = sortField.startsWith("-") ? sortField.slice(1) : sortField;
if (extractFunctionName(field) === "json") parseJsonFunction(field);
}
}
function validateRelationalDepth(query) {
const maxRelationalDepth = Number(env["MAX_RELATIONAL_DEPTH"]) > 2 ? Number(env["MAX_RELATIONAL_DEPTH"]) : 2;
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 (getFieldRelationalDepth(query.alias?.[field] ?? field) > maxRelationalDepth) throw new InvalidQueryError({ reason: "Max relational depth exceeded" });
if (query.filter) {
if (calculateFieldDepth(query.filter) > maxRelationalDepth) throw new InvalidQueryError({ reason: "Max relational depth exceeded" });
}
if (query.sort) for (const sort of query.sort) {
const field = sort.startsWith("-") ? sort.slice(1) : sort;
if (getFieldRelationalDepth(query.alias?.[field] ?? field) > maxRelationalDepth) throw new InvalidQueryError({ reason: "Max relational depth exceeded" });
}
if (query.deep) {
if (calculateFieldDepth(query.deep, ["_sort"]) > maxRelationalDepth) throw new InvalidQueryError({ reason: "Max relational depth exceeded" });
}
}
//#endregion
export { validateBoolean, validateGeometry, validateQuery };