@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.
- package/dist/__utils__/mock-env.d.ts +18 -0
- package/dist/__utils__/mock-env.js +41 -0
- package/dist/auth/drivers/oauth2.js +4 -2
- package/dist/auth/drivers/openid.js +4 -2
- package/dist/cli/load-extensions.js +1 -2
- package/dist/controllers/assets.js +10 -10
- package/dist/controllers/files.js +1 -5
- package/dist/database/index.js +2 -1
- package/dist/database/migrations/run.js +2 -2
- package/dist/database/system-data/collections/collections.yaml +1 -1
- package/dist/database/system-data/fields/settings.yaml +12 -13
- package/dist/database/system-data/fields/users.yaml +10 -10
- package/dist/env.d.ts +2 -4
- package/dist/env.js +12 -9
- package/dist/extensions/lib/get-extensions-path.d.ts +1 -0
- package/dist/extensions/lib/get-extensions-path.js +8 -0
- package/dist/extensions/lib/get-extensions.js +3 -2
- package/dist/extensions/lib/sync-extensions.d.ts +1 -0
- package/dist/extensions/lib/sync-extensions.js +59 -0
- package/dist/extensions/lib/sync-status.d.ts +10 -0
- package/dist/extensions/lib/sync-status.js +27 -0
- package/dist/extensions/manager.js +14 -5
- package/dist/logger.d.ts +2 -1
- package/dist/logger.js +13 -2
- package/dist/messenger.js +1 -3
- package/dist/request/validate-ip.js +1 -2
- package/dist/services/extensions.js +1 -1
- package/dist/services/files.d.ts +2 -2
- package/dist/services/files.js +90 -23
- package/dist/services/mail/index.js +5 -4
- package/dist/services/mail/templates/base.liquid +383 -138
- package/dist/services/mail/templates/password-reset.liquid +35 -17
- package/dist/services/mail/templates/user-invitation.liquid +32 -13
- package/dist/services/server.js +4 -0
- package/dist/storage/register-drivers.js +1 -2
- package/dist/storage/register-locations.js +1 -2
- package/dist/utils/get-auth-providers.d.ts +1 -1
- package/dist/utils/get-config-from-env.js +1 -2
- package/dist/utils/merge-permissions.js +11 -19
- package/dist/utils/sanitize-query.js +1 -2
- package/dist/utils/should-clear-cache.js +1 -2
- package/dist/utils/should-skip-cache.js +3 -4
- package/dist/utils/validate-env.js +1 -2
- package/dist/utils/validate-storage.js +12 -9
- package/package.json +16 -15
- package/dist/__mocks__/cache.d.mts +0 -5
- 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
|
|
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
|
|
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
|
});
|
package/dist/services/files.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/services/files.js
CHANGED
|
@@ -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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
payload.
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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 || '#
|
|
77
|
+
projectColor: projectInfo?.project_color || '#171717',
|
|
77
78
|
projectLogo: getProjectLogoURL(projectInfo?.project_logo),
|
|
78
79
|
projectUrl: projectInfo?.project_url || '',
|
|
79
80
|
};
|