@directus/api 14.0.2 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) 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/controllers/assets.js +10 -10
  7. package/dist/controllers/files.js +1 -5
  8. package/dist/database/index.js +2 -1
  9. package/dist/database/migrations/run.js +2 -2
  10. package/dist/database/system-data/collections/collections.yaml +1 -1
  11. package/dist/database/system-data/fields/settings.yaml +12 -13
  12. package/dist/database/system-data/fields/users.yaml +10 -10
  13. package/dist/env.d.ts +2 -4
  14. package/dist/env.js +12 -9
  15. package/dist/extensions/lib/get-extensions-path.d.ts +1 -0
  16. package/dist/extensions/lib/get-extensions-path.js +8 -0
  17. package/dist/extensions/lib/get-extensions.js +3 -2
  18. package/dist/extensions/lib/sync-extensions.d.ts +1 -0
  19. package/dist/extensions/lib/sync-extensions.js +59 -0
  20. package/dist/extensions/lib/sync-status.d.ts +10 -0
  21. package/dist/extensions/lib/sync-status.js +27 -0
  22. package/dist/extensions/manager.js +14 -5
  23. package/dist/logger.d.ts +2 -1
  24. package/dist/logger.js +13 -2
  25. package/dist/messenger.js +1 -3
  26. package/dist/request/validate-ip.js +1 -2
  27. package/dist/services/extensions.js +1 -1
  28. package/dist/services/files.d.ts +2 -2
  29. package/dist/services/files.js +90 -23
  30. package/dist/services/mail/index.js +5 -4
  31. package/dist/services/mail/templates/base.liquid +383 -138
  32. package/dist/services/mail/templates/password-reset.liquid +35 -17
  33. package/dist/services/mail/templates/user-invitation.liquid +32 -13
  34. package/dist/services/server.js +4 -0
  35. package/dist/storage/register-drivers.js +1 -2
  36. package/dist/storage/register-locations.js +1 -2
  37. package/dist/utils/get-auth-providers.d.ts +1 -1
  38. package/dist/utils/get-config-from-env.js +1 -2
  39. package/dist/utils/merge-permissions.js +11 -19
  40. package/dist/utils/sanitize-query.js +1 -2
  41. package/dist/utils/should-clear-cache.js +1 -2
  42. package/dist/utils/should-skip-cache.js +3 -4
  43. package/dist/utils/validate-env.js +1 -2
  44. package/dist/utils/validate-storage.js +12 -9
  45. package/package.json +16 -15
  46. package/dist/__mocks__/cache.d.mts +0 -5
  47. package/dist/__mocks__/cache.mjs +0 -7
package/dist/messenger.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseJSON } from '@directus/utils';
2
2
  import { Redis } from 'ioredis';
3
- import { getEnv } from './env.js';
3
+ import env from './env.js';
4
4
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
5
5
  export class MessengerMemory {
6
6
  handlers;
@@ -30,7 +30,6 @@ export class MessengerRedis {
30
30
  sub;
31
31
  constructor() {
32
32
  const config = getConfigFromEnv('REDIS');
33
- const env = getEnv();
34
33
  this.pub = new Redis(env['REDIS'] ?? config);
35
34
  this.sub = new Redis(env['REDIS'] ?? config);
36
35
  this.namespace = env['MESSENGER_NAMESPACE'] ?? 'directus-messenger';
@@ -55,7 +54,6 @@ let messenger;
55
54
  export function getMessenger() {
56
55
  if (messenger)
57
56
  return messenger;
58
- const env = getEnv();
59
57
  if (env['MESSENGER_STORE'] === 'redis') {
60
58
  messenger = new MessengerRedis();
61
59
  }
@@ -1,7 +1,6 @@
1
1
  import os from 'node:os';
2
- import { getEnv } from '../env.js';
2
+ import env from '../env.js';
3
3
  export const validateIP = async (ip, url) => {
4
- const env = getEnv();
5
4
  if (env['IMPORT_IP_DENY_LIST'].includes(ip)) {
6
5
  throw new Error(`Requested URL "${url}" resolves to a denied IP address`);
7
6
  }
@@ -128,7 +128,7 @@ export class ExtensionsService {
128
128
  return {
129
129
  name,
130
130
  bundle: bundleName,
131
- schema: schema ? pick(schema, 'type', 'local') : null,
131
+ schema: schema ? pick(schema, 'type', 'local', 'version') : null,
132
132
  meta: omit(meta, 'name'),
133
133
  };
134
134
  });
@@ -1,5 +1,5 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
- import type { File } from '@directus/types';
2
+ import type { File, BusboyFileStream } from '@directus/types';
3
3
  import type { Readable } from 'node:stream';
4
4
  import type { AbstractServiceOptions, MutationOptions, PrimaryKey } from '../types/index.js';
5
5
  import { ItemsService } from './items.js';
@@ -9,7 +9,7 @@ export declare class FilesService extends ItemsService {
9
9
  /**
10
10
  * Upload a single new file to the configured storage adapter
11
11
  */
12
- uploadOne(stream: Readable, data: Partial<File> & {
12
+ uploadOne(stream: BusboyFileStream | Readable, data: Partial<File> & {
13
13
  storage: string;
14
14
  }, primaryKey?: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey>;
15
15
  /**
@@ -14,7 +14,7 @@ import url from 'url';
14
14
  import { SUPPORTED_IMAGE_METADATA_FORMATS } from '../constants.js';
15
15
  import emitter from '../emitter.js';
16
16
  import env from '../env.js';
17
- import { ForbiddenError, InvalidPayloadError, ServiceUnavailableError } from '@directus/errors';
17
+ import { ContentTooLargeError, ForbiddenError, InvalidPayloadError, ServiceUnavailableError } from '@directus/errors';
18
18
  import logger from '../logger.js';
19
19
  import { getAxios } from '../request/index.js';
20
20
  import { getStorage } from '../storage/index.js';
@@ -30,58 +30,124 @@ export class FilesService extends ItemsService {
30
30
  async uploadOne(stream, data, primaryKey, opts) {
31
31
  const storage = await getStorage();
32
32
  let existingFile = null;
33
+ // If the payload contains a primary key, we'll check if the file already exists
33
34
  if (primaryKey !== undefined) {
35
+ // If the file you're uploading already exists, we'll consider this upload a replace so we'll fetch the existing file's folder and filename_download
34
36
  existingFile =
35
37
  (await this.knex
36
- .select('folder', 'filename_download')
38
+ .select('folder', 'filename_download', 'filename_disk', 'title', 'description', 'metadata')
37
39
  .from('directus_files')
38
40
  .where({ id: primaryKey })
39
41
  .first()) ?? null;
40
42
  }
43
+ // Merge the existing file's folder and filename_download with the new payload
41
44
  const payload = { ...(existingFile ?? {}), ...clone(data) };
45
+ const disk = storage.location(payload.storage);
46
+ // If no folder is specified, we'll use the default folder from the settings if it exists
42
47
  if ('folder' in payload === false) {
43
48
  const settings = await this.knex.select('storage_default_folder').from('directus_settings').first();
44
49
  if (settings?.storage_default_folder) {
45
50
  payload.folder = settings.storage_default_folder;
46
51
  }
47
52
  }
48
- if (existingFile !== null && primaryKey !== undefined) {
49
- await this.updateOne(primaryKey, payload, { emitEvents: false });
50
- // If the file you're uploading already exists, we'll consider this upload a replace. In that case, we'll
51
- // delete the previously saved file and thumbnails to ensure they're generated fresh
52
- const disk = storage.location(payload.storage);
53
- for await (const filepath of disk.list(String(primaryKey))) {
54
- await disk.delete(filepath);
55
- }
56
- }
57
- else {
53
+ // Is this file a replacement? if the file data already exists and we have a primary key
54
+ const isReplacement = existingFile !== null && primaryKey !== undefined;
55
+ // If this is a new file upload, we need to generate a new primary key and DB record
56
+ if (isReplacement === false || primaryKey === undefined) {
58
57
  primaryKey = await this.createOne(payload, { emitEvents: false });
59
58
  }
60
59
  const fileExtension = path.extname(payload.filename_download) || (payload.type && '.' + extension(payload.type)) || '';
60
+ // The filename_disk is the FINAL filename on disk
61
61
  payload.filename_disk = primaryKey + (fileExtension || '');
62
+ // Temp filename is used for replacements
63
+ const tempFilenameDisk = 'temp_' + payload.filename_disk;
62
64
  if (!payload.type) {
63
65
  payload.type = 'application/octet-stream';
64
66
  }
67
+ // Used to clean up if something goes wrong
68
+ const cleanUp = async () => {
69
+ try {
70
+ if (isReplacement === true) {
71
+ // If this is a replacement that failed, we need to delete the temp file
72
+ await disk.delete(tempFilenameDisk);
73
+ }
74
+ else {
75
+ // If this is a new file that failed
76
+ // delete the DB record
77
+ await super.deleteMany([primaryKey]);
78
+ // delete the final file
79
+ await disk.delete(payload.filename_disk);
80
+ }
81
+ }
82
+ catch (err) {
83
+ if (isReplacement === true) {
84
+ logger.warn(`Couldn't delete temp file ${tempFilenameDisk}`);
85
+ }
86
+ else {
87
+ logger.warn(`Couldn't delete file ${payload.filename_disk}`);
88
+ }
89
+ logger.warn(err);
90
+ }
91
+ };
65
92
  try {
66
- await storage.location(data.storage).write(payload.filename_disk, stream, payload.type);
93
+ // If this is a replacement, we'll write the file to a temp location first to ensure we don't overwrite the existing file if something goes wrong
94
+ if (isReplacement === true) {
95
+ await disk.write(tempFilenameDisk, stream, payload.type);
96
+ }
97
+ else {
98
+ // If this is a new file upload, we'll write the file to the final location
99
+ await disk.write(payload.filename_disk, stream, payload.type);
100
+ }
101
+ // Check if the file was truncated (if the stream ended early) and throw limit error if it was
102
+ if ('truncated' in stream && stream.truncated === true) {
103
+ throw new ContentTooLargeError();
104
+ }
67
105
  }
68
106
  catch (err) {
69
107
  logger.warn(`Couldn't save file ${payload.filename_disk}`);
70
108
  logger.warn(err);
71
- await this.deleteOne(primaryKey);
72
- throw new ServiceUnavailableError({ service: 'files', reason: `Couldn't save file ${payload.filename_disk}` });
109
+ await cleanUp();
110
+ if (err instanceof ContentTooLargeError) {
111
+ throw err;
112
+ }
113
+ else {
114
+ throw new ServiceUnavailableError({ service: 'files', reason: `Couldn't save file ${payload.filename_disk}` });
115
+ }
116
+ }
117
+ // If the file is a replacement, we need to update the DB record with the new payload, delete the old files, and upgrade the temp file
118
+ if (isReplacement === true) {
119
+ await this.updateOne(primaryKey, payload, { emitEvents: false });
120
+ // delete the previously saved file and thumbnails to ensure they're generated fresh
121
+ for await (const filepath of disk.list(String(primaryKey))) {
122
+ await disk.delete(filepath);
123
+ }
124
+ // Upgrade the temp file to the final filename
125
+ await disk.move(tempFilenameDisk, payload.filename_disk);
73
126
  }
74
127
  const { size } = await storage.location(data.storage).stat(payload.filename_disk);
75
128
  payload.filesize = size;
76
129
  if (SUPPORTED_IMAGE_METADATA_FORMATS.includes(payload.type)) {
77
130
  const stream = await storage.location(data.storage).read(payload.filename_disk);
78
131
  const { height, width, description, title, tags, metadata } = await this.getMetadata(stream);
79
- payload.height ??= height ?? null;
80
- payload.width ??= width ?? null;
81
- payload.description ??= description ?? null;
82
- payload.title ??= title ?? null;
83
- payload.tags ??= tags ?? null;
84
- payload.metadata ??= metadata ?? null;
132
+ if (!payload.height && height) {
133
+ payload.height = height;
134
+ }
135
+ if (!payload.width && width) {
136
+ payload.width = width;
137
+ }
138
+ if (!payload.metadata && metadata) {
139
+ payload.metadata = metadata;
140
+ }
141
+ // Note that if this is a replace file upload, the below properities are fetched and included in the payload above in the `existingFile` variable...so this will ONLY set the values if they're not already set
142
+ if (!payload.description && description) {
143
+ payload.description = description;
144
+ }
145
+ if (!payload.title && title) {
146
+ payload.title = title;
147
+ }
148
+ if (!payload.tags && tags) {
149
+ payload.tags = tags;
150
+ }
85
151
  }
86
152
  // We do this in a service without accountability. Even if you don't have update permissions to the file,
87
153
  // we still want to be able to set the extracted values from the file on create
@@ -255,15 +321,16 @@ export class FilesService extends ItemsService {
255
321
  */
256
322
  async deleteMany(keys) {
257
323
  const storage = await getStorage();
258
- const files = await super.readMany(keys, { fields: ['id', 'storage'], limit: -1 });
324
+ const files = await super.readMany(keys, { fields: ['id', 'storage', 'filename_disk'], limit: -1 });
259
325
  if (!files) {
260
326
  throw new ForbiddenError();
261
327
  }
262
328
  await super.deleteMany(keys);
263
329
  for (const file of files) {
264
330
  const disk = storage.location(file['storage']);
331
+ const filePrefix = path.parse(file['filename_disk']).name;
265
332
  // Delete file + thumbnails
266
- for await (const filepath of disk.list(file['id'])) {
333
+ for await (const filepath of disk.list(filePrefix)) {
267
334
  await disk.delete(filepath);
268
335
  }
269
336
  }
@@ -1,16 +1,17 @@
1
+ import { InvalidPayloadError } from '@directus/errors';
1
2
  import fse from 'fs-extra';
2
3
  import { Liquid } from 'liquidjs';
3
4
  import path from 'path';
4
5
  import { fileURLToPath } from 'url';
5
6
  import getDatabase from '../../database/index.js';
6
7
  import env from '../../env.js';
7
- import { InvalidPayloadError } from '@directus/errors';
8
+ import { getExtensionsPath } from '../../extensions/lib/get-extensions-path.js';
8
9
  import logger from '../../logger.js';
9
10
  import getMailer from '../../mailer.js';
10
11
  import { Url } from '../../utils/url.js';
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
  const liquidEngine = new Liquid({
13
- root: [path.resolve(env['EXTENSIONS_PATH'], 'templates'), path.resolve(__dirname, 'templates')],
14
+ root: [path.resolve(getExtensionsPath(), 'templates'), path.resolve(__dirname, 'templates')],
14
15
  extname: '.liquid',
15
16
  });
16
17
  export class MailService {
@@ -56,7 +57,7 @@ export class MailService {
56
57
  return info;
57
58
  }
58
59
  async renderTemplate(template, variables) {
59
- const customTemplatePath = path.resolve(env['EXTENSIONS_PATH'], 'templates', template + '.liquid');
60
+ const customTemplatePath = path.resolve(getExtensionsPath(), 'templates', template + '.liquid');
60
61
  const systemTemplatePath = path.join(__dirname, 'templates', template + '.liquid');
61
62
  const templatePath = (await fse.pathExists(customTemplatePath)) ? customTemplatePath : systemTemplatePath;
62
63
  if ((await fse.pathExists(templatePath)) === false) {
@@ -73,7 +74,7 @@ export class MailService {
73
74
  .first();
74
75
  return {
75
76
  projectName: projectInfo?.project_name || 'Directus',
76
- projectColor: projectInfo?.project_color || '#546e7a',
77
+ projectColor: projectInfo?.project_color || '#171717',
77
78
  projectLogo: getProjectLogoURL(projectInfo?.project_logo),
78
79
  projectUrl: projectInfo?.project_url || '',
79
80
  };