UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

180 lines (178 loc) 7.94 kB
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 };