@directus/api 32.1.1 → 32.2.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 (165) hide show
  1. package/dist/ai/chat/constants/system-prompt.d.ts +1 -0
  2. package/dist/ai/chat/constants/system-prompt.js +51 -0
  3. package/dist/ai/chat/controllers/chat.post.d.ts +2 -0
  4. package/dist/ai/chat/controllers/chat.post.js +47 -0
  5. package/dist/ai/chat/lib/create-ui-stream.d.ts +15 -0
  6. package/dist/ai/chat/lib/create-ui-stream.js +42 -0
  7. package/dist/ai/chat/middleware/load-settings.d.ts +2 -0
  8. package/dist/ai/chat/middleware/load-settings.js +18 -0
  9. package/dist/ai/chat/models/chat-request.d.ts +34 -0
  10. package/dist/ai/chat/models/chat-request.js +26 -0
  11. package/dist/ai/chat/models/providers.d.ts +9 -0
  12. package/dist/ai/chat/models/providers.js +9 -0
  13. package/dist/ai/chat/router.d.ts +1 -0
  14. package/dist/ai/chat/router.js +5 -0
  15. package/dist/ai/chat/utils/chat-request-tool-to-ai-sdk-tool.d.ts +9 -0
  16. package/dist/ai/chat/utils/chat-request-tool-to-ai-sdk-tool.js +38 -0
  17. package/dist/ai/chat/utils/fix-error-tool-calls.d.ts +12 -0
  18. package/dist/ai/chat/utils/fix-error-tool-calls.js +30 -0
  19. package/dist/ai/chat/utils/parse-json-schema-7.d.ts +13 -0
  20. package/dist/ai/chat/utils/parse-json-schema-7.js +75 -0
  21. package/dist/{mcp → ai/mcp}/server.d.ts +13 -16
  22. package/dist/{mcp → ai/mcp}/server.js +4 -13
  23. package/dist/ai/mcp/types.d.ts +15 -0
  24. package/dist/{mcp/tools/assets.js → ai/tools/assets/index.js} +8 -5
  25. package/dist/{mcp/tools/collections.js → ai/tools/collections/index.js} +7 -4
  26. package/dist/{mcp/tools/fields.js → ai/tools/fields/index.js} +12 -9
  27. package/dist/{mcp/tools/files.js → ai/tools/files/index.js} +11 -5
  28. package/dist/{mcp/tools/flows.js → ai/tools/flows/index.js} +11 -5
  29. package/dist/{mcp/tools/folders.js → ai/tools/folders/index.js} +12 -5
  30. package/dist/ai/tools/index.d.ts +15 -0
  31. package/dist/ai/tools/index.js +29 -0
  32. package/dist/{mcp/tools/items.js → ai/tools/items/index.js} +13 -6
  33. package/dist/{mcp/tools/prompts/items.md → ai/tools/items/prompt.md} +19 -15
  34. package/dist/{mcp/tools/operations.d.ts → ai/tools/operations/index.d.ts} +46 -0
  35. package/dist/{mcp/tools/operations.js → ai/tools/operations/index.js} +12 -5
  36. package/dist/{mcp/tools/relations.js → ai/tools/relations/index.js} +7 -4
  37. package/dist/{mcp/tools/schema.d.ts → ai/tools/schema/index.d.ts} +1 -1
  38. package/dist/{mcp/tools/schema.js → ai/tools/schema/index.js} +9 -6
  39. package/dist/{mcp/tools/system.js → ai/tools/system/index.js} +7 -4
  40. package/dist/{mcp/tools/trigger-flow.js → ai/tools/trigger-flow/index.js} +8 -5
  41. package/dist/{mcp → ai/tools}/types.d.ts +1 -17
  42. package/dist/ai/tools/utils.d.ts +9 -0
  43. package/dist/ai/tools/utils.js +17 -0
  44. package/dist/app.js +5 -0
  45. package/dist/auth/drivers/saml.js +5 -2
  46. package/dist/controllers/assets.js +39 -2
  47. package/dist/controllers/mcp.js +1 -1
  48. package/dist/database/migrations/20240806A-permissions-policies.js +2 -2
  49. package/dist/database/migrations/20251103A-add-ai-settings.d.ts +3 -0
  50. package/dist/database/migrations/20251103A-add-ai-settings.js +14 -0
  51. package/dist/database/run-ast/run-ast.js +1 -1
  52. package/dist/extensions/lib/installation/manager.js +5 -9
  53. package/dist/extensions/lib/sync/status.d.ts +11 -0
  54. package/dist/extensions/lib/sync/status.js +34 -0
  55. package/dist/extensions/lib/sync/sync.d.ts +6 -0
  56. package/dist/extensions/lib/sync/sync.js +90 -0
  57. package/dist/extensions/lib/sync/tracker.d.ts +18 -0
  58. package/dist/extensions/lib/sync/tracker.js +71 -0
  59. package/dist/extensions/lib/sync/utils.d.ts +24 -0
  60. package/dist/extensions/lib/sync/utils.js +62 -0
  61. package/dist/extensions/manager.d.ts +8 -4
  62. package/dist/extensions/manager.js +30 -13
  63. package/dist/middleware/respond.js +2 -2
  64. package/dist/permissions/lib/fetch-policies.d.ts +1 -1
  65. package/dist/permissions/lib/fetch-roles-tree.d.ts +6 -3
  66. package/dist/permissions/lib/fetch-roles-tree.js +5 -27
  67. package/dist/permissions/modules/fetch-global-access/fetch-global-access.d.ts +9 -7
  68. package/dist/permissions/modules/fetch-global-access/fetch-global-access.js +17 -9
  69. package/dist/permissions/modules/fetch-policies-ip-access/fetch-policies-ip-access.d.ts +1 -1
  70. package/dist/permissions/utils/fetch-raw-permissions.d.ts +1 -1
  71. package/dist/permissions/utils/fetch-share-info.d.ts +1 -1
  72. package/dist/permissions/utils/fetch-share-info.js +1 -1
  73. package/dist/permissions/utils/filter-policies-by-ip.js +1 -1
  74. package/dist/permissions/utils/get-permissions-for-share.js +8 -8
  75. package/dist/permissions/utils/with-cache.d.ts +8 -6
  76. package/dist/permissions/utils/with-cache.js +12 -10
  77. package/dist/request/is-denied-ip.js +2 -2
  78. package/dist/services/assets/name-deduper.d.ts +7 -0
  79. package/dist/services/assets/name-deduper.js +23 -0
  80. package/dist/services/assets.d.ts +15 -2
  81. package/dist/services/assets.js +98 -5
  82. package/dist/services/authentication.js +4 -4
  83. package/dist/services/comments.js +2 -2
  84. package/dist/services/extensions.js +4 -0
  85. package/dist/services/folders.d.ts +27 -2
  86. package/dist/services/folders.js +75 -0
  87. package/dist/services/import-export.d.ts +1 -1
  88. package/dist/services/import-export.js +4 -5
  89. package/dist/services/notifications.js +2 -2
  90. package/dist/services/payload.js +20 -0
  91. package/dist/services/roles.js +2 -2
  92. package/dist/services/tus/server.js +3 -3
  93. package/dist/telemetry/utils/get-settings.d.ts +15 -0
  94. package/dist/telemetry/utils/get-settings.js +13 -1
  95. package/dist/test-utils/README.md +95 -24
  96. package/dist/test-utils/cache.d.ts +2 -2
  97. package/dist/test-utils/cache.js +2 -2
  98. package/dist/test-utils/{fields-service.d.ts → services/fields-service.d.ts} +1 -1
  99. package/dist/test-utils/{fields-service.js → services/fields-service.js} +3 -2
  100. package/dist/test-utils/services/files-service.d.ts +28 -0
  101. package/dist/test-utils/services/files-service.js +34 -0
  102. package/dist/test-utils/services/folders-service.d.ts +28 -0
  103. package/dist/test-utils/services/folders-service.js +33 -0
  104. package/dist/utils/encrypt.d.ts +2 -0
  105. package/dist/utils/encrypt.js +64 -0
  106. package/dist/utils/get-accountability-for-role.js +2 -2
  107. package/dist/utils/get-accountability-for-token.js +4 -4
  108. package/dist/utils/get-cache-key.js +2 -2
  109. package/dist/utils/require-text.d.ts +1 -0
  110. package/dist/utils/require-text.js +4 -0
  111. package/dist/utils/require-yaml.js +2 -2
  112. package/package.json +32 -26
  113. package/dist/extensions/lib/sync-extensions.d.ts +0 -3
  114. package/dist/extensions/lib/sync-extensions.js +0 -70
  115. package/dist/extensions/lib/sync-status.d.ts +0 -10
  116. package/dist/extensions/lib/sync-status.js +0 -27
  117. package/dist/mcp/tools/index.d.ts +0 -15
  118. package/dist/mcp/tools/index.js +0 -29
  119. package/dist/mcp/tools/prompts/index.d.ts +0 -16
  120. package/dist/mcp/tools/prompts/index.js +0 -19
  121. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-roles.d.ts +0 -5
  122. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-roles.js +0 -7
  123. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-user.d.ts +0 -5
  124. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-user.js +0 -10
  125. package/dist/permissions/modules/fetch-global-access/types.d.ts +0 -4
  126. package/dist/permissions/modules/fetch-global-access/utils/fetch-global-access-for-query.d.ts +0 -4
  127. package/dist/permissions/modules/fetch-global-access/utils/fetch-global-access-for-query.js +0 -27
  128. package/dist/utils/get-date-formatted.d.ts +0 -1
  129. package/dist/utils/get-date-formatted.js +0 -10
  130. package/dist/utils/ip-in-networks.d.ts +0 -6
  131. package/dist/utils/ip-in-networks.js +0 -13
  132. /package/dist/{mcp → ai/mcp}/index.d.ts +0 -0
  133. /package/dist/{mcp → ai/mcp}/index.js +0 -0
  134. /package/dist/{mcp → ai/mcp}/transport.d.ts +0 -0
  135. /package/dist/{mcp → ai/mcp}/transport.js +0 -0
  136. /package/dist/{mcp → ai/mcp}/types.js +0 -0
  137. /package/dist/{mcp/tools/assets.d.ts → ai/tools/assets/index.d.ts} +0 -0
  138. /package/dist/{mcp/tools/prompts/assets.md → ai/tools/assets/prompt.md} +0 -0
  139. /package/dist/{mcp/tools/collections.d.ts → ai/tools/collections/index.d.ts} +0 -0
  140. /package/dist/{mcp/tools/prompts/collections.md → ai/tools/collections/prompt.md} +0 -0
  141. /package/dist/{mcp/define.d.ts → ai/tools/define-tool.d.ts} +0 -0
  142. /package/dist/{mcp/define.js → ai/tools/define-tool.js} +0 -0
  143. /package/dist/{mcp/tools/fields.d.ts → ai/tools/fields/index.d.ts} +0 -0
  144. /package/dist/{mcp/tools/prompts/fields.md → ai/tools/fields/prompt.md} +0 -0
  145. /package/dist/{mcp/tools/files.d.ts → ai/tools/files/index.d.ts} +0 -0
  146. /package/dist/{mcp/tools/prompts/files.md → ai/tools/files/prompt.md} +0 -0
  147. /package/dist/{mcp/tools/flows.d.ts → ai/tools/flows/index.d.ts} +0 -0
  148. /package/dist/{mcp/tools/prompts/flows.md → ai/tools/flows/prompt.md} +0 -0
  149. /package/dist/{mcp/tools/folders.d.ts → ai/tools/folders/index.d.ts} +0 -0
  150. /package/dist/{mcp/tools/prompts/folders.md → ai/tools/folders/prompt.md} +0 -0
  151. /package/dist/{mcp/tools/items.d.ts → ai/tools/items/index.d.ts} +0 -0
  152. /package/dist/{mcp/tools/prompts/operations.md → ai/tools/operations/prompt.md} +0 -0
  153. /package/dist/{mcp/tools/relations.d.ts → ai/tools/relations/index.d.ts} +0 -0
  154. /package/dist/{mcp/tools/prompts/relations.md → ai/tools/relations/prompt.md} +0 -0
  155. /package/dist/{mcp/tools/prompts/schema.md → ai/tools/schema/prompt.md} +0 -0
  156. /package/dist/{mcp → ai/tools}/schema.d.ts +0 -0
  157. /package/dist/{mcp → ai/tools}/schema.js +0 -0
  158. /package/dist/{mcp/tools/system.d.ts → ai/tools/system/index.d.ts} +0 -0
  159. /package/dist/{mcp/tools/prompts/system-prompt-description.md → ai/tools/system/prompt-description.md} +0 -0
  160. /package/dist/{mcp/tools/prompts/system-prompt.md → ai/tools/system/prompt.md} +0 -0
  161. /package/dist/{mcp/tools/trigger-flow.d.ts → ai/tools/trigger-flow/index.d.ts} +0 -0
  162. /package/dist/{mcp/tools/prompts/trigger-flow.md → ai/tools/trigger-flow/prompt.md} +0 -0
  163. /package/dist/{permissions/modules/fetch-global-access → ai/tools}/types.js +0 -0
  164. /package/dist/test-utils/{items-service.d.ts → services/items-service.d.ts} +0 -0
  165. /package/dist/test-utils/{items-service.js → services/items-service.js} +0 -0
@@ -13,7 +13,7 @@ import ivm from 'isolated-vm';
13
13
  import { clone, debounce, isPlainObject } from 'lodash-es';
14
14
  import { readFile, readdir } from 'node:fs/promises';
15
15
  import os from 'node:os';
16
- import { dirname, join } from 'node:path';
16
+ import { dirname, join, relative, resolve, sep } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import path from 'path';
19
19
  import { rolldown } from 'rolldown';
@@ -37,9 +37,9 @@ import { getSharedDepsMapping } from './lib/get-shared-deps-mapping.js';
37
37
  import { getInstallationManager } from './lib/installation/index.js';
38
38
  import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-api-extensions-sandbox-entrypoint.js';
39
39
  import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
40
- import { syncExtensions } from './lib/sync-extensions.js';
41
40
  import { wrapEmbeds } from './lib/wrap-embeds.js';
42
41
  import DriverLocal from '@directus/storage-driver-local';
42
+ import { syncExtensions } from './lib/sync/sync.js';
43
43
  // Workaround for https://github.com/rollup/plugins/issues/1329
44
44
  const virtual = virtualDefault;
45
45
  const alias = aliasDefault;
@@ -99,6 +99,10 @@ export class ExtensionManager {
99
99
  * sequence.
100
100
  */
101
101
  reloadQueue = new JobQueue();
102
+ /**
103
+ * Used to prevent race condition when reading extension data while reloading extensions
104
+ */
105
+ reloadPromise = Promise.resolve();
102
106
  /**
103
107
  * Optional file system watcher to auto-reload extensions when the local file system changes
104
108
  */
@@ -148,7 +152,7 @@ export class ExtensionManager {
148
152
  await this.closeWatcher();
149
153
  }
150
154
  if (!this.isLoaded) {
151
- await this.load();
155
+ await this.load({ forceSync: true });
152
156
  if (this.extensions.length > 0) {
153
157
  logger.info(`Loaded extensions: ${this.extensions.map((ext) => ext.name).join(', ')}`);
154
158
  }
@@ -160,7 +164,13 @@ export class ExtensionManager {
160
164
  // Ignore requests for reloading that were published by the current process
161
165
  if (isPlainObject(payload) && 'origin' in payload && payload['origin'] === this.processId)
162
166
  return;
163
- this.reload();
167
+ // Reload extensions with event options
168
+ const options = {};
169
+ if (typeof payload['forceSync'] === 'boolean')
170
+ options.forceSync = payload['forceSync'];
171
+ if (typeof payload['partialSync'] === 'string')
172
+ options.partialSync = payload['partialSync'];
173
+ this.reload(options);
164
174
  });
165
175
  }
166
176
  /**
@@ -169,27 +179,31 @@ export class ExtensionManager {
169
179
  async install(versionId) {
170
180
  const logger = useLogger();
171
181
  await this.installationManager.install(versionId);
172
- await this.reload({ forceSync: true });
182
+ const resolvedFolder = relative(sep, resolve(sep, versionId));
183
+ const syncFolder = join('.registry', resolvedFolder);
184
+ await this.broadcastReloadNotification({ partialSync: syncFolder });
185
+ await this.reload({ skipSync: true });
173
186
  emitter.emitAction('extensions.installed', {
174
187
  extensions: this.extensions,
175
188
  versionId,
176
189
  });
177
190
  logger.info(`Installed extension: ${versionId}`);
178
- await this.broadcastReloadNotification();
179
191
  }
180
192
  async uninstall(folder) {
181
193
  const logger = useLogger();
182
194
  await this.installationManager.uninstall(folder);
183
- await this.reload({ forceSync: true });
195
+ const resolvedFolder = relative(sep, resolve(sep, folder));
196
+ const syncFolder = join('.registry', resolvedFolder);
197
+ await this.broadcastReloadNotification({ partialSync: syncFolder });
198
+ await this.reload({ skipSync: true });
184
199
  emitter.emitAction('extensions.uninstalled', {
185
200
  extensions: this.extensions,
186
201
  folder,
187
202
  });
188
203
  logger.info(`Uninstalled extension: ${folder}`);
189
- await this.broadcastReloadNotification();
190
204
  }
191
- async broadcastReloadNotification() {
192
- await this.messenger.publish(this.reloadChannel, { origin: this.processId });
205
+ async broadcastReloadNotification(options) {
206
+ await this.messenger.publish(this.reloadChannel, { ...options, origin: this.processId });
193
207
  }
194
208
  /**
195
209
  * Load all extensions from disk and register them in their respective places
@@ -198,7 +212,7 @@ export class ExtensionManager {
198
212
  const logger = useLogger();
199
213
  if (env['EXTENSIONS_LOCATION']) {
200
214
  try {
201
- await syncExtensions({ force: options?.forceSync ?? false });
215
+ await syncExtensions(options);
202
216
  }
203
217
  catch (error) {
204
218
  logger.error(`Failed to sync extensions`);
@@ -250,7 +264,7 @@ export class ExtensionManager {
250
264
  const logger = useLogger();
251
265
  let resolve;
252
266
  let reject;
253
- const promise = new Promise((res, rej) => {
267
+ this.reloadPromise = new Promise((res, rej) => {
254
268
  resolve = res;
255
269
  reject = rej;
256
270
  });
@@ -283,7 +297,10 @@ export class ExtensionManager {
283
297
  reject(new Error('Extensions have to be loaded before they can be reloaded'));
284
298
  }
285
299
  });
286
- return promise;
300
+ return this.reloadPromise;
301
+ }
302
+ isReloading() {
303
+ return this.reloadPromise;
287
304
  }
288
305
  /**
289
306
  * Return the previously generated app extension bundle chunk by name.
@@ -1,4 +1,5 @@
1
1
  import { useEnv } from '@directus/env';
2
+ import { getDateTimeFormatted } from '@directus/utils';
2
3
  import { parse as parseBytesConfiguration } from 'bytes';
3
4
  import { getCache, setCacheValue } from '../cache.js';
4
5
  import getDatabase from '../database/index.js';
@@ -7,7 +8,6 @@ import { ExportService } from '../services/import-export.js';
7
8
  import asyncHandler from '../utils/async-handler.js';
8
9
  import { getCacheControlHeader } from '../utils/get-cache-headers.js';
9
10
  import { getCacheKey } from '../utils/get-cache-key.js';
10
- import { getDateFormatted } from '../utils/get-date-formatted.js';
11
11
  import { getMilliseconds } from '../utils/get-milliseconds.js';
12
12
  import { stringByteSize } from '../utils/get-string-byte-size.js';
13
13
  import { permissionsCacheable } from '../utils/permissions-cacheable.js';
@@ -58,7 +58,7 @@ export const respond = asyncHandler(async (req, res) => {
58
58
  else {
59
59
  filename += 'Export';
60
60
  }
61
- filename += ' ' + getDateFormatted();
61
+ filename += ' ' + getDateTimeFormatted();
62
62
  if (req.sanitizedQuery.export === 'json') {
63
63
  res.attachment(`${filename}.json`);
64
64
  res.set('Content-Type', 'application/json');
@@ -7,7 +7,7 @@ export interface AccessRow {
7
7
  };
8
8
  role: string | null;
9
9
  }
10
- export declare const fetchPolicies: typeof _fetchPolicies;
10
+ export declare const fetchPolicies: (args_0: Pick<Accountability, "user" | "roles" | "ip">, context: Context) => Promise<string[]>;
11
11
  /**
12
12
  * Fetch the policies associated with the current user accountability
13
13
  */
@@ -1,3 +1,6 @@
1
- import type { Knex } from 'knex';
2
- export declare const fetchRolesTree: typeof _fetchRolesTree;
3
- export declare function _fetchRolesTree(start: string | null, knex: Knex): Promise<string[]>;
1
+ /**
2
+ * Fetches the roles tree starting from a specific role.
3
+ */
4
+ export declare const fetchRolesTree: (start: string | null, context: {
5
+ knex: import("knex").Knex;
6
+ }) => Promise<string[]>;
@@ -1,28 +1,6 @@
1
+ import { fetchRolesTree as _fetchRolesTree } from '@directus/utils/node';
1
2
  import { withCache } from '../utils/with-cache.js';
2
- export const fetchRolesTree = withCache('roles-tree', _fetchRolesTree);
3
- export async function _fetchRolesTree(start, knex) {
4
- if (!start)
5
- return [];
6
- let parent = start;
7
- const roles = [];
8
- while (parent) {
9
- const role = await knex
10
- .select('id', 'parent')
11
- .from('directus_roles')
12
- .where({ id: parent })
13
- .first();
14
- if (!role) {
15
- break;
16
- }
17
- roles.push(role.id);
18
- // Prevent infinite recursion loops
19
- if (role.parent && roles.includes(role.parent) === true) {
20
- roles.reverse();
21
- const rolesStr = roles.map((role) => `"${role}"`).join('->');
22
- throw new Error(`Recursion encountered: role "${role.id}" already exists in tree path ${rolesStr}`);
23
- }
24
- parent = role.parent;
25
- }
26
- roles.reverse();
27
- return roles;
28
- }
3
+ /**
4
+ * Fetches the roles tree starting from a specific role.
5
+ */
6
+ export const fetchRolesTree = withCache('roles-tree', _fetchRolesTree, (start) => ({ start }));
@@ -1,10 +1,12 @@
1
- import type { Accountability } from '@directus/types';
1
+ import type { Accountability, GlobalAccess } from '@directus/types';
2
2
  import type { Knex } from 'knex';
3
- import type { GlobalAccess } from './types.js';
4
- export declare const fetchGlobalAccess: typeof _fetchGlobalAccess;
3
+ interface FetchGlobalAccessContext {
4
+ knex: Knex;
5
+ ip?: Accountability['ip'];
6
+ }
7
+ export declare const fetchGlobalAccess: (accountability: Pick<Accountability, "user" | "roles" | "ip">, context: FetchGlobalAccessContext) => Promise<GlobalAccess>;
5
8
  /**
6
- * Fetch the global access (eg admin/app access) rules for the given roles, or roles+user combination
7
- *
8
- * Will fetch roles and user info separately so they can be cached and reused individually
9
+ * Re-implements fetchGlobalAccess to add caching, fetches roles and user info separately so they can be cached and reused individually
9
10
  */
10
- export declare function _fetchGlobalAccess(accountability: Pick<Accountability, 'user' | 'roles' | 'ip'>, knex: Knex): Promise<GlobalAccess>;
11
+ export declare function _fetchGlobalAccess(accountability: Pick<Accountability, 'user' | 'roles' | 'ip'>, context: FetchGlobalAccessContext): Promise<GlobalAccess>;
12
+ export {};
@@ -1,20 +1,28 @@
1
+ import { fetchGlobalAccessForRoles as _fetchGlobalAccessForRoles, fetchGlobalAccessForUser as _fetchGlobalAccessForUser, } from '@directus/utils/node';
1
2
  import { withCache } from '../../utils/with-cache.js';
2
- import { fetchGlobalAccessForRoles } from './lib/fetch-global-access-for-roles.js';
3
- import { fetchGlobalAccessForUser } from './lib/fetch-global-access-for-user.js';
4
- export const fetchGlobalAccess = withCache('global-access', _fetchGlobalAccess, ({ user, roles, ip }) => ({
3
+ export const fetchGlobalAccess = withCache('global-access', _fetchGlobalAccess, ({ user, roles }, { ip }) => ({
5
4
  user,
6
5
  roles,
7
6
  ip,
8
7
  }));
8
+ const fetchGlobalAccessForRoles = withCache('global-access-roles', _fetchGlobalAccessForRoles, (roles, { ip }) => ({
9
+ roles,
10
+ ip,
11
+ }));
12
+ const fetchGlobalAccessForUser = withCache('global-access-user', _fetchGlobalAccessForUser, (user, { ip }) => ({
13
+ user,
14
+ ip,
15
+ }));
9
16
  /**
10
- * Fetch the global access (eg admin/app access) rules for the given roles, or roles+user combination
11
- *
12
- * Will fetch roles and user info separately so they can be cached and reused individually
17
+ * Re-implements fetchGlobalAccess to add caching, fetches roles and user info separately so they can be cached and reused individually
13
18
  */
14
- export async function _fetchGlobalAccess(accountability, knex) {
15
- const access = await fetchGlobalAccessForRoles(accountability, knex);
19
+ export async function _fetchGlobalAccess(accountability, context) {
20
+ const access = await fetchGlobalAccessForRoles(accountability.roles, { knex: context.knex, ip: accountability.ip });
16
21
  if (accountability.user !== undefined) {
17
- const userAccess = await fetchGlobalAccessForUser(accountability, knex);
22
+ const userAccess = await fetchGlobalAccessForUser(accountability.user, {
23
+ knex: context.knex,
24
+ ip: accountability.ip,
25
+ });
18
26
  // If app/admin is already true, keep it true
19
27
  access.app ||= userAccess.app;
20
28
  access.admin ||= userAccess.admin;
@@ -1,4 +1,4 @@
1
1
  import type { Accountability } from '@directus/types';
2
2
  import type { Knex } from 'knex';
3
- export declare const fetchPoliciesIpAccess: typeof _fetchPoliciesIpAccess;
3
+ export declare const fetchPoliciesIpAccess: (accountability: Pick<Accountability, "user" | "roles">, knex: Knex<any, any[]>) => Promise<string[][]>;
4
4
  export declare function _fetchPoliciesIpAccess(accountability: Pick<Accountability, 'user' | 'roles'>, knex: Knex): Promise<string[][]>;
@@ -1,6 +1,6 @@
1
1
  import type { Accountability, Permission, PermissionsAction } from '@directus/types';
2
2
  import type { Context } from '../types.js';
3
- export declare const fetchRawPermissions: typeof _fetchRawPermissions;
3
+ export declare const fetchRawPermissions: (options: FetchRawPermissionsOptions, context: Context) => Promise<Permission[]>;
4
4
  export interface FetchRawPermissionsOptions {
5
5
  action?: PermissionsAction;
6
6
  policies: string[];
@@ -8,5 +8,5 @@ export interface ShareInfo {
8
8
  role: string;
9
9
  };
10
10
  }
11
- export declare const fetchShareInfo: typeof _fetchShareInfo;
11
+ export declare const fetchShareInfo: (shareId: string, context: AbstractServiceOptions) => Promise<ShareInfo>;
12
12
  export declare function _fetchShareInfo(shareId: string, context: AbstractServiceOptions): Promise<ShareInfo>;
@@ -1,5 +1,5 @@
1
1
  import { withCache } from './with-cache.js';
2
- export const fetchShareInfo = withCache('share-info', _fetchShareInfo);
2
+ export const fetchShareInfo = withCache('share-info', _fetchShareInfo, (shareId) => ({ shareId }));
3
3
  export async function _fetchShareInfo(shareId, context) {
4
4
  const { SharesService } = await import('../../services/shares.js');
5
5
  const sharesService = new SharesService(context);
@@ -1,4 +1,4 @@
1
- import { ipInNetworks } from '../../utils/ip-in-networks.js';
1
+ import { ipInNetworks } from '@directus/utils/node';
2
2
  export function filterPoliciesByIp(policies, ip) {
3
3
  return policies.filter(({ policy }) => {
4
4
  // Keep policies that don't have an ip address allow list configured
@@ -1,13 +1,13 @@
1
1
  import { schemaPermissions } from '@directus/system-data';
2
2
  import { set, uniq } from 'lodash-es';
3
- import { fetchAllowedFieldMap } from '../modules/fetch-allowed-field-map/fetch-allowed-field-map.js';
4
- import { fetchShareInfo } from './fetch-share-info.js';
5
- import { mergePermissions } from './merge-permissions.js';
3
+ import { reduceSchema } from '../../utils/reduce-schema.js';
6
4
  import { fetchPermissions } from '../lib/fetch-permissions.js';
7
5
  import { fetchPolicies } from '../lib/fetch-policies.js';
8
6
  import { fetchRolesTree } from '../lib/fetch-roles-tree.js';
9
- import { reduceSchema } from '../../utils/reduce-schema.js';
7
+ import { fetchAllowedFieldMap } from '../modules/fetch-allowed-field-map/fetch-allowed-field-map.js';
10
8
  import { fetchGlobalAccess } from '../modules/fetch-global-access/fetch-global-access.js';
9
+ import { fetchShareInfo } from './fetch-share-info.js';
10
+ import { mergePermissions } from './merge-permissions.js';
11
11
  export async function getPermissionsForShare(accountability, collections, context) {
12
12
  const defaults = {
13
13
  action: 'read',
@@ -22,7 +22,7 @@ export async function getPermissionsForShare(accountability, collections, contex
22
22
  const userAccountability = {
23
23
  user: user_created.id,
24
24
  role: user_created.role,
25
- roles: await fetchRolesTree(user_created.role, context.knex),
25
+ roles: await fetchRolesTree(user_created.role, { knex: context.knex }),
26
26
  admin: false,
27
27
  app: false,
28
28
  ip: accountability.ip,
@@ -31,14 +31,14 @@ export async function getPermissionsForShare(accountability, collections, contex
31
31
  const shareAccountability = {
32
32
  user: null,
33
33
  role: role,
34
- roles: await fetchRolesTree(role, context.knex),
34
+ roles: await fetchRolesTree(role, { knex: context.knex }),
35
35
  admin: false,
36
36
  app: false,
37
37
  ip: accountability.ip,
38
38
  };
39
39
  const [{ admin: shareIsAdmin }, { admin: userIsAdmin }, userPermissions, sharePermissions, shareFieldMap, userFieldMap,] = await Promise.all([
40
- fetchGlobalAccess(shareAccountability, context.knex),
41
- fetchGlobalAccess(userAccountability, context.knex),
40
+ fetchGlobalAccess(shareAccountability, { knex: context.knex }),
41
+ fetchGlobalAccess(userAccountability, { knex: context.knex }),
42
42
  getPermissionsForAccountability(userAccountability, context),
43
43
  getPermissionsForAccountability(shareAccountability, context),
44
44
  fetchAllowedFieldMap({
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The `pick` parameter can be used to stabilize cache keys, by only using a subset of the available parameters and
3
- * ensuring key order.
2
+ * Wraps a function with caching capabilities.
4
3
  *
5
- * If the `pick` function is provided, we pass the picked result to the handler, in order for TypeScript to ensure that
6
- * the function only relies on the parameters that are used for generating the cache key.
4
+ * @param namespace - A unique namespace for the cache key.
5
+ * @param handler - The function to be wrapped.
6
+ * @param prepareArg - Optional function to prepare arguments for hashing.
7
+ * @returns A new function that caches the results of the original function.
7
8
  *
8
- * @NOTE only uses the first parameter for memoization
9
+ * @NOTE Ensure that the `namespace` is unique to avoid cache key collisions.
10
+ * @NOTE Ensure that the `prepareArg` function returns a JSON stringifiable representation of the arguments.
9
11
  */
10
- export declare function withCache<F extends (arg0: Arg0, ...args: any[]) => R, R, Arg0 = Parameters<F>[0]>(namespace: string, handler: F, prepareArg?: (arg0: Arg0) => Arg0): F;
12
+ export declare function withCache<F extends (...args: any) => any>(namespace: string, handler: F, prepareArg?: (...args: Parameters<F>) => Record<string, unknown>): (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
@@ -1,25 +1,27 @@
1
1
  import { getSimpleHash } from '@directus/utils';
2
2
  import { useCache } from '../cache.js';
3
3
  /**
4
- * The `pick` parameter can be used to stabilize cache keys, by only using a subset of the available parameters and
5
- * ensuring key order.
4
+ * Wraps a function with caching capabilities.
6
5
  *
7
- * If the `pick` function is provided, we pass the picked result to the handler, in order for TypeScript to ensure that
8
- * the function only relies on the parameters that are used for generating the cache key.
6
+ * @param namespace - A unique namespace for the cache key.
7
+ * @param handler - The function to be wrapped.
8
+ * @param prepareArg - Optional function to prepare arguments for hashing.
9
+ * @returns A new function that caches the results of the original function.
9
10
  *
10
- * @NOTE only uses the first parameter for memoization
11
+ * @NOTE Ensure that the `namespace` is unique to avoid cache key collisions.
12
+ * @NOTE Ensure that the `prepareArg` function returns a JSON stringifiable representation of the arguments.
11
13
  */
12
14
  export function withCache(namespace, handler, prepareArg) {
13
15
  const cache = useCache();
14
- return (async (arg0, ...args) => {
15
- arg0 = prepareArg ? prepareArg(arg0) : arg0;
16
- const key = namespace + '-' + getSimpleHash(JSON.stringify(arg0));
16
+ return async (...args) => {
17
+ const hashArgs = prepareArg ? prepareArg(...args) : args;
18
+ const key = namespace + '-' + getSimpleHash(JSON.stringify(hashArgs));
17
19
  const cached = await cache.get(key);
18
20
  if (cached !== undefined) {
19
21
  return cached;
20
22
  }
21
- const res = await handler(arg0, ...args);
23
+ const res = await handler(...args);
22
24
  cache.set(key, res);
23
25
  return res;
24
- });
26
+ };
25
27
  }
@@ -1,8 +1,8 @@
1
1
  import { useEnv } from '@directus/env';
2
- import os from 'node:os';
2
+ import { ipInNetworks } from '@directus/utils/node';
3
3
  import { matches } from 'ip-matching';
4
+ import os from 'node:os';
4
5
  import { useLogger } from '../logger/index.js';
5
- import { ipInNetworks } from '../utils/ip-in-networks.js';
6
6
  export function isDeniedIp(ip) {
7
7
  const env = useEnv();
8
8
  const logger = useLogger();
@@ -0,0 +1,7 @@
1
+ export declare class NameDeduper {
2
+ private map;
3
+ add(name?: string | null, options?: {
4
+ group?: string | null;
5
+ fallback?: string;
6
+ }): string;
7
+ }
@@ -0,0 +1,23 @@
1
+ import sanitize from 'sanitize-filename';
2
+ const DEFAULT_GROUP = Symbol('undefined');
3
+ export class NameDeduper {
4
+ map = {};
5
+ add(name, options) {
6
+ name = sanitize(name ?? '') || options?.fallback;
7
+ if (!name) {
8
+ throw Error('Invalid "name" provided');
9
+ }
10
+ const groupKey = options?.group ?? DEFAULT_GROUP;
11
+ const match = this.map[groupKey]?.[name];
12
+ if (match) {
13
+ const dedupedName = `${name} (${match})`;
14
+ this.map[groupKey][name] += 1;
15
+ return dedupedName;
16
+ }
17
+ if (!this.map[groupKey]) {
18
+ this.map[groupKey] = {};
19
+ }
20
+ this.map[groupKey][name] = 1;
21
+ return name;
22
+ }
23
+ }
@@ -1,4 +1,5 @@
1
- import type { AbstractServiceOptions, Accountability, Range, Stat, SchemaOverview, TransformationSet } from '@directus/types';
1
+ import type { AbstractServiceOptions, Accountability, Range, SchemaOverview, Stat, TransformationSet } from '@directus/types';
2
+ import archiver from 'archiver';
2
3
  import type { Knex } from 'knex';
3
4
  import type { Readable } from 'node:stream';
4
5
  import { FilesService } from './files.js';
@@ -6,8 +7,20 @@ export declare class AssetsService {
6
7
  knex: Knex;
7
8
  accountability: Accountability | null;
8
9
  schema: SchemaOverview;
9
- filesService: FilesService;
10
+ sudoFilesService: FilesService;
10
11
  constructor(options: AbstractServiceOptions);
12
+ private zip;
13
+ zipFiles(files: string[]): Promise<{
14
+ archive: archiver.Archiver;
15
+ complete: () => Promise<void>;
16
+ }>;
17
+ zipFolder(root: string): Promise<{
18
+ archive: archiver.Archiver;
19
+ complete: () => Promise<void>;
20
+ metadata: {
21
+ name: string | undefined;
22
+ };
23
+ }>;
11
24
  getAsset(id: string, transformation?: TransformationSet, range?: Range, deferStream?: false): Promise<{
12
25
  stream: Readable;
13
26
  file: any;
@@ -1,7 +1,8 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { ForbiddenError, IllegalAssetTransformationError, InvalidQueryError, RangeNotSatisfiableError, ServiceUnavailableError, } from '@directus/errors';
2
+ import { ForbiddenError, IllegalAssetTransformationError, InvalidPayloadError, InvalidQueryError, RangeNotSatisfiableError, ServiceUnavailableError, } from '@directus/errors';
3
+ import archiver from 'archiver';
3
4
  import { clamp } from 'lodash-es';
4
- import { contentType } from 'mime-types';
5
+ import { contentType, extension } from 'mime-types';
5
6
  import hash from 'object-hash';
6
7
  import path from 'path';
7
8
  import sharp from 'sharp';
@@ -13,20 +14,112 @@ import { getStorage } from '../storage/index.js';
13
14
  import { getMilliseconds } from '../utils/get-milliseconds.js';
14
15
  import { isValidUuid } from '../utils/is-valid-uuid.js';
15
16
  import * as TransformationUtils from '../utils/transformations.js';
17
+ import { NameDeduper } from './assets/name-deduper.js';
16
18
  import { FilesService } from './files.js';
17
19
  import { getSharpInstance } from './files/lib/get-sharp-instance.js';
20
+ import { FoldersService } from './folders.js';
18
21
  const env = useEnv();
19
22
  const logger = useLogger();
20
23
  export class AssetsService {
21
24
  knex;
22
25
  accountability;
23
26
  schema;
24
- filesService;
27
+ sudoFilesService;
25
28
  constructor(options) {
26
29
  this.knex = options.knex || getDatabase();
27
30
  this.accountability = options.accountability || null;
28
31
  this.schema = options.schema;
29
- this.filesService = new FilesService({ ...options, accountability: null });
32
+ this.sudoFilesService = new FilesService({ ...options, accountability: null });
33
+ }
34
+ zip(options) {
35
+ if (options.files.length === 0) {
36
+ throw new InvalidPayloadError({ reason: 'No files found in the selected folders tree' });
37
+ }
38
+ const archive = archiver('zip');
39
+ const complete = async () => {
40
+ const deduper = new NameDeduper();
41
+ const storage = await getStorage();
42
+ for (const { id, folder, filename_download } of options.files) {
43
+ const file = await this.sudoFilesService.readOne(id, {
44
+ fields: ['id', 'storage', 'filename_disk', 'filename_download', 'modified_on', 'type'],
45
+ });
46
+ const exists = await storage.location(file.storage).exists(file.filename_disk);
47
+ if (!exists)
48
+ throw new ForbiddenError();
49
+ const version = file.modified_on ? (new Date(file.modified_on).getTime() / 1000).toFixed() : undefined;
50
+ const assetStream = await storage.location(file.storage).read(file.filename_disk, { version });
51
+ const fileExtension = path.extname(file.filename_download) || (file.type && '.' + extension(file.type)) || '';
52
+ const dedupedFileName = deduper.add(filename_download, { group: folder, fallback: file.id + fileExtension });
53
+ const folderName = folder ? options.folders?.get(folder) : undefined;
54
+ archive.append(assetStream, { name: dedupedFileName, prefix: folderName });
55
+ }
56
+ // add any empty folders, does not override already filled folder
57
+ if (options.folders) {
58
+ for (const [, folder] of options.folders) {
59
+ archive.append('', { name: folder + '/' });
60
+ }
61
+ }
62
+ await archive.finalize();
63
+ };
64
+ return { archive, complete };
65
+ }
66
+ async zipFiles(files) {
67
+ const filesService = new FilesService({
68
+ schema: this.schema,
69
+ knex: this.knex,
70
+ accountability: this.accountability,
71
+ });
72
+ const filesToZip = await filesService.readByQuery({
73
+ filter: {
74
+ id: {
75
+ _in: files,
76
+ },
77
+ },
78
+ limit: -1,
79
+ });
80
+ return this.zip({
81
+ files: filesToZip.map((file) => ({
82
+ id: file['id'],
83
+ folder: file['folder'],
84
+ filename_download: file['filename_download'],
85
+ })),
86
+ });
87
+ }
88
+ async zipFolder(root) {
89
+ const foldersService = new FoldersService({
90
+ schema: this.schema,
91
+ knex: this.knex,
92
+ accountability: this.accountability,
93
+ });
94
+ const folderTree = await foldersService.buildTree(root);
95
+ const filesService = new FilesService({
96
+ schema: this.schema,
97
+ knex: this.knex,
98
+ accountability: this.accountability,
99
+ });
100
+ const filesToZip = await filesService.readByQuery({
101
+ filter: {
102
+ folder: {
103
+ _in: Array.from(folderTree.keys()),
104
+ },
105
+ },
106
+ limit: -1,
107
+ });
108
+ const { archive, complete } = this.zip({
109
+ folders: folderTree,
110
+ files: filesToZip.map((file) => ({
111
+ id: file['id'],
112
+ folder: file['folder'],
113
+ filename_download: file['filename_download'],
114
+ })),
115
+ });
116
+ return {
117
+ archive,
118
+ complete,
119
+ metadata: {
120
+ name: folderTree.get(root),
121
+ },
122
+ };
30
123
  }
31
124
  async getAsset(id, transformation, range, deferStream = false) {
32
125
  const storage = await getStorage();
@@ -50,7 +143,7 @@ export class AssetsService {
50
143
  primaryKeys: [id],
51
144
  }, { knex: this.knex, schema: this.schema });
52
145
  }
53
- const file = (await this.filesService.readOne(id, { limit: 1 }));
146
+ const file = (await this.sudoFilesService.readOne(id, { limit: 1 }));
54
147
  const exists = await storage.location(file.storage).exists(file.filename_disk);
55
148
  if (!exists)
56
149
  throw new ForbiddenError();
@@ -149,8 +149,8 @@ export class AuthenticationService {
149
149
  throw new InvalidOtpError();
150
150
  }
151
151
  }
152
- const roles = await fetchRolesTree(user.role, this.knex);
153
- const globalAccess = await fetchGlobalAccess({ roles, user: user.id, ip: this.accountability?.ip ?? null }, this.knex);
152
+ const roles = await fetchRolesTree(user.role, { knex: this.knex });
153
+ const globalAccess = await fetchGlobalAccess({ roles, user: user.id, ip: this.accountability?.ip ?? null }, { knex: this.knex });
154
154
  const tokenPayload = {
155
155
  id: user.id,
156
156
  role: user.role,
@@ -277,8 +277,8 @@ export class AuthenticationService {
277
277
  throw new InvalidCredentialsError();
278
278
  }
279
279
  }
280
- const roles = await fetchRolesTree(record.user_role, this.knex);
281
- const globalAccess = await fetchGlobalAccess({ user: record.user_id, roles, ip: this.accountability?.ip ?? null }, this.knex);
280
+ const roles = await fetchRolesTree(record.user_role, { knex: this.knex });
281
+ const globalAccess = await fetchGlobalAccess({ user: record.user_id, roles, ip: this.accountability?.ip ?? null }, { knex: this.knex });
282
282
  if (record.user_id) {
283
283
  const provider = getAuthProvider(record.user_provider);
284
284
  await provider.refresh({