@directus/api 14.0.1 → 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 (50) 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 +12 -5
  4. package/dist/auth/drivers/openid.js +11 -5
  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/app-access-permissions/app-access-permissions.yaml +5 -0
  11. package/dist/database/system-data/collections/collections.yaml +1 -1
  12. package/dist/database/system-data/fields/settings.yaml +12 -13
  13. package/dist/database/system-data/fields/users.yaml +10 -10
  14. package/dist/env.d.ts +2 -4
  15. package/dist/env.js +12 -9
  16. package/dist/extensions/lib/get-extensions-path.d.ts +1 -0
  17. package/dist/extensions/lib/get-extensions-path.js +8 -0
  18. package/dist/extensions/lib/get-extensions.js +3 -2
  19. package/dist/extensions/lib/sync-extensions.d.ts +1 -0
  20. package/dist/extensions/lib/sync-extensions.js +59 -0
  21. package/dist/extensions/lib/sync-status.d.ts +10 -0
  22. package/dist/extensions/lib/sync-status.js +27 -0
  23. package/dist/extensions/manager.js +16 -6
  24. package/dist/logger.d.ts +2 -1
  25. package/dist/logger.js +13 -2
  26. package/dist/messenger.js +1 -3
  27. package/dist/middleware/validate-batch.js +2 -0
  28. package/dist/request/validate-ip.js +1 -2
  29. package/dist/services/extensions.js +1 -1
  30. package/dist/services/files.d.ts +2 -2
  31. package/dist/services/files.js +93 -26
  32. package/dist/services/mail/index.js +5 -4
  33. package/dist/services/mail/templates/base.liquid +383 -138
  34. package/dist/services/mail/templates/password-reset.liquid +35 -17
  35. package/dist/services/mail/templates/user-invitation.liquid +32 -13
  36. package/dist/services/server.js +4 -0
  37. package/dist/storage/register-drivers.js +1 -2
  38. package/dist/storage/register-locations.js +1 -2
  39. package/dist/utils/delete-from-require-cache.js +8 -1
  40. package/dist/utils/get-auth-providers.d.ts +1 -1
  41. package/dist/utils/get-config-from-env.js +1 -2
  42. package/dist/utils/merge-permissions.js +11 -19
  43. package/dist/utils/sanitize-query.js +2 -2
  44. package/dist/utils/should-clear-cache.js +1 -2
  45. package/dist/utils/should-skip-cache.js +3 -4
  46. package/dist/utils/validate-env.js +1 -2
  47. package/dist/utils/validate-storage.js +12 -9
  48. package/package.json +16 -15
  49. package/dist/__mocks__/cache.d.mts +0 -5
  50. package/dist/__mocks__/cache.mjs +0 -7
@@ -1,6 +1,6 @@
1
1
  import { JAVASCRIPT_FILE_EXTS } from '@directus/constants';
2
2
  import { APP_SHARED_DEPS, HYBRID_EXTENSION_TYPES, NESTED_EXTENSION_TYPES } from '@directus/extensions';
3
- import { ensureExtensionDirs, generateExtensionsEntrypoint } from '@directus/extensions/node';
3
+ import { generateExtensionsEntrypoint } from '@directus/extensions/node';
4
4
  import { isIn, isTypeIn, pluralize } from '@directus/utils';
5
5
  import { pathToRelativeUrl } from '@directus/utils/node';
6
6
  import aliasDefault from '@rollup/plugin-alias';
@@ -27,11 +27,13 @@ import { getSchema } from '../utils/get-schema.js';
27
27
  import { importFileUrl } from '../utils/import-file-url.js';
28
28
  import { JobQueue } from '../utils/job-queue.js';
29
29
  import { scheduleSynchronizedJob, validateCron } from '../utils/schedule.js';
30
+ import { getExtensionsPath } from './lib/get-extensions-path.js';
30
31
  import { getExtensionsSettings } from './lib/get-extensions-settings.js';
31
32
  import { getExtensions } from './lib/get-extensions.js';
32
33
  import { getSharedDepsMapping } from './lib/get-shared-deps-mapping.js';
33
34
  import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-api-extensions-sandbox-entrypoint.js';
34
35
  import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
36
+ import { syncExtensions } from './lib/sync-extensions.js';
35
37
  import { wrapEmbeds } from './lib/wrap-embeds.js';
36
38
  // Workaround for https://github.com/rollup/plugins/issues/1329
37
39
  const virtual = virtualDefault;
@@ -132,13 +134,20 @@ export class ExtensionManager {
132
134
  */
133
135
  async load() {
134
136
  try {
135
- await ensureExtensionDirs(env['EXTENSIONS_PATH'], NESTED_EXTENSION_TYPES);
137
+ await syncExtensions();
138
+ }
139
+ catch (error) {
140
+ logger.error(`Failed to sync extensions`);
141
+ logger.error(error);
142
+ process.exit(1);
143
+ }
144
+ try {
136
145
  this.extensions = await getExtensions();
137
146
  this.extensionsSettings = await getExtensionsSettings(this.extensions);
138
147
  }
139
- catch (err) {
148
+ catch (error) {
140
149
  logger.warn(`Couldn't load extensions`);
141
- logger.warn(err);
150
+ logger.warn(error);
142
151
  }
143
152
  await this.registerHooks();
144
153
  await this.registerEndpoints();
@@ -223,7 +232,7 @@ export class ExtensionManager {
223
232
  */
224
233
  initializeWatcher() {
225
234
  logger.info('Watching extensions for changes...');
226
- const extensionDirUrl = pathToRelativeUrl(env['EXTENSIONS_PATH']);
235
+ const extensionDirUrl = pathToRelativeUrl(getExtensionsPath());
227
236
  const localExtensionUrls = NESTED_EXTENSION_TYPES.flatMap((type) => {
228
237
  const typeDir = path.posix.join(extensionDirUrl, pluralize(type));
229
238
  if (isIn(type, HYBRID_EXTENSION_TYPES)) {
@@ -566,7 +575,8 @@ export class ExtensionManager {
566
575
  */
567
576
  registerEndpoint(config, name) {
568
577
  const endpointRegistrationCallback = typeof config === 'function' ? config : config.handler;
569
- const routeName = typeof config === 'function' ? name : config.id;
578
+ const nameWithoutType = name.includes(':') ? name.split(':')[0] : name;
579
+ const routeName = typeof config === 'function' ? nameWithoutType : config.id;
570
580
  const scopedRouter = express.Router();
571
581
  this.endpointRouter.use(`/${routeName}`, scopedRouter);
572
582
  endpointRegistrationCallback(scopedRouter, {
package/dist/logger.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /// <reference types="qs" />
2
2
  import type { RequestHandler } from 'express';
3
- import type { LoggerOptions } from 'pino';
3
+ import { type LoggerOptions } from 'pino';
4
4
  export declare const httpLoggerOptions: LoggerOptions;
5
5
  declare const logger: import("pino").Logger<LoggerOptions & Record<string, any>>;
6
+ export declare const httpLoggerEnvConfig: Record<string, any>;
6
7
  export declare const expressLogger: RequestHandler<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
7
8
  export default logger;
package/dist/logger.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { REDACTED_TEXT, toArray } from '@directus/utils';
2
2
  import { merge } from 'lodash-es';
3
+ import { URL } from 'node:url';
3
4
  import { pino } from 'pino';
4
5
  import { pinoHttp, stdSerializers } from 'pino-http';
5
- import { URL } from 'url';
6
6
  import env from './env.js';
7
7
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
8
8
  const pinoOptions = {
@@ -82,7 +82,18 @@ if (loggerEnvConfig['levels']) {
82
82
  delete loggerEnvConfig['levels'];
83
83
  }
84
84
  const logger = pino(merge(pinoOptions, loggerEnvConfig));
85
- const httpLoggerEnvConfig = getConfigFromEnv('LOGGER_HTTP', ['LOGGER_HTTP_LOGGER']);
85
+ export const httpLoggerEnvConfig = getConfigFromEnv('LOGGER_HTTP', ['LOGGER_HTTP_LOGGER']);
86
+ if (env['LOG_HTTP_IGNORE_PATHS']) {
87
+ const ignorePathsSet = new Set(env['LOG_HTTP_IGNORE_PATHS']);
88
+ httpLoggerEnvConfig['autoLogging'] = {
89
+ ignore: (req) => {
90
+ if (!req.url)
91
+ return false;
92
+ const { pathname } = new URL(req.url, 'http://example.com/');
93
+ return ignorePathsSet.has(pathname);
94
+ },
95
+ };
96
+ }
86
97
  export const expressLogger = pinoHttp({
87
98
  logger: pino(merge(httpLoggerOptions, loggerEnvConfig)),
88
99
  ...httpLoggerEnvConfig,
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
  }
@@ -2,6 +2,7 @@ import Joi from 'joi';
2
2
  import { InvalidPayloadError } from '@directus/errors';
3
3
  import asyncHandler from '../utils/async-handler.js';
4
4
  import { sanitizeQuery } from '../utils/sanitize-query.js';
5
+ import { validateQuery } from '../utils/validate-query.js';
5
6
  export const validateBatch = (scope) => asyncHandler(async (req, _res, next) => {
6
7
  if (req.method.toLowerCase() === 'get') {
7
8
  req.body = {};
@@ -18,6 +19,7 @@ export const validateBatch = (scope) => asyncHandler(async (req, _res, next) =>
18
19
  // In reads, the query in the body should override the query params for searching
19
20
  if (scope === 'read' && req.body.query) {
20
21
  req.sanitizedQuery = sanitizeQuery(req.body.query, req.accountability);
22
+ validateQuery(req.sanitizedQuery);
21
23
  }
22
24
  // Every cRUD action has either keys or query
23
25
  let batchSchema = Joi.object().keys({
@@ -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';
@@ -29,59 +29,125 @@ export class FilesService extends ItemsService {
29
29
  */
30
30
  async uploadOne(stream, data, primaryKey, opts) {
31
31
  const storage = await getStorage();
32
- let existingFile = {};
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
- .first()) ?? {};
41
+ .first()) ?? null;
40
42
  }
41
- const payload = { ...existingFile, ...clone(data) };
43
+ // Merge the existing file's folder and filename_download with the new payload
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 (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
  };