@directus/api 14.0.2 → 14.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/__utils__/mock-env.d.ts +18 -0
  2. package/dist/__utils__/mock-env.js +41 -0
  3. package/dist/auth/drivers/oauth2.js +4 -2
  4. package/dist/auth/drivers/openid.js +4 -2
  5. package/dist/cli/load-extensions.js +1 -2
  6. package/dist/constants.js +1 -1
  7. package/dist/controllers/assets.js +10 -10
  8. package/dist/controllers/files.js +1 -5
  9. package/dist/controllers/server.js +1 -1
  10. package/dist/database/index.js +2 -1
  11. package/dist/database/migrations/run.js +2 -2
  12. package/dist/database/system-data/collections/collections.yaml +1 -1
  13. package/dist/database/system-data/fields/settings.yaml +12 -13
  14. package/dist/database/system-data/fields/users.yaml +10 -10
  15. package/dist/env.d.ts +2 -4
  16. package/dist/env.js +12 -9
  17. package/dist/extensions/lib/get-extensions-path.d.ts +1 -0
  18. package/dist/extensions/lib/get-extensions-path.js +8 -0
  19. package/dist/extensions/lib/get-extensions.js +3 -2
  20. package/dist/extensions/lib/sync-extensions.d.ts +1 -0
  21. package/dist/extensions/lib/sync-extensions.js +59 -0
  22. package/dist/extensions/lib/sync-status.d.ts +10 -0
  23. package/dist/extensions/lib/sync-status.js +27 -0
  24. package/dist/extensions/manager.js +14 -5
  25. package/dist/logger.d.ts +2 -1
  26. package/dist/logger.js +13 -2
  27. package/dist/messenger.js +1 -3
  28. package/dist/request/validate-ip.js +1 -2
  29. package/dist/services/extensions.js +1 -1
  30. package/dist/services/files.d.ts +2 -2
  31. package/dist/services/files.js +90 -23
  32. package/dist/services/mail/index.js +5 -4
  33. package/dist/services/mail/templates/base.liquid +383 -138
  34. package/dist/services/mail/templates/password-reset.liquid +35 -17
  35. package/dist/services/mail/templates/user-invitation.liquid +32 -13
  36. package/dist/services/server.js +4 -0
  37. package/dist/services/specifications.d.ts +3 -16
  38. package/dist/services/specifications.js +63 -64
  39. package/dist/storage/register-drivers.js +1 -2
  40. package/dist/storage/register-locations.js +1 -2
  41. package/dist/types/services.d.ts +1 -1
  42. package/dist/utils/get-auth-providers.d.ts +1 -1
  43. package/dist/utils/get-config-from-env.js +1 -2
  44. package/dist/utils/merge-permissions.js +11 -19
  45. package/dist/utils/sanitize-query.js +1 -2
  46. package/dist/utils/should-clear-cache.js +1 -2
  47. package/dist/utils/should-skip-cache.js +3 -4
  48. package/dist/utils/validate-env.js +1 -2
  49. package/dist/utils/validate-storage.js +12 -9
  50. package/package.json +16 -15
  51. package/dist/__mocks__/cache.d.mts +0 -5
  52. package/dist/__mocks__/cache.mjs +0 -7
@@ -0,0 +1,18 @@
1
+ export declare function setEnv(newEnv: Record<string, string>): void;
2
+ /** Static env mock - to be used inside `vi.mock` */
3
+ export declare function mockEnv(options?: {
4
+ env?: Record<string, string>;
5
+ withDefaults?: boolean;
6
+ }): Promise<{
7
+ readonly default: Record<string, any>;
8
+ }>;
9
+ /** Dynamic env mock */
10
+ export declare function doMockEnv(options?: {
11
+ env?: Record<string, string>;
12
+ withDefaults?: boolean;
13
+ }): {
14
+ env: {
15
+ [x: string]: string;
16
+ };
17
+ setEnv: (newEnv: Record<string, string>) => void;
18
+ };
@@ -0,0 +1,41 @@
1
+ import { afterEach, vi } from 'vitest';
2
+ let env = {};
3
+ export function setEnv(newEnv) {
4
+ env = { ...env, ...newEnv };
5
+ }
6
+ /** Static env mock - to be used inside `vi.mock` */
7
+ export async function mockEnv(options) {
8
+ const initialEnv = options?.env ?? {};
9
+ const withDefaults = options && 'withDefaults' in options ? options.withDefaults : true;
10
+ env = { ...initialEnv };
11
+ afterEach(() => {
12
+ env = { ...initialEnv };
13
+ });
14
+ const { defaults, processValues } = await vi.importActual('../env.js');
15
+ return {
16
+ get default() {
17
+ return processValues({ ...(withDefaults && defaults), ...env });
18
+ },
19
+ };
20
+ }
21
+ /** Dynamic env mock */
22
+ export function doMockEnv(options) {
23
+ const initialEnv = options?.env ?? {};
24
+ const withDefaults = options && 'withDefaults' in options ? options.withDefaults : true;
25
+ let env = { ...initialEnv };
26
+ vi.doMock('../env.js', async () => {
27
+ const { defaults, processValues } = await vi.importActual('../env.js');
28
+ return {
29
+ get default() {
30
+ return processValues({ ...(withDefaults && defaults), ...env });
31
+ },
32
+ };
33
+ });
34
+ afterEach(() => {
35
+ env = { ...initialEnv };
36
+ });
37
+ return { env, setEnv };
38
+ function setEnv(newEnv) {
39
+ env = { ...env, ...newEnv };
40
+ }
41
+ }
@@ -122,13 +122,15 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
122
122
  if (userId) {
123
123
  // Run hook so the end user has the chance to augment the
124
124
  // user that is about to be updated
125
- const updatedUserPayload = await emitter.emitFilter(`auth.update`, { auth_data: userPayload.auth_data ?? null }, {
125
+ const updatedUserPayload = await emitter.emitFilter(`auth.update`, { auth_data: userPayload.auth_data }, {
126
126
  identifier,
127
127
  provider: this.config['provider'],
128
128
  providerPayload: { accessToken: tokenSet.access_token, userInfo },
129
129
  }, { database: getDatabase(), schema: this.schema, accountability: null });
130
130
  // Update user to update refresh_token and other properties that might have changed
131
- await this.usersService.updateOne(userId, updatedUserPayload);
131
+ if (Object.values(updatedUserPayload).some((value) => value !== undefined)) {
132
+ await this.usersService.updateOne(userId, updatedUserPayload);
133
+ }
132
134
  return userId;
133
135
  }
134
136
  // Is public registration allowed?
@@ -141,13 +141,15 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
141
141
  if (userId) {
142
142
  // Run hook so the end user has the chance to augment the
143
143
  // user that is about to be updated
144
- const updatedUserPayload = await emitter.emitFilter(`auth.update`, { auth_data: userPayload.auth_data ?? null }, {
144
+ const updatedUserPayload = await emitter.emitFilter(`auth.update`, { auth_data: userPayload.auth_data }, {
145
145
  identifier,
146
146
  provider: this.config['provider'],
147
147
  providerPayload: { accessToken: tokenSet.access_token, userInfo },
148
148
  }, { database: getDatabase(), schema: this.schema, accountability: null });
149
149
  // Update user to update refresh_token and other properties that might have changed
150
- await this.usersService.updateOne(userId, updatedUserPayload);
150
+ if (Object.values(updatedUserPayload).some((value) => value !== undefined)) {
151
+ await this.usersService.updateOne(userId, updatedUserPayload);
152
+ }
151
153
  return userId;
152
154
  }
153
155
  const isEmailVerified = !requireVerifiedEmail || userInfo['email_verified'];
@@ -1,9 +1,8 @@
1
1
  import { isInstalled, validateMigrations } from '../database/index.js';
2
- import { getEnv } from '../env.js';
2
+ import env from '../env.js';
3
3
  import { getExtensionManager } from '../extensions/index.js';
4
4
  import logger from '../logger.js';
5
5
  export const loadExtensions = async () => {
6
- const env = getEnv();
7
6
  if (!('DB_CLIENT' in env))
8
7
  return;
9
8
  const installed = await isInstalled();
package/dist/constants.js CHANGED
@@ -55,7 +55,7 @@ export const COOKIE_OPTIONS = {
55
55
  secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
56
56
  sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
57
57
  };
58
- export const OAS_REQUIRED_SCHEMAS = ['Diff', 'Schema', 'Query', 'x-metadata'];
58
+ export const OAS_REQUIRED_SCHEMAS = ['Query', 'x-metadata'];
59
59
  /** Formats from which transformation is supported */
60
60
  export const SUPPORTED_IMAGE_TRANSFORM_FORMATS = ['image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/avif'];
61
61
  /** Formats where metadata extraction is supported */
@@ -32,11 +32,6 @@ asyncHandler(async (req, res, next) => {
32
32
  }
33
33
  const assetSettings = savedAssetSettings || defaults;
34
34
  const transformation = pick(req.query, ASSET_TRANSFORM_QUERY_KEYS);
35
- if ('key' in transformation && Object.keys(transformation).length > 1) {
36
- throw new InvalidQueryError({
37
- reason: `You can't combine the "key" query parameter with any other transformation`,
38
- });
39
- }
40
35
  if ('transforms' in transformation) {
41
36
  let transforms;
42
37
  // Try parse the JSON array
@@ -90,13 +85,17 @@ asyncHandler(async (req, res, next) => {
90
85
  return next();
91
86
  }
92
87
  else if (assetSettings.storage_asset_transform === 'presets') {
93
- if (allKeys.includes(transformation['key']))
88
+ if (allKeys.includes(transformation['key']) && Object.keys(transformation).length === 1) {
94
89
  return next();
90
+ }
95
91
  throw new InvalidQueryError({ reason: `Only configured presets can be used in asset generation` });
96
92
  }
97
93
  else {
98
- if (transformation['key'] && systemKeys.includes(transformation['key']))
94
+ if (transformation['key'] &&
95
+ systemKeys.includes(transformation['key']) &&
96
+ Object.keys(transformation).length === 1) {
99
97
  return next();
98
+ }
100
99
  throw new InvalidQueryError({ reason: `Dynamic asset generation has been disabled for this project` });
101
100
  }
102
101
  }), asyncHandler(async (req, res, next) => {
@@ -116,9 +115,10 @@ asyncHandler(async (req, res) => {
116
115
  schema: req.schema,
117
116
  });
118
117
  const vary = ['Origin', 'Cache-Control'];
119
- const transformationParams = res.locals['transformation'].key
120
- ? res.locals['shortcuts'].find((transformation) => transformation['key'] === res.locals['transformation'].key)
121
- : res.locals['transformation'];
118
+ const transformationParams = {
119
+ ...res.locals['shortcuts'].find((transformation) => transformation['key'] === res.locals['transformation']?.key),
120
+ ...res.locals['transformation'],
121
+ };
122
122
  let acceptFormat;
123
123
  if (transformationParams.format === 'auto') {
124
124
  if (req.headers.accept?.includes('image/avif')) {
@@ -8,7 +8,7 @@ import Joi from 'joi';
8
8
  import { minimatch } from 'minimatch';
9
9
  import path from 'path';
10
10
  import env from '../env.js';
11
- import { ContentTooLargeError, ErrorCode, InvalidPayloadError } from '@directus/errors';
11
+ import { ErrorCode, InvalidPayloadError } from '@directus/errors';
12
12
  import { respond } from '../middleware/respond.js';
13
13
  import useCollection from '../middleware/use-collection.js';
14
14
  import { validateBatch } from '../middleware/validate-batch.js';
@@ -85,10 +85,6 @@ export const multipartHandler = (req, res, next) => {
85
85
  };
86
86
  // Clear the payload for the next to-be-uploaded file
87
87
  payload = {};
88
- fileStream.on('limit', () => {
89
- const error = new ContentTooLargeError();
90
- next(error);
91
- });
92
88
  try {
93
89
  const primaryKey = await service.uploadOne(fileStream, payloadWithRequiredFields, existingPrimaryKey);
94
90
  savedFiles.push(primaryKey);
@@ -11,7 +11,7 @@ router.get('/specs/oas', asyncHandler(async (req, res, next) => {
11
11
  accountability: req.accountability,
12
12
  schema: req.schema,
13
13
  });
14
- res.locals['payload'] = await service.oas.generate();
14
+ res.locals['payload'] = await service.oas.generate(req.headers.host);
15
15
  return next();
16
16
  }), respond);
17
17
  router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
@@ -8,6 +8,7 @@ import path from 'path';
8
8
  import { performance } from 'perf_hooks';
9
9
  import { promisify } from 'util';
10
10
  import env from '../env.js';
11
+ import { getExtensionsPath } from '../extensions/lib/get-extensions-path.js';
11
12
  import logger from '../logger.js';
12
13
  import { getConfigFromEnv } from '../utils/get-config-from-env.js';
13
14
  import { validateEnv } from '../utils/validate-env.js';
@@ -209,7 +210,7 @@ export async function validateMigrations() {
209
210
  const database = getDatabase();
210
211
  try {
211
212
  let migrationFiles = await fse.readdir(path.join(__dirname, 'migrations'));
212
- const customMigrationsPath = path.resolve(env['EXTENSIONS_PATH'], 'migrations');
213
+ const customMigrationsPath = path.resolve(getExtensionsPath(), 'migrations');
213
214
  let customMigrationFiles = ((await fse.pathExists(customMigrationsPath)) && (await fse.readdir(customMigrationsPath))) || [];
214
215
  migrationFiles = migrationFiles.filter((file) => file.startsWith('run') === false && file.endsWith('.d.ts') === false);
215
216
  customMigrationFiles = customMigrationFiles.filter((file) => file.endsWith('.js'));
@@ -5,13 +5,13 @@ import { dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import path from 'path';
7
7
  import { flushCaches } from '../../cache.js';
8
- import env from '../../env.js';
8
+ import { getExtensionsPath } from '../../extensions/lib/get-extensions-path.js';
9
9
  import logger 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
13
  let migrationFiles = await fse.readdir(__dirname);
14
- const customMigrationsPath = path.resolve(env['EXTENSIONS_PATH'], 'migrations');
14
+ const customMigrationsPath = path.resolve(getExtensionsPath(), 'migrations');
15
15
  let customMigrationFiles = ((await fse.pathExists(customMigrationsPath)) && (await fse.readdir(customMigrationsPath))) || [];
16
16
  migrationFiles = migrationFiles.filter((file) => /^[0-9]+[A-Z]-[^.]+\.(?:js|ts)$/.test(file));
17
17
  customMigrationFiles = customMigrationFiles.filter((file) => file.includes('-') && /\.(c|m)?js$/.test(file));
@@ -16,7 +16,7 @@ data:
16
16
  accountability: null
17
17
 
18
18
  - collection: directus_collections
19
- icon: list_alt
19
+ icon: database
20
20
  note: $t:directus_collection.directus_collections
21
21
 
22
22
  - collection: directus_fields
@@ -86,8 +86,20 @@ fields:
86
86
  width: half
87
87
  group: theming_group
88
88
 
89
+ - field: theming_divider
90
+ interface: presentation-divider
91
+ options:
92
+ icon: palette
93
+ title: $t:fields.directus_settings.theming
94
+ special:
95
+ - alias
96
+ - no-data
97
+ width: full
98
+ group: theming_group
99
+
89
100
  - field: default_appearance
90
101
  interface: select-dropdown
102
+ width: half
91
103
  options:
92
104
  choices:
93
105
  - value: auto
@@ -98,24 +110,12 @@ fields:
98
110
  text: $t:appearance_dark
99
111
  group: theming_group
100
112
 
101
- - field: theming_divider
102
- interface: presentation-divider
103
- options:
104
- icon: palette
105
- title: $t:fields.directus_settings.theming
106
- special:
107
- - alias
108
- - no-data
109
- width: full
110
- group: theming_group
111
-
112
113
  - field: default_theme_light
113
114
  width: full
114
115
  interface: system-theme
115
116
  options:
116
117
  appearance: light
117
118
  group: theming_group
118
- hidden: true
119
119
 
120
120
  - field: theme_light_overrides
121
121
  width: full
@@ -130,7 +130,6 @@ fields:
130
130
  options:
131
131
  appearance: dark
132
132
  group: theming_group
133
- hidden: true
134
133
 
135
134
  - field: theme_dark_overrides
136
135
  width: full
@@ -105,7 +105,7 @@ fields:
105
105
  options:
106
106
  choices:
107
107
  - value: null
108
- text: $t:appearance_global
108
+ text: $t:default_sync_with_project
109
109
  - value: auto
110
110
  text: $t:appearance_auto
111
111
  - value: light
@@ -115,18 +115,11 @@ fields:
115
115
  width: half
116
116
 
117
117
  - field: theme_light
118
- width: half
118
+ width: full
119
119
  interface: system-theme
120
120
  options:
121
121
  appearance: light
122
- hidden: true
123
-
124
- - field: theme_dark
125
- width: half
126
- interface: system-theme
127
- options:
128
- appearance: dark
129
- hidden: true
122
+ includeNull: true
130
123
 
131
124
  - field: theme_light_overrides
132
125
  width: full
@@ -134,6 +127,13 @@ fields:
134
127
  special:
135
128
  - cast-json
136
129
 
130
+ - field: theme_dark
131
+ width: full
132
+ interface: system-theme
133
+ options:
134
+ appearance: dark
135
+ includeNull: true
136
+
137
137
  - field: theme_dark_overrides
138
138
  width: full
139
139
  interface: system-theme-overrides
package/dist/env.d.ts CHANGED
@@ -2,14 +2,12 @@
2
2
  * @NOTE
3
3
  * For all possible keys, see: https://docs.directus.io/self-hosted/config-options/
4
4
  */
5
+ export declare const defaults: Record<string, any>;
5
6
  declare let env: Record<string, any>;
6
7
  export default env;
7
- /**
8
- * Small wrapper function that makes it easier to write unit tests against changing environments
9
- */
10
- export declare const getEnv: () => Record<string, any>;
11
8
  /**
12
9
  * When changes have been made during runtime, like in the CLI, we can refresh the env object with
13
10
  * the newly created variables
14
11
  */
15
12
  export declare function refreshEnv(): Promise<void>;
13
+ export declare function processValues(env: Record<string, any>): Record<string, any>;
package/dist/env.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * @NOTE
3
3
  * For all possible keys, see: https://docs.directus.io/self-hosted/config-options/
4
4
  */
5
- import { parseJSON, toArray } from '@directus/utils';
6
5
  import { JAVASCRIPT_FILE_EXTS } from '@directus/constants';
6
+ import { parseJSON, toArray } from '@directus/utils';
7
7
  import dotenv from 'dotenv';
8
8
  import fs from 'fs';
9
9
  import { clone, toNumber, toString } from 'lodash-es';
@@ -23,6 +23,7 @@ const allowedEnvironmentVars = [
23
23
  'PUBLIC_URL',
24
24
  'LOG_LEVEL',
25
25
  'LOG_STYLE',
26
+ 'LOG_HTTP_IGNORE_PATHS',
26
27
  'MAX_PAYLOAD_SIZE',
27
28
  'ROOT_REDIRECT',
28
29
  'SERVE_APP',
@@ -32,6 +33,7 @@ const allowedEnvironmentVars = [
32
33
  'QUERY_LIMIT_MAX',
33
34
  'QUERY_LIMIT_DEFAULT',
34
35
  'ROBOTS_TXT',
36
+ 'TEMP_PATH',
35
37
  // server
36
38
  'SERVER_.+',
37
39
  // database
@@ -156,6 +158,7 @@ const allowedEnvironmentVars = [
156
158
  'AUTH_.+_SP.+',
157
159
  // extensions
158
160
  'PACKAGE_FILE_LOCATION',
161
+ 'EXTENSIONS_LOCATION',
159
162
  'EXTENSIONS_PATH',
160
163
  'EXTENSIONS_AUTO_RELOAD',
161
164
  'EXTENSIONS_CACHE_TTL',
@@ -204,7 +207,7 @@ const allowedEnvironmentVars = [
204
207
  'WEBSOCKETS_.+',
205
208
  ].map((name) => new RegExp(`^${name}$`));
206
209
  const acceptedEnvTypes = ['string', 'number', 'regex', 'array', 'json'];
207
- const defaults = {
210
+ export const defaults = {
208
211
  CONFIG_PATH: path.resolve(process.cwd(), '.env'),
209
212
  HOST: '0.0.0.0',
210
213
  PORT: 8055,
@@ -214,6 +217,7 @@ const defaults = {
214
217
  QUERY_LIMIT_DEFAULT: 100,
215
218
  MAX_BATCH_MUTATION: Infinity,
216
219
  ROBOTS_TXT: 'User-agent: *\nDisallow: /',
220
+ TEMP_PATH: './node_modules/.directus',
217
221
  DB_EXCLUDE_TABLES: 'spatial_ref_sys,sysdiagrams',
218
222
  STORAGE_LOCATIONS: 'local',
219
223
  STORAGE_LOCAL_DRIVER: 'local',
@@ -302,8 +306,10 @@ const defaults = {
302
306
  PRESSURE_LIMITER_RETRY_AFTER: false,
303
307
  FILES_MIME_TYPE_ALLOW_LIST: '*/*',
304
308
  };
305
- // Allows us to force certain environment variable into a type, instead of relying
306
- // on the auto-parsed type in processValues. ref #3705
309
+ /**
310
+ * Allows us to force certain environment variable into a type, instead of relying
311
+ * on the auto-parsed type in processValues.
312
+ */
307
313
  const typeMap = {
308
314
  HOST: 'string',
309
315
  PORT: 'string',
@@ -320,6 +326,7 @@ const typeMap = {
320
326
  GRAPHQL_INTROSPECTION: 'boolean',
321
327
  MAX_BATCH_MUTATION: 'number',
322
328
  SERVER_SHUTDOWN_TIMEOUT: 'number',
329
+ LOG_HTTP_IGNORE_PATHS: 'array',
323
330
  };
324
331
  let env = {
325
332
  ...defaults,
@@ -329,10 +336,6 @@ let env = {
329
336
  process.env = env;
330
337
  env = processValues(env);
331
338
  export default env;
332
- /**
333
- * Small wrapper function that makes it easier to write unit tests against changing environments
334
- */
335
- export const getEnv = () => env;
336
339
  /**
337
340
  * When changes have been made during runtime, like in the CLI, we can refresh the env object with
338
341
  * the newly created variables
@@ -408,7 +411,7 @@ function getEnvironmentValueByType(envVariableString) {
408
411
  function isEnvSyntaxPrefixPresent(value) {
409
412
  return acceptedEnvTypes.some((envType) => value.includes(`${envType}:`));
410
413
  }
411
- function processValues(env) {
414
+ export function processValues(env) {
412
415
  env = clone(env);
413
416
  for (let [key, value] of Object.entries(env)) {
414
417
  // If key ends with '_FILE', try to get the value from the file defined in this variable
@@ -0,0 +1 @@
1
+ export declare const getExtensionsPath: () => any;
@@ -0,0 +1,8 @@
1
+ import { join } from 'path';
2
+ import env from '../../env.js';
3
+ export const getExtensionsPath = () => {
4
+ if (env['EXTENSIONS_LOCATION']) {
5
+ return join(env['TEMP_PATH'], 'extensions');
6
+ }
7
+ return env['EXTENSIONS_PATH'];
8
+ };
@@ -1,10 +1,11 @@
1
1
  import { getLocalExtensions, getPackageExtensions, resolvePackageExtensions } from '@directus/extensions/node';
2
2
  import env from '../../env.js';
3
+ import { getExtensionsPath } from './get-extensions-path.js';
3
4
  export const getExtensions = async () => {
4
- const localExtensions = await getLocalExtensions(env['EXTENSIONS_PATH']);
5
+ const localExtensions = await getLocalExtensions(getExtensionsPath());
5
6
  const loadedNames = localExtensions.map(({ name }) => name);
6
7
  const filterDuplicates = ({ name }) => loadedNames.includes(name) === false;
7
- const localPackageExtensions = (await resolvePackageExtensions(env['EXTENSIONS_PATH'])).filter((extension) => filterDuplicates(extension));
8
+ const localPackageExtensions = (await resolvePackageExtensions(getExtensionsPath())).filter((extension) => filterDuplicates(extension));
8
9
  loadedNames.push(...localPackageExtensions.map(({ name }) => name));
9
10
  const packageExtensions = (await getPackageExtensions(env['PACKAGE_FILE_LOCATION'])).filter((extension) => filterDuplicates(extension));
10
11
  return [...packageExtensions, ...localPackageExtensions, ...localExtensions];
@@ -0,0 +1 @@
1
+ export declare const syncExtensions: () => Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { NESTED_EXTENSION_TYPES } from '@directus/extensions';
2
+ import { ensureExtensionDirs } from '@directus/extensions/node';
3
+ import mid from 'node-machine-id';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir } from 'node:fs/promises';
6
+ import { dirname, join, relative, resolve, sep } from 'node:path';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import Queue from 'p-queue';
9
+ import env from '../../env.js';
10
+ import logger from '../../logger.js';
11
+ import { getMessenger } from '../../messenger.js';
12
+ import { getStorage } from '../../storage/index.js';
13
+ import { getExtensionsPath } from './get-extensions-path.js';
14
+ import { SyncStatus, getSyncStatus, setSyncStatus } from './sync-status.js';
15
+ export const syncExtensions = async () => {
16
+ const extensionsPath = getExtensionsPath();
17
+ if (!env['EXTENSIONS_LOCATION']) {
18
+ // Safe to run with multiple instances since dirs are created with `recursive: true`
19
+ return ensureExtensionDirs(extensionsPath, NESTED_EXTENSION_TYPES);
20
+ }
21
+ const messenger = getMessenger();
22
+ const isPrimaryProcess = String(process.env['NODE_APP_INSTANCE']) === '0' || process.env['NODE_APP_INSTANCE'] === undefined;
23
+ const id = await mid.machineId();
24
+ const message = `extensions-sync/${id}`;
25
+ if (isPrimaryProcess === false) {
26
+ const isDone = (await getSyncStatus()) === SyncStatus.DONE;
27
+ if (isDone)
28
+ return;
29
+ logger.trace('Extensions already being synced to this machine from another process.');
30
+ /**
31
+ * Wait until the process that called the lock publishes a message that the syncing is complete
32
+ */
33
+ return new Promise((resolve) => {
34
+ messenger.subscribe(message, () => resolve());
35
+ });
36
+ }
37
+ // Ensure that the local extensions cache path exists
38
+ await mkdir(extensionsPath, { recursive: true });
39
+ await setSyncStatus(SyncStatus.SYNCING);
40
+ logger.trace('Syncing extensions from configured storage location...');
41
+ const storage = await getStorage();
42
+ const disk = storage.location(env['EXTENSIONS_LOCATION']);
43
+ // Make sure we don't overload the file handles
44
+ const queue = new Queue({ concurrency: 1000 });
45
+ for await (const filepath of disk.list(env['EXTENSIONS_PATH'])) {
46
+ const readStream = await disk.read(filepath);
47
+ // We want files to be stored in the root of `$TEMP_PATH/extensions`, so gotta remove the
48
+ // extensions path on disk from the start of the file path
49
+ const destPath = join(extensionsPath, relative(resolve(sep, env['EXTENSIONS_PATH']), resolve(sep, filepath)));
50
+ // Ensure that the directory path exists
51
+ await mkdir(dirname(destPath), { recursive: true });
52
+ const writeStream = createWriteStream(destPath);
53
+ queue.add(() => pipeline(readStream, writeStream));
54
+ }
55
+ await queue.onIdle();
56
+ await ensureExtensionDirs(extensionsPath, NESTED_EXTENSION_TYPES);
57
+ await setSyncStatus(SyncStatus.DONE);
58
+ messenger.publish(message, { ready: true });
59
+ };
@@ -0,0 +1,10 @@
1
+ export declare enum SyncStatus {
2
+ UNKNOWN = "UNKNOWN",
3
+ SYNCING = "SYNCING",
4
+ DONE = "DONE"
5
+ }
6
+ /**
7
+ * Retrieves the sync status from the `.status` file in the local extensions folder
8
+ */
9
+ export declare const getSyncStatus: () => Promise<string>;
10
+ export declare const setSyncStatus: (status: SyncStatus) => Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { exists } from 'fs-extra';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { getExtensionsPath } from './get-extensions-path.js';
5
+ export var SyncStatus;
6
+ (function (SyncStatus) {
7
+ SyncStatus["UNKNOWN"] = "UNKNOWN";
8
+ SyncStatus["SYNCING"] = "SYNCING";
9
+ SyncStatus["DONE"] = "DONE";
10
+ })(SyncStatus || (SyncStatus = {}));
11
+ /**
12
+ * Retrieves the sync status from the `.status` file in the local extensions folder
13
+ */
14
+ export const getSyncStatus = async () => {
15
+ const statusFilePath = join(getExtensionsPath(), '.status');
16
+ if (await exists(statusFilePath)) {
17
+ const status = await readFile(statusFilePath, 'utf8');
18
+ return status;
19
+ }
20
+ else {
21
+ return SyncStatus.UNKNOWN;
22
+ }
23
+ };
24
+ export const setSyncStatus = async (status) => {
25
+ const statusFilePath = join(getExtensionsPath(), '.status');
26
+ await writeFile(statusFilePath, status);
27
+ };
@@ -1,6 +1,6 @@
1
1
  import { JAVASCRIPT_FILE_EXTS } from '@directus/constants';
2
2
  import { APP_SHARED_DEPS, HYBRID_EXTENSION_TYPES, NESTED_EXTENSION_TYPES } from '@directus/extensions';
3
- import { ensureExtensionDirs, generateExtensionsEntrypoint } from '@directus/extensions/node';
3
+ import { generateExtensionsEntrypoint } from '@directus/extensions/node';
4
4
  import { isIn, isTypeIn, pluralize } from '@directus/utils';
5
5
  import { pathToRelativeUrl } from '@directus/utils/node';
6
6
  import aliasDefault from '@rollup/plugin-alias';
@@ -27,11 +27,13 @@ import { getSchema } from '../utils/get-schema.js';
27
27
  import { importFileUrl } from '../utils/import-file-url.js';
28
28
  import { JobQueue } from '../utils/job-queue.js';
29
29
  import { scheduleSynchronizedJob, validateCron } from '../utils/schedule.js';
30
+ import { getExtensionsPath } from './lib/get-extensions-path.js';
30
31
  import { getExtensionsSettings } from './lib/get-extensions-settings.js';
31
32
  import { getExtensions } from './lib/get-extensions.js';
32
33
  import { getSharedDepsMapping } from './lib/get-shared-deps-mapping.js';
33
34
  import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-api-extensions-sandbox-entrypoint.js';
34
35
  import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
36
+ import { syncExtensions } from './lib/sync-extensions.js';
35
37
  import { wrapEmbeds } from './lib/wrap-embeds.js';
36
38
  // Workaround for https://github.com/rollup/plugins/issues/1329
37
39
  const virtual = virtualDefault;
@@ -132,13 +134,20 @@ export class ExtensionManager {
132
134
  */
133
135
  async load() {
134
136
  try {
135
- await ensureExtensionDirs(env['EXTENSIONS_PATH'], NESTED_EXTENSION_TYPES);
137
+ await syncExtensions();
138
+ }
139
+ catch (error) {
140
+ logger.error(`Failed to sync extensions`);
141
+ logger.error(error);
142
+ process.exit(1);
143
+ }
144
+ try {
136
145
  this.extensions = await getExtensions();
137
146
  this.extensionsSettings = await getExtensionsSettings(this.extensions);
138
147
  }
139
- catch (err) {
148
+ catch (error) {
140
149
  logger.warn(`Couldn't load extensions`);
141
- logger.warn(err);
150
+ logger.warn(error);
142
151
  }
143
152
  await this.registerHooks();
144
153
  await this.registerEndpoints();
@@ -223,7 +232,7 @@ export class ExtensionManager {
223
232
  */
224
233
  initializeWatcher() {
225
234
  logger.info('Watching extensions for changes...');
226
- const extensionDirUrl = pathToRelativeUrl(env['EXTENSIONS_PATH']);
235
+ const extensionDirUrl = pathToRelativeUrl(getExtensionsPath());
227
236
  const localExtensionUrls = NESTED_EXTENSION_TYPES.flatMap((type) => {
228
237
  const typeDir = path.posix.join(extensionDirUrl, pluralize(type));
229
238
  if (isIn(type, HYBRID_EXTENSION_TYPES)) {
package/dist/logger.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /// <reference types="qs" />
2
2
  import type { RequestHandler } from 'express';
3
- import type { LoggerOptions } from 'pino';
3
+ import { type LoggerOptions } from 'pino';
4
4
  export declare const httpLoggerOptions: LoggerOptions;
5
5
  declare const logger: import("pino").Logger<LoggerOptions & Record<string, any>>;
6
+ export declare const httpLoggerEnvConfig: Record<string, any>;
6
7
  export declare const expressLogger: RequestHandler<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
7
8
  export default logger;