@directus/api 17.1.0 → 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 (95) 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/items.js +3 -2
  16. package/dist/controllers/permissions.js +1 -1
  17. package/dist/controllers/shares.js +19 -4
  18. package/dist/database/migrations/20220429A-add-flows.js +3 -3
  19. package/dist/database/migrations/20230526A-migrate-translation-strings.js +2 -2
  20. package/dist/database/migrations/20240204A-marketplace.d.ts +3 -0
  21. package/dist/database/migrations/20240204A-marketplace.js +68 -0
  22. package/dist/database/migrations/run.js +3 -2
  23. package/dist/extensions/lib/get-extensions-settings.d.ts +6 -2
  24. package/dist/extensions/lib/get-extensions-settings.js +70 -22
  25. package/dist/extensions/lib/get-extensions.d.ts +5 -1
  26. package/dist/extensions/lib/get-extensions.js +7 -31
  27. package/dist/extensions/lib/installation/index.d.ts +2 -0
  28. package/dist/extensions/lib/installation/index.js +9 -0
  29. package/dist/extensions/lib/installation/manager.d.ts +5 -0
  30. package/dist/extensions/lib/installation/manager.js +90 -0
  31. package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +1 -1
  32. package/dist/extensions/lib/sync-extensions.js +11 -10
  33. package/dist/extensions/manager.d.ts +27 -25
  34. package/dist/extensions/manager.js +214 -183
  35. package/dist/middleware/authenticate.d.ts +1 -0
  36. package/dist/middleware/error-handler.js +22 -18
  37. package/dist/middleware/extract-token.d.ts +6 -5
  38. package/dist/middleware/extract-token.js +27 -11
  39. package/dist/middleware/merge-content-versions.d.ts +2 -0
  40. package/dist/middleware/merge-content-versions.js +26 -0
  41. package/dist/middleware/respond.js +0 -12
  42. package/dist/middleware/validate-batch.d.ts +1 -0
  43. package/dist/request/agent-with-ip-validation.d.ts +1 -1
  44. package/dist/request/agent-with-ip-validation.js +5 -1
  45. package/dist/services/activity.js +3 -3
  46. package/dist/services/assets.js +2 -3
  47. package/dist/services/authentication.d.ts +7 -2
  48. package/dist/services/authentication.js +21 -13
  49. package/dist/services/extensions.d.ts +4 -8
  50. package/dist/services/extensions.js +110 -93
  51. package/dist/services/fields.js +28 -22
  52. package/dist/services/graphql/index.js +98 -42
  53. package/dist/services/index.d.ts +1 -1
  54. package/dist/services/index.js +1 -1
  55. package/dist/services/mail/index.d.ts +1 -1
  56. package/dist/services/mail/index.js +4 -2
  57. package/dist/services/payload.js +2 -2
  58. package/dist/services/{permissions.d.ts → permissions/index.d.ts} +3 -4
  59. package/dist/services/{permissions.js → permissions/index.js} +6 -23
  60. package/dist/services/permissions/lib/with-app-minimal-permissions.d.ts +2 -0
  61. package/dist/services/permissions/lib/with-app-minimal-permissions.js +13 -0
  62. package/dist/services/relations.d.ts +2 -3
  63. package/dist/services/relations.js +2 -2
  64. package/dist/services/roles.js +1 -1
  65. package/dist/services/server.js +3 -0
  66. package/dist/services/shares.d.ts +3 -1
  67. package/dist/services/shares.js +9 -5
  68. package/dist/storage/index.js +5 -4
  69. package/dist/types/auth.d.ts +6 -4
  70. package/dist/types/graphql.d.ts +1 -0
  71. package/dist/utils/apply-query.js +3 -3
  72. package/dist/utils/filter-items.d.ts +2 -2
  73. package/dist/utils/filter-items.js +1 -3
  74. package/dist/utils/get-cache-headers.d.ts +1 -0
  75. package/dist/utils/get-cache-key.d.ts +1 -0
  76. package/dist/utils/get-graphql-query-and-variables.d.ts +1 -0
  77. package/dist/utils/get-ip-from-req.d.ts +1 -0
  78. package/dist/utils/get-milliseconds.d.ts +1 -1
  79. package/dist/utils/get-milliseconds.js +4 -1
  80. package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
  81. package/dist/utils/is-login-redirect-allowed.js +34 -0
  82. package/dist/utils/is-url-allowed.d.ts +1 -1
  83. package/dist/utils/is-url-allowed.js +5 -5
  84. package/dist/utils/is-valid-uuid.d.ts +3 -0
  85. package/dist/utils/is-valid-uuid.js +21 -0
  86. package/dist/utils/jwt.d.ts +1 -1
  87. package/dist/utils/jwt.js +3 -3
  88. package/dist/utils/merge-version-data.d.ts +3 -0
  89. package/dist/utils/merge-version-data.js +134 -0
  90. package/dist/utils/sanitize-query.js +2 -0
  91. package/dist/utils/should-skip-cache.d.ts +1 -0
  92. package/dist/utils/validate-keys.js +2 -2
  93. package/dist/utils/validate-query.js +1 -0
  94. package/dist/websocket/controllers/base.js +2 -2
  95. package/package.json +44 -45
@@ -1,6 +1,10 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { ErrorCode, ForbiddenError, RouteNotFoundError, isDirectusError } from '@directus/errors';
3
+ import { EXTENSION_TYPES } from '@directus/extensions';
4
+ import { account, describe, list, } from '@directus/extensions-registry';
5
+ import { isIn } from '@directus/utils';
3
6
  import express from 'express';
7
+ import { UUID_REGEX } from '../constants.js';
4
8
  import { getExtensionManager } from '../extensions/index.js';
5
9
  import { respond } from '../middleware/respond.js';
6
10
  import useCollection from '../middleware/use-collection.js';
@@ -20,18 +24,96 @@ router.get('/', asyncHandler(async (req, res, next) => {
20
24
  res.locals['payload'] = { data: extensions || null };
21
25
  return next();
22
26
  }), respond);
23
- router.patch('/:bundleOrName/:name?', asyncHandler(async (req, res, next) => {
27
+ router.get('/registry', asyncHandler(async (req, res, next) => {
28
+ if (req.accountability && req.accountability.admin !== true) {
29
+ throw new ForbiddenError();
30
+ }
31
+ const { search, limit, offset, type, by, sort } = req.query;
32
+ const query = {};
33
+ if (typeof search === 'string') {
34
+ query.search = search;
35
+ }
36
+ if (typeof limit === 'string') {
37
+ query.limit = Number(limit);
38
+ }
39
+ if (typeof offset === 'string') {
40
+ query.offset = Number(offset);
41
+ }
42
+ if (typeof by === 'string') {
43
+ query.by = by;
44
+ }
45
+ if (typeof sort === 'string' && isIn(sort, ['popular', 'recent', 'downloads'])) {
46
+ query.sort = sort;
47
+ }
48
+ if (typeof type === 'string') {
49
+ if (isIn(type, EXTENSION_TYPES) === false) {
50
+ throw new ForbiddenError();
51
+ }
52
+ query.type = type;
53
+ }
54
+ if (env['MARKETPLACE_TRUST'] === 'sandbox') {
55
+ query.sandbox = true;
56
+ }
57
+ const options = {};
58
+ if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
59
+ options.registry = env['MARKETPLACE_REGISTRY'];
60
+ }
61
+ const payload = await list(query, options);
62
+ res.locals['payload'] = payload;
63
+ return next();
64
+ }), respond);
65
+ router.get(`/registry/account/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
66
+ if (typeof req.params['pk'] !== 'string') {
67
+ throw new ForbiddenError();
68
+ }
69
+ const options = {};
70
+ if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
71
+ options.registry = env['MARKETPLACE_REGISTRY'];
72
+ }
73
+ const payload = await account(req.params['pk'], options);
74
+ res.locals['payload'] = payload;
75
+ return next();
76
+ }), respond);
77
+ router.get(`/registry/extension/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
78
+ if (typeof req.params['pk'] !== 'string') {
79
+ throw new ForbiddenError();
80
+ }
81
+ const options = {};
82
+ if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
83
+ options.registry = env['MARKETPLACE_REGISTRY'];
84
+ }
85
+ const payload = await describe(req.params['pk'], options);
86
+ res.locals['payload'] = payload;
87
+ return next();
88
+ }), respond);
89
+ router.post('/registry/install', asyncHandler(async (req, _res, next) => {
90
+ if (req.accountability && req.accountability.admin !== true) {
91
+ throw new ForbiddenError();
92
+ }
93
+ const { version, extension } = req.body;
94
+ if (!version || !extension) {
95
+ throw new ForbiddenError();
96
+ }
24
97
  const service = new ExtensionsService({
25
98
  accountability: req.accountability,
26
99
  schema: req.schema,
27
100
  });
28
- const bundle = req.params['name'] ? req.params['bundleOrName'] : null;
29
- const name = req.params['name'] ? req.params['name'] : req.params['bundleOrName'];
30
- if (bundle === undefined || !name) {
101
+ await service.install(extension, version);
102
+ return next();
103
+ }), respond);
104
+ router.patch(`/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
105
+ if (req.accountability && req.accountability.admin !== true) {
31
106
  throw new ForbiddenError();
32
107
  }
108
+ if (typeof req.params['pk'] !== 'string') {
109
+ throw new ForbiddenError();
110
+ }
111
+ const service = new ExtensionsService({
112
+ accountability: req.accountability,
113
+ schema: req.schema,
114
+ });
33
115
  try {
34
- const result = await service.updateOne(bundle, name, req.body);
116
+ const result = await service.updateOne(req.params['pk'], req.body);
35
117
  res.locals['payload'] = { data: result || null };
36
118
  }
37
119
  catch (error) {
@@ -46,6 +128,21 @@ router.patch('/:bundleOrName/:name?', asyncHandler(async (req, res, next) => {
46
128
  }
47
129
  return next();
48
130
  }), respond);
131
+ router.delete(`/:pk(${UUID_REGEX})`, asyncHandler(async (req, _res, next) => {
132
+ if (req.accountability && req.accountability.admin !== true) {
133
+ throw new ForbiddenError();
134
+ }
135
+ const service = new ExtensionsService({
136
+ accountability: req.accountability,
137
+ schema: req.schema,
138
+ });
139
+ const pk = req.params['pk'];
140
+ if (typeof pk !== 'string') {
141
+ throw new ForbiddenError();
142
+ }
143
+ await service.deleteOne(pk);
144
+ return next();
145
+ }), respond);
49
146
  router.get('/sources/:chunk', asyncHandler(async (req, res) => {
50
147
  const chunk = req.params['chunk'];
51
148
  const extensionManager = getExtensionManager();
@@ -4,6 +4,7 @@ import { ErrorCode, ForbiddenError, RouteNotFoundError } from '@directus/errors'
4
4
  import collectionExists from '../middleware/collection-exists.js';
5
5
  import { respond } from '../middleware/respond.js';
6
6
  import { validateBatch } from '../middleware/validate-batch.js';
7
+ import { mergeContentVersions } from '../middleware/merge-content-versions.js';
7
8
  import { ItemsService } from '../services/items.js';
8
9
  import { MetaService } from '../services/meta.js';
9
10
  import asyncHandler from '../utils/async-handler.js';
@@ -76,7 +77,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
76
77
  return next();
77
78
  });
78
79
  router.search('/:collection', collectionExists, validateBatch('read'), readHandler, respond);
79
- router.get('/:collection', collectionExists, readHandler, respond);
80
+ router.get('/:collection', collectionExists, readHandler, mergeContentVersions, respond);
80
81
  router.get('/:collection/:pk', collectionExists, asyncHandler(async (req, res, next) => {
81
82
  if (isSystemCollection(req.params['collection']))
82
83
  throw new ForbiddenError();
@@ -89,7 +90,7 @@ router.get('/:collection/:pk', collectionExists, asyncHandler(async (req, res, n
89
90
  data: result || null,
90
91
  };
91
92
  return next();
92
- }), respond);
93
+ }), mergeContentVersions, respond);
93
94
  router.patch('/:collection', collectionExists, validateBatch('update'), asyncHandler(async (req, res, next) => {
94
95
  if (isSystemCollection(req.params['collection']))
95
96
  throw new ForbiddenError();
@@ -4,7 +4,7 @@ import { respond } from '../middleware/respond.js';
4
4
  import useCollection from '../middleware/use-collection.js';
5
5
  import { validateBatch } from '../middleware/validate-batch.js';
6
6
  import { MetaService } from '../services/meta.js';
7
- import { PermissionsService } from '../services/permissions.js';
7
+ import { PermissionsService } from '../services/permissions/index.js';
8
8
  import asyncHandler from '../utils/async-handler.js';
9
9
  import { sanitizeQuery } from '../utils/sanitize-query.js';
10
10
  const router = express.Router();
@@ -2,7 +2,7 @@ import { useEnv } from '@directus/env';
2
2
  import { ErrorCode, InvalidPayloadError, isDirectusError } from '@directus/errors';
3
3
  import express from 'express';
4
4
  import Joi from 'joi';
5
- import { COOKIE_OPTIONS, UUID_REGEX } from '../constants.js';
5
+ import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS, UUID_REGEX } from '../constants.js';
6
6
  import { respond } from '../middleware/respond.js';
7
7
  import useCollection from '../middleware/use-collection.js';
8
8
  import { validateBatch } from '../middleware/validate-batch.js';
@@ -15,6 +15,7 @@ router.use(useCollection('directus_shares'));
15
15
  const sharedLoginSchema = Joi.object({
16
16
  share: Joi.string().required(),
17
17
  password: Joi.string(),
18
+ mode: Joi.string().valid('cookie', 'json', 'session').optional(),
18
19
  }).unknown();
19
20
  router.post('/auth', asyncHandler(async (req, res, next) => {
20
21
  // This doesn't use accountability, as the user isn't logged in at this point
@@ -25,9 +26,23 @@ router.post('/auth', asyncHandler(async (req, res, next) => {
25
26
  if (error) {
26
27
  throw new InvalidPayloadError({ reason: error.message });
27
28
  }
28
- const { accessToken, refreshToken, expires } = await service.login(req.body);
29
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, COOKIE_OPTIONS);
30
- res.locals['payload'] = { data: { access_token: accessToken, expires } };
29
+ const mode = req.body.mode ?? 'json';
30
+ const { accessToken, refreshToken, expires } = await service.login(req.body, {
31
+ session: mode === 'session',
32
+ });
33
+ const payload = { expires };
34
+ if (mode === 'json') {
35
+ payload.refresh_token = refreshToken;
36
+ payload.access_token = accessToken;
37
+ }
38
+ if (mode === 'cookie') {
39
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
40
+ payload.access_token = accessToken;
41
+ }
42
+ if (mode === 'session') {
43
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
44
+ }
45
+ res.locals['payload'] = { data: payload };
31
46
  return next();
32
47
  }), respond);
33
48
  const sharedInviteSchema = Joi.object({
@@ -1,5 +1,5 @@
1
1
  import { parseJSON, toArray } from '@directus/utils';
2
- import { v4 as uuid } from 'uuid';
2
+ import { randomUUID } from 'node:crypto';
3
3
  export async function up(knex) {
4
4
  await knex.schema.createTable('directus_flows', (table) => {
5
5
  table.uuid('id').primary().notNullable();
@@ -33,7 +33,7 @@ export async function up(knex) {
33
33
  const flows = [];
34
34
  const operations = [];
35
35
  for (const webhook of webhooks) {
36
- const flowID = uuid();
36
+ const flowID = randomUUID();
37
37
  flows.push({
38
38
  id: flowID,
39
39
  name: webhook.name,
@@ -47,7 +47,7 @@ export async function up(knex) {
47
47
  }),
48
48
  });
49
49
  operations.push({
50
- id: uuid(),
50
+ id: randomUUID(),
51
51
  name: 'Request',
52
52
  key: 'request',
53
53
  type: 'request',
@@ -1,11 +1,11 @@
1
1
  import { set } from 'lodash-es';
2
- import { v4 as uuid } from 'uuid';
2
+ import { randomUUID } from 'node:crypto';
3
3
  function transformStringsNewFormat(oldStrings) {
4
4
  return oldStrings.reduce((result, item) => {
5
5
  if (!item.key || !item.translations)
6
6
  return result;
7
7
  for (const [language, value] of Object.entries(item.translations)) {
8
- result.push({ id: uuid(), key: item.key, language, value });
8
+ result.push({ id: randomUUID(), key: item.key, language, value });
9
9
  }
10
10
  return result;
11
11
  }, []);
@@ -0,0 +1,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { resolvePackage } from '@directus/utils/node';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ export async function up(knex) {
7
+ await knex.schema.alterTable('directus_extensions', (table) => {
8
+ table.uuid('id').nullable();
9
+ table.string('source', 255);
10
+ table.uuid('bundle');
11
+ });
12
+ const installedExtensions = await knex.select('name').from('directus_extensions');
13
+ // name: id
14
+ const idMap = new Map();
15
+ for (const { name } of installedExtensions) {
16
+ // Delete extension meta status that used the legacy `${name}:${type}` name syntax for
17
+ // extension-folder scoped extensions
18
+ if (name.includes(':')) {
19
+ await knex('directus_extensions').delete().where({ name });
20
+ }
21
+ else {
22
+ const id = randomUUID();
23
+ let source;
24
+ try {
25
+ // The NPM package name is the name used in the database. If we can resolve the
26
+ // extension as a node module it's safe to assume it's a npm-module source
27
+ resolvePackage(name, __dirname);
28
+ source = 'module';
29
+ }
30
+ catch {
31
+ source = 'local';
32
+ }
33
+ await knex('directus_extensions').update({ id, source }).where({ name });
34
+ idMap.set(name, id);
35
+ }
36
+ }
37
+ for (const { name } of installedExtensions) {
38
+ if (!name.includes('/'))
39
+ continue;
40
+ const splittedName = name.split('/');
41
+ const isScopedModuleBundleParent = name.startsWith('@') && splittedName.length == 2;
42
+ if (isScopedModuleBundleParent)
43
+ continue;
44
+ const isScopedModuleBundleChild = name.startsWith('@') && splittedName.length > 2;
45
+ const bundleParentName = isScopedModuleBundleParent || isScopedModuleBundleChild ? splittedName.slice(0, 2).join('/') : splittedName[0];
46
+ const bundleParentId = idMap.get(bundleParentName);
47
+ if (!bundleParentId)
48
+ continue;
49
+ await knex('directus_extensions')
50
+ .update({ bundle: bundleParentId, name: name.substring(bundleParentName.length + 1) })
51
+ .where({ name });
52
+ }
53
+ await knex.schema.alterTable('directus_extensions', (table) => {
54
+ table.dropPrimary();
55
+ table.uuid('id').alter().primary().notNullable();
56
+ table.string('source', 255).alter().notNullable().defaultTo('local');
57
+ table.renameColumn('name', 'folder');
58
+ });
59
+ }
60
+ export async function down(knex) {
61
+ await knex.schema.alterTable('directus_extensions', (table) => {
62
+ table.dropColumns('id', 'source', 'bundle');
63
+ table.renameColumn('folder', 'name');
64
+ });
65
+ await knex.schema.alterTable('directus_extensions', (table) => {
66
+ table.string('name', 255).primary().alter();
67
+ });
68
+ }
@@ -1,3 +1,4 @@
1
+ import { useEnv } from '@directus/env';
1
2
  import formatTitle from '@directus/format-title';
2
3
  import fse from 'fs-extra';
3
4
  import { orderBy } from 'lodash-es';
@@ -5,14 +6,14 @@ import { dirname } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import path from 'path';
7
8
  import { flushCaches } from '../../cache.js';
8
- import { getExtensionsPath } from '../../extensions/lib/get-extensions-path.js';
9
9
  import { useLogger } from '../../logger.js';
10
10
  import getModuleDefault from '../../utils/get-module-default.js';
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  export default async function run(database, direction, log = true) {
13
+ const env = useEnv();
13
14
  const logger = useLogger();
14
15
  let migrationFiles = await fse.readdir(__dirname);
15
- const customMigrationsPath = path.resolve(getExtensionsPath(), 'migrations');
16
+ const customMigrationsPath = path.resolve(env['MIGRATIONS_PATH']);
16
17
  let customMigrationFiles = ((await fse.pathExists(customMigrationsPath)) && (await fse.readdir(customMigrationsPath))) || [];
17
18
  migrationFiles = migrationFiles.filter((file) => /^[0-9]+[A-Z]-[^.]+\.(?:js|ts)$/.test(file));
18
19
  customMigrationFiles = customMigrationFiles.filter((file) => file.includes('-') && /\.(c|m)?js$/.test(file));
@@ -1,7 +1,11 @@
1
- import type { Extension } from '@directus/extensions';
1
+ import type { Extension, ExtensionSettings } from '@directus/extensions';
2
2
  /**
3
3
  * Loads stored settings for all extensions. Creates empty new rows in extensions tables for
4
4
  * extensions that don't have settings yet, and remove any settings for extensions that are no
5
5
  * longer installed.
6
6
  */
7
- export declare const getExtensionsSettings: (extensions: Extension[]) => Promise<import("@directus/extensions").ExtensionSettings[]>;
7
+ export declare const getExtensionsSettings: ({ local, module, registry, }: {
8
+ local: Map<string, Extension>;
9
+ module: Map<string, Extension>;
10
+ registry: Map<string, Extension>;
11
+ }) => Promise<ExtensionSettings[]>;
@@ -1,4 +1,4 @@
1
- import { difference } from 'lodash-es';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import getDatabase from '../../database/index.js';
3
3
  import { ExtensionsService } from '../../services/extensions.js';
4
4
  import { getSchema } from '../../utils/get-schema.js';
@@ -7,33 +7,81 @@ import { getSchema } from '../../utils/get-schema.js';
7
7
  * extensions that don't have settings yet, and remove any settings for extensions that are no
8
8
  * longer installed.
9
9
  */
10
- export const getExtensionsSettings = async (extensions) => {
10
+ export const getExtensionsSettings = async ({ local, module, registry, }) => {
11
11
  const database = getDatabase();
12
12
  const service = new ExtensionsService({
13
13
  knex: database,
14
14
  schema: await getSchema(),
15
15
  });
16
- const settings = await service.extensionsItemService.readByQuery({ limit: -1 });
17
- const extensionNames = extensions
18
- .map((extension) => {
16
+ const existingSettings = await service.extensionsItemService.readByQuery({ limit: -1 });
17
+ const newSettings = [];
18
+ const localSettings = existingSettings.filter(({ source }) => source === 'local');
19
+ const registrySettings = existingSettings.filter(({ source }) => source === 'registry');
20
+ const moduleSettings = existingSettings.filter(({ source }) => source === 'module');
21
+ const generateSettingsEntry = (folder, extension, source) => {
19
22
  if (extension.type === 'bundle') {
20
- return [extension.name, ...extension.entries.map((entry) => `${extension.name}/${entry.name}`)];
23
+ const bundleId = randomUUID();
24
+ newSettings.push({
25
+ id: bundleId,
26
+ enabled: true,
27
+ source: source,
28
+ bundle: null,
29
+ folder: folder,
30
+ });
31
+ for (const entry of extension.entries) {
32
+ newSettings.push({
33
+ id: randomUUID(),
34
+ enabled: true,
35
+ source: source,
36
+ bundle: bundleId,
37
+ folder: entry.name,
38
+ });
39
+ }
21
40
  }
22
- return extension.name;
23
- })
24
- .flat();
25
- const extensionSettingNames = settings.map(({ name }) => name);
26
- const missing = difference(extensionNames, extensionSettingNames);
27
- if (missing.length > 0) {
28
- const missingRows = missing.map((name) => ({ name, enabled: true }));
29
- await database.insert(missingRows).into('directus_extensions');
30
- settings.push(...missingRows);
41
+ else {
42
+ newSettings.push({
43
+ id: randomUUID(),
44
+ enabled: true,
45
+ source: source,
46
+ bundle: null,
47
+ folder: folder,
48
+ });
49
+ }
50
+ };
51
+ for (const [folder, extension] of local.entries()) {
52
+ const settingsExist = localSettings.some((settings) => settings.folder === folder);
53
+ if (settingsExist)
54
+ continue;
55
+ const settingsForName = localSettings.find((settings) => settings.folder === extension.name);
56
+ /*
57
+ * TODO: Consider removing this in follow-up versions after v10.10.0
58
+ *
59
+ * Previously, the package name (from package.json) was used to identify
60
+ * local extensions - now it's the folder name.
61
+ * If those two are different, we need to check for existing settings
62
+ * with the package name, too. On a match and if there's no other local extension
63
+ * with such a folder name, these settings can be taken over with the folder updated.
64
+ */
65
+ if (settingsForName && !local.has(extension.name)) {
66
+ await service.extensionsItemService.updateOne(settingsForName.id, { folder });
67
+ continue;
68
+ }
69
+ if (!settingsExist)
70
+ generateSettingsEntry(folder, extension, 'local');
71
+ }
72
+ for (const [folder, extension] of module.entries()) {
73
+ const settingsExist = moduleSettings.some((settings) => settings.folder === folder);
74
+ if (!settingsExist)
75
+ generateSettingsEntry(folder, extension, 'module');
76
+ }
77
+ for (const [folder, extension] of registry.entries()) {
78
+ const settingsExist = registrySettings.some((settings) => settings.folder === folder);
79
+ if (!settingsExist)
80
+ generateSettingsEntry(folder, extension, 'registry');
81
+ }
82
+ if (newSettings.length > 0) {
83
+ await database.insert(newSettings).into('directus_extensions');
31
84
  }
32
- /**
33
- * Silently ignore settings for extensions that have been manually removed from the extensions
34
- * folder. Having them automatically synced feels dangerous, as it's a destructive action. In the
35
- * edge case you'd deploy / start with the extensions folder misconfigured, it would remove all
36
- * previous options on startup, without an option to undo.
37
- */
38
- return settings.filter(({ name }) => extensionNames.includes(name));
85
+ const settings = [...existingSettings, ...newSettings];
86
+ return settings;
39
87
  };
@@ -1 +1,5 @@
1
- export declare const getExtensions: () => Promise<any[]>;
1
+ export declare const getExtensions: () => Promise<{
2
+ local: Map<string, import("@directus/extensions/node").Extension>;
3
+ registry: Map<string, import("@directus/extensions/node").Extension>;
4
+ module: Map<string, import("@directus/extensions/node").Extension>;
5
+ }>;
@@ -1,36 +1,12 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { getLocalExtensions, getPackageExtensions, resolvePackageExtensions } from '@directus/extensions/node';
3
- import { useLogger } from '../../logger.js';
2
+ import { resolveFsExtensions, resolveModuleExtensions } from '@directus/extensions/node';
3
+ import { join } from 'node:path';
4
4
  import { getExtensionsPath } from './get-extensions-path.js';
5
5
  export const getExtensions = async () => {
6
6
  const env = useEnv();
7
- const logger = useLogger();
8
- const loadedExtensions = new Map();
9
- const duplicateExtensions = [];
10
- const filterDuplicates = (extension) => {
11
- const isExistingExtension = loadedExtensions.has(extension.name);
12
- if (isExistingExtension) {
13
- duplicateExtensions.push(extension.name);
14
- return;
15
- }
16
- if (extension.type === 'bundle') {
17
- const bundleEntryNames = new Set();
18
- for (const entry of extension.entries) {
19
- if (bundleEntryNames.has(entry.name)) {
20
- // Do not load entire bundle if it has duplicated entries
21
- duplicateExtensions.push(extension.name);
22
- return;
23
- }
24
- bundleEntryNames.add(entry.name);
25
- }
26
- }
27
- loadedExtensions.set(extension.name, extension);
28
- };
29
- (await getLocalExtensions(getExtensionsPath())).forEach(filterDuplicates);
30
- (await resolvePackageExtensions(getExtensionsPath())).forEach(filterDuplicates);
31
- (await getPackageExtensions(env['PACKAGE_FILE_LOCATION'])).forEach(filterDuplicates);
32
- if (duplicateExtensions.length > 0) {
33
- logger.warn(`Failed to load the following extensions because they have/contain duplicate names: ${duplicateExtensions.join(', ')}`);
34
- }
35
- return Array.from(loadedExtensions.values());
7
+ const localExtensions = await resolveFsExtensions(getExtensionsPath());
8
+ const registryExtensions = await resolveFsExtensions(join(getExtensionsPath(), '.registry'));
9
+ /** Extensions that are listed as dependencies in the root package.json */
10
+ const moduleExtensions = await resolveModuleExtensions(env['PACKAGE_FILE_LOCATION']);
11
+ return { local: localExtensions, registry: registryExtensions, module: moduleExtensions };
36
12
  };
@@ -0,0 +1,2 @@
1
+ import { InstallationManager } from './manager.js';
2
+ export declare function getInstallationManager(): InstallationManager;
@@ -0,0 +1,9 @@
1
+ import { InstallationManager } from './manager.js';
2
+ let manager;
3
+ export function getInstallationManager() {
4
+ if (manager) {
5
+ return manager;
6
+ }
7
+ manager = new InstallationManager();
8
+ return manager;
9
+ }
@@ -0,0 +1,5 @@
1
+ export declare class InstallationManager {
2
+ extensionPath: string;
3
+ install(versionId: string): Promise<void>;
4
+ uninstall(folder: string): Promise<void>;
5
+ }
@@ -0,0 +1,90 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { ServiceUnavailableError } from '@directus/errors';
3
+ import { EXTENSION_PKG_KEY, ExtensionManifest } from '@directus/extensions';
4
+ import { download } from '@directus/extensions-registry';
5
+ import DriverLocal from '@directus/storage-driver-local';
6
+ import { move, remove } from 'fs-extra';
7
+ import { mkdir, readFile, rm } from 'node:fs/promises';
8
+ import { Readable } from 'node:stream';
9
+ import Queue from 'p-queue';
10
+ import { join } from 'path';
11
+ import tar from 'tar';
12
+ import { useLogger } from '../../../logger.js';
13
+ import { getStorage } from '../../../storage/index.js';
14
+ import { getExtensionsPath } from '../get-extensions-path.js';
15
+ const env = useEnv();
16
+ export class InstallationManager {
17
+ extensionPath = getExtensionsPath();
18
+ async install(versionId) {
19
+ const logger = useLogger();
20
+ const tempDir = join(env['TEMP_PATH'], 'marketplace', versionId);
21
+ const tmpStorage = new DriverLocal({ root: tempDir });
22
+ try {
23
+ await mkdir(tempDir, { recursive: true });
24
+ const options = {};
25
+ if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
26
+ options.registry = env['MARKETPLACE_REGISTRY'];
27
+ }
28
+ const tarReadableStream = await download(versionId, env['MARKETPLACE_TRUST'] === 'sandbox', options);
29
+ if (!tarReadableStream) {
30
+ throw new Error(`No readable stream returned from download`);
31
+ }
32
+ const tarStream = Readable.fromWeb(tarReadableStream);
33
+ const tarPath = join(tempDir, `bin.tar.tgz`);
34
+ await tmpStorage.write('bin.tar.tgz', tarStream);
35
+ /**
36
+ * NPM modules that are packed are always tarballed in a folder called "package"
37
+ */
38
+ const extractedPath = 'package';
39
+ await tar.extract({
40
+ file: tarPath,
41
+ cwd: tempDir,
42
+ });
43
+ const packageFile = JSON.parse(await readFile(join(tempDir, extractedPath, 'package.json'), { encoding: 'utf-8' }));
44
+ const extensionManifest = await ExtensionManifest.parseAsync(packageFile);
45
+ if (!extensionManifest[EXTENSION_PKG_KEY]?.type) {
46
+ throw new Error(`Extension type not found in package.json`);
47
+ }
48
+ if (env['EXTENSIONS_LOCATION']) {
49
+ // Upload the extension into the configured extensions location
50
+ const storage = await getStorage();
51
+ const remoteDisk = storage.location(env['EXTENSIONS_LOCATION']);
52
+ const queue = new Queue({ concurrency: 1000 });
53
+ for await (const filepath of tmpStorage.list(extractedPath)) {
54
+ const readStream = await tmpStorage.read(filepath);
55
+ const remotePath = join(env['EXTENSIONS_PATH'], '.registry', versionId, filepath.substring(extractedPath.length));
56
+ queue.add(() => remoteDisk.write(remotePath, readStream));
57
+ }
58
+ await queue.onIdle();
59
+ }
60
+ else {
61
+ // No custom location, so save to regular local extensions folder
62
+ const dest = join(this.extensionPath, '.registry', versionId);
63
+ await move(join(tempDir, extractedPath), dest, { overwrite: true });
64
+ }
65
+ }
66
+ catch (err) {
67
+ logger.warn(err);
68
+ throw new ServiceUnavailableError({ service: 'marketplace', reason: 'Could not download and extract the extension' }, { cause: err });
69
+ }
70
+ finally {
71
+ await rm(tempDir, { recursive: true });
72
+ }
73
+ }
74
+ async uninstall(folder) {
75
+ if (env['EXTENSIONS_LOCATION']) {
76
+ const storage = await getStorage();
77
+ const remoteDisk = storage.location(env['EXTENSIONS_LOCATION']);
78
+ const queue = new Queue({ concurrency: 1000 });
79
+ const prefix = join(env['EXTENSIONS_PATH'], '.registry', folder);
80
+ for await (const filepath of remoteDisk.list(prefix)) {
81
+ queue.add(() => remoteDisk.delete(filepath));
82
+ }
83
+ await queue.onIdle();
84
+ }
85
+ else {
86
+ const path = join(this.extensionPath, '.registry', folder);
87
+ await remove(path);
88
+ }
89
+ }
90
+ }
@@ -16,7 +16,7 @@ export declare function generateApiExtensionsSandboxEntrypoint(type: ApiExtensio
16
16
  unregisterFunction: () => Promise<void>;
17
17
  } | {
18
18
  code: string;
19
- hostFunctions: ((path: import("isolated-vm").Reference<string>, method: import("isolated-vm").Reference<"GET" | "POST" | "DELETE" | "PATCH" | "PUT">, cb: import("isolated-vm").Reference<(req: {
19
+ hostFunctions: ((path: import("isolated-vm").Reference<string>, method: import("isolated-vm").Reference<"GET" | "POST" | "DELETE" | "PUT" | "PATCH">, cb: import("isolated-vm").Reference<(req: {
20
20
  url: string;
21
21
  headers: import("http").IncomingHttpHeaders;
22
22
  body: string;