@directus/api 17.1.0 → 18.1.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 +88 -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 +6 -5
  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 +45 -46
@@ -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,88 @@
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('folder');
10
+ table.string('source');
11
+ table.uuid('bundle');
12
+ });
13
+ const installedExtensions = await knex.select('name').from('directus_extensions');
14
+ // name: id
15
+ const idMap = new Map();
16
+ for (const { name } of installedExtensions) {
17
+ // Delete extension meta status that used the legacy `${name}:${type}` name syntax for
18
+ // extension-folder scoped extensions
19
+ if (name.includes(':')) {
20
+ await knex('directus_extensions').delete().where({ name });
21
+ }
22
+ else {
23
+ const id = randomUUID();
24
+ let source;
25
+ try {
26
+ // The NPM package name is the name used in the database. If we can resolve the
27
+ // extension as a node module it's safe to assume it's a npm-module source
28
+ resolvePackage(name, __dirname);
29
+ source = 'module';
30
+ }
31
+ catch {
32
+ source = 'local';
33
+ }
34
+ await knex('directus_extensions').update({ id, source, folder: name }).where({ name });
35
+ idMap.set(name, id);
36
+ }
37
+ }
38
+ for (const { name } of installedExtensions) {
39
+ if (!name.includes('/'))
40
+ continue;
41
+ const splittedName = name.split('/');
42
+ const isScopedModuleBundleParent = name.startsWith('@') && splittedName.length == 2;
43
+ if (isScopedModuleBundleParent)
44
+ continue;
45
+ const isScopedModuleBundleChild = name.startsWith('@') && splittedName.length > 2;
46
+ const bundleParentName = isScopedModuleBundleParent || isScopedModuleBundleChild ? splittedName.slice(0, 2).join('/') : splittedName[0];
47
+ const bundleParentId = idMap.get(bundleParentName);
48
+ if (!bundleParentId)
49
+ continue;
50
+ await knex('directus_extensions')
51
+ .update({ bundle: bundleParentId, folder: name.substring(bundleParentName.length + 1) })
52
+ .where({ folder: name });
53
+ }
54
+ await knex.schema.alterTable('directus_extensions', (table) => {
55
+ table.dropColumn('name');
56
+ table.uuid('id').alter().primary().notNullable();
57
+ table.string('source').alter().notNullable();
58
+ table.string('folder').alter().notNullable();
59
+ });
60
+ }
61
+ /*
62
+ * Note: For local extensions having a different package & folder name,
63
+ * we aren't able to revert to the exact same state as before.
64
+ * But we still need to do the name convertion, in order for the migration to succeed.
65
+ */
66
+ export async function down(knex) {
67
+ await knex.schema.alterTable('directus_extensions', (table) => {
68
+ table.string('name');
69
+ });
70
+ const installedExtensions = await knex.select(['id', 'folder', 'bundle']).from('directus_extensions');
71
+ const idMap = new Map(installedExtensions.map((extension) => [extension.id, extension.folder]));
72
+ for (const { id, folder, bundle, source } of installedExtensions) {
73
+ if (source === 'registry') {
74
+ await knex('directus_extensions').delete().where({ id });
75
+ continue;
76
+ }
77
+ let name = folder;
78
+ if (bundle) {
79
+ const bundleParentName = idMap.get(bundle);
80
+ name = `${bundleParentName}/${name}`;
81
+ }
82
+ await knex('directus_extensions').update({ name }).where({ id });
83
+ }
84
+ await knex.schema.alterTable('directus_extensions', (table) => {
85
+ table.dropColumns('id', 'folder', 'source', 'bundle');
86
+ table.string('name').alter().primary().notNullable();
87
+ });
88
+ }
@@ -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
+ }