@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,12 +1,16 @@
1
+ import { InvalidPayloadError } from '@directus/errors';
2
+ import { useEnv } from '@directus/env';
1
3
  /**
2
- * Extract access token from:
4
+ * Extract access token from
3
5
  *
4
- * Authorization: Bearer
5
- * access_token query parameter
6
+ * - 'access_token' query parameter
7
+ * - 'Authorization' header
8
+ * - Session cookie
6
9
  *
7
- * and store in req.token
10
+ * and store it under req.token
8
11
  */
9
12
  const extractToken = (req, _res, next) => {
13
+ const env = useEnv();
10
14
  let token = null;
11
15
  if (req.query && req.query['access_token']) {
12
16
  token = req.query['access_token'];
@@ -14,16 +18,28 @@ const extractToken = (req, _res, next) => {
14
18
  if (req.headers && req.headers.authorization) {
15
19
  const parts = req.headers.authorization.split(' ');
16
20
  if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
21
+ if (token !== null) {
22
+ /*
23
+ * RFC6750 compliance (https://datatracker.ietf.org/doc/html/rfc6750#section-2)
24
+ * > Clients MUST NOT use more than one method to transmit the token in each request.
25
+ */
26
+ throw new InvalidPayloadError({
27
+ reason: 'The request uses more than one method for including an access token',
28
+ });
29
+ }
17
30
  token = parts[1];
18
31
  }
19
32
  }
20
- /**
21
- * @TODO
22
- * Look into RFC6750 compliance:
23
- * In order to be fully compliant with RFC6750, we have to throw a 400 error when you have the
24
- * token in more than 1 place afaik. We also might have to support "access_token" as a post body
25
- * key
26
- */
33
+ if (req.cookies && req.cookies[env['SESSION_COOKIE_NAME']]) {
34
+ /*
35
+ * Exclude session cookie from "RFC6750 multi auth method" rule, e.g.
36
+ * - allow using a different token to perform requests from within the Data Studio (static token in WYSIWYG interface / Extensions)
37
+ * - to not break external apps running under the same domain as the Data Studio while using a different method
38
+ */
39
+ if (token === null) {
40
+ token = req.cookies[env['SESSION_COOKIE_NAME']];
41
+ }
42
+ }
27
43
  req.token = token;
28
44
  next();
29
45
  };
@@ -0,0 +1,2 @@
1
+ import type { RequestHandler } from 'express';
2
+ export declare const mergeContentVersions: RequestHandler;
@@ -0,0 +1,26 @@
1
+ import { isObject } from '@directus/utils';
2
+ import { VersionsService } from '../services/versions.js';
3
+ import asyncHandler from '../utils/async-handler.js';
4
+ import { mergeVersionsRaw, mergeVersionsRecursive } from '../utils/merge-version-data.js';
5
+ export const mergeContentVersions = asyncHandler(async (req, res, next) => {
6
+ if (req.sanitizedQuery.version &&
7
+ req.collection &&
8
+ (req.singleton || req.params['pk']) &&
9
+ 'data' in res.locals['payload']) {
10
+ const originalData = res.locals['payload'].data;
11
+ // only act on single item requests
12
+ if (!isObject(originalData))
13
+ return next();
14
+ const versionsService = new VersionsService({ accountability: req.accountability ?? null, schema: req.schema });
15
+ const versionData = await versionsService.getVersionSaves(req.sanitizedQuery.version, req.collection, req.params['pk']);
16
+ if (!versionData || versionData.length === 0)
17
+ return next();
18
+ if (req.sanitizedQuery.versionRaw) {
19
+ res.locals['payload'].data = mergeVersionsRaw(originalData, versionData);
20
+ }
21
+ else {
22
+ res.locals['payload'].data = mergeVersionsRecursive(originalData, versionData, req.collection, req.schema);
23
+ }
24
+ }
25
+ return next();
26
+ });
@@ -1,10 +1,8 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { parse as parseBytesConfiguration } from 'bytes';
3
- import { assign } from 'lodash-es';
4
3
  import { getCache, setCacheValue } from '../cache.js';
5
4
  import { useLogger } from '../logger.js';
6
5
  import { ExportService } from '../services/import-export.js';
7
- import { VersionsService } from '../services/versions.js';
8
6
  import asyncHandler from '../utils/async-handler.js';
9
7
  import { getCacheControlHeader } from '../utils/get-cache-headers.js';
10
8
  import { getCacheKey } from '../utils/get-cache-key.js';
@@ -21,16 +19,6 @@ export const respond = asyncHandler(async (req, res) => {
21
19
  const maxSize = parseBytesConfiguration(env['CACHE_VALUE_MAX_SIZE']);
22
20
  exceedsMaxSize = valueSize > maxSize;
23
21
  }
24
- if (req.sanitizedQuery.version &&
25
- req.collection &&
26
- (req.singleton || req.params['pk']) &&
27
- 'data' in res.locals['payload']) {
28
- const versionsService = new VersionsService({ accountability: req.accountability ?? null, schema: req.schema });
29
- const saves = await versionsService.getVersionSaves(req.sanitizedQuery.version, req.collection, req.params['pk']);
30
- if (saves) {
31
- assign(res.locals['payload'].data, ...saves);
32
- }
33
- }
34
22
  if ((req.method.toLowerCase() === 'get' || req.originalUrl?.startsWith('/graphql')) &&
35
23
  env['CACHE_ENABLED'] === true &&
36
24
  cache &&
@@ -1,3 +1,4 @@
1
1
  /// <reference types="qs" />
2
2
  /// <reference types="express" />
3
+ /// <reference types="cookie-parser" />
3
4
  export declare const validateBatch: (scope: 'read' | 'update' | 'delete') => (req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>, res: import("express").Response<any, Record<string, any>>, next: import("express").NextFunction) => Promise<void>;
@@ -32,7 +32,10 @@ export default defineOperationApi({
32
32
  return null;
33
33
  }
34
34
  let result;
35
- if (!key || (Array.isArray(key) && key.length === 0)) {
35
+ if (Array.isArray(payloadObject)) {
36
+ result = await itemsService.updateBatch(payloadObject, { emitEvents: !!emitEvents });
37
+ }
38
+ else if (!key || (Array.isArray(key) && key.length === 0)) {
36
39
  result = await itemsService.updateByQuery(sanitizedQueryObject, payloadObject, { emitEvents: !!emitEvents });
37
40
  }
38
41
  else {
@@ -5,7 +5,7 @@ import type { Agent, ClientRequestArgs } from 'node:http';
5
5
  * https://github.com/nodejs/node/blob/8a41d9b636be86350cd32847c3f89d327c4f6ff7/lib/_http_agent.js#L215
6
6
  */
7
7
  export type _Agent = Agent & {
8
- createConnection: NonNullable<ClientRequestArgs['createConnection']>;
8
+ createConnection: ClientRequestArgs['createConnection'];
9
9
  };
10
10
  /** Extends a HTTP agent with IP validation */
11
11
  export declare const agentWithIpValidation: (agent: Agent) => Agent;
@@ -21,7 +21,11 @@ export const agentWithIpValidation = (agent) => {
21
21
  */
22
22
  if (isIP(host) !== 0 && isDeniedIp(host))
23
23
  throw deniedError(host);
24
- const socket = createConnection.call(this, options, oncreate);
24
+ const socket = createConnection?.call(this, options, oncreate);
25
+ // Unexpected, but in that case the request is denied to be on the safe side
26
+ if (!socket) {
27
+ throw new Error('Request cannot be verified due to lost socket');
28
+ }
25
29
  // Emitted after resolving the host name but before connecting.
26
30
  socket.on('lookup', (error, address) => {
27
31
  if (error || !isDeniedIp(address))
@@ -2,9 +2,9 @@ import { Action } from '@directus/constants';
2
2
  import { useEnv } from '@directus/env';
3
3
  import { ErrorCode, isDirectusError } from '@directus/errors';
4
4
  import { uniq } from 'lodash-es';
5
- import validateUUID from 'uuid-validate';
6
5
  import { useLogger } from '../logger.js';
7
6
  import { getPermissions } from '../utils/get-permissions.js';
7
+ import { isValidUuid } from '../utils/is-valid-uuid.js';
8
8
  import { Url } from '../utils/url.js';
9
9
  import { userName } from '../utils/user-name.js';
10
10
  import { AuthorizationService } from './authorization.js';
@@ -55,8 +55,8 @@ export class ActivityService extends ItemsService {
55
55
  let comment = data['comment'];
56
56
  for (const mention of mentions) {
57
57
  const uuid = mention.substring(1);
58
- // We only match on UUIDs in the first place. This is just an extra sanity check
59
- if (validateUUID(uuid) === false)
58
+ // We only match on UUIDs in the first place. This is just an extra sanity check.
59
+ if (isValidUuid(uuid) === false)
60
60
  continue;
61
61
  comment = comment.replace(new RegExp(mention, 'gm'), userPreviews[uuid] ?? '@Unknown User');
62
62
  }
@@ -5,12 +5,12 @@ import { contentType } from 'mime-types';
5
5
  import hash from 'object-hash';
6
6
  import path from 'path';
7
7
  import sharp from 'sharp';
8
- import validateUUID from 'uuid-validate';
9
8
  import { SUPPORTED_IMAGE_TRANSFORM_FORMATS } from '../constants.js';
10
9
  import getDatabase from '../database/index.js';
11
10
  import { useLogger } from '../logger.js';
12
11
  import { getStorage } from '../storage/index.js';
13
12
  import { getMilliseconds } from '../utils/get-milliseconds.js';
13
+ import { isValidUuid } from '../utils/is-valid-uuid.js';
14
14
  import * as TransformationUtils from '../utils/transformations.js';
15
15
  import { AuthorizationService } from './authorization.js';
16
16
  import { FilesService } from './files.js';
@@ -39,8 +39,7 @@ export class AssetsService {
39
39
  * with a wrong type. In case of directus_files where id is a uuid, we'll have to verify the
40
40
  * validity of the uuid ahead of time.
41
41
  */
42
- const isValidUUID = validateUUID(id, 4);
43
- if (isValidUUID === false)
42
+ if (!isValidUuid(id))
44
43
  throw new ForbiddenError();
45
44
  if (systemPublicKeys.includes(id) === false && this.accountability?.admin !== true) {
46
45
  await this.authorizationService.checkAccess('read', 'directus_files', id);
@@ -14,8 +14,13 @@ export declare class AuthenticationService {
14
14
  * Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
15
15
  * to handle password existence checks elsewhere
16
16
  */
17
- login(providerName: string | undefined, payload: Record<string, any>, otp?: string): Promise<LoginResult>;
18
- refresh(refreshToken: string): Promise<Record<string, any>>;
17
+ login(providerName: string | undefined, payload: Record<string, any>, options?: Partial<{
18
+ otp: string;
19
+ session: boolean;
20
+ }>): Promise<LoginResult>;
21
+ refresh(refreshToken: string, options?: Partial<{
22
+ session: boolean;
23
+ }>): Promise<LoginResult>;
19
24
  logout(refreshToken: string): Promise<void>;
20
25
  verifyPassword(userID: string, password: string): Promise<void>;
21
26
  }
@@ -33,7 +33,7 @@ export class AuthenticationService {
33
33
  * Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
34
34
  * to handle password existence checks elsewhere
35
35
  */
36
- async login(providerName = DEFAULT_AUTH_PROVIDER, payload, otp) {
36
+ async login(providerName = DEFAULT_AUTH_PROVIDER, payload, options) {
37
37
  const { nanoid } = await import('nanoid');
38
38
  const STALL_TIME = env['LOGIN_STALL_TIME'];
39
39
  const timeStart = performance.now();
@@ -123,14 +123,14 @@ export class AuthenticationService {
123
123
  await stall(STALL_TIME, timeStart);
124
124
  throw e;
125
125
  }
126
- if (user.tfa_secret && !otp) {
126
+ if (user.tfa_secret && !options?.otp) {
127
127
  emitStatus('fail');
128
128
  await stall(STALL_TIME, timeStart);
129
129
  throw new InvalidOtpError();
130
130
  }
131
- if (user.tfa_secret && otp) {
131
+ if (user.tfa_secret && options?.otp) {
132
132
  const tfaService = new TFAService({ knex: this.knex, schema: this.schema });
133
- const otpValid = await tfaService.verifyOTP(user.id, otp);
133
+ const otpValid = await tfaService.verifyOTP(user.id, options?.otp);
134
134
  if (otpValid === false) {
135
135
  emitStatus('fail');
136
136
  await stall(STALL_TIME, timeStart);
@@ -143,6 +143,11 @@ export class AuthenticationService {
143
143
  app_access: user.app_access,
144
144
  admin_access: user.admin_access,
145
145
  };
146
+ const refreshToken = nanoid(64);
147
+ const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
148
+ if (options?.session) {
149
+ tokenPayload.session = refreshToken;
150
+ }
146
151
  const customClaims = await emitter.emitFilter('auth.jwt', tokenPayload, {
147
152
  status: 'pending',
148
153
  user: user?.id,
@@ -153,12 +158,11 @@ export class AuthenticationService {
153
158
  schema: this.schema,
154
159
  accountability: this.accountability,
155
160
  });
161
+ const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
156
162
  const accessToken = jwt.sign(customClaims, env['SECRET'], {
157
- expiresIn: env['ACCESS_TOKEN_TTL'],
163
+ expiresIn: TTL,
158
164
  issuer: 'directus',
159
165
  });
160
- const refreshToken = nanoid(64);
161
- const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
162
166
  await this.knex('directus_sessions').insert({
163
167
  token: refreshToken,
164
168
  user: user.id,
@@ -188,11 +192,11 @@ export class AuthenticationService {
188
192
  return {
189
193
  accessToken,
190
194
  refreshToken,
191
- expires: getMilliseconds(env['ACCESS_TOKEN_TTL']),
195
+ expires: getMilliseconds(TTL),
192
196
  id: user.id,
193
197
  };
194
198
  }
195
- async refresh(refreshToken) {
199
+ async refresh(refreshToken, options) {
196
200
  const { nanoid } = await import('nanoid');
197
201
  const STALL_TIME = env['LOGIN_STALL_TIME'];
198
202
  const timeStart = performance.now();
@@ -269,12 +273,17 @@ export class AuthenticationService {
269
273
  admin_access: record.role_admin_access,
270
274
  });
271
275
  }
276
+ const newRefreshToken = nanoid(64);
277
+ const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
272
278
  const tokenPayload = {
273
279
  id: record.user_id,
274
280
  role: record.role_id,
275
281
  app_access: record.role_app_access,
276
282
  admin_access: record.role_admin_access,
277
283
  };
284
+ if (options?.session) {
285
+ tokenPayload.session = newRefreshToken;
286
+ }
278
287
  if (record.share_id) {
279
288
  tokenPayload.share = record.share_id;
280
289
  tokenPayload.role = record.share_role;
@@ -296,12 +305,11 @@ export class AuthenticationService {
296
305
  schema: this.schema,
297
306
  accountability: this.accountability,
298
307
  });
308
+ const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
299
309
  const accessToken = jwt.sign(customClaims, env['SECRET'], {
300
- expiresIn: env['ACCESS_TOKEN_TTL'],
310
+ expiresIn: TTL,
301
311
  issuer: 'directus',
302
312
  });
303
- const newRefreshToken = nanoid(64);
304
- const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
305
313
  await this.knex('directus_sessions')
306
314
  .update({
307
315
  token: newRefreshToken,
@@ -314,7 +322,7 @@ export class AuthenticationService {
314
322
  return {
315
323
  accessToken,
316
324
  refreshToken: newRefreshToken,
317
- expires: getMilliseconds(env['ACCESS_TOKEN_TTL']),
325
+ expires: getMilliseconds(TTL),
318
326
  id: record.user_id,
319
327
  };
320
328
  }
@@ -15,10 +15,11 @@ export declare class ExtensionsService {
15
15
  extensionsItemService: ItemsService<ExtensionSettings>;
16
16
  extensionsManager: ExtensionManager;
17
17
  constructor(options: AbstractServiceOptions);
18
+ install(extensionId: string, versionId: string): Promise<void>;
18
19
  readAll(): Promise<ApiOutput[]>;
19
- readOne(bundle: string | null, name: string): Promise<ApiOutput>;
20
- updateOne(bundle: string | null, name: string, data: DeepPartial<ApiOutput>): Promise<ApiOutput>;
21
- private getKey;
20
+ readOne(id: string): Promise<ApiOutput>;
21
+ updateOne(id: string, data: DeepPartial<ApiOutput>): Promise<ApiOutput>;
22
+ deleteOne(id: string): Promise<void>;
22
23
  /**
23
24
  * Sync a bundles enabled status
24
25
  * - If the extension or extensions parent is not a bundle changes are skipped
@@ -29,9 +30,4 @@ export declare class ExtensionsService {
29
30
  * - Entry status change resulted in at least one child being enabled then the parent bundle is enabled
30
31
  */
31
32
  private checkBundleAndSyncStatus;
32
- /**
33
- * Combine the settings stored in the database with the information available from the installed
34
- * extensions into the standardized extensions api output
35
- */
36
- private stitch;
37
33
  }
@@ -1,6 +1,7 @@
1
- import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
1
+ import { useEnv } from '@directus/env';
2
+ import { ForbiddenError, InvalidPayloadError, LimitExceededError, UnprocessableContentError } from '@directus/errors';
3
+ import { describe } from '@directus/extensions-registry';
2
4
  import { isObject } from '@directus/utils';
3
- import { omit, pick } from 'lodash-es';
4
5
  import getDatabase from '../database/index.js';
5
6
  import { getExtensionManager } from '../extensions/index.js';
6
7
  import { ItemsService } from './items.js';
@@ -28,21 +29,88 @@ export class ExtensionsService {
28
29
  accountability: this.accountability,
29
30
  });
30
31
  }
32
+ async install(extensionId, versionId) {
33
+ const env = useEnv();
34
+ const describeOptions = {};
35
+ if (typeof env['MARKETPLACE_REGISTRY'] === 'string') {
36
+ describeOptions.registry = env['MARKETPLACE_REGISTRY'];
37
+ }
38
+ const extension = await describe(extensionId, describeOptions);
39
+ const version = extension.data.versions.find((version) => version.id === versionId);
40
+ if (!version) {
41
+ throw new ForbiddenError();
42
+ }
43
+ const limit = env['EXTENSIONS_LIMIT'] ? Number(env['EXTENSIONS_LIMIT']) : null;
44
+ if (limit !== null) {
45
+ const currentlyInstalledCount = this.extensionsManager.extensions.length;
46
+ /**
47
+ * Bundle extensions should be counted as the number of nested entries rather than a single
48
+ * extension to avoid a vulnerability where you can get around the technical limit by bundling
49
+ * all extensions you want
50
+ */
51
+ const points = version.bundled.length ?? 1;
52
+ const afterInstallCount = currentlyInstalledCount + points;
53
+ if (afterInstallCount >= limit) {
54
+ throw new LimitExceededError();
55
+ }
56
+ }
57
+ await this.extensionsItemService.createOne({
58
+ id: extensionId,
59
+ enabled: true,
60
+ folder: versionId,
61
+ source: 'registry',
62
+ bundle: null,
63
+ });
64
+ if (extension.data.type === 'bundle' && version.bundled.length > 0) {
65
+ await this.extensionsItemService.createMany(version.bundled.map((entry) => ({
66
+ enabled: true,
67
+ folder: entry.name,
68
+ source: 'registry',
69
+ bundle: extensionId,
70
+ })));
71
+ }
72
+ await this.extensionsManager.install(versionId);
73
+ }
31
74
  async readAll() {
32
- const installedExtensions = this.extensionsManager.getExtensions();
33
- const configuredExtensions = await this.extensionsItemService.readByQuery({ limit: -1 });
34
- return this.stitch(installedExtensions, configuredExtensions);
75
+ const settings = await this.extensionsItemService.readByQuery({ limit: -1 });
76
+ const regular = settings.filter(({ bundle }) => bundle === null);
77
+ const bundled = settings.filter(({ bundle }) => bundle !== null);
78
+ const output = [];
79
+ for (const meta of regular) {
80
+ output.push({
81
+ id: meta.id,
82
+ bundle: meta.bundle,
83
+ meta: meta,
84
+ schema: this.extensionsManager.getExtension(meta.source, meta.folder) ?? null,
85
+ });
86
+ }
87
+ for (const meta of bundled) {
88
+ const parentBundle = output.find((ext) => ext.id === meta.bundle);
89
+ if (!parentBundle)
90
+ continue;
91
+ const schema = parentBundle.schema?.entries.find((entry) => entry.name === meta.folder);
92
+ if (!schema)
93
+ continue;
94
+ output.push({
95
+ id: meta.id,
96
+ bundle: meta.bundle,
97
+ meta: meta,
98
+ schema: schema,
99
+ });
100
+ }
101
+ return output;
35
102
  }
36
- async readOne(bundle, name) {
37
- const key = this.getKey(bundle, name);
38
- const schema = this.extensionsManager.getExtensions().find((extension) => extension.name === (bundle ?? name));
39
- const meta = await this.extensionsItemService.readOne(key);
40
- const stitched = this.stitch(schema ? [schema] : [], [meta])[0];
41
- if (stitched)
42
- return stitched;
43
- throw new ForbiddenError();
103
+ async readOne(id) {
104
+ const meta = await this.extensionsItemService.readOne(id);
105
+ const schema = this.extensionsManager.getExtension(meta.source, meta.folder) ?? null;
106
+ return {
107
+ id: meta.id,
108
+ bundle: meta.bundle,
109
+ schema,
110
+ meta,
111
+ };
44
112
  }
45
- async updateOne(bundle, name, data) {
113
+ async updateOne(id, data) {
46
114
  const result = await this.knex.transaction(async (trx) => {
47
115
  if (!isObject(data.meta)) {
48
116
  throw new InvalidPayloadError({ reason: `"meta" is required` });
@@ -52,25 +120,32 @@ export class ExtensionsService {
52
120
  accountability: this.accountability,
53
121
  schema: this.schema,
54
122
  });
55
- const key = this.getKey(bundle, name);
56
- await service.extensionsItemService.updateOne(key, data.meta);
123
+ await service.extensionsItemService.updateOne(id, data.meta);
57
124
  let extension;
58
125
  try {
59
- extension = await service.readOne(bundle, name);
126
+ extension = await service.readOne(id);
60
127
  }
61
128
  catch (error) {
62
129
  throw new ExtensionReadError(error);
63
130
  }
64
131
  if ('enabled' in data.meta) {
65
- await service.checkBundleAndSyncStatus(trx, extension);
132
+ await service.checkBundleAndSyncStatus(trx, id, extension);
66
133
  }
67
134
  return extension;
68
135
  });
69
136
  this.extensionsManager.reload();
70
137
  return result;
71
138
  }
72
- getKey(bundle, name) {
73
- return bundle ? `${bundle}/${name}` : name;
139
+ async deleteOne(id) {
140
+ const settings = await this.extensionsItemService.readOne(id);
141
+ if (settings.source !== 'registry') {
142
+ throw new InvalidPayloadError({
143
+ reason: 'Cannot uninstall extensions that were not installed from the marketplace registry',
144
+ });
145
+ }
146
+ await this.extensionsItemService.deleteOne(id);
147
+ await this.extensionsItemService.deleteByQuery({ filter: { bundle: { _eq: id } } });
148
+ await this.extensionsManager.uninstall(settings.folder);
74
149
  }
75
150
  /**
76
151
  * Sync a bundles enabled status
@@ -81,16 +156,16 @@ export class ExtensionsService {
81
156
  * - Entry status change resulted in all children being disabled then the parent bundle is disabled
82
157
  * - Entry status change resulted in at least one child being enabled then the parent bundle is enabled
83
158
  */
84
- async checkBundleAndSyncStatus(trx, extension) {
85
- if (extension.bundle === null) {
86
- if (extension.schema?.type === 'bundle') {
87
- await trx('directus_extensions')
88
- .update({ enabled: extension.meta.enabled })
89
- .where('name', 'LIKE', this.getKey(extension.name, '%'));
90
- }
159
+ async checkBundleAndSyncStatus(trx, bundleId, extension) {
160
+ if (extension.bundle === null && extension.schema?.type === 'bundle') {
161
+ // If extension is the parent bundle, set it and all nested extensions to enabled
162
+ await trx('directus_extensions')
163
+ .update({ enabled: extension.meta.enabled })
164
+ .where({ bundle: bundleId })
165
+ .orWhere({ id: bundleId });
91
166
  return;
92
167
  }
93
- const parent = await this.readOne(null, extension.bundle);
168
+ const parent = await this.readOne(bundleId);
94
169
  if (parent.schema?.type !== 'bundle') {
95
170
  return;
96
171
  }
@@ -99,73 +174,15 @@ export class ExtensionsService {
99
174
  reason: 'Unable to toggle status of an entry for a bundle marked as non partial',
100
175
  });
101
176
  }
102
- const child = await trx('directus_extensions')
103
- .where('name', 'LIKE', this.getKey(extension.bundle, '%'))
177
+ const hasEnabledChildren = !!(await trx('directus_extensions')
178
+ .where({ bundle: bundleId })
104
179
  .where({ enabled: true })
105
- .first();
106
- if (!child && parent.meta.enabled) {
107
- await trx('directus_extensions').update({ enabled: false }).where({ name: parent.name });
180
+ .first());
181
+ if (hasEnabledChildren) {
182
+ await trx('directus_extensions').update({ enabled: true }).where({ id: bundleId });
108
183
  }
109
- else if (child && !parent.meta.enabled) {
110
- await trx('directus_extensions').update({ enabled: true }).where({ name: parent.name });
184
+ else {
185
+ await trx('directus_extensions').update({ enabled: false }).where({ id: bundleId });
111
186
  }
112
187
  }
113
- /**
114
- * Combine the settings stored in the database with the information available from the installed
115
- * extensions into the standardized extensions api output
116
- */
117
- stitch(installed, configured) {
118
- /**
119
- * On startup, the extensions manager will automatically create the rows for installed
120
- * extensions that don't have configured settings yet, so there should always be equal or more
121
- * settings rows than installed extensions.
122
- */
123
- return configured.map((meta) => {
124
- let bundleName = null;
125
- let name = meta.name;
126
- if (name.includes('/')) {
127
- const parts = name.split('/');
128
- // NPM packages can have an optional organization scope in the format
129
- // `@<org>/<package>`. This is limited to a single `/`.
130
- //
131
- // `foo` -> extension
132
- // `foo/bar` -> bundle
133
- // `@rijk/foo` -> extension
134
- // `@rijk/foo/bar -> bundle
135
- const hasOrg = parts.at(0).startsWith('@');
136
- if (hasOrg && parts.length > 2) {
137
- name = parts.pop();
138
- bundleName = parts.join('/');
139
- }
140
- else if (hasOrg === false) {
141
- [bundleName, name] = parts;
142
- }
143
- }
144
- let schema;
145
- if (bundleName) {
146
- const bundle = installed.find((extension) => extension.name === bundleName);
147
- if (bundle && 'entries' in bundle) {
148
- const entry = bundle.entries.find((entry) => entry.name === name) ?? null;
149
- if (entry) {
150
- schema = {
151
- type: entry.type,
152
- local: bundle.local,
153
- };
154
- }
155
- }
156
- else {
157
- schema = null;
158
- }
159
- }
160
- else {
161
- schema = installed.find((extension) => extension.name === name) ?? null;
162
- }
163
- return {
164
- name,
165
- bundle: bundleName,
166
- schema: schema ? pick(schema, 'type', 'local', 'version', 'partial') : null,
167
- meta: omit(meta, 'name'),
168
- };
169
- });
170
- }
171
188
  }