@directus/api 25.0.1 → 26.0.1

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 (115) hide show
  1. package/dist/app.js +3 -3
  2. package/dist/auth/drivers/oauth2.d.ts +2 -0
  3. package/dist/auth/drivers/oauth2.js +40 -2
  4. package/dist/auth/drivers/openid.js +8 -1
  5. package/dist/controllers/access.js +2 -2
  6. package/dist/controllers/comments.js +2 -2
  7. package/dist/controllers/dashboards.js +2 -2
  8. package/dist/controllers/files.js +2 -2
  9. package/dist/controllers/flows.js +2 -2
  10. package/dist/controllers/folders.js +2 -2
  11. package/dist/controllers/items.js +2 -2
  12. package/dist/controllers/notifications.js +2 -2
  13. package/dist/controllers/operations.js +2 -2
  14. package/dist/controllers/panels.js +2 -2
  15. package/dist/controllers/permissions.js +2 -2
  16. package/dist/controllers/policies.js +2 -2
  17. package/dist/controllers/presets.js +2 -2
  18. package/dist/controllers/roles.js +2 -2
  19. package/dist/controllers/shares.js +2 -2
  20. package/dist/controllers/translations.js +2 -2
  21. package/dist/controllers/users.js +2 -2
  22. package/dist/controllers/utils.js +8 -3
  23. package/dist/controllers/versions.js +2 -2
  24. package/dist/controllers/webhooks.js +1 -1
  25. package/dist/database/helpers/capabilities/dialects/default.d.ts +3 -0
  26. package/dist/database/helpers/capabilities/dialects/default.js +3 -0
  27. package/dist/database/helpers/capabilities/dialects/mysql.d.ts +4 -0
  28. package/dist/database/helpers/capabilities/dialects/mysql.js +9 -0
  29. package/dist/database/helpers/capabilities/dialects/postgres.d.ts +5 -0
  30. package/dist/database/helpers/capabilities/dialects/postgres.js +14 -0
  31. package/dist/database/helpers/capabilities/index.d.ts +7 -0
  32. package/dist/database/helpers/capabilities/index.js +7 -0
  33. package/dist/database/helpers/capabilities/types.d.ts +11 -0
  34. package/dist/database/helpers/capabilities/types.js +15 -0
  35. package/dist/database/helpers/index.d.ts +2 -0
  36. package/dist/database/helpers/index.js +2 -0
  37. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +1 -2
  38. package/dist/database/helpers/schema/dialects/cockroachdb.js +0 -4
  39. package/dist/database/helpers/schema/dialects/postgres.d.ts +1 -2
  40. package/dist/database/helpers/schema/dialects/postgres.js +0 -4
  41. package/dist/database/index.js +1 -1
  42. package/dist/database/migrations/20250224A-visual-editor.d.ts +3 -0
  43. package/dist/database/migrations/20250224A-visual-editor.js +35 -0
  44. package/dist/database/run-ast/lib/get-db-query.js +16 -4
  45. package/dist/logger/index.js +3 -3
  46. package/dist/middleware/sanitize-query.js +17 -7
  47. package/dist/middleware/validate-batch.js +1 -1
  48. package/dist/operations/item-delete/index.js +1 -1
  49. package/dist/operations/item-read/index.js +1 -1
  50. package/dist/operations/item-update/index.js +1 -1
  51. package/dist/permissions/lib/fetch-permissions.js +6 -4
  52. package/dist/permissions/modules/process-ast/utils/context-has-dynamic-variables.d.ts +2 -0
  53. package/dist/permissions/modules/process-ast/utils/context-has-dynamic-variables.js +3 -0
  54. package/dist/permissions/modules/process-payload/process-payload.d.ts +1 -0
  55. package/dist/permissions/modules/process-payload/process-payload.js +19 -4
  56. package/dist/permissions/types.d.ts +2 -1
  57. package/dist/permissions/utils/extract-required-dynamic-variable-context.d.ts +3 -2
  58. package/dist/permissions/utils/extract-required-dynamic-variable-context.js +24 -5
  59. package/dist/permissions/utils/fetch-dynamic-variable-data.d.ts +9 -0
  60. package/dist/permissions/utils/{fetch-dynamic-variable-context.js → fetch-dynamic-variable-data.js} +13 -12
  61. package/dist/rate-limiter.js +1 -1
  62. package/dist/services/assets.js +12 -2
  63. package/dist/services/authentication.js +2 -2
  64. package/dist/services/collections.js +8 -2
  65. package/dist/services/graphql/resolvers/get-collection-type.d.ts +3 -0
  66. package/dist/services/graphql/resolvers/get-collection-type.js +34 -0
  67. package/dist/services/graphql/resolvers/get-field-type.d.ts +3 -0
  68. package/dist/services/graphql/resolvers/get-field-type.js +51 -0
  69. package/dist/services/graphql/resolvers/get-relation-type.d.ts +3 -0
  70. package/dist/services/graphql/resolvers/get-relation-type.js +39 -0
  71. package/dist/services/graphql/resolvers/mutation.js +1 -1
  72. package/dist/services/graphql/resolvers/query.js +4 -4
  73. package/dist/services/graphql/resolvers/system-admin.d.ts +2 -2
  74. package/dist/services/graphql/resolvers/system-admin.js +207 -199
  75. package/dist/services/graphql/resolvers/system.d.ts +1 -7
  76. package/dist/services/graphql/resolvers/system.js +12 -113
  77. package/dist/services/graphql/schema/index.js +1 -1
  78. package/dist/services/graphql/schema/parse-query.d.ts +2 -2
  79. package/dist/services/graphql/schema/parse-query.js +6 -6
  80. package/dist/services/graphql/schema/read.d.ts +2 -2
  81. package/dist/services/graphql/schema/read.js +86 -2
  82. package/dist/services/graphql/schema-cache.d.ts +2 -2
  83. package/dist/services/graphql/schema-cache.js +1 -3
  84. package/dist/services/graphql/subscription.d.ts +3 -3
  85. package/dist/services/graphql/subscription.js +3 -3
  86. package/dist/services/graphql/utils/{aggrgate-query.d.ts → aggregate-query.d.ts} +2 -2
  87. package/dist/services/graphql/utils/{aggrgate-query.js → aggregate-query.js} +3 -3
  88. package/dist/services/items.d.ts +1 -0
  89. package/dist/services/items.js +30 -16
  90. package/dist/services/payload.d.ts +1 -0
  91. package/dist/services/payload.js +32 -17
  92. package/dist/services/shares.js +1 -1
  93. package/dist/services/specifications.js +10 -5
  94. package/dist/services/tus/lockers.d.ts +1 -1
  95. package/dist/services/tus/lockers.js +6 -5
  96. package/dist/services/tus/server.js +24 -0
  97. package/dist/services/users.js +1 -0
  98. package/dist/types/services.d.ts +2 -0
  99. package/dist/utils/apply-query.d.ts +1 -0
  100. package/dist/utils/apply-query.js +21 -12
  101. package/dist/utils/generate-hash.js +1 -1
  102. package/dist/utils/get-config-from-env.d.ts +6 -1
  103. package/dist/utils/get-config-from-env.js +16 -11
  104. package/dist/utils/get-graphql-type.js +3 -1
  105. package/dist/utils/is-login-redirect-allowed.js +2 -0
  106. package/dist/utils/redact-object.js +5 -1
  107. package/dist/utils/sanitize-query.d.ts +5 -2
  108. package/dist/utils/sanitize-query.js +34 -9
  109. package/dist/websocket/controllers/base.d.ts +2 -2
  110. package/dist/websocket/handlers/items.js +4 -4
  111. package/dist/websocket/handlers/subscribe.js +2 -2
  112. package/dist/websocket/messages.d.ts +7 -7
  113. package/dist/websocket/messages.js +1 -1
  114. package/package.json +59 -59
  115. package/dist/permissions/utils/fetch-dynamic-variable-context.d.ts +0 -8
@@ -20,12 +20,14 @@ export class PayloadService {
20
20
  helpers;
21
21
  collection;
22
22
  schema;
23
+ nested;
23
24
  constructor(collection, options) {
24
25
  this.accountability = options.accountability || null;
25
26
  this.knex = options.knex || getDatabase();
26
27
  this.helpers = getHelpers(this.knex);
27
28
  this.collection = collection;
28
29
  this.schema = options.schema;
30
+ this.nested = options.nested ?? [];
29
31
  return this;
30
32
  }
31
33
  transformers = {
@@ -346,6 +348,7 @@ export class PayloadService {
346
348
  accountability: this.accountability,
347
349
  knex: this.knex,
348
350
  schema: this.schema,
351
+ nested: [...this.nested, relation.field],
349
352
  });
350
353
  const relatedPrimaryKeyField = this.schema.collections[relatedCollection].primary;
351
354
  const relatedRecord = payload[relation.field];
@@ -412,6 +415,7 @@ export class PayloadService {
412
415
  accountability: this.accountability,
413
416
  knex: this.knex,
414
417
  schema: this.schema,
418
+ nested: [...this.nested, relation.field],
415
419
  });
416
420
  const relatedRecord = payload[relation.field];
417
421
  if (['string', 'number'].includes(typeof relatedRecord))
@@ -482,6 +486,7 @@ export class PayloadService {
482
486
  accountability: this.accountability,
483
487
  knex: this.knex,
484
488
  schema: this.schema,
489
+ nested: [...this.nested, relation.meta.one_field],
485
490
  });
486
491
  const recordsToUpsert = [];
487
492
  const savedPrimaryKeys = [];
@@ -490,15 +495,23 @@ export class PayloadService {
490
495
  if (!field || Array.isArray(field)) {
491
496
  const updates = field || []; // treat falsey values as removing all children
492
497
  for (let i = 0; i < updates.length; i++) {
498
+ const currentId = parent || payload[currentPrimaryKeyField];
493
499
  const relatedRecord = updates[i];
500
+ const relatedId = typeof relatedRecord === 'string' || typeof relatedRecord === 'number'
501
+ ? relatedRecord
502
+ : relatedRecord[relatedPrimaryKeyField];
494
503
  let record = cloneDeep(relatedRecord);
495
- if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') {
496
- const existingRecord = await this.knex
504
+ let existingRecord;
505
+ // No relatedId means it's a new record
506
+ if (relatedId) {
507
+ existingRecord = await this.knex
497
508
  .select(relatedPrimaryKeyField, relation.field)
498
509
  .from(relation.collection)
499
- .where({ [relatedPrimaryKeyField]: record })
510
+ .where({ [relatedPrimaryKeyField]: relatedId })
500
511
  .first();
501
- if (!!existingRecord === false) {
512
+ }
513
+ if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') {
514
+ if (!existingRecord) {
502
515
  throw new ForbiddenError();
503
516
  }
504
517
  // If the related item is already associated to the current item, and there's no
@@ -507,9 +520,7 @@ export class PayloadService {
507
520
  // for items that aren't actually being updated. NOTE: We use == here, as the
508
521
  // primary key might be reported as a string instead of number, coming from the
509
522
  // http route, and or a bigInteger in the DB
510
- if (isNil(existingRecord[relation.field]) === false &&
511
- (existingRecord[relation.field] == parent ||
512
- existingRecord[relation.field] == payload[currentPrimaryKeyField])) {
523
+ if (isNil(existingRecord[relation.field]) === false && existingRecord[relation.field] == currentId) {
513
524
  savedPrimaryKeys.push(existingRecord[relatedPrimaryKeyField]);
514
525
  continue;
515
526
  }
@@ -517,10 +528,10 @@ export class PayloadService {
517
528
  [relatedPrimaryKeyField]: relatedRecord,
518
529
  };
519
530
  }
520
- recordsToUpsert.push({
521
- ...record,
522
- [relation.field]: parent || payload[currentPrimaryKeyField],
523
- });
531
+ if (!existingRecord || existingRecord[relation.field] != parent) {
532
+ record[relation.field] = currentId;
533
+ }
534
+ recordsToUpsert.push(record);
524
535
  }
525
536
  savedPrimaryKeys.push(...(await service.upsertMany(recordsToUpsert, {
526
537
  onRevisionCreate: (pk) => revisions.push(pk),
@@ -608,13 +619,17 @@ export class PayloadService {
608
619
  });
609
620
  }
610
621
  if (alterations.update) {
611
- const primaryKeyField = this.schema.collections[relation.collection].primary;
612
622
  for (const item of alterations.update) {
613
- const { [primaryKeyField]: key, ...record } = item;
614
- await service.updateOne(key, {
615
- ...record,
616
- [relation.field]: parent || payload[currentPrimaryKeyField],
617
- }, {
623
+ const { [relatedPrimaryKeyField]: key, ...record } = item;
624
+ const existingRecord = await this.knex
625
+ .select(relatedPrimaryKeyField, relation.field)
626
+ .from(relation.collection)
627
+ .where({ [relatedPrimaryKeyField]: key })
628
+ .first();
629
+ if (!existingRecord || existingRecord[relation.field] != parent) {
630
+ record[relation.field] = parent || payload[currentPrimaryKeyField];
631
+ }
632
+ await service.updateOne(key, record, {
618
633
  onRevisionCreate: (pk) => revisions.push(pk),
619
634
  onRequireUserIntegrityCheck: (flags) => (userIntegrityCheckFlags |= flags),
620
635
  bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
@@ -4,6 +4,7 @@ import argon2 from 'argon2';
4
4
  import jwt from 'jsonwebtoken';
5
5
  import { nanoid } from 'nanoid';
6
6
  import { useLogger } from '../logger/index.js';
7
+ import { clearCache as clearPermissionsCache } from '../permissions/cache.js';
7
8
  import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
8
9
  import { getMilliseconds } from '../utils/get-milliseconds.js';
9
10
  import { getSecret } from '../utils/get-secret.js';
@@ -13,7 +14,6 @@ import { userName } from '../utils/user-name.js';
13
14
  import { ItemsService } from './items.js';
14
15
  import { MailService } from './mail/index.js';
15
16
  import { UsersService } from './users.js';
16
- import { clearCache as clearPermissionsCache } from '../permissions/cache.js';
17
17
  const env = useEnv();
18
18
  const logger = useLogger();
19
19
  export class SharesService extends ItemsService {
@@ -49,7 +49,7 @@ class OASSpecsService {
49
49
  permissions = await fetchPermissions({ policies, accountability: this.accountability }, { schema: this.schema, knex: this.knex });
50
50
  }
51
51
  const tags = await this.generateTags(schemaForSpec);
52
- const paths = await this.generatePaths(permissions, tags);
52
+ const paths = await this.generatePaths(schemaForSpec, permissions, tags);
53
53
  const components = await this.generateComponents(schemaForSpec, tags);
54
54
  const isDefaultPublicUrl = env['PUBLIC_URL'] === '/';
55
55
  const url = isDefaultPublicUrl && host ? host : env['PUBLIC_URL'];
@@ -114,7 +114,7 @@ class OASSpecsService {
114
114
  // Filter out the generic Items information
115
115
  return tags.filter((tag) => tag.name !== 'Items');
116
116
  }
117
- async generatePaths(permissions, tags) {
117
+ async generatePaths(schema, permissions, tags) {
118
118
  const paths = {};
119
119
  if (!tags)
120
120
  return paths;
@@ -195,11 +195,16 @@ class OASSpecsService {
195
195
  'application/json': {
196
196
  schema: {
197
197
  properties: {
198
- data: {
199
- items: {
198
+ data: schema.collections[collection]?.singleton
199
+ ? {
200
200
  $ref: `#/components/schemas/${tag.name}`,
201
+ }
202
+ : {
203
+ type: 'array',
204
+ items: {
205
+ $ref: `#/components/schemas/${tag.name}`,
206
+ },
201
207
  },
202
- },
203
208
  },
204
209
  },
205
210
  },
@@ -29,7 +29,7 @@ export declare class KvLock implements Lock {
29
29
  private acquireTimeout;
30
30
  private kv;
31
31
  constructor(id: string, lockTimeout?: number, acquireTimeout?: number);
32
- lock(cancelReq: RequestRelease): Promise<void>;
32
+ lock(signal: AbortSignal, cancelReq: RequestRelease): Promise<void>;
33
33
  protected acquireLock(id: string, requestRelease: RequestRelease, signal: AbortSignal): Promise<boolean>;
34
34
  unlock(): Promise<void>;
35
35
  }
@@ -38,11 +38,12 @@ export class KvLock {
38
38
  this.acquireTimeout = acquireTimeout;
39
39
  this.kv = useLock();
40
40
  }
41
- async lock(cancelReq) {
41
+ async lock(signal, cancelReq) {
42
42
  const abortController = new AbortController();
43
+ const abortSignal = AbortSignal.any([signal, abortController.signal]);
43
44
  const lock = await Promise.race([
44
- waitTimeout(this.acquireTimeout, abortController.signal),
45
- this.acquireLock(this.id, cancelReq, abortController.signal),
45
+ waitTimeout(this.acquireTimeout, abortSignal),
46
+ this.acquireLock(this.id, cancelReq, abortSignal),
46
47
  ]);
47
48
  abortController.abort();
48
49
  if (!lock) {
@@ -50,10 +51,10 @@ export class KvLock {
50
51
  }
51
52
  }
52
53
  async acquireLock(id, requestRelease, signal) {
54
+ const lockTime = await this.kv.get(id);
53
55
  if (signal.aborted) {
54
- return false;
56
+ return typeof lockTime !== 'undefined';
55
57
  }
56
- const lockTime = await this.kv.get(id);
57
58
  const now = Date.now();
58
59
  if (!lockTime || Number(lockTime) < now - this.lockTimeout) {
59
60
  await this.kv.set(id, now);
@@ -14,6 +14,8 @@ import { ItemsService } from '../index.js';
14
14
  import { TusDataStore } from './data-store.js';
15
15
  import { getTusLocker } from './lockers.js';
16
16
  import { pick } from 'lodash-es';
17
+ import emitter from '../../emitter.js';
18
+ import getDatabase from '../../database/index.js';
17
19
  async function createTusStore(context) {
18
20
  const env = useEnv();
19
21
  const storage = await getStorage();
@@ -48,6 +50,7 @@ export async function createTusServer(context) {
48
50
  }))[0];
49
51
  if (!file)
50
52
  return res;
53
+ let fileData;
51
54
  // update metadata when file is replaced
52
55
  if (file.tus_data?.['metadata']?.['replace_id']) {
53
56
  const newFile = await service.readOne(file.tus_data['metadata']['replace_id']);
@@ -60,6 +63,12 @@ export async function createTusServer(context) {
60
63
  ...updateFields,
61
64
  ...metadata,
62
65
  });
66
+ fileData = {
67
+ ...newFile,
68
+ ...updateFields,
69
+ ...metadata,
70
+ id: file.tus_data['metadata']['replace_id'],
71
+ };
63
72
  await service.deleteOne(file.id);
64
73
  }
65
74
  else {
@@ -69,7 +78,22 @@ export async function createTusServer(context) {
69
78
  tus_id: null,
70
79
  tus_data: null,
71
80
  });
81
+ fileData = {
82
+ ...file,
83
+ ...metadata,
84
+ tus_id: null,
85
+ tus_data: null,
86
+ };
72
87
  }
88
+ emitter.emitAction('files.upload', {
89
+ payload: fileData,
90
+ key: fileData.id,
91
+ collection: 'directus_files',
92
+ }, {
93
+ database: getDatabase(),
94
+ schema: req.schema,
95
+ accountability: req.accountability,
96
+ });
73
97
  return res;
74
98
  },
75
99
  generateUrl(_req, opts) {
@@ -137,6 +137,7 @@ export class UsersService extends ItemsService {
137
137
  throw new FailedValidationError({
138
138
  field: 'email',
139
139
  type: 'email',
140
+ path: [],
140
141
  });
141
142
  }
142
143
  }
@@ -4,10 +4,12 @@ export type AbstractServiceOptions = {
4
4
  knex?: Knex | undefined;
5
5
  accountability?: Accountability | null | undefined;
6
6
  schema: SchemaOverview;
7
+ nested?: string[];
7
8
  };
8
9
  export interface AbstractService {
9
10
  knex: Knex;
10
11
  accountability: Accountability | null | undefined;
12
+ nested: string[];
11
13
  createOne(data: Partial<Item>): Promise<PrimaryKey>;
12
14
  createMany(data: Partial<Item>[]): Promise<PrimaryKey[]>;
13
15
  readOne(key: PrimaryKey, query?: Query): Promise<Item>;
@@ -7,6 +7,7 @@ type ApplyQueryOptions = {
7
7
  isInnerQuery?: boolean;
8
8
  hasMultiRelationalSort?: boolean | undefined;
9
9
  groupWhenCases?: number[][] | undefined;
10
+ groupColumnPositions?: number[] | undefined;
10
11
  };
11
12
  /**
12
13
  * Apply the Query to a given Knex query builder instance
@@ -47,20 +47,28 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, cas
47
47
  hasMultiRelationalFilter = filterResult.hasMultiRelationalFilter;
48
48
  }
49
49
  if (query.group) {
50
+ const helpers = getHelpers(knex);
50
51
  const rawColumns = query.group.map((column) => getColumn(knex, collection, column, false, schema));
51
52
  let columns;
52
53
  if (options?.groupWhenCases) {
53
- columns = rawColumns.map((column, index) => applyCaseWhen({
54
- columnCases: options.groupWhenCases[index].map((caseIndex) => cases[caseIndex]),
55
- column,
56
- aliasMap,
57
- cases,
58
- table: collection,
59
- permissions,
60
- }, {
61
- knex,
62
- schema,
63
- }));
54
+ if (helpers.capabilities.supportsColumnPositionInGroupBy() && options.groupColumnPositions) {
55
+ // This can be streamlined for databases that support reusing the alias in group by expressions
56
+ columns = query.group.map((column, index) => options.groupColumnPositions[index] !== undefined ? knex.raw(options.groupColumnPositions[index]) : column);
57
+ }
58
+ else {
59
+ // Reconstruct the columns with the case/when logic
60
+ columns = rawColumns.map((column, index) => applyCaseWhen({
61
+ columnCases: options.groupWhenCases[index].map((caseIndex) => cases[caseIndex]),
62
+ column,
63
+ aliasMap,
64
+ cases,
65
+ table: collection,
66
+ permissions,
67
+ }, {
68
+ knex,
69
+ schema,
70
+ }));
71
+ }
64
72
  if (query.sort && query.sort.length === 1 && query.sort[0] === query.group[0]) {
65
73
  // Special case, where the sort query is injected by the group by operation
66
74
  dbQuery.clear('order');
@@ -620,7 +628,8 @@ export function applySearch(knex, schema, dbQuery, searchQuery, collection, alia
620
628
  dbQuery.andWhere(function (queryBuilder) {
621
629
  let needsFallbackCondition = true;
622
630
  fields.forEach(([name, field]) => {
623
- const whenCases = (caseMap[name] ?? []).map((caseIndex) => cases[caseIndex]);
631
+ // only account for when cases when full access is not given
632
+ const whenCases = allowedFields.has('*') ? [] : (caseMap[name] ?? []).map((caseIndex) => cases[caseIndex]);
624
633
  const fieldType = getFieldType(field);
625
634
  if (fieldType !== null) {
626
635
  needsFallbackCondition = false;
@@ -1,7 +1,7 @@
1
1
  import argon2 from 'argon2';
2
2
  import { getConfigFromEnv } from './get-config-from-env.js';
3
3
  export function generateHash(stringToHash) {
4
- const argon2HashConfigOptions = getConfigFromEnv('HASH_', 'HASH_RAW'); // Disallow the HASH_RAW option, see https://github.com/directus/directus/discussions/7670#discussioncomment-1255805
4
+ const argon2HashConfigOptions = getConfigFromEnv('HASH_', { omitPrefix: 'HASH_RAW' }); // Disallow the HASH_RAW option, see https://github.com/directus/directus/discussions/7670#discussioncomment-1255805
5
5
  // associatedData, if specified, must be passed as a Buffer to argon2.hash, see https://github.com/ranisalt/node-argon2/wiki/Options#associateddata
6
6
  if ('associatedData' in argon2HashConfigOptions)
7
7
  argon2HashConfigOptions['associatedData'] = Buffer.from(argon2HashConfigOptions['associatedData']);
@@ -1 +1,6 @@
1
- export declare function getConfigFromEnv(prefix: string, omitPrefix?: string | string[], type?: 'camelcase' | 'underscore'): Record<string, any>;
1
+ export interface GetConfigFromEnvOptions {
2
+ omitPrefix?: string | string[];
3
+ omitKey?: string | string[];
4
+ type?: 'camelcase' | 'underscore';
5
+ }
6
+ export declare function getConfigFromEnv(prefix: string, options?: GetConfigFromEnvOptions): Record<string, any>;
@@ -1,21 +1,26 @@
1
1
  import { useEnv } from '@directus/env';
2
+ import { toArray } from '@directus/utils';
2
3
  import camelcase from 'camelcase';
3
4
  import { set } from 'lodash-es';
4
- export function getConfigFromEnv(prefix, omitPrefix, type = 'camelcase') {
5
+ export function getConfigFromEnv(prefix, options) {
5
6
  const env = useEnv();
7
+ const type = options?.type ?? 'camelcase';
6
8
  const config = {};
9
+ const lowerCasePrefix = prefix.toLowerCase();
10
+ const omitKeys = toArray(options?.omitKey ?? []).map((key) => key.toLowerCase());
11
+ const omitPrefixes = toArray(options?.omitPrefix ?? []).map((prefix) => prefix.toLowerCase());
7
12
  for (const [key, value] of Object.entries(env)) {
8
- if (key.toLowerCase().startsWith(prefix.toLowerCase()) === false)
13
+ const lowerCaseKey = key.toLowerCase();
14
+ if (lowerCaseKey.startsWith(lowerCasePrefix) === false)
9
15
  continue;
10
- if (omitPrefix) {
11
- let matches = false;
12
- if (Array.isArray(omitPrefix)) {
13
- matches = omitPrefix.some((prefix) => key.toLowerCase().startsWith(prefix.toLowerCase()));
14
- }
15
- else {
16
- matches = key.toLowerCase().startsWith(omitPrefix.toLowerCase());
17
- }
18
- if (matches)
16
+ if (omitKeys.length > 0) {
17
+ const isKeyInOmitKeys = omitKeys.some((keyToOmit) => lowerCaseKey === keyToOmit);
18
+ if (isKeyInOmitKeys)
19
+ continue;
20
+ }
21
+ if (omitPrefixes.length > 0) {
22
+ const keyStartsWithAnyPrefix = omitPrefixes.some((prefix) => lowerCaseKey.startsWith(prefix));
23
+ if (keyStartsWithAnyPrefix)
19
24
  continue;
20
25
  }
21
26
  if (key.includes('__')) {
@@ -1,4 +1,4 @@
1
- import { GraphQLBoolean, GraphQLFloat, GraphQLInt, GraphQLList, GraphQLScalarType, GraphQLString } from 'graphql';
1
+ import { GraphQLBoolean, GraphQLFloat, GraphQLID, GraphQLInt, GraphQLList, GraphQLScalarType, GraphQLString, } from 'graphql';
2
2
  import { GraphQLJSON } from 'graphql-compose';
3
3
  import { GraphQLBigInt } from '../services/graphql/types/bigint.js';
4
4
  import { GraphQLDate } from '../services/graphql/types/date.js';
@@ -31,6 +31,8 @@ export function getGraphQLType(localType, special) {
31
31
  return GraphQLDate;
32
32
  case 'hash':
33
33
  return GraphQLHash;
34
+ case 'uuid':
35
+ return GraphQLID;
34
36
  default:
35
37
  return GraphQLString;
36
38
  }
@@ -1,5 +1,6 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { toArray } from '@directus/utils';
3
+ import { useLogger } from '../logger/index.js';
3
4
  import isUrlAllowed from './is-url-allowed.js';
4
5
  /**
5
6
  * Checks if the defined redirect after successful SSO login is in the allow list
@@ -26,6 +27,7 @@ export function isLoginRedirectAllowed(redirect, provider) {
26
27
  return true;
27
28
  }
28
29
  if (URL.canParse(publicUrl) === false) {
30
+ useLogger().error('Invalid PUBLIC_URL for login redirect');
29
31
  return false;
30
32
  }
31
33
  // allow redirects to the defined PUBLIC_URL
@@ -116,7 +116,8 @@ export function getReplacer(replacement, values) {
116
116
  let finalValue = value;
117
117
  for (const [redactKey, valueToRedact] of filteredValues) {
118
118
  if (finalValue.includes(valueToRedact)) {
119
- finalValue = finalValue.replace(new RegExp(valueToRedact, 'g'), replacement(redactKey));
119
+ const regexp = new RegExp(escapeRegexString(valueToRedact), 'g');
120
+ finalValue = finalValue.replace(regexp, replacement(redactKey));
120
121
  }
121
122
  }
122
123
  return finalValue;
@@ -125,3 +126,6 @@ export function getReplacer(replacement, values) {
125
126
  const seen = new WeakSet();
126
127
  return replacer(seen);
127
128
  }
129
+ function escapeRegexString(string) {
130
+ return string.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
131
+ }
@@ -1,2 +1,5 @@
1
- import type { Accountability, Query } from '@directus/types';
2
- export declare function sanitizeQuery(rawQuery: Record<string, any>, accountability?: Accountability | null): Query;
1
+ import type { Accountability, Query, SchemaOverview } from '@directus/types';
2
+ /**
3
+ * Sanitize the query parameters and parse them where necessary.
4
+ */
5
+ export declare function sanitizeQuery(rawQuery: Record<string, any>, schema: SchemaOverview, accountability?: Accountability | null): Promise<Query>;
@@ -2,9 +2,17 @@ import { useEnv } from '@directus/env';
2
2
  import { InvalidQueryError } from '@directus/errors';
3
3
  import { parseFilter, parseJSON } from '@directus/utils';
4
4
  import { flatten, get, isPlainObject, merge, set } from 'lodash-es';
5
+ import getDatabase from '../database/index.js';
5
6
  import { useLogger } from '../logger/index.js';
7
+ import { fetchPolicies } from '../permissions/lib/fetch-policies.js';
8
+ import { contextHasDynamicVariables } from '../permissions/modules/process-ast/utils/context-has-dynamic-variables.js';
9
+ import { extractRequiredDynamicVariableContext } from '../permissions/utils/extract-required-dynamic-variable-context.js';
10
+ import { fetchDynamicVariableData } from '../permissions/utils/fetch-dynamic-variable-data.js';
6
11
  import { Meta } from '../types/index.js';
7
- export function sanitizeQuery(rawQuery, accountability) {
12
+ /**
13
+ * Sanitize the query parameters and parse them where necessary.
14
+ */
15
+ export async function sanitizeQuery(rawQuery, schema, accountability) {
8
16
  const env = useEnv();
9
17
  const query = {};
10
18
  const hasMaxLimit = 'QUERY_LIMIT_MAX' in env &&
@@ -33,7 +41,7 @@ export function sanitizeQuery(rawQuery, accountability) {
33
41
  query.sort = sanitizeSort(rawQuery['sort']);
34
42
  }
35
43
  if (rawQuery['filter']) {
36
- query.filter = sanitizeFilter(rawQuery['filter'], accountability || null);
44
+ query.filter = await sanitizeFilter(rawQuery['filter'], schema, accountability || null);
37
45
  }
38
46
  if (rawQuery['offset'] !== undefined) {
39
47
  query.offset = sanitizeOffset(rawQuery['offset']);
@@ -58,7 +66,7 @@ export function sanitizeQuery(rawQuery, accountability) {
58
66
  if (rawQuery['deep']) {
59
67
  if (!query.deep)
60
68
  query.deep = {};
61
- query.deep = sanitizeDeep(rawQuery['deep'], accountability);
69
+ query.deep = await sanitizeDeep(rawQuery['deep'], schema, accountability);
62
70
  }
63
71
  if (rawQuery['alias']) {
64
72
  query.alias = sanitizeAlias(rawQuery['alias']);
@@ -106,7 +114,7 @@ function sanitizeAggregate(rawAggregate) {
106
114
  }
107
115
  return aggregate;
108
116
  }
109
- function sanitizeFilter(rawFilter, accountability) {
117
+ async function sanitizeFilter(rawFilter, schema, accountability) {
110
118
  let filters = rawFilter;
111
119
  if (typeof filters === 'string') {
112
120
  try {
@@ -117,7 +125,24 @@ function sanitizeFilter(rawFilter, accountability) {
117
125
  }
118
126
  }
119
127
  try {
120
- return parseFilter(filters, accountability);
128
+ let filterContext;
129
+ if (accountability) {
130
+ const dynamicVariableContext = extractRequiredDynamicVariableContext(filters);
131
+ if (contextHasDynamicVariables(dynamicVariableContext)) {
132
+ const context = {
133
+ schema,
134
+ knex: getDatabase(),
135
+ };
136
+ const policies = await fetchPolicies(accountability, context);
137
+ context.accountability = accountability;
138
+ filterContext = await fetchDynamicVariableData({
139
+ dynamicVariableContext,
140
+ accountability,
141
+ policies,
142
+ }, context);
143
+ }
144
+ }
145
+ return parseFilter(filters, accountability, filterContext);
121
146
  }
122
147
  catch {
123
148
  throw new InvalidQueryError({ reason: 'Invalid filter object' });
@@ -146,7 +171,7 @@ function sanitizeMeta(rawMeta) {
146
171
  }
147
172
  return [rawMeta];
148
173
  }
149
- function sanitizeDeep(deep, accountability) {
174
+ async function sanitizeDeep(deep, schema, accountability) {
150
175
  const logger = useLogger();
151
176
  const result = {};
152
177
  if (typeof deep === 'string') {
@@ -157,9 +182,9 @@ function sanitizeDeep(deep, accountability) {
157
182
  logger.warn('Invalid value passed for deep query parameter.');
158
183
  }
159
184
  }
160
- parse(deep);
185
+ await parse(deep);
161
186
  return result;
162
- function parse(level, path = []) {
187
+ async function parse(level, path = []) {
163
188
  const subQuery = {};
164
189
  const parsedLevel = {};
165
190
  for (const [key, value] of Object.entries(level)) {
@@ -175,7 +200,7 @@ function sanitizeDeep(deep, accountability) {
175
200
  }
176
201
  if (Object.keys(subQuery).length > 0) {
177
202
  // Sanitize the entire sub query
178
- const parsedSubQuery = sanitizeQuery(subQuery, accountability);
203
+ const parsedSubQuery = await sanitizeQuery(subQuery, schema, accountability);
179
204
  for (const [parsedKey, parsedValue] of Object.entries(parsedSubQuery)) {
180
205
  parsedLevel[`_${parsedKey}`] = parsedValue;
181
206
  }
@@ -2,11 +2,11 @@ import type { Accountability } from '@directus/types';
2
2
  import type { IncomingMessage, Server as httpServer } from 'http';
3
3
  import type { RateLimiterAbstract } from 'rate-limiter-flexible';
4
4
  import type internal from 'stream';
5
- import WebSocket from 'ws';
5
+ import WebSocket, { type Server } from 'ws';
6
6
  import { WebSocketAuthMessage, WebSocketMessage } from '../messages.js';
7
7
  import type { AuthenticationState, UpgradeContext, WebSocketAuthentication, WebSocketClient } from '../types.js';
8
8
  export default abstract class SocketController {
9
- server: WebSocket.Server;
9
+ server: Server;
10
10
  clients: Set<WebSocketClient>;
11
11
  authentication: WebSocketAuthentication;
12
12
  endpoint: string;
@@ -35,7 +35,7 @@ export class ItemsHandler {
35
35
  const metaService = new MetaService({ schema, accountability });
36
36
  let result, meta;
37
37
  if (message.action === 'create') {
38
- const query = sanitizeQuery(message?.query ?? {}, accountability);
38
+ const query = await sanitizeQuery(message?.query ?? {}, schema, accountability);
39
39
  if (Array.isArray(message.data)) {
40
40
  const keys = await service.createMany(message.data);
41
41
  result = await service.readMany(keys, query);
@@ -46,7 +46,7 @@ export class ItemsHandler {
46
46
  }
47
47
  }
48
48
  if (message.action === 'read') {
49
- const query = sanitizeQuery(message.query ?? {}, accountability);
49
+ const query = await sanitizeQuery(message.query ?? {}, schema, accountability);
50
50
  if (message.id) {
51
51
  result = await service.readOne(message.id, query);
52
52
  }
@@ -62,7 +62,7 @@ export class ItemsHandler {
62
62
  meta = await metaService.getMetaForQuery(message.collection, query);
63
63
  }
64
64
  if (message.action === 'update') {
65
- const query = sanitizeQuery(message.query ?? {}, accountability);
65
+ const query = await sanitizeQuery(message.query ?? {}, schema, accountability);
66
66
  if (message.id) {
67
67
  const key = await service.updateOne(message.id, message.data);
68
68
  result = await service.readOne(key);
@@ -92,7 +92,7 @@ export class ItemsHandler {
92
92
  result = message.ids;
93
93
  }
94
94
  else if (message.query) {
95
- const query = sanitizeQuery(message.query, accountability);
95
+ const query = await sanitizeQuery(message.query, schema, accountability);
96
96
  result = await service.deleteByQuery(query);
97
97
  }
98
98
  else {
@@ -140,8 +140,8 @@ export class SubscribeHandler {
140
140
  if ('event' in message) {
141
141
  subscription.event = message.event;
142
142
  }
143
- if ('query' in message) {
144
- subscription.query = sanitizeQuery(message.query, accountability);
143
+ if (message.query) {
144
+ subscription.query = await sanitizeQuery(message.query, schema, accountability);
145
145
  }
146
146
  if ('item' in message)
147
147
  subscription.item = String(message.item);