@directus/api 17.1.0 → 18.0.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/app.js +8 -2
- package/dist/auth/drivers/ldap.js +14 -16
- package/dist/auth/drivers/local.js +16 -10
- package/dist/auth/drivers/oauth2.js +16 -11
- package/dist/auth/drivers/openid.js +16 -11
- package/dist/auth/drivers/saml.js +27 -12
- package/dist/cli/commands/init/index.js +3 -3
- package/dist/cli/commands/security/key.js +2 -2
- package/dist/cli/utils/create-env/env-stub.liquid +19 -4
- package/dist/cli/utils/create-env/index.js +2 -2
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +11 -4
- package/dist/controllers/auth.js +54 -19
- package/dist/controllers/extensions.js +102 -5
- package/dist/controllers/items.js +3 -2
- package/dist/controllers/permissions.js +1 -1
- package/dist/controllers/shares.js +19 -4
- package/dist/database/migrations/20220429A-add-flows.js +3 -3
- package/dist/database/migrations/20230526A-migrate-translation-strings.js +2 -2
- package/dist/database/migrations/20240204A-marketplace.d.ts +3 -0
- package/dist/database/migrations/20240204A-marketplace.js +68 -0
- package/dist/database/migrations/run.js +3 -2
- package/dist/extensions/lib/get-extensions-settings.d.ts +6 -2
- package/dist/extensions/lib/get-extensions-settings.js +70 -22
- package/dist/extensions/lib/get-extensions.d.ts +5 -1
- package/dist/extensions/lib/get-extensions.js +7 -31
- package/dist/extensions/lib/installation/index.d.ts +2 -0
- package/dist/extensions/lib/installation/index.js +9 -0
- package/dist/extensions/lib/installation/manager.d.ts +5 -0
- package/dist/extensions/lib/installation/manager.js +90 -0
- package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +1 -1
- package/dist/extensions/lib/sync-extensions.js +11 -10
- package/dist/extensions/manager.d.ts +27 -25
- package/dist/extensions/manager.js +214 -183
- package/dist/middleware/authenticate.d.ts +1 -0
- package/dist/middleware/error-handler.js +22 -18
- package/dist/middleware/extract-token.d.ts +6 -5
- package/dist/middleware/extract-token.js +27 -11
- package/dist/middleware/merge-content-versions.d.ts +2 -0
- package/dist/middleware/merge-content-versions.js +26 -0
- package/dist/middleware/respond.js +0 -12
- package/dist/middleware/validate-batch.d.ts +1 -0
- package/dist/request/agent-with-ip-validation.d.ts +1 -1
- package/dist/request/agent-with-ip-validation.js +5 -1
- package/dist/services/activity.js +3 -3
- package/dist/services/assets.js +2 -3
- package/dist/services/authentication.d.ts +7 -2
- package/dist/services/authentication.js +21 -13
- package/dist/services/extensions.d.ts +4 -8
- package/dist/services/extensions.js +110 -93
- package/dist/services/fields.js +28 -22
- package/dist/services/graphql/index.js +98 -42
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/mail/index.d.ts +1 -1
- package/dist/services/mail/index.js +4 -2
- package/dist/services/payload.js +2 -2
- package/dist/services/{permissions.d.ts → permissions/index.d.ts} +3 -4
- package/dist/services/{permissions.js → permissions/index.js} +6 -23
- package/dist/services/permissions/lib/with-app-minimal-permissions.d.ts +2 -0
- package/dist/services/permissions/lib/with-app-minimal-permissions.js +13 -0
- package/dist/services/relations.d.ts +2 -3
- package/dist/services/relations.js +2 -2
- package/dist/services/roles.js +1 -1
- package/dist/services/server.js +3 -0
- package/dist/services/shares.d.ts +3 -1
- package/dist/services/shares.js +9 -5
- package/dist/storage/index.js +5 -4
- package/dist/types/auth.d.ts +6 -4
- package/dist/types/graphql.d.ts +1 -0
- package/dist/utils/apply-query.js +3 -3
- package/dist/utils/filter-items.d.ts +2 -2
- package/dist/utils/filter-items.js +1 -3
- package/dist/utils/get-cache-headers.d.ts +1 -0
- package/dist/utils/get-cache-key.d.ts +1 -0
- package/dist/utils/get-graphql-query-and-variables.d.ts +1 -0
- package/dist/utils/get-ip-from-req.d.ts +1 -0
- package/dist/utils/get-milliseconds.d.ts +1 -1
- package/dist/utils/get-milliseconds.js +4 -1
- package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
- package/dist/utils/is-login-redirect-allowed.js +34 -0
- package/dist/utils/is-url-allowed.d.ts +1 -1
- package/dist/utils/is-url-allowed.js +5 -5
- package/dist/utils/is-valid-uuid.d.ts +3 -0
- package/dist/utils/is-valid-uuid.js +21 -0
- package/dist/utils/jwt.d.ts +1 -1
- package/dist/utils/jwt.js +3 -3
- package/dist/utils/merge-version-data.d.ts +3 -0
- package/dist/utils/merge-version-data.js +134 -0
- package/dist/utils/sanitize-query.js +2 -0
- package/dist/utils/should-skip-cache.d.ts +1 -0
- package/dist/utils/validate-keys.js +2 -2
- package/dist/utils/validate-query.js +1 -0
- package/dist/websocket/controllers/base.js +2 -2
- package/package.json +44 -45
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { useEnv } from '@directus/env';
|
|
2
2
|
import { ErrorCode, ForbiddenError, RouteNotFoundError, isDirectusError } from '@directus/errors';
|
|
3
|
+
import { EXTENSION_TYPES } from '@directus/extensions';
|
|
4
|
+
import { account, describe, list, } from '@directus/extensions-registry';
|
|
5
|
+
import { isIn } from '@directus/utils';
|
|
3
6
|
import express from 'express';
|
|
7
|
+
import { UUID_REGEX } from '../constants.js';
|
|
4
8
|
import { getExtensionManager } from '../extensions/index.js';
|
|
5
9
|
import { respond } from '../middleware/respond.js';
|
|
6
10
|
import useCollection from '../middleware/use-collection.js';
|
|
@@ -20,18 +24,96 @@ router.get('/', asyncHandler(async (req, res, next) => {
|
|
|
20
24
|
res.locals['payload'] = { data: extensions || null };
|
|
21
25
|
return next();
|
|
22
26
|
}), respond);
|
|
23
|
-
router.
|
|
27
|
+
router.get('/registry', asyncHandler(async (req, res, next) => {
|
|
28
|
+
if (req.accountability && req.accountability.admin !== true) {
|
|
29
|
+
throw new ForbiddenError();
|
|
30
|
+
}
|
|
31
|
+
const { search, limit, offset, type, by, sort } = req.query;
|
|
32
|
+
const query = {};
|
|
33
|
+
if (typeof search === 'string') {
|
|
34
|
+
query.search = search;
|
|
35
|
+
}
|
|
36
|
+
if (typeof limit === 'string') {
|
|
37
|
+
query.limit = Number(limit);
|
|
38
|
+
}
|
|
39
|
+
if (typeof offset === 'string') {
|
|
40
|
+
query.offset = Number(offset);
|
|
41
|
+
}
|
|
42
|
+
if (typeof by === 'string') {
|
|
43
|
+
query.by = by;
|
|
44
|
+
}
|
|
45
|
+
if (typeof sort === 'string' && isIn(sort, ['popular', 'recent', 'downloads'])) {
|
|
46
|
+
query.sort = sort;
|
|
47
|
+
}
|
|
48
|
+
if (typeof type === 'string') {
|
|
49
|
+
if (isIn(type, EXTENSION_TYPES) === false) {
|
|
50
|
+
throw new ForbiddenError();
|
|
51
|
+
}
|
|
52
|
+
query.type = type;
|
|
53
|
+
}
|
|
54
|
+
if (env['MARKETPLACE_TRUST'] === 'sandbox') {
|
|
55
|
+
query.sandbox = true;
|
|
56
|
+
}
|
|
57
|
+
const options = {};
|
|
58
|
+
if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
|
|
59
|
+
options.registry = env['MARKETPLACE_REGISTRY'];
|
|
60
|
+
}
|
|
61
|
+
const payload = await list(query, options);
|
|
62
|
+
res.locals['payload'] = payload;
|
|
63
|
+
return next();
|
|
64
|
+
}), respond);
|
|
65
|
+
router.get(`/registry/account/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
|
|
66
|
+
if (typeof req.params['pk'] !== 'string') {
|
|
67
|
+
throw new ForbiddenError();
|
|
68
|
+
}
|
|
69
|
+
const options = {};
|
|
70
|
+
if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
|
|
71
|
+
options.registry = env['MARKETPLACE_REGISTRY'];
|
|
72
|
+
}
|
|
73
|
+
const payload = await account(req.params['pk'], options);
|
|
74
|
+
res.locals['payload'] = payload;
|
|
75
|
+
return next();
|
|
76
|
+
}), respond);
|
|
77
|
+
router.get(`/registry/extension/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
|
|
78
|
+
if (typeof req.params['pk'] !== 'string') {
|
|
79
|
+
throw new ForbiddenError();
|
|
80
|
+
}
|
|
81
|
+
const options = {};
|
|
82
|
+
if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
|
|
83
|
+
options.registry = env['MARKETPLACE_REGISTRY'];
|
|
84
|
+
}
|
|
85
|
+
const payload = await describe(req.params['pk'], options);
|
|
86
|
+
res.locals['payload'] = payload;
|
|
87
|
+
return next();
|
|
88
|
+
}), respond);
|
|
89
|
+
router.post('/registry/install', asyncHandler(async (req, _res, next) => {
|
|
90
|
+
if (req.accountability && req.accountability.admin !== true) {
|
|
91
|
+
throw new ForbiddenError();
|
|
92
|
+
}
|
|
93
|
+
const { version, extension } = req.body;
|
|
94
|
+
if (!version || !extension) {
|
|
95
|
+
throw new ForbiddenError();
|
|
96
|
+
}
|
|
24
97
|
const service = new ExtensionsService({
|
|
25
98
|
accountability: req.accountability,
|
|
26
99
|
schema: req.schema,
|
|
27
100
|
});
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
101
|
+
await service.install(extension, version);
|
|
102
|
+
return next();
|
|
103
|
+
}), respond);
|
|
104
|
+
router.patch(`/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
|
|
105
|
+
if (req.accountability && req.accountability.admin !== true) {
|
|
31
106
|
throw new ForbiddenError();
|
|
32
107
|
}
|
|
108
|
+
if (typeof req.params['pk'] !== 'string') {
|
|
109
|
+
throw new ForbiddenError();
|
|
110
|
+
}
|
|
111
|
+
const service = new ExtensionsService({
|
|
112
|
+
accountability: req.accountability,
|
|
113
|
+
schema: req.schema,
|
|
114
|
+
});
|
|
33
115
|
try {
|
|
34
|
-
const result = await service.updateOne(
|
|
116
|
+
const result = await service.updateOne(req.params['pk'], req.body);
|
|
35
117
|
res.locals['payload'] = { data: result || null };
|
|
36
118
|
}
|
|
37
119
|
catch (error) {
|
|
@@ -46,6 +128,21 @@ router.patch('/:bundleOrName/:name?', asyncHandler(async (req, res, next) => {
|
|
|
46
128
|
}
|
|
47
129
|
return next();
|
|
48
130
|
}), respond);
|
|
131
|
+
router.delete(`/:pk(${UUID_REGEX})`, asyncHandler(async (req, _res, next) => {
|
|
132
|
+
if (req.accountability && req.accountability.admin !== true) {
|
|
133
|
+
throw new ForbiddenError();
|
|
134
|
+
}
|
|
135
|
+
const service = new ExtensionsService({
|
|
136
|
+
accountability: req.accountability,
|
|
137
|
+
schema: req.schema,
|
|
138
|
+
});
|
|
139
|
+
const pk = req.params['pk'];
|
|
140
|
+
if (typeof pk !== 'string') {
|
|
141
|
+
throw new ForbiddenError();
|
|
142
|
+
}
|
|
143
|
+
await service.deleteOne(pk);
|
|
144
|
+
return next();
|
|
145
|
+
}), respond);
|
|
49
146
|
router.get('/sources/:chunk', asyncHandler(async (req, res) => {
|
|
50
147
|
const chunk = req.params['chunk'];
|
|
51
148
|
const extensionManager = getExtensionManager();
|
|
@@ -4,6 +4,7 @@ import { ErrorCode, ForbiddenError, RouteNotFoundError } from '@directus/errors'
|
|
|
4
4
|
import collectionExists from '../middleware/collection-exists.js';
|
|
5
5
|
import { respond } from '../middleware/respond.js';
|
|
6
6
|
import { validateBatch } from '../middleware/validate-batch.js';
|
|
7
|
+
import { mergeContentVersions } from '../middleware/merge-content-versions.js';
|
|
7
8
|
import { ItemsService } from '../services/items.js';
|
|
8
9
|
import { MetaService } from '../services/meta.js';
|
|
9
10
|
import asyncHandler from '../utils/async-handler.js';
|
|
@@ -76,7 +77,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
|
|
|
76
77
|
return next();
|
|
77
78
|
});
|
|
78
79
|
router.search('/:collection', collectionExists, validateBatch('read'), readHandler, respond);
|
|
79
|
-
router.get('/:collection', collectionExists, readHandler, respond);
|
|
80
|
+
router.get('/:collection', collectionExists, readHandler, mergeContentVersions, respond);
|
|
80
81
|
router.get('/:collection/:pk', collectionExists, asyncHandler(async (req, res, next) => {
|
|
81
82
|
if (isSystemCollection(req.params['collection']))
|
|
82
83
|
throw new ForbiddenError();
|
|
@@ -89,7 +90,7 @@ router.get('/:collection/:pk', collectionExists, asyncHandler(async (req, res, n
|
|
|
89
90
|
data: result || null,
|
|
90
91
|
};
|
|
91
92
|
return next();
|
|
92
|
-
}), respond);
|
|
93
|
+
}), mergeContentVersions, respond);
|
|
93
94
|
router.patch('/:collection', collectionExists, validateBatch('update'), asyncHandler(async (req, res, next) => {
|
|
94
95
|
if (isSystemCollection(req.params['collection']))
|
|
95
96
|
throw new ForbiddenError();
|
|
@@ -4,7 +4,7 @@ import { respond } from '../middleware/respond.js';
|
|
|
4
4
|
import useCollection from '../middleware/use-collection.js';
|
|
5
5
|
import { validateBatch } from '../middleware/validate-batch.js';
|
|
6
6
|
import { MetaService } from '../services/meta.js';
|
|
7
|
-
import { PermissionsService } from '../services/permissions.js';
|
|
7
|
+
import { PermissionsService } from '../services/permissions/index.js';
|
|
8
8
|
import asyncHandler from '../utils/async-handler.js';
|
|
9
9
|
import { sanitizeQuery } from '../utils/sanitize-query.js';
|
|
10
10
|
const router = express.Router();
|
|
@@ -2,7 +2,7 @@ import { useEnv } from '@directus/env';
|
|
|
2
2
|
import { ErrorCode, InvalidPayloadError, isDirectusError } from '@directus/errors';
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import Joi from 'joi';
|
|
5
|
-
import {
|
|
5
|
+
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS, UUID_REGEX } from '../constants.js';
|
|
6
6
|
import { respond } from '../middleware/respond.js';
|
|
7
7
|
import useCollection from '../middleware/use-collection.js';
|
|
8
8
|
import { validateBatch } from '../middleware/validate-batch.js';
|
|
@@ -15,6 +15,7 @@ router.use(useCollection('directus_shares'));
|
|
|
15
15
|
const sharedLoginSchema = Joi.object({
|
|
16
16
|
share: Joi.string().required(),
|
|
17
17
|
password: Joi.string(),
|
|
18
|
+
mode: Joi.string().valid('cookie', 'json', 'session').optional(),
|
|
18
19
|
}).unknown();
|
|
19
20
|
router.post('/auth', asyncHandler(async (req, res, next) => {
|
|
20
21
|
// This doesn't use accountability, as the user isn't logged in at this point
|
|
@@ -25,9 +26,23 @@ router.post('/auth', asyncHandler(async (req, res, next) => {
|
|
|
25
26
|
if (error) {
|
|
26
27
|
throw new InvalidPayloadError({ reason: error.message });
|
|
27
28
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const mode = req.body.mode ?? 'json';
|
|
30
|
+
const { accessToken, refreshToken, expires } = await service.login(req.body, {
|
|
31
|
+
session: mode === 'session',
|
|
32
|
+
});
|
|
33
|
+
const payload = { expires };
|
|
34
|
+
if (mode === 'json') {
|
|
35
|
+
payload.refresh_token = refreshToken;
|
|
36
|
+
payload.access_token = accessToken;
|
|
37
|
+
}
|
|
38
|
+
if (mode === 'cookie') {
|
|
39
|
+
res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
|
|
40
|
+
payload.access_token = accessToken;
|
|
41
|
+
}
|
|
42
|
+
if (mode === 'session') {
|
|
43
|
+
res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
|
|
44
|
+
}
|
|
45
|
+
res.locals['payload'] = { data: payload };
|
|
31
46
|
return next();
|
|
32
47
|
}), respond);
|
|
33
48
|
const sharedInviteSchema = Joi.object({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseJSON, toArray } from '@directus/utils';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
3
|
export async function up(knex) {
|
|
4
4
|
await knex.schema.createTable('directus_flows', (table) => {
|
|
5
5
|
table.uuid('id').primary().notNullable();
|
|
@@ -33,7 +33,7 @@ export async function up(knex) {
|
|
|
33
33
|
const flows = [];
|
|
34
34
|
const operations = [];
|
|
35
35
|
for (const webhook of webhooks) {
|
|
36
|
-
const flowID =
|
|
36
|
+
const flowID = randomUUID();
|
|
37
37
|
flows.push({
|
|
38
38
|
id: flowID,
|
|
39
39
|
name: webhook.name,
|
|
@@ -47,7 +47,7 @@ export async function up(knex) {
|
|
|
47
47
|
}),
|
|
48
48
|
});
|
|
49
49
|
operations.push({
|
|
50
|
-
id:
|
|
50
|
+
id: randomUUID(),
|
|
51
51
|
name: 'Request',
|
|
52
52
|
key: 'request',
|
|
53
53
|
type: 'request',
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { set } from 'lodash-es';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
3
|
function transformStringsNewFormat(oldStrings) {
|
|
4
4
|
return oldStrings.reduce((result, item) => {
|
|
5
5
|
if (!item.key || !item.translations)
|
|
6
6
|
return result;
|
|
7
7
|
for (const [language, value] of Object.entries(item.translations)) {
|
|
8
|
-
result.push({ id:
|
|
8
|
+
result.push({ id: randomUUID(), key: item.key, language, value });
|
|
9
9
|
}
|
|
10
10
|
return result;
|
|
11
11
|
}, []);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolvePackage } from '@directus/utils/node';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
export async function up(knex) {
|
|
7
|
+
await knex.schema.alterTable('directus_extensions', (table) => {
|
|
8
|
+
table.uuid('id').nullable();
|
|
9
|
+
table.string('source', 255);
|
|
10
|
+
table.uuid('bundle');
|
|
11
|
+
});
|
|
12
|
+
const installedExtensions = await knex.select('name').from('directus_extensions');
|
|
13
|
+
// name: id
|
|
14
|
+
const idMap = new Map();
|
|
15
|
+
for (const { name } of installedExtensions) {
|
|
16
|
+
// Delete extension meta status that used the legacy `${name}:${type}` name syntax for
|
|
17
|
+
// extension-folder scoped extensions
|
|
18
|
+
if (name.includes(':')) {
|
|
19
|
+
await knex('directus_extensions').delete().where({ name });
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const id = randomUUID();
|
|
23
|
+
let source;
|
|
24
|
+
try {
|
|
25
|
+
// The NPM package name is the name used in the database. If we can resolve the
|
|
26
|
+
// extension as a node module it's safe to assume it's a npm-module source
|
|
27
|
+
resolvePackage(name, __dirname);
|
|
28
|
+
source = 'module';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
source = 'local';
|
|
32
|
+
}
|
|
33
|
+
await knex('directus_extensions').update({ id, source }).where({ name });
|
|
34
|
+
idMap.set(name, id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const { name } of installedExtensions) {
|
|
38
|
+
if (!name.includes('/'))
|
|
39
|
+
continue;
|
|
40
|
+
const splittedName = name.split('/');
|
|
41
|
+
const isScopedModuleBundleParent = name.startsWith('@') && splittedName.length == 2;
|
|
42
|
+
if (isScopedModuleBundleParent)
|
|
43
|
+
continue;
|
|
44
|
+
const isScopedModuleBundleChild = name.startsWith('@') && splittedName.length > 2;
|
|
45
|
+
const bundleParentName = isScopedModuleBundleParent || isScopedModuleBundleChild ? splittedName.slice(0, 2).join('/') : splittedName[0];
|
|
46
|
+
const bundleParentId = idMap.get(bundleParentName);
|
|
47
|
+
if (!bundleParentId)
|
|
48
|
+
continue;
|
|
49
|
+
await knex('directus_extensions')
|
|
50
|
+
.update({ bundle: bundleParentId, name: name.substring(bundleParentName.length + 1) })
|
|
51
|
+
.where({ name });
|
|
52
|
+
}
|
|
53
|
+
await knex.schema.alterTable('directus_extensions', (table) => {
|
|
54
|
+
table.dropPrimary();
|
|
55
|
+
table.uuid('id').alter().primary().notNullable();
|
|
56
|
+
table.string('source', 255).alter().notNullable().defaultTo('local');
|
|
57
|
+
table.renameColumn('name', 'folder');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function down(knex) {
|
|
61
|
+
await knex.schema.alterTable('directus_extensions', (table) => {
|
|
62
|
+
table.dropColumns('id', 'source', 'bundle');
|
|
63
|
+
table.renameColumn('folder', 'name');
|
|
64
|
+
});
|
|
65
|
+
await knex.schema.alterTable('directus_extensions', (table) => {
|
|
66
|
+
table.string('name', 255).primary().alter();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
1
2
|
import formatTitle from '@directus/format-title';
|
|
2
3
|
import fse from 'fs-extra';
|
|
3
4
|
import { orderBy } from 'lodash-es';
|
|
@@ -5,14 +6,14 @@ import { dirname } from 'node:path';
|
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import path from 'path';
|
|
7
8
|
import { flushCaches } from '../../cache.js';
|
|
8
|
-
import { getExtensionsPath } from '../../extensions/lib/get-extensions-path.js';
|
|
9
9
|
import { useLogger } from '../../logger.js';
|
|
10
10
|
import getModuleDefault from '../../utils/get-module-default.js';
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
export default async function run(database, direction, log = true) {
|
|
13
|
+
const env = useEnv();
|
|
13
14
|
const logger = useLogger();
|
|
14
15
|
let migrationFiles = await fse.readdir(__dirname);
|
|
15
|
-
const customMigrationsPath = path.resolve(
|
|
16
|
+
const customMigrationsPath = path.resolve(env['MIGRATIONS_PATH']);
|
|
16
17
|
let customMigrationFiles = ((await fse.pathExists(customMigrationsPath)) && (await fse.readdir(customMigrationsPath))) || [];
|
|
17
18
|
migrationFiles = migrationFiles.filter((file) => /^[0-9]+[A-Z]-[^.]+\.(?:js|ts)$/.test(file));
|
|
18
19
|
customMigrationFiles = customMigrationFiles.filter((file) => file.includes('-') && /\.(c|m)?js$/.test(file));
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type { Extension } from '@directus/extensions';
|
|
1
|
+
import type { Extension, ExtensionSettings } from '@directus/extensions';
|
|
2
2
|
/**
|
|
3
3
|
* Loads stored settings for all extensions. Creates empty new rows in extensions tables for
|
|
4
4
|
* extensions that don't have settings yet, and remove any settings for extensions that are no
|
|
5
5
|
* longer installed.
|
|
6
6
|
*/
|
|
7
|
-
export declare const getExtensionsSettings: (
|
|
7
|
+
export declare const getExtensionsSettings: ({ local, module, registry, }: {
|
|
8
|
+
local: Map<string, Extension>;
|
|
9
|
+
module: Map<string, Extension>;
|
|
10
|
+
registry: Map<string, Extension>;
|
|
11
|
+
}) => Promise<ExtensionSettings[]>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import getDatabase from '../../database/index.js';
|
|
3
3
|
import { ExtensionsService } from '../../services/extensions.js';
|
|
4
4
|
import { getSchema } from '../../utils/get-schema.js';
|
|
@@ -7,33 +7,81 @@ import { getSchema } from '../../utils/get-schema.js';
|
|
|
7
7
|
* extensions that don't have settings yet, and remove any settings for extensions that are no
|
|
8
8
|
* longer installed.
|
|
9
9
|
*/
|
|
10
|
-
export const getExtensionsSettings = async (
|
|
10
|
+
export const getExtensionsSettings = async ({ local, module, registry, }) => {
|
|
11
11
|
const database = getDatabase();
|
|
12
12
|
const service = new ExtensionsService({
|
|
13
13
|
knex: database,
|
|
14
14
|
schema: await getSchema(),
|
|
15
15
|
});
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
16
|
+
const existingSettings = await service.extensionsItemService.readByQuery({ limit: -1 });
|
|
17
|
+
const newSettings = [];
|
|
18
|
+
const localSettings = existingSettings.filter(({ source }) => source === 'local');
|
|
19
|
+
const registrySettings = existingSettings.filter(({ source }) => source === 'registry');
|
|
20
|
+
const moduleSettings = existingSettings.filter(({ source }) => source === 'module');
|
|
21
|
+
const generateSettingsEntry = (folder, extension, source) => {
|
|
19
22
|
if (extension.type === 'bundle') {
|
|
20
|
-
|
|
23
|
+
const bundleId = randomUUID();
|
|
24
|
+
newSettings.push({
|
|
25
|
+
id: bundleId,
|
|
26
|
+
enabled: true,
|
|
27
|
+
source: source,
|
|
28
|
+
bundle: null,
|
|
29
|
+
folder: folder,
|
|
30
|
+
});
|
|
31
|
+
for (const entry of extension.entries) {
|
|
32
|
+
newSettings.push({
|
|
33
|
+
id: randomUUID(),
|
|
34
|
+
enabled: true,
|
|
35
|
+
source: source,
|
|
36
|
+
bundle: bundleId,
|
|
37
|
+
folder: entry.name,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
21
40
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
41
|
+
else {
|
|
42
|
+
newSettings.push({
|
|
43
|
+
id: randomUUID(),
|
|
44
|
+
enabled: true,
|
|
45
|
+
source: source,
|
|
46
|
+
bundle: null,
|
|
47
|
+
folder: folder,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
for (const [folder, extension] of local.entries()) {
|
|
52
|
+
const settingsExist = localSettings.some((settings) => settings.folder === folder);
|
|
53
|
+
if (settingsExist)
|
|
54
|
+
continue;
|
|
55
|
+
const settingsForName = localSettings.find((settings) => settings.folder === extension.name);
|
|
56
|
+
/*
|
|
57
|
+
* TODO: Consider removing this in follow-up versions after v10.10.0
|
|
58
|
+
*
|
|
59
|
+
* Previously, the package name (from package.json) was used to identify
|
|
60
|
+
* local extensions - now it's the folder name.
|
|
61
|
+
* If those two are different, we need to check for existing settings
|
|
62
|
+
* with the package name, too. On a match and if there's no other local extension
|
|
63
|
+
* with such a folder name, these settings can be taken over with the folder updated.
|
|
64
|
+
*/
|
|
65
|
+
if (settingsForName && !local.has(extension.name)) {
|
|
66
|
+
await service.extensionsItemService.updateOne(settingsForName.id, { folder });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!settingsExist)
|
|
70
|
+
generateSettingsEntry(folder, extension, 'local');
|
|
71
|
+
}
|
|
72
|
+
for (const [folder, extension] of module.entries()) {
|
|
73
|
+
const settingsExist = moduleSettings.some((settings) => settings.folder === folder);
|
|
74
|
+
if (!settingsExist)
|
|
75
|
+
generateSettingsEntry(folder, extension, 'module');
|
|
76
|
+
}
|
|
77
|
+
for (const [folder, extension] of registry.entries()) {
|
|
78
|
+
const settingsExist = registrySettings.some((settings) => settings.folder === folder);
|
|
79
|
+
if (!settingsExist)
|
|
80
|
+
generateSettingsEntry(folder, extension, 'registry');
|
|
81
|
+
}
|
|
82
|
+
if (newSettings.length > 0) {
|
|
83
|
+
await database.insert(newSettings).into('directus_extensions');
|
|
31
84
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* folder. Having them automatically synced feels dangerous, as it's a destructive action. In the
|
|
35
|
-
* edge case you'd deploy / start with the extensions folder misconfigured, it would remove all
|
|
36
|
-
* previous options on startup, without an option to undo.
|
|
37
|
-
*/
|
|
38
|
-
return settings.filter(({ name }) => extensionNames.includes(name));
|
|
85
|
+
const settings = [...existingSettings, ...newSettings];
|
|
86
|
+
return settings;
|
|
39
87
|
};
|
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export declare const getExtensions: () => Promise<
|
|
1
|
+
export declare const getExtensions: () => Promise<{
|
|
2
|
+
local: Map<string, import("@directus/extensions/node").Extension>;
|
|
3
|
+
registry: Map<string, import("@directus/extensions/node").Extension>;
|
|
4
|
+
module: Map<string, import("@directus/extensions/node").Extension>;
|
|
5
|
+
}>;
|
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
import { useEnv } from '@directus/env';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { resolveFsExtensions, resolveModuleExtensions } from '@directus/extensions/node';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
4
|
import { getExtensionsPath } from './get-extensions-path.js';
|
|
5
5
|
export const getExtensions = async () => {
|
|
6
6
|
const env = useEnv();
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
if (isExistingExtension) {
|
|
13
|
-
duplicateExtensions.push(extension.name);
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
if (extension.type === 'bundle') {
|
|
17
|
-
const bundleEntryNames = new Set();
|
|
18
|
-
for (const entry of extension.entries) {
|
|
19
|
-
if (bundleEntryNames.has(entry.name)) {
|
|
20
|
-
// Do not load entire bundle if it has duplicated entries
|
|
21
|
-
duplicateExtensions.push(extension.name);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
bundleEntryNames.add(entry.name);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
loadedExtensions.set(extension.name, extension);
|
|
28
|
-
};
|
|
29
|
-
(await getLocalExtensions(getExtensionsPath())).forEach(filterDuplicates);
|
|
30
|
-
(await resolvePackageExtensions(getExtensionsPath())).forEach(filterDuplicates);
|
|
31
|
-
(await getPackageExtensions(env['PACKAGE_FILE_LOCATION'])).forEach(filterDuplicates);
|
|
32
|
-
if (duplicateExtensions.length > 0) {
|
|
33
|
-
logger.warn(`Failed to load the following extensions because they have/contain duplicate names: ${duplicateExtensions.join(', ')}`);
|
|
34
|
-
}
|
|
35
|
-
return Array.from(loadedExtensions.values());
|
|
7
|
+
const localExtensions = await resolveFsExtensions(getExtensionsPath());
|
|
8
|
+
const registryExtensions = await resolveFsExtensions(join(getExtensionsPath(), '.registry'));
|
|
9
|
+
/** Extensions that are listed as dependencies in the root package.json */
|
|
10
|
+
const moduleExtensions = await resolveModuleExtensions(env['PACKAGE_FILE_LOCATION']);
|
|
11
|
+
return { local: localExtensions, registry: registryExtensions, module: moduleExtensions };
|
|
36
12
|
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { ServiceUnavailableError } from '@directus/errors';
|
|
3
|
+
import { EXTENSION_PKG_KEY, ExtensionManifest } from '@directus/extensions';
|
|
4
|
+
import { download } from '@directus/extensions-registry';
|
|
5
|
+
import DriverLocal from '@directus/storage-driver-local';
|
|
6
|
+
import { move, remove } from 'fs-extra';
|
|
7
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { Readable } from 'node:stream';
|
|
9
|
+
import Queue from 'p-queue';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import tar from 'tar';
|
|
12
|
+
import { useLogger } from '../../../logger.js';
|
|
13
|
+
import { getStorage } from '../../../storage/index.js';
|
|
14
|
+
import { getExtensionsPath } from '../get-extensions-path.js';
|
|
15
|
+
const env = useEnv();
|
|
16
|
+
export class InstallationManager {
|
|
17
|
+
extensionPath = getExtensionsPath();
|
|
18
|
+
async install(versionId) {
|
|
19
|
+
const logger = useLogger();
|
|
20
|
+
const tempDir = join(env['TEMP_PATH'], 'marketplace', versionId);
|
|
21
|
+
const tmpStorage = new DriverLocal({ root: tempDir });
|
|
22
|
+
try {
|
|
23
|
+
await mkdir(tempDir, { recursive: true });
|
|
24
|
+
const options = {};
|
|
25
|
+
if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
|
|
26
|
+
options.registry = env['MARKETPLACE_REGISTRY'];
|
|
27
|
+
}
|
|
28
|
+
const tarReadableStream = await download(versionId, env['MARKETPLACE_TRUST'] === 'sandbox', options);
|
|
29
|
+
if (!tarReadableStream) {
|
|
30
|
+
throw new Error(`No readable stream returned from download`);
|
|
31
|
+
}
|
|
32
|
+
const tarStream = Readable.fromWeb(tarReadableStream);
|
|
33
|
+
const tarPath = join(tempDir, `bin.tar.tgz`);
|
|
34
|
+
await tmpStorage.write('bin.tar.tgz', tarStream);
|
|
35
|
+
/**
|
|
36
|
+
* NPM modules that are packed are always tarballed in a folder called "package"
|
|
37
|
+
*/
|
|
38
|
+
const extractedPath = 'package';
|
|
39
|
+
await tar.extract({
|
|
40
|
+
file: tarPath,
|
|
41
|
+
cwd: tempDir,
|
|
42
|
+
});
|
|
43
|
+
const packageFile = JSON.parse(await readFile(join(tempDir, extractedPath, 'package.json'), { encoding: 'utf-8' }));
|
|
44
|
+
const extensionManifest = await ExtensionManifest.parseAsync(packageFile);
|
|
45
|
+
if (!extensionManifest[EXTENSION_PKG_KEY]?.type) {
|
|
46
|
+
throw new Error(`Extension type not found in package.json`);
|
|
47
|
+
}
|
|
48
|
+
if (env['EXTENSIONS_LOCATION']) {
|
|
49
|
+
// Upload the extension into the configured extensions location
|
|
50
|
+
const storage = await getStorage();
|
|
51
|
+
const remoteDisk = storage.location(env['EXTENSIONS_LOCATION']);
|
|
52
|
+
const queue = new Queue({ concurrency: 1000 });
|
|
53
|
+
for await (const filepath of tmpStorage.list(extractedPath)) {
|
|
54
|
+
const readStream = await tmpStorage.read(filepath);
|
|
55
|
+
const remotePath = join(env['EXTENSIONS_PATH'], '.registry', versionId, filepath.substring(extractedPath.length));
|
|
56
|
+
queue.add(() => remoteDisk.write(remotePath, readStream));
|
|
57
|
+
}
|
|
58
|
+
await queue.onIdle();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// No custom location, so save to regular local extensions folder
|
|
62
|
+
const dest = join(this.extensionPath, '.registry', versionId);
|
|
63
|
+
await move(join(tempDir, extractedPath), dest, { overwrite: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
logger.warn(err);
|
|
68
|
+
throw new ServiceUnavailableError({ service: 'marketplace', reason: 'Could not download and extract the extension' }, { cause: err });
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await rm(tempDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async uninstall(folder) {
|
|
75
|
+
if (env['EXTENSIONS_LOCATION']) {
|
|
76
|
+
const storage = await getStorage();
|
|
77
|
+
const remoteDisk = storage.location(env['EXTENSIONS_LOCATION']);
|
|
78
|
+
const queue = new Queue({ concurrency: 1000 });
|
|
79
|
+
const prefix = join(env['EXTENSIONS_PATH'], '.registry', folder);
|
|
80
|
+
for await (const filepath of remoteDisk.list(prefix)) {
|
|
81
|
+
queue.add(() => remoteDisk.delete(filepath));
|
|
82
|
+
}
|
|
83
|
+
await queue.onIdle();
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const path = join(this.extensionPath, '.registry', folder);
|
|
87
|
+
await remove(path);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -16,7 +16,7 @@ export declare function generateApiExtensionsSandboxEntrypoint(type: ApiExtensio
|
|
|
16
16
|
unregisterFunction: () => Promise<void>;
|
|
17
17
|
} | {
|
|
18
18
|
code: string;
|
|
19
|
-
hostFunctions: ((path: import("isolated-vm").Reference<string>, method: import("isolated-vm").Reference<"GET" | "POST" | "DELETE" | "
|
|
19
|
+
hostFunctions: ((path: import("isolated-vm").Reference<string>, method: import("isolated-vm").Reference<"GET" | "POST" | "DELETE" | "PUT" | "PATCH">, cb: import("isolated-vm").Reference<(req: {
|
|
20
20
|
url: string;
|
|
21
21
|
headers: import("http").IncomingHttpHeaders;
|
|
22
22
|
body: string;
|