@directus/api 12.1.3 → 13.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/__utils__/snapshots.js +9 -0
  2. package/dist/app.js +2 -0
  3. package/dist/cli/utils/create-env/env-stub.liquid +2 -2
  4. package/dist/controllers/branches.d.ts +2 -0
  5. package/dist/controllers/branches.js +190 -0
  6. package/dist/database/migrations/20230721A-require-shares-fields.js +16 -0
  7. package/dist/database/migrations/20230823A-add-content-versioning.d.ts +3 -0
  8. package/dist/database/migrations/20230823A-add-content-versioning.js +26 -0
  9. package/dist/database/system-data/fields/branches.yaml +19 -0
  10. package/dist/database/system-data/fields/collections.yaml +19 -0
  11. package/dist/env.js +4 -2
  12. package/dist/flows.d.ts +1 -0
  13. package/dist/flows.js +17 -12
  14. package/dist/logger.js +1 -2
  15. package/dist/middleware/respond.js +20 -0
  16. package/dist/operations/exec/index.js +42 -33
  17. package/dist/operations/json-web-token/index.d.ts +10 -0
  18. package/dist/operations/json-web-token/index.js +35 -0
  19. package/dist/operations/notification/index.js +2 -2
  20. package/dist/services/branches.d.ts +25 -0
  21. package/dist/services/branches.js +205 -0
  22. package/dist/services/fields.js +2 -2
  23. package/dist/services/graphql/index.js +0 -10
  24. package/dist/services/server.js +0 -3
  25. package/dist/services/specifications.js +2 -1
  26. package/dist/types/collection.d.ts +1 -0
  27. package/dist/utils/get-default-value.d.ts +4 -1
  28. package/dist/utils/get-default-value.js +2 -2
  29. package/dist/utils/redact-object.d.ts +23 -0
  30. package/dist/utils/{redact.js → redact-object.js} +45 -26
  31. package/dist/utils/sanitize-query.js +13 -6
  32. package/dist/utils/validate-query.js +1 -0
  33. package/package.json +17 -17
  34. package/dist/utils/redact.d.ts +0 -15
@@ -0,0 +1,35 @@
1
+ import { defineOperationApi, optionToObject, optionToString } from '@directus/utils';
2
+ import jwt from 'jsonwebtoken';
3
+ export default defineOperationApi({
4
+ id: 'json-web-token',
5
+ handler: async ({ operation, payload, token, secret, options }) => {
6
+ if (operation === 'sign') {
7
+ if (!payload)
8
+ throw new Error('Undefined JSON Web Token payload');
9
+ if (!secret)
10
+ throw new Error('Undefined JSON Web Token secret');
11
+ const payloadObject = optionToObject(payload);
12
+ const secretString = optionToString(secret);
13
+ const optionsObject = optionToObject(options);
14
+ return jwt.sign(payloadObject, secretString, optionsObject);
15
+ }
16
+ else if (operation === 'verify') {
17
+ if (!token)
18
+ throw new Error('Undefined JSON Web Token token');
19
+ if (!secret)
20
+ throw new Error('Undefined JSON Web Token secret');
21
+ const tokenString = optionToString(token);
22
+ const secretString = optionToString(secret);
23
+ const optionsObject = optionToObject(options);
24
+ return jwt.verify(tokenString, secretString, optionsObject);
25
+ }
26
+ else if (operation === 'decode') {
27
+ if (!token)
28
+ throw new Error('Undefined JSON Web Token token');
29
+ const tokenString = optionToString(token);
30
+ const optionsObject = optionToObject(options);
31
+ return jwt.decode(tokenString, optionsObject);
32
+ }
33
+ throw new Error('Undefined "Operation" for JSON Web Token');
34
+ },
35
+ });
@@ -24,8 +24,8 @@ export default defineOperationApi({
24
24
  knex: database,
25
25
  });
26
26
  const messageString = message ? optionToString(message) : null;
27
- const collectionString = message ? optionToString(collection) : null;
28
- const itemString = message ? optionToString(item) : null;
27
+ const collectionString = collection ? optionToString(collection) : null;
28
+ const itemString = item ? optionToString(item) : null;
29
29
  const payload = toArray(recipient).map((userId) => {
30
30
  return {
31
31
  recipient: userId,
@@ -0,0 +1,25 @@
1
+ import type { Item, PrimaryKey, Query } from '@directus/types';
2
+ import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
3
+ import { AuthorizationService } from './authorization.js';
4
+ import { CollectionsService } from './collections.js';
5
+ import { ItemsService } from './items.js';
6
+ export declare class BranchesService extends ItemsService {
7
+ authorizationService: AuthorizationService;
8
+ collectionsService: CollectionsService;
9
+ constructor(options: AbstractServiceOptions);
10
+ private validateCreateData;
11
+ private validateUpdateData;
12
+ getMainBranchItem(collection: string, item: PrimaryKey, query?: Query): Promise<Item>;
13
+ verifyHash(collection: string, item: PrimaryKey, hash: string): Promise<{
14
+ outdated: boolean;
15
+ mainHash: string;
16
+ }>;
17
+ getBranchCommits(key: PrimaryKey): Promise<Partial<Item>[]>;
18
+ createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
19
+ createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
20
+ updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
21
+ updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
22
+ updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
23
+ commit(key: PrimaryKey, data: Partial<Item>): Promise<Partial<Item>>;
24
+ merge(branch: PrimaryKey, mainHash: string, fields?: string[]): Promise<import("../types/items.js").PrimaryKey>;
25
+ }
@@ -0,0 +1,205 @@
1
+ import Joi from 'joi';
2
+ import { assign, pick } from 'lodash-es';
3
+ import objectHash from 'object-hash';
4
+ import getDatabase from '../database/index.js';
5
+ import emitter from '../emitter.js';
6
+ import { InvalidPayloadError, UnprocessableContentError } from '../errors/index.js';
7
+ import { ActivityService } from './activity.js';
8
+ import { AuthorizationService } from './authorization.js';
9
+ import { CollectionsService } from './collections.js';
10
+ import { ItemsService } from './items.js';
11
+ import { PayloadService } from './payload.js';
12
+ import { RevisionsService } from './revisions.js';
13
+ const branchUpdateSchema = Joi.object({
14
+ name: Joi.string(),
15
+ });
16
+ export class BranchesService extends ItemsService {
17
+ authorizationService;
18
+ collectionsService;
19
+ constructor(options) {
20
+ super('directus_branches', options);
21
+ this.authorizationService = new AuthorizationService({
22
+ accountability: this.accountability,
23
+ knex: this.knex,
24
+ schema: this.schema,
25
+ });
26
+ this.collectionsService = new CollectionsService({
27
+ accountability: this.accountability,
28
+ knex: this.knex,
29
+ schema: this.schema,
30
+ });
31
+ }
32
+ async validateCreateData(data) {
33
+ if (!data['name'])
34
+ throw new InvalidPayloadError({ reason: `"name" is required` });
35
+ // Reserves the "main" branch name for the branch query parameter
36
+ if (data['name'] === 'main')
37
+ throw new InvalidPayloadError({ reason: `"main" is a reserved branch name` });
38
+ if (!data['collection']) {
39
+ throw new InvalidPayloadError({ reason: `"collection" is required` });
40
+ }
41
+ if (!data['item'])
42
+ throw new InvalidPayloadError({ reason: `"item" is required` });
43
+ // will throw an error if the collection does not exist or the accountability does not have permission to read it
44
+ const existingCollection = await this.collectionsService.readOne(data['collection']);
45
+ if (!existingCollection.meta?.branches_enabled) {
46
+ throw new UnprocessableContentError({
47
+ reason: `Branch feature is not enabled for collection "${data['collection']}"`,
48
+ });
49
+ }
50
+ const existingBranches = await super.readByQuery({
51
+ fields: ['name', 'collection', 'item'],
52
+ filter: { name: { _eq: data['name'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
53
+ });
54
+ if (existingBranches.length > 0) {
55
+ throw new UnprocessableContentError({
56
+ reason: `Branch "${data['name']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
57
+ });
58
+ }
59
+ // will throw an error if the accountability does not have permission to read the item
60
+ await this.authorizationService.checkAccess('read', data['collection'], data['item']);
61
+ }
62
+ async validateUpdateData(data) {
63
+ // Only allow updates on "name" field
64
+ const { error } = branchUpdateSchema.validate(data);
65
+ if (error)
66
+ throw new InvalidPayloadError({ reason: error.message });
67
+ // Reserves the "main" branch name for the branch query parameter
68
+ if ('name' in data && data['name'] === 'main') {
69
+ throw new InvalidPayloadError({ reason: `"main" is a reserved branch name` });
70
+ }
71
+ }
72
+ async getMainBranchItem(collection, item, query) {
73
+ // will throw an error if the accountability does not have permission to read the item
74
+ await this.authorizationService.checkAccess('read', collection, item);
75
+ const itemsService = new ItemsService(collection, {
76
+ knex: this.knex,
77
+ accountability: this.accountability,
78
+ schema: this.schema,
79
+ });
80
+ return await itemsService.readOne(item, query);
81
+ }
82
+ async verifyHash(collection, item, hash) {
83
+ const mainBranchItem = await this.getMainBranchItem(collection, item);
84
+ const mainHash = objectHash(mainBranchItem);
85
+ return { outdated: hash !== mainHash, mainHash };
86
+ }
87
+ async getBranchCommits(key) {
88
+ const revisionsService = new RevisionsService({
89
+ knex: this.knex,
90
+ schema: this.schema,
91
+ });
92
+ const result = await revisionsService.readByQuery({
93
+ filter: { branch: { _eq: key } },
94
+ });
95
+ return result.map((revision) => revision['delta']);
96
+ }
97
+ async createOne(data, opts) {
98
+ await this.validateCreateData(data);
99
+ const mainBranchItem = await this.getMainBranchItem(data['collection'], data['item']);
100
+ data['hash'] = objectHash(mainBranchItem);
101
+ return super.createOne(data, opts);
102
+ }
103
+ async createMany(data, opts) {
104
+ if (!Array.isArray(data)) {
105
+ throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
106
+ }
107
+ for (const item of data) {
108
+ await this.validateCreateData(item);
109
+ const mainBranchItem = await this.getMainBranchItem(item['collection'], item['item']);
110
+ item['hash'] = objectHash(mainBranchItem);
111
+ }
112
+ return super.createMany(data, opts);
113
+ }
114
+ async updateBatch(data, opts) {
115
+ if (!Array.isArray(data)) {
116
+ throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
117
+ }
118
+ for (const item of data) {
119
+ await this.validateUpdateData(item);
120
+ }
121
+ return super.updateBatch(data, opts);
122
+ }
123
+ async updateMany(keys, data, opts) {
124
+ await this.validateUpdateData(data);
125
+ return super.updateMany(keys, data, opts);
126
+ }
127
+ async updateByQuery(query, data, opts) {
128
+ await this.validateUpdateData(data);
129
+ return super.updateByQuery(query, data, opts);
130
+ }
131
+ async commit(key, data) {
132
+ const branch = await super.readOne(key);
133
+ const payloadService = new PayloadService(this.collection, {
134
+ accountability: this.accountability,
135
+ knex: this.knex,
136
+ schema: this.schema,
137
+ });
138
+ const activityService = new ActivityService({
139
+ knex: this.knex,
140
+ schema: this.schema,
141
+ });
142
+ const revisionsService = new RevisionsService({
143
+ knex: this.knex,
144
+ schema: this.schema,
145
+ });
146
+ const activity = await activityService.createOne({
147
+ action: 'commit',
148
+ user: this.accountability?.user ?? null,
149
+ collection: branch['collection'],
150
+ ip: this.accountability?.ip ?? null,
151
+ user_agent: this.accountability?.userAgent ?? null,
152
+ origin: this.accountability?.origin ?? null,
153
+ item: branch['item'],
154
+ });
155
+ const revisionDelta = await payloadService.prepareDelta(data);
156
+ await revisionsService.createOne({
157
+ activity,
158
+ branch: key,
159
+ collection: branch['collection'],
160
+ item: branch['item'],
161
+ data: revisionDelta,
162
+ delta: revisionDelta,
163
+ });
164
+ return data;
165
+ }
166
+ async merge(branch, mainHash, fields) {
167
+ const { id, collection, item } = (await this.readOne(branch));
168
+ // will throw an error if the accountability does not have permission to update the item
169
+ await this.authorizationService.checkAccess('update', collection, item);
170
+ const { outdated } = await this.verifyHash(collection, item, mainHash);
171
+ if (outdated) {
172
+ throw new UnprocessableContentError({
173
+ reason: `Main branch has changed since this branch was last updated`,
174
+ });
175
+ }
176
+ const commits = await this.getBranchCommits(id);
177
+ const branchResult = assign({}, ...commits);
178
+ const payloadToUpdate = fields ? pick(branchResult, fields) : branchResult;
179
+ const itemsService = new ItemsService(collection, {
180
+ accountability: this.accountability,
181
+ schema: this.schema,
182
+ });
183
+ const payloadAfterHooks = await emitter.emitFilter(['items.merge', `${collection}.items.merge`], payloadToUpdate, {
184
+ collection,
185
+ item,
186
+ branch,
187
+ }, {
188
+ database: getDatabase(),
189
+ schema: this.schema,
190
+ accountability: this.accountability,
191
+ });
192
+ const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks);
193
+ emitter.emitAction(['items.merge', `${collection}.items.merge`], {
194
+ payload: payloadAfterHooks,
195
+ collection,
196
+ item: updatedItemKey,
197
+ branch,
198
+ }, {
199
+ database: getDatabase(),
200
+ schema: this.schema,
201
+ accountability: this.accountability,
202
+ });
203
+ return updatedItemKey;
204
+ }
205
+ }
@@ -67,7 +67,7 @@ export class FieldsService {
67
67
  }
68
68
  const columns = (await this.schemaInspector.columnInfo(collection)).map((column) => ({
69
69
  ...column,
70
- default_value: getDefaultValue(column),
70
+ default_value: getDefaultValue(column, fields.find((field) => field.collection === column.table && field.field === column.name)),
71
71
  }));
72
72
  const columnsWithSystem = columns.map((column) => {
73
73
  const field = fields.find((field) => {
@@ -184,7 +184,7 @@ export class FieldsService {
184
184
  const columnWithCastDefaultValue = column
185
185
  ? {
186
186
  ...column,
187
- default_value: getDefaultValue(column),
187
+ default_value: getDefaultValue(column, fieldInfo),
188
188
  }
189
189
  : null;
190
190
  const data = {
@@ -1532,16 +1532,6 @@ export class GraphQLService {
1532
1532
  }),
1533
1533
  }
1534
1534
  : GraphQLBoolean,
1535
- flows: {
1536
- type: new GraphQLObjectType({
1537
- name: 'server_info_flows',
1538
- fields: {
1539
- execAllowedModules: {
1540
- type: new GraphQLList(GraphQLString),
1541
- },
1542
- },
1543
- }),
1544
- },
1545
1535
  websocket: toBoolean(env['WEBSOCKETS_ENABLED'])
1546
1536
  ? {
1547
1537
  type: new GraphQLObjectType({
@@ -60,9 +60,6 @@ export class ServerService {
60
60
  else {
61
61
  info['rateLimitGlobal'] = false;
62
62
  }
63
- info['flows'] = {
64
- execAllowedModules: env['FLOWS_EXEC_ALLOWED_MODULES'] ? toArray(env['FLOWS_EXEC_ALLOWED_MODULES']) : [],
65
- };
66
63
  info['queryLimit'] = {
67
64
  default: env['QUERY_LIMIT_DEFAULT'],
68
65
  max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
@@ -186,6 +186,7 @@ class OASSpecsService {
186
186
  },
187
187
  responses: {
188
188
  '200': {
189
+ description: 'Successful request',
189
190
  content: method === 'delete'
190
191
  ? undefined
191
192
  : {
@@ -376,7 +377,7 @@ class OASSpecsService {
376
377
  {
377
378
  type: 'string',
378
379
  },
379
- relatedTags.map((tag) => ({
380
+ ...relatedTags.map((tag) => ({
380
381
  $ref: `#/components/schemas/${tag.name}`,
381
382
  })),
382
383
  ],
@@ -7,6 +7,7 @@ export type CollectionMeta = {
7
7
  singleton: boolean;
8
8
  icon: string | null;
9
9
  translations: Record<string, string>;
10
+ branches_enabled: boolean;
10
11
  item_duplication_fields: string[] | null;
11
12
  accountability: 'all' | 'accountability' | null;
12
13
  group: string | null;
@@ -1,3 +1,6 @@
1
1
  import type { SchemaOverview } from '@directus/schema/types/overview';
2
2
  import type { Column } from '@directus/schema';
3
- export default function getDefaultValue(column: SchemaOverview[string]['columns'][string] | Column): string | boolean | number | Record<string, any> | any[] | null;
3
+ import type { FieldMeta } from '@directus/types';
4
+ export default function getDefaultValue(column: SchemaOverview[string]['columns'][string] | Column, field?: {
5
+ special?: FieldMeta['special'];
6
+ }): string | boolean | number | Record<string, any> | any[] | null;
@@ -2,8 +2,8 @@ import { parseJSON } from '@directus/utils';
2
2
  import env from '../env.js';
3
3
  import logger from '../logger.js';
4
4
  import getLocalType from './get-local-type.js';
5
- export default function getDefaultValue(column) {
6
- const type = getLocalType(column);
5
+ export default function getDefaultValue(column, field) {
6
+ const type = getLocalType(column, field);
7
7
  const defaultValue = column.default_value ?? null;
8
8
  if (defaultValue === null)
9
9
  return null;
@@ -0,0 +1,23 @@
1
+ import type { UnknownObject } from '@directus/types';
2
+ type Keys = string[][];
3
+ type Values = Record<string, any>;
4
+ type Replacement = (key?: string) => string;
5
+ /**
6
+ * Redact values in an object.
7
+ *
8
+ * @param input Input object in which values should be redacted.
9
+ * @param redact The key paths at which and values itself which should be redacted.
10
+ * @param redact.keys Nested array of key paths at which values should be redacted. (Supports `*` for shallow matching, `**` for deep matching.)
11
+ * @param redact.values Value names and the corresponding values that should be redacted.
12
+ * @param replacement Replacement function with which the values are redacted.
13
+ * @returns Redacted object.
14
+ */
15
+ export declare function redactObject(input: UnknownObject, redact: {
16
+ keys?: Keys;
17
+ values?: Values;
18
+ }, replacement: Replacement): UnknownObject;
19
+ /**
20
+ * Replace values and extract Error objects for use with JSON.stringify()
21
+ */
22
+ export declare function getReplacer(replacement: Replacement, values?: Values): (_key: string, value: unknown) => unknown;
23
+ export {};
@@ -1,26 +1,32 @@
1
1
  import { isObject } from '@directus/utils';
2
2
  /**
3
- * Redact values at certain paths in an object.
3
+ * Redact values in an object.
4
+ *
4
5
  * @param input Input object in which values should be redacted.
5
- * @param paths Nested array of object paths to be redacted (supports `*` for shallow matching, `**` for deep matching).
6
- * @param replacement Replacement the values are redacted by.
6
+ * @param redact The key paths at which and values itself which should be redacted.
7
+ * @param redact.keys Nested array of key paths at which values should be redacted. (Supports `*` for shallow matching, `**` for deep matching.)
8
+ * @param redact.values Value names and the corresponding values that should be redacted.
9
+ * @param replacement Replacement function with which the values are redacted.
7
10
  * @returns Redacted object.
8
11
  */
9
- export function redact(input, paths, replacement) {
12
+ export function redactObject(input, redact, replacement) {
10
13
  const wildcardChars = ['*', '**'];
11
- const clone = JSON.parse(JSON.stringify(input, errorReplacer));
14
+ const clone = JSON.parse(JSON.stringify(input, getReplacer(replacement, redact.values)));
12
15
  const visited = new WeakSet();
13
- traverse(clone, paths);
16
+ if (redact.keys) {
17
+ traverse(clone, redact.keys);
18
+ }
14
19
  return clone;
15
- function traverse(object, checkPaths) {
16
- if (checkPaths.length === 0) {
20
+ function traverse(object, checkKeyPaths) {
21
+ if (checkKeyPaths.length === 0) {
17
22
  return;
18
23
  }
19
24
  visited.add(object);
25
+ const REDACTED_TEXT = replacement();
20
26
  const globalCheckPaths = [];
21
27
  for (const key of Object.keys(object)) {
22
28
  const localCheckPaths = [];
23
- for (const [index, path] of [...checkPaths].entries()) {
29
+ for (const [index, path] of [...checkKeyPaths].entries()) {
24
30
  const [current, ...remaining] = path;
25
31
  const escapedKey = wildcardChars.includes(key) ? `\\${key}` : key;
26
32
  switch (current) {
@@ -29,17 +35,17 @@ export function redact(input, paths, replacement) {
29
35
  localCheckPaths.push(remaining);
30
36
  }
31
37
  else {
32
- object[key] = replacement;
33
- checkPaths.splice(index, 1);
38
+ object[key] = REDACTED_TEXT;
39
+ checkKeyPaths.splice(index, 1);
34
40
  }
35
41
  break;
36
42
  case '*':
37
43
  if (remaining.length > 0) {
38
44
  globalCheckPaths.push(remaining);
39
- checkPaths.splice(index, 1);
45
+ checkKeyPaths.splice(index, 1);
40
46
  }
41
47
  else {
42
- object[key] = replacement;
48
+ object[key] = REDACTED_TEXT;
43
49
  }
44
50
  break;
45
51
  case '**':
@@ -47,7 +53,7 @@ export function redact(input, paths, replacement) {
47
53
  const [next, ...nextRemaining] = remaining;
48
54
  if (next === escapedKey) {
49
55
  if (nextRemaining.length === 0) {
50
- object[key] = replacement;
56
+ object[key] = REDACTED_TEXT;
51
57
  }
52
58
  else {
53
59
  localCheckPaths.push(nextRemaining);
@@ -61,7 +67,7 @@ export function redact(input, paths, replacement) {
61
67
  }
62
68
  }
63
69
  else {
64
- object[key] = replacement;
70
+ object[key] = REDACTED_TEXT;
65
71
  }
66
72
  break;
67
73
  }
@@ -74,16 +80,29 @@ export function redact(input, paths, replacement) {
74
80
  }
75
81
  }
76
82
  /**
77
- * Extract values from Error objects for use with JSON.stringify()
83
+ * Replace values and extract Error objects for use with JSON.stringify()
78
84
  */
79
- export function errorReplacer(_key, value) {
80
- if (value instanceof Error) {
81
- return {
82
- name: value.name,
83
- message: value.message,
84
- stack: value.stack,
85
- cause: value.cause,
86
- };
87
- }
88
- return value;
85
+ export function getReplacer(replacement, values) {
86
+ const filteredValues = values
87
+ ? Object.entries(values).filter(([_k, v]) => typeof v === 'string' && v.length > 0)
88
+ : [];
89
+ return (_key, value) => {
90
+ if (value instanceof Error) {
91
+ return {
92
+ name: value.name,
93
+ message: value.message,
94
+ stack: value.stack,
95
+ cause: value.cause,
96
+ };
97
+ }
98
+ if (!values || filteredValues.length === 0 || typeof value !== 'string')
99
+ return value;
100
+ let finalValue = value;
101
+ for (const [redactKey, valueToRedact] of filteredValues) {
102
+ if (finalValue.includes(valueToRedact)) {
103
+ finalValue = finalValue.replace(new RegExp(valueToRedact, 'g'), replacement(redactKey));
104
+ }
105
+ }
106
+ return finalValue;
107
+ };
89
108
  }
@@ -46,6 +46,9 @@ export function sanitizeQuery(rawQuery, accountability) {
46
46
  if (rawQuery['search'] && typeof rawQuery['search'] === 'string') {
47
47
  query.search = rawQuery['search'];
48
48
  }
49
+ if (rawQuery['branch']) {
50
+ query.branch = rawQuery['branch'];
51
+ }
49
52
  if (rawQuery['export']) {
50
53
  query.export = rawQuery['export'];
51
54
  }
@@ -146,22 +149,26 @@ function sanitizeDeep(deep, accountability) {
146
149
  parse(deep);
147
150
  return result;
148
151
  function parse(level, path = []) {
152
+ const subQuery = {};
149
153
  const parsedLevel = {};
150
154
  for (const [key, value] of Object.entries(level)) {
151
155
  if (!key)
152
156
  break;
153
157
  if (key.startsWith('_')) {
154
- // Sanitize query only accepts non-underscore-prefixed query options
155
- const parsedSubQuery = sanitizeQuery({ [key.substring(1)]: value }, accountability);
156
- // ...however we want to keep them for the nested structure of deep, otherwise there's no
157
- // way of knowing when to keep nesting and when to stop
158
- const [parsedKey, parsedValue] = Object.entries(parsedSubQuery)[0];
159
- parsedLevel[`_${parsedKey}`] = parsedValue;
158
+ // Collect all sub query parameters without the leading underscore
159
+ subQuery[key.substring(1)] = value;
160
160
  }
161
161
  else if (isPlainObject(value)) {
162
162
  parse(value, [...path, key]);
163
163
  }
164
164
  }
165
+ if (Object.keys(subQuery).length > 0) {
166
+ // Sanitize the entire sub query
167
+ const parsedSubQuery = sanitizeQuery(subQuery, accountability);
168
+ for (const [parsedKey, parsedValue] of Object.entries(parsedSubQuery)) {
169
+ parsedLevel[`_${parsedKey}`] = parsedValue;
170
+ }
171
+ }
165
172
  if (Object.keys(parsedLevel).length > 0) {
166
173
  set(result, path, merge({}, get(result, path, {}), parsedLevel));
167
174
  }
@@ -17,6 +17,7 @@ const querySchema = Joi.object({
17
17
  meta: Joi.array().items(Joi.string().valid('total_count', 'filter_count')),
18
18
  search: Joi.string(),
19
19
  export: Joi.string().valid('csv', 'json', 'xml', 'yaml'),
20
+ branch: Joi.string(),
20
21
  aggregate: Joi.object(),
21
22
  deep: Joi.object(),
22
23
  alias: Joi.object(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "12.1.3",
3
+ "version": "13.1.0-beta.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -97,11 +97,12 @@
97
97
  "icc": "3.0.0",
98
98
  "inquirer": "9.2.4",
99
99
  "ioredis": "5.3.2",
100
+ "isolated-vm": "4.6.0",
100
101
  "joi": "17.9.2",
101
102
  "js-yaml": "4.1.0",
102
103
  "js2xmlparser": "5.0.0",
103
104
  "json2csv": "5.0.7",
104
- "jsonwebtoken": "9.0.0",
105
+ "jsonwebtoken": "9.0.1",
105
106
  "keyv": "4.5.2",
106
107
  "knex": "2.4.2",
107
108
  "ldapjs": "2.3.3",
@@ -138,27 +139,26 @@
138
139
  "tsx": "3.12.7",
139
140
  "uuid": "9.0.0",
140
141
  "uuid-validate": "0.0.3",
141
- "vm2": "3.9.19",
142
142
  "wellknown": "0.5.0",
143
143
  "ws": "8.12.1",
144
144
  "zod": "3.21.4",
145
145
  "zod-validation-error": "1.0.1",
146
- "@directus/app": "10.6.2",
147
- "@directus/constants": "10.2.2",
146
+ "@directus/app": "10.8.0-beta.0",
147
+ "@directus/constants": "10.2.4-beta.0",
148
148
  "@directus/errors": "0.0.2",
149
- "@directus/extensions-sdk": "10.1.8",
150
- "@directus/pressure": "1.0.7",
149
+ "@directus/extensions-sdk": "10.1.10-beta.0",
150
+ "@directus/pressure": "1.0.9-beta.0",
151
151
  "@directus/schema": "10.0.2",
152
- "@directus/specs": "10.1.1",
152
+ "@directus/specs": "10.2.0",
153
153
  "@directus/storage": "10.0.5",
154
- "@directus/storage-driver-azure": "10.0.8",
155
- "@directus/storage-driver-cloudinary": "10.0.8",
156
- "@directus/storage-driver-gcs": "10.0.8",
157
- "@directus/storage-driver-local": "10.0.8",
158
- "@directus/storage-driver-s3": "10.0.8",
159
- "@directus/storage-driver-supabase": "1.0.0",
160
- "@directus/utils": "10.0.8",
161
- "@directus/validation": "0.0.3"
154
+ "@directus/storage-driver-azure": "10.0.10-beta.0",
155
+ "@directus/storage-driver-cloudinary": "10.0.10-beta.0",
156
+ "@directus/storage-driver-gcs": "10.0.10-beta.0",
157
+ "@directus/storage-driver-local": "10.0.10-beta.0",
158
+ "@directus/storage-driver-s3": "10.0.10-beta.0",
159
+ "@directus/storage-driver-supabase": "1.0.2-beta.0",
160
+ "@directus/utils": "10.0.10-beta.0",
161
+ "@directus/validation": "0.0.5-beta.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@ngneat/falso": "6.4.0",
@@ -207,7 +207,7 @@
207
207
  "vitest": "0.31.1",
208
208
  "@directus/random": "0.2.2",
209
209
  "@directus/tsconfig": "1.0.0",
210
- "@directus/types": "10.1.4"
210
+ "@directus/types": "10.1.6-beta.0"
211
211
  },
212
212
  "optionalDependencies": {
213
213
  "@keyv/redis": "2.5.8",