@directus/api 19.0.2 → 19.1.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 (75) hide show
  1. package/dist/app.js +8 -7
  2. package/dist/auth/drivers/oauth2.js +3 -2
  3. package/dist/auth/drivers/openid.js +3 -2
  4. package/dist/cli/utils/create-env/env-stub.liquid +0 -3
  5. package/dist/cli/utils/create-env/index.js +0 -2
  6. package/dist/controllers/auth.js +3 -2
  7. package/dist/controllers/extensions.js +30 -19
  8. package/dist/controllers/users.js +25 -0
  9. package/dist/database/helpers/fn/types.js +13 -4
  10. package/dist/database/helpers/index.d.ts +2 -0
  11. package/dist/database/helpers/index.js +2 -0
  12. package/dist/database/helpers/number/dialects/default.d.ts +3 -0
  13. package/dist/database/helpers/number/dialects/default.js +3 -0
  14. package/dist/database/helpers/number/dialects/mssql.d.ts +7 -0
  15. package/dist/database/helpers/number/dialects/mssql.js +11 -0
  16. package/dist/database/helpers/number/dialects/oracle.d.ts +6 -0
  17. package/dist/database/helpers/number/dialects/oracle.js +7 -0
  18. package/dist/database/helpers/number/dialects/postgres.d.ts +5 -0
  19. package/dist/database/helpers/number/dialects/postgres.js +15 -0
  20. package/dist/database/helpers/number/dialects/sqlite.d.ts +6 -0
  21. package/dist/database/helpers/number/dialects/sqlite.js +7 -0
  22. package/dist/database/helpers/number/index.d.ts +7 -0
  23. package/dist/database/helpers/number/index.js +7 -0
  24. package/dist/database/helpers/number/types.d.ts +12 -0
  25. package/dist/database/helpers/number/types.js +9 -0
  26. package/dist/database/helpers/number/utils/decimal-limit.d.ts +4 -0
  27. package/dist/database/helpers/number/utils/decimal-limit.js +10 -0
  28. package/dist/database/helpers/number/utils/maybe-stringify-big-int.d.ts +1 -0
  29. package/dist/database/helpers/number/utils/maybe-stringify-big-int.js +6 -0
  30. package/dist/database/helpers/number/utils/number-in-range.d.ts +3 -0
  31. package/dist/database/helpers/number/utils/number-in-range.js +20 -0
  32. package/dist/database/migrations/20240422A-public-registration.d.ts +3 -0
  33. package/dist/database/migrations/20240422A-public-registration.js +14 -0
  34. package/dist/database/migrations/20240515A-add-session-window.d.ts +3 -0
  35. package/dist/database/migrations/20240515A-add-session-window.js +10 -0
  36. package/dist/database/run-ast.js +5 -4
  37. package/dist/extensions/lib/get-extensions-settings.js +48 -11
  38. package/dist/extensions/lib/installation/manager.js +2 -2
  39. package/dist/middleware/authenticate.d.ts +1 -1
  40. package/dist/middleware/authenticate.js +17 -2
  41. package/dist/middleware/rate-limiter-global.js +1 -1
  42. package/dist/middleware/rate-limiter-registration.d.ts +5 -0
  43. package/dist/middleware/rate-limiter-registration.js +32 -0
  44. package/dist/services/authentication.d.ts +1 -0
  45. package/dist/services/authentication.js +63 -10
  46. package/dist/services/authorization.js +4 -4
  47. package/dist/services/fields.js +2 -2
  48. package/dist/services/graphql/index.js +41 -2
  49. package/dist/services/mail/templates/user-registration.liquid +37 -0
  50. package/dist/services/meta.js +1 -1
  51. package/dist/services/payload.d.ts +2 -0
  52. package/dist/services/payload.js +16 -4
  53. package/dist/services/server.js +3 -1
  54. package/dist/services/shares.js +2 -1
  55. package/dist/services/users.d.ts +3 -1
  56. package/dist/services/users.js +92 -5
  57. package/dist/utils/apply-query.d.ts +1 -1
  58. package/dist/utils/apply-query.js +61 -34
  59. package/dist/utils/get-accountability-for-token.js +6 -3
  60. package/dist/utils/get-secret.d.ts +4 -0
  61. package/dist/utils/get-secret.js +14 -0
  62. package/dist/utils/parse-filter-key.d.ts +7 -0
  63. package/dist/utils/parse-filter-key.js +22 -0
  64. package/dist/utils/parse-numeric-string.d.ts +2 -0
  65. package/dist/utils/parse-numeric-string.js +21 -0
  66. package/dist/utils/sanitize-query.js +10 -5
  67. package/dist/utils/transaction.d.ts +1 -1
  68. package/dist/utils/transaction.js +39 -2
  69. package/dist/utils/validate-query.js +0 -2
  70. package/dist/utils/verify-session-jwt.d.ts +7 -0
  71. package/dist/utils/verify-session-jwt.js +22 -0
  72. package/dist/websocket/messages.d.ts +78 -50
  73. package/package.json +60 -61
  74. package/dist/utils/strip-function.d.ts +0 -4
  75. package/dist/utils/strip-function.js +0 -12
@@ -1,6 +1,6 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { ForbiddenError, InvalidPayloadError, RecordNotUniqueError, UnprocessableContentError } from '@directus/errors';
3
- import { getSimpleHash, toArray } from '@directus/utils';
3
+ import { getSimpleHash, toArray, validatePayload } from '@directus/utils';
4
4
  import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from '@directus/validation';
5
5
  import Joi from 'joi';
6
6
  import jwt from 'jsonwebtoken';
@@ -8,6 +8,7 @@ import { cloneDeep, isEmpty } from 'lodash-es';
8
8
  import { performance } from 'perf_hooks';
9
9
  import getDatabase from '../database/index.js';
10
10
  import { useLogger } from '../logger.js';
11
+ import { getSecret } from '../utils/get-secret.js';
11
12
  import isUrlAllowed from '../utils/is-url-allowed.js';
12
13
  import { verifyJWT } from '../utils/jwt.js';
13
14
  import { stall } from '../utils/stall.js';
@@ -129,7 +130,7 @@ export class UsersService extends ItemsService {
129
130
  */
130
131
  inviteUrl(email, url) {
131
132
  const payload = { email, scope: 'invite' };
132
- const token = jwt.sign(payload, env['SECRET'], { expiresIn: '7d', issuer: 'directus' });
133
+ const token = jwt.sign(payload, getSecret(), { expiresIn: '7d', issuer: 'directus' });
133
134
  const inviteURL = url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite');
134
135
  inviteURL.setQuery('token', token);
135
136
  return inviteURL.toString();
@@ -357,7 +358,7 @@ export class UsersService extends ItemsService {
357
358
  }
358
359
  }
359
360
  async acceptInvite(token, password) {
360
- const { email, scope } = verifyJWT(token, env['SECRET']);
361
+ const { email, scope } = verifyJWT(token, getSecret());
361
362
  if (scope !== 'invite')
362
363
  throw new ForbiddenError();
363
364
  const user = await this.getUserByEmail(email);
@@ -371,6 +372,92 @@ export class UsersService extends ItemsService {
371
372
  });
372
373
  await service.updateOne(user.id, { password, status: 'active' });
373
374
  }
375
+ async registerUser(input) {
376
+ const STALL_TIME = env['REGISTER_STALL_TIME'];
377
+ const timeStart = performance.now();
378
+ const serviceOptions = { accountability: this.accountability, schema: this.schema };
379
+ const settingsService = new SettingsService(serviceOptions);
380
+ const settings = await settingsService.readSingleton({
381
+ fields: [
382
+ 'public_registration',
383
+ 'public_registration_verify_email',
384
+ 'public_registration_role',
385
+ 'public_registration_email_filter',
386
+ ],
387
+ });
388
+ if (settings?.['public_registration'] == false) {
389
+ throw new ForbiddenError();
390
+ }
391
+ const publicRegistrationRole = settings?.['public_registration_role'] ?? null;
392
+ const hasEmailVerification = settings?.['public_registration_verify_email'];
393
+ const emailFilter = settings?.['public_registration_email_filter'];
394
+ const first_name = input.first_name ?? null;
395
+ const last_name = input.last_name ?? null;
396
+ const partialUser = {
397
+ // Required fields
398
+ email: input.email,
399
+ password: input.password,
400
+ role: publicRegistrationRole,
401
+ status: hasEmailVerification ? 'unverified' : 'active',
402
+ // Optional fields
403
+ first_name,
404
+ last_name,
405
+ };
406
+ if (emailFilter && validatePayload(emailFilter, { email: input.email }).length !== 0) {
407
+ await stall(STALL_TIME, timeStart);
408
+ throw new ForbiddenError();
409
+ }
410
+ const user = await this.getUserByEmail(input.email);
411
+ if (isEmpty(user)) {
412
+ await this.createOne(partialUser);
413
+ }
414
+ // We want to be able to re-send the verification email
415
+ else if (user.status !== ('unverified')) {
416
+ // To avoid giving attackers infos about registered emails we dont fail for violated unique constraints
417
+ await stall(STALL_TIME, timeStart);
418
+ return;
419
+ }
420
+ if (hasEmailVerification) {
421
+ const mailService = new MailService(serviceOptions);
422
+ const payload = { email: input.email, scope: 'pending-registration' };
423
+ const token = jwt.sign(payload, env['SECRET'], {
424
+ expiresIn: env['EMAIL_VERIFICATION_TOKEN_TTL'],
425
+ issuer: 'directus',
426
+ });
427
+ const verificationURL = new Url(env['PUBLIC_URL'])
428
+ .addPath('users', 'register', 'verify-email')
429
+ .setQuery('token', token);
430
+ mailService
431
+ .send({
432
+ to: input.email,
433
+ subject: 'Verify your email address', // TODO: translate after theres support for internationalized emails
434
+ template: {
435
+ name: 'user-registration',
436
+ data: {
437
+ url: verificationURL.toString(),
438
+ email: input.email,
439
+ first_name,
440
+ last_name,
441
+ },
442
+ },
443
+ })
444
+ .catch((error) => {
445
+ logger.error(error, 'Could not send email verification mail');
446
+ });
447
+ }
448
+ await stall(STALL_TIME, timeStart);
449
+ }
450
+ async verifyRegistration(token) {
451
+ const { email, scope } = verifyJWT(token, env['SECRET']);
452
+ if (scope !== 'pending-registration')
453
+ throw new ForbiddenError();
454
+ const user = await this.getUserByEmail(email);
455
+ if (user?.status !== ('unverified')) {
456
+ throw new InvalidPayloadError({ reason: 'Invalid verification code' });
457
+ }
458
+ await this.updateOne(user.id, { status: 'active' });
459
+ return user.id;
460
+ }
374
461
  async requestPasswordReset(email, url, subject) {
375
462
  const STALL_TIME = 500;
376
463
  const timeStart = performance.now();
@@ -388,7 +475,7 @@ export class UsersService extends ItemsService {
388
475
  accountability: this.accountability,
389
476
  });
390
477
  const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
391
- const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
478
+ const token = jwt.sign(payload, getSecret(), { expiresIn: '1d', issuer: 'directus' });
392
479
  const acceptURL = url
393
480
  ? new Url(url).setQuery('token', token).toString()
394
481
  : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
@@ -411,7 +498,7 @@ export class UsersService extends ItemsService {
411
498
  await stall(STALL_TIME, timeStart);
412
499
  }
413
500
  async resetPassword(token, password) {
414
- const { email, scope, hash } = jwt.verify(token, env['SECRET'], { issuer: 'directus' });
501
+ const { email, scope, hash } = jwt.verify(token, getSecret(), { issuer: 'directus' });
415
502
  if (scope !== 'password-reset' || !hash)
416
503
  throw new ForbiddenError();
417
504
  const opts = {};
@@ -37,5 +37,5 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
37
37
  hasJoins: boolean;
38
38
  hasMultiRelationalFilter: boolean;
39
39
  };
40
- export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
40
+ export declare function applySearch(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
41
41
  export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string, hasJoins: boolean): void;
@@ -1,5 +1,6 @@
1
+ import { NUMERIC_TYPES } from '@directus/constants';
1
2
  import { InvalidQueryError } from '@directus/errors';
2
- import { getFilterOperatorsForType, getOutputTypeForFunction } from '@directus/utils';
3
+ import { getFilterOperatorsForType, getFunctionsForType, getOutputTypeForFunction, isIn } from '@directus/utils';
3
4
  import { clone, isPlainObject } from 'lodash-es';
4
5
  import { customAlphabet } from 'nanoid/non-secure';
5
6
  import { getHelpers } from '../database/helpers/index.js';
@@ -7,7 +8,8 @@ import { getColumnPath } from './get-column-path.js';
7
8
  import { getColumn } from './get-column.js';
8
9
  import { getRelationInfo } from './get-relation-info.js';
9
10
  import { isValidUuid } from './is-valid-uuid.js';
10
- import { stripFunction } from './strip-function.js';
11
+ import { parseFilterKey } from './parse-filter-key.js';
12
+ import { parseNumericString } from './parse-numeric-string.js';
11
13
  export const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
12
14
  /**
13
15
  * Apply the Query to a given Knex query builder instance
@@ -30,7 +32,7 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, opt
30
32
  }
31
33
  }
32
34
  if (query.search) {
33
- applySearch(schema, dbQuery, query.search, collection);
35
+ applySearch(knex, schema, dbQuery, query.search, collection);
34
36
  }
35
37
  if (query.group) {
36
38
  dbQuery.groupBy(query.group.map((column) => getColumn(knex, collection, column, false, schema)));
@@ -84,7 +86,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
84
86
  }
85
87
  rootQuery.leftJoin({ [alias]: pathScope }, (joinClause) => {
86
88
  joinClause
87
- .onVal(relation.meta.one_collection_field, '=', pathScope)
89
+ .onVal(`${aliasedParentCollection}.${relation.meta.one_collection_field}`, '=', pathScope)
88
90
  .andOn(`${aliasedParentCollection}.${relation.field}`, '=', knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), `${alias}.${schema.collections[pathScope].primary}`));
89
91
  });
90
92
  aliasMap[aliasKey].collection = pathScope;
@@ -93,7 +95,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
93
95
  else if (relationType === 'o2a') {
94
96
  rootQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => {
95
97
  joinClause
96
- .onVal(relation.meta.one_collection_field, '=', parentCollection)
98
+ .onVal(`${alias}.${relation.meta.one_collection_field}`, '=', parentCollection)
97
99
  .andOn(`${alias}.${relation.field}`, '=', knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), `${aliasedParentCollection}.${schema.collections[parentCollection].primary}`));
98
100
  });
99
101
  aliasMap[aliasKey].collection = relation.collection;
@@ -288,7 +290,10 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
288
290
  */
289
291
  const pathRoot = filterPath[0].split(':')[0];
290
292
  const { relation, relationType } = getRelationInfo(relations, collection, pathRoot);
291
- const { operator: filterOperator, value: filterValue } = getOperation(key, value);
293
+ const operation = getOperation(key, value);
294
+ if (!operation)
295
+ continue;
296
+ const { operator: filterOperator, value: filterValue } = operation;
292
297
  if (filterPath.length > 1 ||
293
298
  (!(key.includes('(') && key.includes(')')) && schema.collections[collection]?.fields[key]?.type === 'alias')) {
294
299
  if (!relation)
@@ -335,21 +340,33 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
335
340
  }
336
341
  if (!columnPath)
337
342
  continue;
338
- const { type, special } = validateFilterField(schema.collections[targetCollection].fields, stripFunction(filterPath[filterPath.length - 1]), targetCollection);
343
+ const { type, special } = getFilterType(schema.collections[targetCollection].fields, filterPath.at(-1), targetCollection);
339
344
  validateFilterOperator(type, filterOperator, special);
340
345
  applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection);
341
346
  }
342
347
  else {
343
- const { type, special } = validateFilterField(schema.collections[collection].fields, stripFunction(filterPath[0]), collection);
348
+ const { type, special } = getFilterType(schema.collections[collection].fields, filterPath[0], collection);
344
349
  validateFilterOperator(type, filterOperator, special);
345
- applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical);
350
+ const aliasedCollection = aliasMap['']?.alias || collection;
351
+ applyFilterToQuery(`${aliasedCollection}.${filterPath[0]}`, filterOperator, filterValue, logical, collection);
346
352
  }
347
353
  }
348
- function validateFilterField(fields, key, collection = 'unknown') {
349
- if (fields[key] === undefined) {
354
+ function getFilterType(fields, key, collection = 'unknown') {
355
+ const { fieldName, functionName } = parseFilterKey(key);
356
+ const field = fields[fieldName];
357
+ if (!field) {
350
358
  throw new InvalidQueryError({ reason: `Invalid filter key "${key}" on "${collection}"` });
351
359
  }
352
- return fields[key];
360
+ const { type } = field;
361
+ if (functionName) {
362
+ const availableFunctions = getFunctionsForType(type);
363
+ if (!availableFunctions.includes(functionName)) {
364
+ throw new InvalidQueryError({ reason: `Invalid filter key "${key}" on "${collection}"` });
365
+ }
366
+ const functionType = getOutputTypeForFunction(functionName);
367
+ return { type: functionType };
368
+ }
369
+ return { type, special: field.special };
353
370
  }
354
371
  function validateFilterOperator(type, filterOperator, special) {
355
372
  if (filterOperator.startsWith('_')) {
@@ -360,7 +377,7 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
360
377
  reason: `"${type}" field type does not contain the "_${filterOperator}" filter operator`,
361
378
  });
362
379
  }
363
- if (special.includes('conceal') &&
380
+ if (special?.includes('conceal') &&
364
381
  !getFilterOperatorsForType('hash').includes(filterOperator)) {
365
382
  throw new InvalidQueryError({
366
383
  reason: `Field with "conceal" special does not allow the "_${filterOperator}" filter operator`,
@@ -406,7 +423,7 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
406
423
  const functionName = column.split('(')[0];
407
424
  const type = getOutputTypeForFunction(functionName);
408
425
  if (['integer', 'float', 'decimal'].includes(type)) {
409
- compareValue = Number(compareValue);
426
+ compareValue = Array.isArray(compareValue) ? compareValue.map(Number) : Number(compareValue);
410
427
  }
411
428
  }
412
429
  // Cast filter value (compareValue) based on type of field being filtered against
@@ -504,19 +521,19 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
504
521
  dbQuery[logical].whereNotIn(selectionRaw, value);
505
522
  }
506
523
  if (operator === '_between') {
507
- if (compareValue.length !== 2)
508
- return;
509
524
  let value = compareValue;
510
525
  if (typeof value === 'string')
511
526
  value = value.split(',');
527
+ if (value.length !== 2)
528
+ return;
512
529
  dbQuery[logical].whereBetween(selectionRaw, value);
513
530
  }
514
531
  if (operator === '_nbetween') {
515
- if (compareValue.length !== 2)
516
- return;
517
532
  let value = compareValue;
518
533
  if (typeof value === 'string')
519
534
  value = value.split(',');
535
+ if (value.length !== 2)
536
+ return;
520
537
  dbQuery[logical].whereNotBetween(selectionRaw, value);
521
538
  }
522
539
  if (operator == '_intersects') {
@@ -534,33 +551,36 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
534
551
  }
535
552
  }
536
553
  }
537
- export async function applySearch(schema, dbQuery, searchQuery, collection) {
554
+ export async function applySearch(knex, schema, dbQuery, searchQuery, collection) {
555
+ const { number: numberHelper } = getHelpers(knex);
538
556
  const fields = Object.entries(schema.collections[collection].fields);
539
557
  dbQuery.andWhere(function () {
558
+ let needsFallbackCondition = true;
540
559
  fields.forEach(([name, field]) => {
541
560
  if (['text', 'string'].includes(field.type)) {
542
561
  this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
562
+ needsFallbackCondition = false;
543
563
  }
544
- else if (['bigInteger', 'integer', 'decimal', 'float'].includes(field.type)) {
545
- const number = Number(searchQuery);
546
- // only cast finite base10 numeric values
547
- if (validateNumber(searchQuery, number)) {
548
- this.orWhere({ [`${collection}.${name}`]: number });
564
+ else if (isNumericField(field)) {
565
+ const number = parseNumericString(searchQuery);
566
+ if (number === null) {
567
+ return; // unable to parse
568
+ }
569
+ if (numberHelper.isNumberValid(number, field)) {
570
+ numberHelper.addSearchCondition(this, collection, name, number);
571
+ needsFallbackCondition = false;
549
572
  }
550
573
  }
551
574
  else if (field.type === 'uuid' && isValidUuid(searchQuery)) {
552
575
  this.orWhere({ [`${collection}.${name}`]: searchQuery });
576
+ needsFallbackCondition = false;
553
577
  }
554
578
  });
579
+ if (needsFallbackCondition) {
580
+ this.orWhereRaw('1 = 0');
581
+ }
555
582
  });
556
583
  }
557
- function validateNumber(value, parsed) {
558
- if (isNaN(parsed) || !Number.isFinite(parsed))
559
- return false;
560
- // casting parsed value back to string should be equal the original value
561
- // (prevent unintended number parsing, e.g. String(7) !== "ob111")
562
- return String(parsed) === value;
563
- }
564
584
  export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins) {
565
585
  for (const [operation, fields] of Object.entries(aggregate)) {
566
586
  if (!fields)
@@ -610,7 +630,7 @@ export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins)
610
630
  function getFilterPath(key, value) {
611
631
  const path = [key];
612
632
  const childKey = Object.keys(value)[0];
613
- if (typeof childKey === 'string' && childKey.startsWith('_') === true && !['_none', '_some'].includes(childKey)) {
633
+ if (!childKey || (childKey.startsWith('_') === true && !['_none', '_some'].includes(childKey))) {
614
634
  return path;
615
635
  }
616
636
  if (isPlainObject(value)) {
@@ -622,8 +642,15 @@ function getOperation(key, value) {
622
642
  if (key.startsWith('_') && !['_and', '_or', '_none', '_some'].includes(key)) {
623
643
  return { operator: key, value };
624
644
  }
625
- else if (isPlainObject(value) === false) {
645
+ else if (!isPlainObject(value)) {
626
646
  return { operator: '_eq', value };
627
647
  }
628
- return getOperation(Object.keys(value)[0], Object.values(value)[0]);
648
+ const childKey = Object.keys(value)[0];
649
+ if (childKey) {
650
+ return getOperation(childKey, Object.values(value)[0]);
651
+ }
652
+ return null;
653
+ }
654
+ function isNumericField(field) {
655
+ return isIn(field.type, NUMERIC_TYPES);
629
656
  }
@@ -1,10 +1,10 @@
1
- import { useEnv } from '@directus/env';
2
1
  import { InvalidCredentialsError } from '@directus/errors';
3
2
  import getDatabase from '../database/index.js';
3
+ import { getSecret } from './get-secret.js';
4
4
  import isDirectusJWT from './is-directus-jwt.js';
5
+ import { verifySessionJWT } from './verify-session-jwt.js';
5
6
  import { verifyAccessJWT } from './jwt.js';
6
7
  export async function getAccountabilityForToken(token, accountability) {
7
- const env = useEnv();
8
8
  if (!accountability) {
9
9
  accountability = {
10
10
  user: null,
@@ -15,7 +15,10 @@ export async function getAccountabilityForToken(token, accountability) {
15
15
  }
16
16
  if (token) {
17
17
  if (isDirectusJWT(token)) {
18
- const payload = verifyAccessJWT(token, env['SECRET']);
18
+ const payload = verifyAccessJWT(token, getSecret());
19
+ if ('session' in payload) {
20
+ await verifySessionJWT(payload);
21
+ }
19
22
  accountability.role = payload.role;
20
23
  accountability.admin = payload.admin_access === true || payload.admin_access == 1;
21
24
  accountability.app = payload.app_access === true || payload.app_access == 1;
@@ -0,0 +1,4 @@
1
+ export declare const _cache: {
2
+ secret: string | null;
3
+ };
4
+ export declare const getSecret: () => string;
@@ -0,0 +1,14 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { nanoid } from 'nanoid';
3
+ export const _cache = { secret: null };
4
+ export const getSecret = () => {
5
+ if (_cache.secret) {
6
+ return _cache.secret;
7
+ }
8
+ const env = useEnv();
9
+ if (env['SECRET']) {
10
+ return env['SECRET'];
11
+ }
12
+ _cache.secret = nanoid(32);
13
+ return _cache.secret;
14
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Parses a filter key, returning its field name and function name (if defined) separately.
3
+ */
4
+ export declare function parseFilterKey(key: string): {
5
+ fieldName: string;
6
+ functionName: string | undefined;
7
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Result for keys with a function (e.g. `year(date_created)`):
3
+ * - Group 1: Function (`year`)
4
+ * - Group 3: Field (`date_created`)
5
+ *
6
+ * If group 3 is undefined, it is a key without a function.
7
+ */
8
+ const FILTER_KEY_REGEX = /^([^()]+)(\(([^)]+)\))?/;
9
+ /**
10
+ * Parses a filter key, returning its field name and function name (if defined) separately.
11
+ */
12
+ export function parseFilterKey(key) {
13
+ const match = key.match(FILTER_KEY_REGEX);
14
+ const fieldNameWithFunction = match?.[3]?.trim();
15
+ const fieldName = fieldNameWithFunction || key.trim();
16
+ let functionName;
17
+ if (fieldNameWithFunction) {
18
+ functionName = match?.[1]?.trim();
19
+ return { fieldName, functionName };
20
+ }
21
+ return { fieldName, functionName };
22
+ }
@@ -0,0 +1,2 @@
1
+ import type { NumericValue } from '@directus/types';
2
+ export declare function parseNumericString(stringValue: string): NumericValue | null;
@@ -0,0 +1,21 @@
1
+ export function parseNumericString(stringValue) {
2
+ let number = Number(stringValue);
3
+ if (isNaN(number) || !Number.isFinite(number)) {
4
+ return null; // invalid numbers
5
+ }
6
+ if (number > Number.MAX_SAFE_INTEGER || number < Number.MIN_SAFE_INTEGER) {
7
+ try {
8
+ number = BigInt(stringValue);
9
+ }
10
+ catch {
11
+ // BigInt parsing failed, e.g. it was a float larger than MAX_SAFE_INTEGER
12
+ return null;
13
+ }
14
+ }
15
+ // casting parsed value back to string should be equal the original value
16
+ // (prevent unintended number parsing, e.g. String(7) !== "ob111")
17
+ if (String(number) !== stringValue) {
18
+ return null;
19
+ }
20
+ return number;
21
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEnv } from '@directus/env';
2
+ import { InvalidQueryError } from '@directus/errors';
2
3
  import { parseFilter, parseJSON } from '@directus/utils';
3
4
  import { flatten, get, isPlainObject, merge, set } from 'lodash-es';
4
5
  import { useLogger } from '../logger.js';
@@ -106,17 +107,21 @@ function sanitizeAggregate(rawAggregate) {
106
107
  return aggregate;
107
108
  }
108
109
  function sanitizeFilter(rawFilter, accountability) {
109
- const logger = useLogger();
110
110
  let filters = rawFilter;
111
- if (typeof rawFilter === 'string') {
111
+ if (typeof filters === 'string') {
112
112
  try {
113
- filters = parseJSON(rawFilter);
113
+ filters = parseJSON(filters);
114
114
  }
115
115
  catch {
116
- logger.warn('Invalid value passed for filter query parameter.');
116
+ throw new InvalidQueryError({ reason: 'Invalid JSON for filter object' });
117
117
  }
118
118
  }
119
- return parseFilter(filters, accountability);
119
+ try {
120
+ return parseFilter(filters, accountability);
121
+ }
122
+ catch {
123
+ throw new InvalidQueryError({ reason: 'Invalid filter object' });
124
+ }
120
125
  }
121
126
  function sanitizeLimit(rawLimit) {
122
127
  if (rawLimit === undefined || rawLimit === null)
@@ -1,4 +1,4 @@
1
- import type { Knex } from 'knex';
1
+ import { type Knex } from 'knex';
2
2
  /**
3
3
  * Execute the given handler within the current transaction or a newly created one
4
4
  * if the current knex state isn't a transaction yet.
@@ -1,3 +1,6 @@
1
+ import {} from 'knex';
2
+ import { getDatabaseClient } from '../database/index.js';
3
+ import { useLogger } from '../logger.js';
1
4
  /**
2
5
  * Execute the given handler within the current transaction or a newly created one
3
6
  * if the current knex state isn't a transaction yet.
@@ -5,11 +8,45 @@
5
8
  * Can be used to ensure the handler is run within a transaction,
6
9
  * while preventing nested transactions.
7
10
  */
8
- export const transaction = (knex, handler) => {
11
+ export const transaction = async (knex, handler) => {
9
12
  if (knex.isTransaction) {
10
13
  return handler(knex);
11
14
  }
12
15
  else {
13
- return knex.transaction((trx) => handler(trx));
16
+ try {
17
+ return await knex.transaction((trx) => handler(trx));
18
+ }
19
+ catch (error) {
20
+ const client = getDatabaseClient(knex);
21
+ /**
22
+ * This error code indicates that the transaction failed due to another
23
+ * concurrent or recent transaction attempting to write to the same data.
24
+ * This can usually be solved by restarting the transaction on client-side
25
+ * after a short delay, so that it is executed against the latest state.
26
+ *
27
+ * @link https://www.cockroachlabs.com/docs/stable/transaction-retry-error-reference
28
+ */
29
+ const COCKROACH_RETRY_ERROR_CODE = '40001';
30
+ if (client !== 'cockroachdb' || error?.code !== COCKROACH_RETRY_ERROR_CODE)
31
+ throw error;
32
+ const MAX_ATTEMPTS = 3;
33
+ const BASE_DELAY = 100;
34
+ const logger = useLogger();
35
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) {
36
+ const delay = 2 ** attempt * BASE_DELAY;
37
+ await new Promise((resolve) => setTimeout(resolve, delay));
38
+ logger.trace(`Restarting failed transaction (attempt ${attempt + 1}/${MAX_ATTEMPTS})`);
39
+ try {
40
+ return await knex.transaction((trx) => handler(trx));
41
+ }
42
+ catch (error) {
43
+ if (error?.code !== COCKROACH_RETRY_ERROR_CODE)
44
+ throw error;
45
+ }
46
+ }
47
+ /** Initial execution + additional attempts */
48
+ const attempts = 1 + MAX_ATTEMPTS;
49
+ throw new Error(`Transaction failed after ${attempts} attempts`, { cause: error });
50
+ }
14
51
  }
15
52
  };
@@ -42,8 +42,6 @@ export function validateQuery(query) {
42
42
  return query;
43
43
  }
44
44
  function validateFilter(filter) {
45
- if (!filter)
46
- throw new InvalidQueryError({ reason: 'Invalid filter object' });
47
45
  for (const [key, nested] of Object.entries(filter)) {
48
46
  if (key === '_and' || key === '_or') {
49
47
  nested.forEach(validateFilter);
@@ -0,0 +1,7 @@
1
+ import type { DirectusTokenPayload } from '../types/index.js';
2
+ /**
3
+ * Verifies the associated session is still available and valid.
4
+ *
5
+ * @throws If session not found.
6
+ */
7
+ export declare function verifySessionJWT(payload: DirectusTokenPayload): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import getDatabase from '../database/index.js';
2
+ import { InvalidCredentialsError } from '@directus/errors';
3
+ /**
4
+ * Verifies the associated session is still available and valid.
5
+ *
6
+ * @throws If session not found.
7
+ */
8
+ export async function verifySessionJWT(payload) {
9
+ const database = getDatabase();
10
+ const session = await database
11
+ .select(1)
12
+ .from('directus_sessions')
13
+ .where({
14
+ token: payload['session'],
15
+ user: payload['id'],
16
+ })
17
+ .andWhere('expires', '>=', new Date())
18
+ .first();
19
+ if (!session) {
20
+ throw new InvalidCredentialsError();
21
+ }
22
+ }