@directus/api 32.1.0 → 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 (175) 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/oauth2.d.ts +2 -1
  46. package/dist/auth/drivers/oauth2.js +17 -22
  47. package/dist/auth/drivers/openid.d.ts +2 -1
  48. package/dist/auth/drivers/openid.js +13 -18
  49. package/dist/auth/drivers/saml.js +6 -3
  50. package/dist/controllers/assets.js +39 -2
  51. package/dist/controllers/mcp.js +1 -1
  52. package/dist/database/migrations/20240806A-permissions-policies.js +2 -2
  53. package/dist/database/migrations/20251103A-add-ai-settings.d.ts +3 -0
  54. package/dist/database/migrations/20251103A-add-ai-settings.js +14 -0
  55. package/dist/database/run-ast/run-ast.js +1 -1
  56. package/dist/extensions/lib/installation/manager.js +5 -9
  57. package/dist/extensions/lib/sync/status.d.ts +11 -0
  58. package/dist/extensions/lib/sync/status.js +34 -0
  59. package/dist/extensions/lib/sync/sync.d.ts +6 -0
  60. package/dist/extensions/lib/sync/sync.js +90 -0
  61. package/dist/extensions/lib/sync/tracker.d.ts +18 -0
  62. package/dist/extensions/lib/sync/tracker.js +71 -0
  63. package/dist/extensions/lib/sync/utils.d.ts +24 -0
  64. package/dist/extensions/lib/sync/utils.js +62 -0
  65. package/dist/extensions/manager.d.ts +8 -4
  66. package/dist/extensions/manager.js +30 -13
  67. package/dist/middleware/respond.js +2 -2
  68. package/dist/permissions/lib/fetch-policies.d.ts +1 -1
  69. package/dist/permissions/lib/fetch-roles-tree.d.ts +6 -3
  70. package/dist/permissions/lib/fetch-roles-tree.js +5 -27
  71. package/dist/permissions/modules/fetch-global-access/fetch-global-access.d.ts +9 -7
  72. package/dist/permissions/modules/fetch-global-access/fetch-global-access.js +17 -9
  73. package/dist/permissions/modules/fetch-policies-ip-access/fetch-policies-ip-access.d.ts +1 -1
  74. package/dist/permissions/utils/fetch-raw-permissions.d.ts +1 -1
  75. package/dist/permissions/utils/fetch-share-info.d.ts +1 -1
  76. package/dist/permissions/utils/fetch-share-info.js +1 -1
  77. package/dist/permissions/utils/filter-policies-by-ip.js +1 -1
  78. package/dist/permissions/utils/get-permissions-for-share.js +8 -8
  79. package/dist/permissions/utils/with-cache.d.ts +8 -6
  80. package/dist/permissions/utils/with-cache.js +12 -10
  81. package/dist/request/is-denied-ip.js +2 -2
  82. package/dist/services/assets/name-deduper.d.ts +7 -0
  83. package/dist/services/assets/name-deduper.js +23 -0
  84. package/dist/services/assets.d.ts +15 -2
  85. package/dist/services/assets.js +98 -5
  86. package/dist/services/authentication.js +4 -4
  87. package/dist/services/comments.js +2 -2
  88. package/dist/services/extensions.js +4 -0
  89. package/dist/services/folders.d.ts +27 -2
  90. package/dist/services/folders.js +75 -0
  91. package/dist/services/graphql/resolvers/query.js +1 -1
  92. package/dist/services/import-export.d.ts +1 -1
  93. package/dist/services/import-export.js +4 -5
  94. package/dist/services/notifications.js +2 -2
  95. package/dist/services/payload.js +20 -0
  96. package/dist/services/roles.js +2 -2
  97. package/dist/services/tus/server.js +3 -3
  98. package/dist/telemetry/utils/get-settings.d.ts +15 -0
  99. package/dist/telemetry/utils/get-settings.js +25 -9
  100. package/dist/test-utils/README.md +95 -24
  101. package/dist/test-utils/cache.d.ts +2 -2
  102. package/dist/test-utils/cache.js +2 -2
  103. package/dist/test-utils/{fields-service.d.ts → services/fields-service.d.ts} +1 -1
  104. package/dist/test-utils/{fields-service.js → services/fields-service.js} +3 -2
  105. package/dist/test-utils/services/files-service.d.ts +28 -0
  106. package/dist/test-utils/services/files-service.js +34 -0
  107. package/dist/test-utils/services/folders-service.d.ts +28 -0
  108. package/dist/test-utils/services/folders-service.js +33 -0
  109. package/dist/utils/encrypt.d.ts +2 -0
  110. package/dist/utils/encrypt.js +64 -0
  111. package/dist/utils/get-accountability-for-role.js +2 -2
  112. package/dist/utils/get-accountability-for-token.js +4 -4
  113. package/dist/utils/get-cache-key.js +2 -2
  114. package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
  115. package/dist/{auth/utils → utils}/is-login-redirect-allowed.js +8 -16
  116. package/dist/utils/require-text.d.ts +1 -0
  117. package/dist/utils/require-text.js +4 -0
  118. package/dist/utils/require-yaml.js +2 -2
  119. package/package.json +31 -25
  120. package/dist/auth/utils/generate-callback-url.d.ts +0 -8
  121. package/dist/auth/utils/generate-callback-url.js +0 -11
  122. package/dist/auth/utils/is-login-redirect-allowed.d.ts +0 -8
  123. package/dist/extensions/lib/sync-extensions.d.ts +0 -3
  124. package/dist/extensions/lib/sync-extensions.js +0 -70
  125. package/dist/extensions/lib/sync-status.d.ts +0 -10
  126. package/dist/extensions/lib/sync-status.js +0 -27
  127. package/dist/mcp/tools/index.d.ts +0 -15
  128. package/dist/mcp/tools/index.js +0 -29
  129. package/dist/mcp/tools/prompts/index.d.ts +0 -16
  130. package/dist/mcp/tools/prompts/index.js +0 -19
  131. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-roles.d.ts +0 -5
  132. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-roles.js +0 -7
  133. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-user.d.ts +0 -5
  134. package/dist/permissions/modules/fetch-global-access/lib/fetch-global-access-for-user.js +0 -10
  135. package/dist/permissions/modules/fetch-global-access/types.d.ts +0 -4
  136. package/dist/permissions/modules/fetch-global-access/utils/fetch-global-access-for-query.d.ts +0 -4
  137. package/dist/permissions/modules/fetch-global-access/utils/fetch-global-access-for-query.js +0 -27
  138. package/dist/utils/get-date-formatted.d.ts +0 -1
  139. package/dist/utils/get-date-formatted.js +0 -10
  140. package/dist/utils/ip-in-networks.d.ts +0 -6
  141. package/dist/utils/ip-in-networks.js +0 -13
  142. /package/dist/{mcp → ai/mcp}/index.d.ts +0 -0
  143. /package/dist/{mcp → ai/mcp}/index.js +0 -0
  144. /package/dist/{mcp → ai/mcp}/transport.d.ts +0 -0
  145. /package/dist/{mcp → ai/mcp}/transport.js +0 -0
  146. /package/dist/{mcp → ai/mcp}/types.js +0 -0
  147. /package/dist/{mcp/tools/assets.d.ts → ai/tools/assets/index.d.ts} +0 -0
  148. /package/dist/{mcp/tools/prompts/assets.md → ai/tools/assets/prompt.md} +0 -0
  149. /package/dist/{mcp/tools/collections.d.ts → ai/tools/collections/index.d.ts} +0 -0
  150. /package/dist/{mcp/tools/prompts/collections.md → ai/tools/collections/prompt.md} +0 -0
  151. /package/dist/{mcp/define.d.ts → ai/tools/define-tool.d.ts} +0 -0
  152. /package/dist/{mcp/define.js → ai/tools/define-tool.js} +0 -0
  153. /package/dist/{mcp/tools/fields.d.ts → ai/tools/fields/index.d.ts} +0 -0
  154. /package/dist/{mcp/tools/prompts/fields.md → ai/tools/fields/prompt.md} +0 -0
  155. /package/dist/{mcp/tools/files.d.ts → ai/tools/files/index.d.ts} +0 -0
  156. /package/dist/{mcp/tools/prompts/files.md → ai/tools/files/prompt.md} +0 -0
  157. /package/dist/{mcp/tools/flows.d.ts → ai/tools/flows/index.d.ts} +0 -0
  158. /package/dist/{mcp/tools/prompts/flows.md → ai/tools/flows/prompt.md} +0 -0
  159. /package/dist/{mcp/tools/folders.d.ts → ai/tools/folders/index.d.ts} +0 -0
  160. /package/dist/{mcp/tools/prompts/folders.md → ai/tools/folders/prompt.md} +0 -0
  161. /package/dist/{mcp/tools/items.d.ts → ai/tools/items/index.d.ts} +0 -0
  162. /package/dist/{mcp/tools/prompts/operations.md → ai/tools/operations/prompt.md} +0 -0
  163. /package/dist/{mcp/tools/relations.d.ts → ai/tools/relations/index.d.ts} +0 -0
  164. /package/dist/{mcp/tools/prompts/relations.md → ai/tools/relations/prompt.md} +0 -0
  165. /package/dist/{mcp/tools/prompts/schema.md → ai/tools/schema/prompt.md} +0 -0
  166. /package/dist/{mcp → ai/tools}/schema.d.ts +0 -0
  167. /package/dist/{mcp → ai/tools}/schema.js +0 -0
  168. /package/dist/{mcp/tools/system.d.ts → ai/tools/system/index.d.ts} +0 -0
  169. /package/dist/{mcp/tools/prompts/system-prompt-description.md → ai/tools/system/prompt-description.md} +0 -0
  170. /package/dist/{mcp/tools/prompts/system-prompt.md → ai/tools/system/prompt.md} +0 -0
  171. /package/dist/{mcp/tools/trigger-flow.d.ts → ai/tools/trigger-flow/index.d.ts} +0 -0
  172. /package/dist/{mcp/tools/prompts/trigger-flow.md → ai/tools/trigger-flow/prompt.md} +0 -0
  173. /package/dist/{permissions/modules/fetch-global-access → ai/tools}/types.js +0 -0
  174. /package/dist/test-utils/{items-service.d.ts → services/items-service.d.ts} +0 -0
  175. /package/dist/test-utils/{items-service.js → services/items-service.js} +0 -0
@@ -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({
@@ -63,10 +63,10 @@ export class CommentsService extends ItemsService {
63
63
  role: user['role']?.id ?? null,
64
64
  admin: false,
65
65
  app: false,
66
- roles: await fetchRolesTree(user['role']?.id ?? null, this.knex),
66
+ roles: await fetchRolesTree(user['role']?.id ?? null, { knex: this.knex }),
67
67
  ip: null,
68
68
  };
69
- const userGlobalAccess = await fetchGlobalAccess(accountability, this.knex);
69
+ const userGlobalAccess = await fetchGlobalAccess(accountability, { knex: this.knex });
70
70
  accountability.admin = userGlobalAccess.admin;
71
71
  accountability.app = userGlobalAccess.app;
72
72
  const usersService = new UsersService({ schema: this.schema, accountability });
@@ -109,6 +109,8 @@ export class ExtensionsService {
109
109
  await this.extensionsManager.install(versionId);
110
110
  }
111
111
  async readAll() {
112
+ // wait for extensions to be reloaded
113
+ await this.extensionsManager.isReloading();
112
114
  const settings = await this.extensionsItemService.readByQuery({ limit: -1 });
113
115
  const regular = settings.filter(({ bundle }) => bundle === null);
114
116
  const bundled = settings.filter(({ bundle }) => bundle !== null);
@@ -138,6 +140,8 @@ export class ExtensionsService {
138
140
  return output;
139
141
  }
140
142
  async readOne(id) {
143
+ // wait for extensions to be reloaded
144
+ await this.extensionsManager.isReloading();
141
145
  const meta = await this.extensionsItemService.readOne(id);
142
146
  const schema = this.extensionsManager.getExtension(meta.source, meta.folder) ?? null;
143
147
  return {
@@ -1,5 +1,30 @@
1
- import type { AbstractServiceOptions } from '@directus/types';
1
+ import type { AbstractServiceOptions, Folder } from '@directus/types';
2
2
  import { ItemsService } from './items.js';
3
- export declare class FoldersService extends ItemsService {
3
+ export declare class FoldersService extends ItemsService<Folder> {
4
4
  constructor(options: AbstractServiceOptions);
5
+ /**
6
+ * Builds a full folder tree starting from a given root folder.
7
+ *
8
+ * This method returns a map of folder IDs to their corresponding paths
9
+ * relative to the root. It resolves all nested child folders and ensures
10
+ * that folder names are deduplicated within the same parent.
11
+ *
12
+ * Access control is applied automatically when non-admin, only folders the user has `read`
13
+ * access to are included.
14
+ *
15
+ * @param {string} root - The ID of the root folder to start building the tree from.
16
+ * @returns {Promise<Map<string, string>>} A `Map` where:
17
+ * - Key: folder ID
18
+ * - Value: folder path relative to the root (e.g., "Documents/Photos")
19
+ *
20
+ * @example
21
+ * const foldersService = new FoldersService({ schema, accountability });
22
+ * const tree = await foldersService.buildTree('root-folder-id');
23
+ * console.log(tree.get('folder1')); // e.g., "RootFolder/SubFolder1"
24
+ *
25
+ * @remarks
26
+ * - The returned `Map` includes the root folder itself.
27
+ * - If a folder has no name, its ID will be used as a fallback.
28
+ */
29
+ buildTree(root: string): Promise<Map<string, string>>;
5
30
  }
@@ -1,6 +1,81 @@
1
+ import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
2
+ import { NameDeduper } from './assets/name-deduper.js';
1
3
  import { ItemsService } from './items.js';
2
4
  export class FoldersService extends ItemsService {
3
5
  constructor(options) {
4
6
  super('directus_folders', options);
5
7
  }
8
+ /**
9
+ * Builds a full folder tree starting from a given root folder.
10
+ *
11
+ * This method returns a map of folder IDs to their corresponding paths
12
+ * relative to the root. It resolves all nested child folders and ensures
13
+ * that folder names are deduplicated within the same parent.
14
+ *
15
+ * Access control is applied automatically when non-admin, only folders the user has `read`
16
+ * access to are included.
17
+ *
18
+ * @param {string} root - The ID of the root folder to start building the tree from.
19
+ * @returns {Promise<Map<string, string>>} A `Map` where:
20
+ * - Key: folder ID
21
+ * - Value: folder path relative to the root (e.g., "Documents/Photos")
22
+ *
23
+ * @example
24
+ * const foldersService = new FoldersService({ schema, accountability });
25
+ * const tree = await foldersService.buildTree('root-folder-id');
26
+ * console.log(tree.get('folder1')); // e.g., "RootFolder/SubFolder1"
27
+ *
28
+ * @remarks
29
+ * - The returned `Map` includes the root folder itself.
30
+ * - If a folder has no name, its ID will be used as a fallback.
31
+ */
32
+ async buildTree(root) {
33
+ if (this.accountability && this.accountability.admin !== true) {
34
+ await validateAccess({
35
+ collection: 'directus_folders',
36
+ accountability: this.accountability,
37
+ action: 'read',
38
+ primaryKeys: [root],
39
+ }, {
40
+ knex: this.knex,
41
+ schema: this.schema,
42
+ });
43
+ }
44
+ const folders = await this.readByQuery({ limit: -1 });
45
+ // build folder and child lookup
46
+ const folderLookup = new Map();
47
+ const childFolderLookup = new Map();
48
+ for (const folder of folders) {
49
+ if (!folder['id'])
50
+ continue;
51
+ folderLookup.set(folder['id'], folder);
52
+ // root is always at the top level, we can therfor safely skip any parent references to it.
53
+ if (folder['parent'] && folder['id'] !== root) {
54
+ const children = childFolderLookup.get(folder['parent']) ?? [];
55
+ children.push(folder['id']);
56
+ childFolderLookup.set(folder['parent'], children);
57
+ }
58
+ }
59
+ const deduper = new NameDeduper();
60
+ const rootName = deduper.add(folderLookup.get(root)?.name, { fallback: root });
61
+ const stack = [[root, '']];
62
+ const tree = new Map();
63
+ // build tree from stack
64
+ while (stack.length > 0) {
65
+ const [folderId, path] = stack.pop() ?? [];
66
+ if (!folderId)
67
+ continue;
68
+ const folder = folderLookup.get(folderId);
69
+ if (!folder)
70
+ continue;
71
+ const children = childFolderLookup.get(folderId);
72
+ const folderName = deduper.add(folder['name'], { group: folder['parent'], fallback: folderId });
73
+ const folderPath = path === '' ? rootName : `${path}/${folderName}`;
74
+ tree.set(folderId, folderPath);
75
+ for (const childFolderId of children ?? []) {
76
+ stack.push([childFolderId, folderPath]);
77
+ }
78
+ }
79
+ return tree;
80
+ }
6
81
  }
@@ -23,10 +23,10 @@ export async function resolveQuery(gql, info) {
23
23
  query = await getAggregateQuery(args, selections, gql.schema, gql.accountability, collection);
24
24
  }
25
25
  else {
26
- query = await getQuery(args, gql.schema, selections, info.variableValues, gql.accountability, collection);
27
26
  if (collection.endsWith('_by_id') && collection in gql.schema.collections === false) {
28
27
  collection = collection.slice(0, -6);
29
28
  }
29
+ query = await getQuery(args, gql.schema, selections, info.variableValues, gql.accountability, collection);
30
30
  if (collection.endsWith('_by_version') && collection in gql.schema.collections === false) {
31
31
  collection = collection.slice(0, -11);
32
32
  query.versionRaw = true;
@@ -1,7 +1,7 @@
1
1
  import type { AbstractServiceOptions, Accountability, DirectusError, ExportFormat, File, Query, SchemaOverview } from '@directus/types';
2
2
  import type { Knex } from 'knex';
3
3
  import type { Readable } from 'node:stream';
4
- import type { FunctionFieldNode, FieldNode, NestedCollectionNode } from '../types/index.js';
4
+ import type { FieldNode, FunctionFieldNode, NestedCollectionNode } from '../types/index.js';
5
5
  export declare function createErrorTracker(): {
6
6
  addCapturedError: (err: any, rowNumber: number) => void;
7
7
  buildFinalErrors: () => DirectusError<any>[];
@@ -1,22 +1,23 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { createError, ErrorCode, ForbiddenError, InvalidPayloadError, ServiceUnavailableError, UnsupportedMediaTypeError, } from '@directus/errors';
3
3
  import { isSystemCollection } from '@directus/system-data';
4
- import { parseJSON, toArray } from '@directus/utils';
4
+ import { getDateTimeFormatted, parseJSON, toArray } from '@directus/utils';
5
5
  import { createTmpFile } from '@directus/utils/node';
6
6
  import { queue } from 'async';
7
7
  import destroyStream from 'destroy';
8
8
  import { dump as toYAML } from 'js-yaml';
9
9
  import { parse as toXML } from 'js2xmlparser';
10
10
  import { Parser as CSVParser, transforms as CSVTransforms } from 'json2csv';
11
+ import { set } from 'lodash-es';
11
12
  import { createReadStream, createWriteStream } from 'node:fs';
12
13
  import { appendFile } from 'node:fs/promises';
13
14
  import Papa from 'papaparse';
14
15
  import StreamArray from 'stream-json/streamers/StreamArray.js';
16
+ import { parseFields } from '../database/get-ast-from-query/lib/parse-fields.js';
15
17
  import getDatabase from '../database/index.js';
16
18
  import emitter from '../emitter.js';
17
19
  import { useLogger } from '../logger/index.js';
18
20
  import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
19
- import { getDateFormatted } from '../utils/get-date-formatted.js';
20
21
  import { getService } from '../utils/get-service.js';
21
22
  import { transaction } from '../utils/transaction.js';
22
23
  import { Url } from '../utils/url.js';
@@ -24,8 +25,6 @@ import { userName } from '../utils/user-name.js';
24
25
  import { FilesService } from './files.js';
25
26
  import { NotificationsService } from './notifications.js';
26
27
  import { UsersService } from './users.js';
27
- import { parseFields } from '../database/get-ast-from-query/lib/parse-fields.js';
28
- import { set } from 'lodash-es';
29
28
  const env = useEnv();
30
29
  const logger = useLogger();
31
30
  const MAX_IMPORT_ERRORS = env['MAX_IMPORT_ERRORS'];
@@ -514,7 +513,7 @@ export class ExportService {
514
513
  schema: this.schema,
515
514
  });
516
515
  const storage = toArray(env['STORAGE_LOCATIONS'])[0];
517
- const title = `export-${collection}-${getDateFormatted()}`;
516
+ const title = `export-${collection}-${getDateTimeFormatted()}`;
518
517
  const filename = `${title}.${format}`;
519
518
  const fileWithDefaults = {
520
519
  ...(options?.file ?? {}),
@@ -29,12 +29,12 @@ export class NotificationsService extends ItemsService {
29
29
  .addPath('admin', 'users', user['id'])
30
30
  .toString();
31
31
  const html = data.message ? md(data.message) : '';
32
- const roles = await fetchRolesTree(user['role'], this.knex);
32
+ const roles = await fetchRolesTree(user['role'], { knex: this.knex });
33
33
  const { app: app_access } = await fetchGlobalAccess({
34
34
  user: user['id'],
35
35
  roles,
36
36
  ip: null,
37
- }, this.knex);
37
+ }, { knex: this.knex });
38
38
  const mailService = new MailService({
39
39
  schema: this.schema,
40
40
  knex: this.knex,
@@ -10,6 +10,8 @@ import { parse as wktToGeoJSON } from 'wellknown';
10
10
  import { getHelpers } from '../database/helpers/index.js';
11
11
  import getDatabase from '../database/index.js';
12
12
  import { generateHash } from '../utils/generate-hash.js';
13
+ import { decrypt, encrypt } from '../utils/encrypt.js';
14
+ import { getSecret } from '../utils/get-secret.js';
13
15
  /**
14
16
  * Process a given payload for a collection to ensure the special fields (hash, uuid, date etc) are
15
17
  * handled correctly.
@@ -124,6 +126,24 @@ export class PayloadService {
124
126
  }
125
127
  return value;
126
128
  },
129
+ async encrypt({ action, value, accountability }) {
130
+ if (!value)
131
+ return value;
132
+ if (action === 'read') {
133
+ // In-system calls can still get the decrypted value
134
+ if (accountability === null) {
135
+ const key = getSecret();
136
+ return await decrypt(value, key);
137
+ }
138
+ // Requests from the API entrypoints have accountability and shouldn't get the raw value
139
+ return '**********';
140
+ }
141
+ if (typeof value === 'string') {
142
+ const key = getSecret();
143
+ return await encrypt(value, key);
144
+ }
145
+ return value;
146
+ },
127
147
  };
128
148
  async processValues(action, payload, aliasMap = {}, aggregate = {}) {
129
149
  const processedPayload = toArray(payload);
@@ -3,8 +3,8 @@ import { UserIntegrityCheckFlag } from '@directus/types';
3
3
  import { clearSystemCache } from '../cache.js';
4
4
  import { fetchRolesTree } from '../permissions/lib/fetch-roles-tree.js';
5
5
  import { transaction } from '../utils/transaction.js';
6
- import { ItemsService } from './items.js';
7
6
  import { AccessService } from './access.js';
7
+ import { ItemsService } from './items.js';
8
8
  import { PresetsService } from './presets.js';
9
9
  import { UsersService } from './users.js';
10
10
  export class RolesService extends ItemsService {
@@ -72,7 +72,7 @@ export class RolesService extends ItemsService {
72
72
  if (ids.includes(parent)) {
73
73
  throw new InvalidPayloadError({ reason: 'A role cannot be a parent of itself' });
74
74
  }
75
- const roles = await fetchRolesTree(parent, this.knex);
75
+ const roles = await fetchRolesTree(parent, { knex: this.knex });
76
76
  if (ids.some((id) => roles.includes(id))) {
77
77
  // The role tree up from the parent already includes this role, so it would create a circular reference
78
78
  throw new InvalidPayloadError({ reason: 'A role cannot have a parent that is already a descendant of itself' });
@@ -41,7 +41,7 @@ export async function createTusServer(context) {
41
41
  datastore: store,
42
42
  locker: getTusLocker(),
43
43
  ...(RESUMABLE_UPLOADS.MAX_SIZE !== null && { maxSize: RESUMABLE_UPLOADS.MAX_SIZE }),
44
- async onUploadFinish(req, upload) {
44
+ async onUploadFinish(_req, upload) {
45
45
  const schema = await getSchema();
46
46
  const service = new ItemsService('directus_files', {
47
47
  schema,
@@ -92,9 +92,9 @@ export async function createTusServer(context) {
92
92
  key: fileData.id,
93
93
  collection: 'directus_files',
94
94
  }, {
95
- database: getDatabase(),
96
95
  schema,
97
- accountability: req.accountability,
96
+ database: getDatabase(),
97
+ accountability: context.accountability ?? null,
98
98
  });
99
99
  return {
100
100
  headers: {
@@ -5,5 +5,20 @@ export type TelemetrySettings = {
5
5
  mcp_allow_deletes: boolean;
6
6
  mcp_system_prompt_enabled: boolean;
7
7
  visual_editor_urls: number;
8
+ ai_openai_api_key: boolean;
9
+ ai_anthropic_api_key: boolean;
10
+ ai_system_prompt: boolean;
11
+ };
12
+ export type DatabaseSettings = {
13
+ project_id: string;
14
+ mcp_enabled?: boolean;
15
+ mcp_allow_deletes?: boolean;
16
+ mcp_system_prompt_enabled?: boolean;
17
+ visual_editor_urls?: {
18
+ url: string;
19
+ }[];
20
+ ai_openai_api_key?: string;
21
+ ai_anthropic_api_key?: string;
22
+ ai_system_prompt?: string;
8
23
  };
9
24
  export declare const getSettings: (db: Knex) => Promise<TelemetrySettings>;