@directus/api 17.0.1 → 18.0.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 (100) hide show
  1. package/dist/app.js +8 -2
  2. package/dist/auth/drivers/ldap.js +14 -16
  3. package/dist/auth/drivers/local.js +16 -10
  4. package/dist/auth/drivers/oauth2.js +16 -11
  5. package/dist/auth/drivers/openid.js +16 -11
  6. package/dist/auth/drivers/saml.js +27 -12
  7. package/dist/cli/commands/init/index.js +3 -3
  8. package/dist/cli/commands/security/key.js +2 -2
  9. package/dist/cli/utils/create-env/env-stub.liquid +19 -4
  10. package/dist/cli/utils/create-env/index.js +2 -2
  11. package/dist/constants.d.ts +2 -1
  12. package/dist/constants.js +11 -4
  13. package/dist/controllers/auth.js +54 -19
  14. package/dist/controllers/extensions.js +102 -5
  15. package/dist/controllers/fields.js +0 -3
  16. package/dist/controllers/items.js +3 -2
  17. package/dist/controllers/permissions.js +1 -1
  18. package/dist/controllers/shares.js +19 -4
  19. package/dist/database/migrations/20220429A-add-flows.js +3 -3
  20. package/dist/database/migrations/20230526A-migrate-translation-strings.js +2 -2
  21. package/dist/database/migrations/20240204A-marketplace.d.ts +3 -0
  22. package/dist/database/migrations/20240204A-marketplace.js +68 -0
  23. package/dist/database/migrations/run.js +3 -2
  24. package/dist/extensions/lib/get-extensions-settings.d.ts +6 -2
  25. package/dist/extensions/lib/get-extensions-settings.js +70 -22
  26. package/dist/extensions/lib/get-extensions.d.ts +5 -1
  27. package/dist/extensions/lib/get-extensions.js +7 -31
  28. package/dist/extensions/lib/installation/index.d.ts +2 -0
  29. package/dist/extensions/lib/installation/index.js +9 -0
  30. package/dist/extensions/lib/installation/manager.d.ts +5 -0
  31. package/dist/extensions/lib/installation/manager.js +90 -0
  32. package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +1 -1
  33. package/dist/extensions/lib/sync-extensions.js +11 -10
  34. package/dist/extensions/manager.d.ts +27 -25
  35. package/dist/extensions/manager.js +214 -183
  36. package/dist/middleware/authenticate.d.ts +1 -0
  37. package/dist/middleware/error-handler.js +22 -18
  38. package/dist/middleware/extract-token.d.ts +6 -5
  39. package/dist/middleware/extract-token.js +27 -11
  40. package/dist/middleware/merge-content-versions.d.ts +2 -0
  41. package/dist/middleware/merge-content-versions.js +26 -0
  42. package/dist/middleware/respond.js +0 -12
  43. package/dist/middleware/validate-batch.d.ts +1 -0
  44. package/dist/operations/item-update/index.js +4 -1
  45. package/dist/request/agent-with-ip-validation.d.ts +1 -1
  46. package/dist/request/agent-with-ip-validation.js +5 -1
  47. package/dist/services/activity.js +3 -3
  48. package/dist/services/assets.js +2 -3
  49. package/dist/services/authentication.d.ts +7 -2
  50. package/dist/services/authentication.js +21 -13
  51. package/dist/services/extensions.d.ts +4 -8
  52. package/dist/services/extensions.js +110 -93
  53. package/dist/services/fields.js +34 -22
  54. package/dist/services/graphql/index.js +98 -42
  55. package/dist/services/import-export.js +61 -26
  56. package/dist/services/index.d.ts +1 -1
  57. package/dist/services/index.js +1 -1
  58. package/dist/services/mail/index.d.ts +1 -1
  59. package/dist/services/mail/index.js +4 -2
  60. package/dist/services/payload.js +2 -2
  61. package/dist/services/{permissions.d.ts → permissions/index.d.ts} +3 -4
  62. package/dist/services/{permissions.js → permissions/index.js} +6 -23
  63. package/dist/services/permissions/lib/with-app-minimal-permissions.d.ts +2 -0
  64. package/dist/services/permissions/lib/with-app-minimal-permissions.js +13 -0
  65. package/dist/services/relations.d.ts +2 -3
  66. package/dist/services/relations.js +2 -2
  67. package/dist/services/roles.d.ts +9 -4
  68. package/dist/services/roles.js +51 -3
  69. package/dist/services/server.js +3 -0
  70. package/dist/services/shares.d.ts +3 -1
  71. package/dist/services/shares.js +9 -5
  72. package/dist/storage/index.js +5 -4
  73. package/dist/types/auth.d.ts +6 -4
  74. package/dist/types/graphql.d.ts +1 -0
  75. package/dist/utils/apply-query.js +3 -3
  76. package/dist/utils/filter-items.d.ts +2 -2
  77. package/dist/utils/filter-items.js +1 -3
  78. package/dist/utils/get-cache-headers.d.ts +1 -0
  79. package/dist/utils/get-cache-key.d.ts +1 -0
  80. package/dist/utils/get-graphql-query-and-variables.d.ts +1 -0
  81. package/dist/utils/get-ip-from-req.d.ts +1 -0
  82. package/dist/utils/get-milliseconds.d.ts +1 -1
  83. package/dist/utils/get-milliseconds.js +4 -1
  84. package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
  85. package/dist/utils/is-login-redirect-allowed.js +34 -0
  86. package/dist/utils/is-url-allowed.d.ts +1 -1
  87. package/dist/utils/is-url-allowed.js +5 -5
  88. package/dist/utils/is-valid-uuid.d.ts +3 -0
  89. package/dist/utils/is-valid-uuid.js +21 -0
  90. package/dist/utils/jwt.d.ts +1 -1
  91. package/dist/utils/jwt.js +3 -3
  92. package/dist/utils/merge-version-data.d.ts +3 -0
  93. package/dist/utils/merge-version-data.js +134 -0
  94. package/dist/utils/sanitize-query.js +2 -0
  95. package/dist/utils/should-skip-cache.d.ts +1 -0
  96. package/dist/utils/validate-keys.js +2 -2
  97. package/dist/utils/validate-query.js +1 -0
  98. package/dist/websocket/controllers/base.js +2 -2
  99. package/dist/websocket/controllers/hooks.js +1 -1
  100. package/package.json +50 -51
@@ -1,13 +1,18 @@
1
1
  import type { Query } from '@directus/types';
2
- import type { AbstractServiceOptions, MutationOptions, PrimaryKey } from '../types/index.js';
2
+ import type { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types/index.js';
3
3
  import { ItemsService } from './items.js';
4
4
  export declare class RolesService extends ItemsService {
5
5
  constructor(options: AbstractServiceOptions);
6
6
  private checkForOtherAdminRoles;
7
7
  private checkForOtherAdminUsers;
8
- updateOne(key: PrimaryKey, data: Record<string, any>, opts?: MutationOptions): Promise<PrimaryKey>;
9
- updateBatch(data: Record<string, any>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
10
- updateMany(keys: PrimaryKey[], data: Record<string, any>, opts?: MutationOptions): Promise<PrimaryKey[]>;
8
+ private isIpAccessValid;
9
+ private assertValidIpAccess;
10
+ createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
11
+ createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
12
+ updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
13
+ updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
14
+ updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
15
+ updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions | undefined): Promise<PrimaryKey[]>;
11
16
  deleteOne(key: PrimaryKey): Promise<PrimaryKey>;
12
17
  deleteMany(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
13
18
  deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]>;
@@ -1,6 +1,7 @@
1
- import { ForbiddenError, UnprocessableContentError } from '@directus/errors';
1
+ import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
2
+ import { getMatch } from 'ip-matching';
2
3
  import { ItemsService } from './items.js';
3
- import { PermissionsService } from './permissions.js';
4
+ import { PermissionsService } from './permissions/index.js';
4
5
  import { PresetsService } from './presets.js';
5
6
  import { UsersService } from './users.js';
6
7
  export class RolesService extends ItemsService {
@@ -74,7 +75,7 @@ export class RolesService extends ItemsService {
74
75
  .count('*', { as: 'count' })
75
76
  .from('directus_users')
76
77
  .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
77
- .whereNotIn('directus_users.id', usersAdded)
78
+ .whereNotIn('directus_users.id', usersAdded.map((user) => user.id))
78
79
  .andWhere({ 'directus_roles.admin_access': true, status: 'active' })
79
80
  .first();
80
81
  const otherAdminUsersCount = Number(otherAdminUsers?.count ?? 0);
@@ -121,7 +122,46 @@ export class RolesService extends ItemsService {
121
122
  }
122
123
  return;
123
124
  }
125
+ isIpAccessValid(value) {
126
+ if (value === undefined)
127
+ return false;
128
+ if (value === null)
129
+ return true;
130
+ if (Array.isArray(value) && value.length === 0)
131
+ return true;
132
+ for (const ip of value) {
133
+ if (typeof ip !== 'string' || ip.includes('*'))
134
+ return false;
135
+ try {
136
+ const match = getMatch(ip);
137
+ if (match.type == 'IPMask')
138
+ return false;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ return true;
145
+ }
146
+ assertValidIpAccess(partialItem) {
147
+ if ('ip_access' in partialItem && !this.isIpAccessValid(partialItem['ip_access'])) {
148
+ throw new InvalidPayloadError({
149
+ reason: 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks',
150
+ });
151
+ }
152
+ }
153
+ async createOne(data, opts) {
154
+ this.assertValidIpAccess(data);
155
+ return super.createOne(data, opts);
156
+ }
157
+ async createMany(data, opts) {
158
+ for (const partialItem of data) {
159
+ this.assertValidIpAccess(partialItem);
160
+ }
161
+ return super.createMany(data, opts);
162
+ }
124
163
  async updateOne(key, data, opts) {
164
+ this.assertValidIpAccess(data);
125
165
  try {
126
166
  if ('users' in data) {
127
167
  await this.checkForOtherAdminUsers(key, data['users']);
@@ -133,6 +173,9 @@ export class RolesService extends ItemsService {
133
173
  return super.updateOne(key, data, opts);
134
174
  }
135
175
  async updateBatch(data, opts) {
176
+ for (const partialItem of data) {
177
+ this.assertValidIpAccess(partialItem);
178
+ }
136
179
  const primaryKeyField = this.schema.collections[this.collection].primary;
137
180
  const keys = data.map((item) => item[primaryKeyField]);
138
181
  const setsToNoAdmin = data.some((item) => item['admin_access'] === false);
@@ -147,6 +190,7 @@ export class RolesService extends ItemsService {
147
190
  return super.updateBatch(data, opts);
148
191
  }
149
192
  async updateMany(keys, data, opts) {
193
+ this.assertValidIpAccess(data);
150
194
  try {
151
195
  if ('admin_access' in data && data['admin_access'] === false) {
152
196
  await this.checkForOtherAdminRoles(keys);
@@ -157,6 +201,10 @@ export class RolesService extends ItemsService {
157
201
  }
158
202
  return super.updateMany(keys, data, opts);
159
203
  }
204
+ async updateByQuery(query, data, opts) {
205
+ this.assertValidIpAccess(data);
206
+ return super.updateByQuery(query, data, opts);
207
+ }
160
208
  async deleteOne(key) {
161
209
  await this.deleteMany([key]);
162
210
  return key;
@@ -68,6 +68,9 @@ export class ServerService {
68
68
  else {
69
69
  info['rateLimitGlobal'] = false;
70
70
  }
71
+ info['extensions'] = {
72
+ limit: env['EXTENSIONS_LIMIT'] ?? null,
73
+ };
71
74
  info['queryLimit'] = {
72
75
  default: env['QUERY_LIMIT_DEFAULT'],
73
76
  max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
@@ -5,7 +5,9 @@ export declare class SharesService extends ItemsService {
5
5
  authorizationService: AuthorizationService;
6
6
  constructor(options: AbstractServiceOptions);
7
7
  createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
8
- login(payload: Record<string, any>): Promise<LoginResult>;
8
+ login(payload: Record<string, any>, options?: Partial<{
9
+ session: boolean;
10
+ }>): Promise<Omit<LoginResult, 'id'>>;
9
11
  /**
10
12
  * Send a link to the given share ID to the given email(s). Note: you can only send a link to a share
11
13
  * if you have read access to that particular share
@@ -25,7 +25,7 @@ export class SharesService extends ItemsService {
25
25
  await this.authorizationService.checkAccess('share', data['collection'], data['item']);
26
26
  return super.createOne(data, opts);
27
27
  }
28
- async login(payload) {
28
+ async login(payload, options) {
29
29
  const { nanoid } = await import('nanoid');
30
30
  const record = await this.knex
31
31
  .select({
@@ -70,12 +70,16 @@ export class SharesService extends ItemsService {
70
70
  collection: record.share_collection,
71
71
  },
72
72
  };
73
+ const refreshToken = nanoid(64);
74
+ const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
75
+ if (options?.session) {
76
+ tokenPayload.session = refreshToken;
77
+ }
78
+ const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
73
79
  const accessToken = jwt.sign(tokenPayload, env['SECRET'], {
74
- expiresIn: env['ACCESS_TOKEN_TTL'],
80
+ expiresIn: TTL,
75
81
  issuer: 'directus',
76
82
  });
77
- const refreshToken = nanoid(64);
78
- const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
79
83
  await this.knex('directus_sessions').insert({
80
84
  token: refreshToken,
81
85
  expires: refreshTokenExpiration,
@@ -88,7 +92,7 @@ export class SharesService extends ItemsService {
88
92
  return {
89
93
  accessToken,
90
94
  refreshToken,
91
- expires: getMilliseconds(env['ACCESS_TOKEN_TTL']),
95
+ expires: getMilliseconds(TTL),
92
96
  };
93
97
  }
94
98
  /**
@@ -9,8 +9,9 @@ export const getStorage = async () => {
9
9
  return _cache.storage;
10
10
  const { StorageManager } = await import('@directus/storage');
11
11
  validateEnv(['STORAGE_LOCATIONS']);
12
- _cache.storage = new StorageManager();
13
- await registerDrivers(_cache.storage);
14
- await registerLocations(_cache.storage);
15
- return _cache.storage;
12
+ const storage = new StorageManager();
13
+ await registerDrivers(storage);
14
+ await registerLocations(storage);
15
+ _cache.storage = storage;
16
+ return storage;
16
17
  };
@@ -27,6 +27,7 @@ export interface Session {
27
27
  export type DirectusTokenPayload = {
28
28
  id?: string;
29
29
  role: string | null;
30
+ session?: string;
30
31
  app_access: boolean | number;
31
32
  admin_access: boolean | number;
32
33
  share?: string;
@@ -47,8 +48,9 @@ export type ShareData = {
47
48
  share_password?: string;
48
49
  };
49
50
  export type LoginResult = {
50
- accessToken: any;
51
- refreshToken: any;
52
- expires: any;
53
- id?: any;
51
+ accessToken: string;
52
+ refreshToken: string;
53
+ expires: number;
54
+ id?: string;
54
55
  };
56
+ export type AuthenticationMode = 'json' | 'cookie' | 'session';
@@ -1,3 +1,4 @@
1
+ /// <reference types="cookie-parser" />
1
2
  import type { Request, Response } from 'express';
2
3
  import type { DocumentNode } from 'graphql';
3
4
  export interface GraphQLParams {
@@ -1,12 +1,12 @@
1
+ import { InvalidQueryError } from '@directus/errors';
1
2
  import { getFilterOperatorsForType, getOutputTypeForFunction } from '@directus/utils';
2
3
  import { clone, isPlainObject } from 'lodash-es';
3
4
  import { customAlphabet } from 'nanoid/non-secure';
4
- import validate from 'uuid-validate';
5
5
  import { getHelpers } from '../database/helpers/index.js';
6
- import { InvalidQueryError } from '@directus/errors';
7
6
  import { getColumnPath } from './get-column-path.js';
8
7
  import { getColumn } from './get-column.js';
9
8
  import { getRelationInfo } from './get-relation-info.js';
9
+ import { isValidUuid } from './is-valid-uuid.js';
10
10
  import { stripFunction } from './strip-function.js';
11
11
  export const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
12
12
  /**
@@ -548,7 +548,7 @@ export async function applySearch(schema, dbQuery, searchQuery, collection) {
548
548
  this.orWhere({ [`${collection}.${name}`]: number });
549
549
  }
550
550
  }
551
- else if (field.type === 'uuid' && validate(searchQuery)) {
551
+ else if (field.type === 'uuid' && isValidUuid(searchQuery)) {
552
552
  this.orWhere({ [`${collection}.${name}`]: searchQuery });
553
553
  }
554
554
  });
@@ -1,2 +1,2 @@
1
- import type { Query } from '@directus/types';
2
- export declare function filterItems(items: Record<string, any>[], filter: Query['filter']): Record<string, any>[];
1
+ import type { Item, Query } from '@directus/types';
2
+ export declare function filterItems<T extends Item[]>(items: T, filter: Query['filter']): T;
@@ -6,9 +6,7 @@ import { generateJoi } from '@directus/utils';
6
6
  export function filterItems(items, filter) {
7
7
  if (!filter)
8
8
  return items;
9
- return items.filter((item) => {
10
- return passesFilter(item, filter);
11
- });
9
+ return items.filter((item) => passesFilter(item, filter));
12
10
  function passesFilter(item, filter) {
13
11
  if (!filter || Object.keys(filter).length === 0)
14
12
  return true;
@@ -1,3 +1,4 @@
1
+ /// <reference types="cookie-parser" />
1
2
  import type { Request } from 'express';
2
3
  /**
3
4
  * Returns the Cache-Control header for the current request
@@ -1,2 +1,3 @@
1
+ /// <reference types="cookie-parser" />
1
2
  import type { Request } from 'express';
2
3
  export declare function getCacheKey(req: Request): string;
@@ -1,2 +1,3 @@
1
+ /// <reference types="cookie-parser" />
1
2
  import type { Request } from 'express';
2
3
  export declare function getGraphqlQueryAndVariables(req: Request): Pick<any, "query" | "variables">;
@@ -1,2 +1,3 @@
1
+ /// <reference types="cookie-parser" />
1
2
  import type { Request } from 'express';
2
3
  export declare function getIPFromReq(req: Request): string | null;
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Safely parse human readable time format into milliseconds
3
3
  */
4
- export declare function getMilliseconds<T>(value: unknown, fallback?: T): number | T;
4
+ export declare function getMilliseconds<T = undefined>(value: unknown, fallback?: T): number | T;
@@ -1,5 +1,8 @@
1
1
  import ms from 'ms';
2
- export function getMilliseconds(value, fallback = undefined) {
2
+ /**
3
+ * Safely parse human readable time format into milliseconds
4
+ */
5
+ export function getMilliseconds(value, fallback) {
3
6
  if ((typeof value !== 'string' && typeof value !== 'number') || value === '') {
4
7
  return fallback;
5
8
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Checks if the defined redirect after successful SSO login is in the allow list
3
+ */
4
+ export declare function isLoginRedirectAllowed(redirect: unknown, provider: string): boolean;
@@ -0,0 +1,34 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { toArray } from '@directus/utils';
3
+ import isUrlAllowed from './is-url-allowed.js';
4
+ /**
5
+ * Checks if the defined redirect after successful SSO login is in the allow list
6
+ */
7
+ export function isLoginRedirectAllowed(redirect, provider) {
8
+ if (!redirect)
9
+ return true; // empty redirect
10
+ if (typeof redirect !== 'string')
11
+ return false; // invalid type
12
+ const env = useEnv();
13
+ const publicUrl = env['PUBLIC_URL'];
14
+ if (URL.canParse(redirect) === false) {
15
+ if (redirect.startsWith('//') === false) {
16
+ // should be a relative path like `/admin/test`
17
+ return true;
18
+ }
19
+ // domain without protocol `//example.com/test`
20
+ return false;
21
+ }
22
+ const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);
23
+ const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
24
+ if (envKey in env) {
25
+ if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
26
+ return true;
27
+ }
28
+ if (URL.canParse(publicUrl) === false) {
29
+ return false;
30
+ }
31
+ // allow redirects to the defined PUBLIC_URL
32
+ const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);
33
+ return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
34
+ }
@@ -1,4 +1,4 @@
1
1
  /**
2
- * Check if url matches allow list either exactly or by domain+path
2
+ * Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
3
3
  */
4
4
  export default function isUrlAllowed(url: string, allowList: string | string[]): boolean;
@@ -2,7 +2,7 @@ import { toArray } from '@directus/utils';
2
2
  import { URL } from 'url';
3
3
  import { useLogger } from '../logger.js';
4
4
  /**
5
- * Check if url matches allow list either exactly or by domain+path
5
+ * Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
6
6
  */
7
7
  export default function isUrlAllowed(url, allowList) {
8
8
  const logger = useLogger();
@@ -12,8 +12,8 @@ export default function isUrlAllowed(url, allowList) {
12
12
  const parsedWhitelist = urlAllowList
13
13
  .map((allowedURL) => {
14
14
  try {
15
- const { hostname, pathname } = new URL(allowedURL);
16
- return hostname + pathname;
15
+ const { origin, pathname } = new URL(allowedURL);
16
+ return origin + pathname;
17
17
  }
18
18
  catch {
19
19
  logger.warn(`Invalid URL used "${url}"`);
@@ -22,8 +22,8 @@ export default function isUrlAllowed(url, allowList) {
22
22
  })
23
23
  .filter((f) => f);
24
24
  try {
25
- const { hostname, pathname } = new URL(url);
26
- return parsedWhitelist.includes(hostname + pathname);
25
+ const { origin, pathname } = new URL(url);
26
+ return parsedWhitelist.includes(origin + pathname);
27
27
  }
28
28
  catch {
29
29
  return false;
@@ -0,0 +1,3 @@
1
+ type UUID = `${string}-${string}-${string}-${string}-${string}`;
2
+ export declare function isValidUuid(value: string): value is UUID;
3
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Based on the patterns found in the 'uuid' and 'uuid-validate' npm packages, both of which are MIT licensed.
3
+ *
4
+ * The primary difference between this pattern and the patterns found in the referenced packages is that
5
+ * no validation over the version component (the 14th character) is performed, while the
6
+ * packages fail if the version is not a known one (only versions 1 through 5 are accepted).
7
+ *
8
+ * This specification complies with all major database vendors.
9
+ *
10
+ * e22f209d-9e85-4ef5-b1fe-7dc09d2b67cf
11
+ * ^ version
12
+ *
13
+ * @see https://datatracker.ietf.org/doc/html/rfc4122
14
+ * @see https://github.com/uuidjs/uuid/blob/bc46e198ab06311a9d82d3c9c6222062dd27f760/src/regex.js
15
+ * @see https://github.com/microsoft/uuid-validate/blob/06554db1b093aa6bb429156fa8964e1cde2b750c/index.js
16
+ * @see https://github.com/directus/directus/issues/21573
17
+ */
18
+ const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+ export function isValidUuid(value) {
20
+ return regex.test(value);
21
+ }
@@ -1,3 +1,3 @@
1
1
  import type { DirectusTokenPayload } from '../types/index.js';
2
- export declare function verifyJWT(token: string, secret: string): Record<string, any>;
2
+ export declare function verifyJWT(token: string, secret: string): Record<string, unknown>;
3
3
  export declare function verifyAccessJWT(token: string, secret: string): DirectusTokenPayload;
package/dist/utils/jwt.js CHANGED
@@ -21,9 +21,9 @@ export function verifyJWT(token, secret) {
21
21
  return payload;
22
22
  }
23
23
  export function verifyAccessJWT(token, secret) {
24
- const { id, role, app_access, admin_access, share, share_scope } = verifyJWT(token, secret);
25
- if (role === undefined || app_access === undefined || admin_access === undefined) {
24
+ const payload = verifyJWT(token, secret);
25
+ if (payload.role === undefined || payload.app_access === undefined || payload.admin_access === undefined) {
26
26
  throw new InvalidTokenError();
27
27
  }
28
- return { id, role, app_access, admin_access, share, share_scope };
28
+ return payload;
29
29
  }
@@ -0,0 +1,3 @@
1
+ import type { Item, SchemaOverview } from '@directus/types';
2
+ export declare function mergeVersionsRaw(item: Item, versionData: Partial<Item>[]): Item;
3
+ export declare function mergeVersionsRecursive(item: Item, versionData: Item[], collection: string, schema: SchemaOverview): Item;
@@ -0,0 +1,134 @@
1
+ import Joi from 'joi';
2
+ import { isObject } from '@directus/utils';
3
+ import { cloneDeep } from 'lodash-es';
4
+ const alterationSchema = Joi.object({
5
+ create: Joi.array().items(Joi.object().unknown()),
6
+ update: Joi.array().items(Joi.object().unknown()),
7
+ delete: Joi.array().items(Joi.string(), Joi.number()),
8
+ });
9
+ export function mergeVersionsRaw(item, versionData) {
10
+ const result = cloneDeep(item);
11
+ for (const versionRecord of versionData) {
12
+ for (const key of Object.keys(versionRecord)) {
13
+ result[key] = versionRecord[key];
14
+ }
15
+ }
16
+ return result;
17
+ }
18
+ export function mergeVersionsRecursive(item, versionData, collection, schema) {
19
+ if (versionData.length === 0)
20
+ return item;
21
+ return recursiveMerging(item, versionData, collection, schema);
22
+ }
23
+ function recursiveMerging(data, versionData, collection, schema) {
24
+ const result = cloneDeep(data);
25
+ const relations = getRelations(collection, schema);
26
+ for (const versionRecord of versionData) {
27
+ if (!isObject(versionRecord)) {
28
+ continue;
29
+ }
30
+ for (const key of Object.keys(data)) {
31
+ if (key in versionRecord === false) {
32
+ continue;
33
+ }
34
+ const currentValue = data[key];
35
+ const newValue = versionRecord[key];
36
+ if (typeof newValue !== 'object' || newValue === null) {
37
+ // primitive type substitution, json and non relational array values are handled in the next check
38
+ result[key] = newValue;
39
+ continue;
40
+ }
41
+ if (key in relations === false) {
42
+ // check for m2a exception
43
+ if (isManyToAnyCollection(collection, schema) && key === 'item') {
44
+ const item = addMissingKeys(isObject(currentValue) ? currentValue : {}, newValue);
45
+ result[key] = recursiveMerging(item, [newValue], data['collection'], schema);
46
+ }
47
+ else {
48
+ // item is not a relation
49
+ result[key] = newValue;
50
+ }
51
+ continue;
52
+ }
53
+ const { error } = alterationSchema.validate(newValue);
54
+ if (error) {
55
+ if (typeof newValue === 'object' && key in relations) {
56
+ const newItem = !currentValue || typeof currentValue !== 'object' ? newValue : currentValue;
57
+ result[key] = recursiveMerging(newItem, [newValue], relations[key], schema);
58
+ }
59
+ continue;
60
+ }
61
+ const alterations = newValue;
62
+ const currentPrimaryKeyField = schema.collections[collection].primary;
63
+ const relatedPrimaryKeyField = schema.collections[relations[key]].primary;
64
+ const mergedRelation = [];
65
+ if (Array.isArray(currentValue)) {
66
+ if (alterations.delete.length > 0) {
67
+ for (const currentItem of currentValue) {
68
+ const currentId = typeof currentItem === 'object' ? currentItem[currentPrimaryKeyField] : currentItem;
69
+ if (alterations.delete.includes(currentId) === false) {
70
+ mergedRelation.push(currentItem);
71
+ }
72
+ }
73
+ }
74
+ else {
75
+ mergedRelation.push(...currentValue);
76
+ }
77
+ if (alterations.update.length > 0) {
78
+ for (const updatedItem of alterations.update) {
79
+ // find existing item to update
80
+ const itemIndex = mergedRelation.findIndex((currentItem) => currentItem[relatedPrimaryKeyField] === updatedItem[currentPrimaryKeyField]);
81
+ if (itemIndex === -1) {
82
+ // check for raw primary keys
83
+ const pkIndex = mergedRelation.findIndex((currentItem) => currentItem === updatedItem[currentPrimaryKeyField]);
84
+ if (pkIndex === -1) {
85
+ // nothing to update so add the item as is
86
+ mergedRelation.push(updatedItem);
87
+ }
88
+ else {
89
+ mergedRelation[pkIndex] = updatedItem;
90
+ }
91
+ continue;
92
+ }
93
+ const item = addMissingKeys(mergedRelation[itemIndex], updatedItem);
94
+ mergedRelation[itemIndex] = recursiveMerging(item, [updatedItem], relations[key], schema);
95
+ }
96
+ }
97
+ }
98
+ if (alterations.create.length > 0) {
99
+ for (const createdItem of alterations.create) {
100
+ const item = addMissingKeys({}, createdItem);
101
+ mergedRelation.push(recursiveMerging(item, [createdItem], relations[key], schema));
102
+ }
103
+ }
104
+ result[key] = mergedRelation;
105
+ }
106
+ }
107
+ return result;
108
+ }
109
+ function addMissingKeys(item, edits) {
110
+ const result = { ...item };
111
+ for (const key of Object.keys(edits)) {
112
+ if (key in item === false) {
113
+ result[key] = null;
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+ function isManyToAnyCollection(collection, schema) {
119
+ const relation = schema.relations.find((relation) => relation.collection === collection && relation.meta?.many_collection === collection);
120
+ if (!relation || !relation.meta?.one_field || !relation.related_collection)
121
+ return false;
122
+ return Boolean(schema.collections[relation.related_collection]?.fields[relation.meta.one_field]?.special.includes('m2a'));
123
+ }
124
+ function getRelations(collection, schema) {
125
+ return schema.relations.reduce((result, relation) => {
126
+ if (relation.related_collection === collection && relation.meta?.one_field) {
127
+ result[relation.meta.one_field] = relation.collection;
128
+ }
129
+ if (relation.collection === collection && relation.related_collection) {
130
+ result[relation.field] = relation.related_collection;
131
+ }
132
+ return result;
133
+ }, {});
134
+ }
@@ -48,6 +48,8 @@ export function sanitizeQuery(rawQuery, accountability) {
48
48
  }
49
49
  if (rawQuery['version']) {
50
50
  query.version = rawQuery['version'];
51
+ // whether or not to merge the relational results
52
+ query.versionRaw = Boolean('versionRaw' in rawQuery && (rawQuery['versionRaw'] === '' || rawQuery['versionRaw'] === 'true'));
51
53
  }
52
54
  if (rawQuery['export']) {
53
55
  query.export = rawQuery['export'];
@@ -1,3 +1,4 @@
1
+ /// <reference types="cookie-parser" />
1
2
  import type { Request } from 'express';
2
3
  /**
3
4
  * Whether to skip caching for the current request
@@ -1,5 +1,5 @@
1
1
  import { ForbiddenError } from '@directus/errors';
2
- import validateUUID from 'uuid-validate';
2
+ import { isValidUuid } from './is-valid-uuid.js';
3
3
  /**
4
4
  * Validate keys based on its type
5
5
  */
@@ -11,7 +11,7 @@ export function validateKeys(schema, collection, keyField, keys) {
11
11
  }
12
12
  else {
13
13
  const primaryKeyFieldType = schema.collections[collection]?.fields[keyField]?.type;
14
- if (primaryKeyFieldType === 'uuid' && !validateUUID(String(keys))) {
14
+ if (primaryKeyFieldType === 'uuid' && !isValidUuid(String(keys))) {
15
15
  throw new ForbiddenError();
16
16
  }
17
17
  else if (primaryKeyFieldType === 'integer' && !Number.isInteger(Number(keys))) {
@@ -22,6 +22,7 @@ const querySchema = Joi.object({
22
22
  search: Joi.string(),
23
23
  export: Joi.string().valid('csv', 'json', 'xml', 'yaml'),
24
24
  version: Joi.string(),
25
+ versionRaw: Joi.boolean(),
25
26
  aggregate: Joi.object(),
26
27
  deep: Joi.object(),
27
28
  alias: Joi.object(),
@@ -1,8 +1,8 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { InvalidProviderConfigError, TokenExpiredError } from '@directus/errors';
3
3
  import { parseJSON, toBoolean } from '@directus/utils';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { parse } from 'url';
5
- import { v4 as uuid } from 'uuid';
6
6
  import WebSocket, { WebSocketServer } from 'ws';
7
7
  import { fromZodError } from 'zod-validation-error';
8
8
  import emitter from '../../emitter.js';
@@ -157,7 +157,7 @@ export default class SocketController {
157
157
  const client = ws;
158
158
  client.accountability = accountability;
159
159
  client.expires_at = expires_at;
160
- client.uid = uuid();
160
+ client.uid = randomUUID();
161
161
  client.auth_timer = null;
162
162
  ws.on('message', async (data) => {
163
163
  if (this.rateLimiter !== null) {
@@ -130,7 +130,7 @@ function registerSortHooks() {
130
130
  */
131
131
  function registerAction(event, transform) {
132
132
  const messenger = useBus();
133
- emitter.onAction(event, async (data) => {
133
+ emitter.onAction(event, (data) => {
134
134
  // push the event through the Redis pub/sub
135
135
  messenger.publish('websocket.event', transform(data));
136
136
  });