@directus/api 17.0.1 → 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/fields.js +0 -3
- 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/operations/item-update/index.js +4 -1
- 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 +34 -22
- package/dist/services/graphql/index.js +98 -42
- package/dist/services/import-export.js +61 -26
- 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.d.ts +9 -4
- package/dist/services/roles.js +51 -3
- 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/dist/websocket/controllers/hooks.js +1 -1
- package/package.json +50 -51
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { InvalidPayloadError } from '@directus/errors';
|
|
2
|
+
import { useEnv } from '@directus/env';
|
|
1
3
|
/**
|
|
2
|
-
* Extract access token from
|
|
4
|
+
* Extract access token from
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* - 'access_token' query parameter
|
|
7
|
+
* - 'Authorization' header
|
|
8
|
+
* - Session cookie
|
|
6
9
|
*
|
|
7
|
-
* and store
|
|
10
|
+
* and store it under req.token
|
|
8
11
|
*/
|
|
9
12
|
const extractToken = (req, _res, next) => {
|
|
13
|
+
const env = useEnv();
|
|
10
14
|
let token = null;
|
|
11
15
|
if (req.query && req.query['access_token']) {
|
|
12
16
|
token = req.query['access_token'];
|
|
@@ -14,16 +18,28 @@ const extractToken = (req, _res, next) => {
|
|
|
14
18
|
if (req.headers && req.headers.authorization) {
|
|
15
19
|
const parts = req.headers.authorization.split(' ');
|
|
16
20
|
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
|
|
21
|
+
if (token !== null) {
|
|
22
|
+
/*
|
|
23
|
+
* RFC6750 compliance (https://datatracker.ietf.org/doc/html/rfc6750#section-2)
|
|
24
|
+
* > Clients MUST NOT use more than one method to transmit the token in each request.
|
|
25
|
+
*/
|
|
26
|
+
throw new InvalidPayloadError({
|
|
27
|
+
reason: 'The request uses more than one method for including an access token',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
17
30
|
token = parts[1];
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
if (req.cookies && req.cookies[env['SESSION_COOKIE_NAME']]) {
|
|
34
|
+
/*
|
|
35
|
+
* Exclude session cookie from "RFC6750 multi auth method" rule, e.g.
|
|
36
|
+
* - allow using a different token to perform requests from within the Data Studio (static token in WYSIWYG interface / Extensions)
|
|
37
|
+
* - to not break external apps running under the same domain as the Data Studio while using a different method
|
|
38
|
+
*/
|
|
39
|
+
if (token === null) {
|
|
40
|
+
token = req.cookies[env['SESSION_COOKIE_NAME']];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
27
43
|
req.token = token;
|
|
28
44
|
next();
|
|
29
45
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isObject } from '@directus/utils';
|
|
2
|
+
import { VersionsService } from '../services/versions.js';
|
|
3
|
+
import asyncHandler from '../utils/async-handler.js';
|
|
4
|
+
import { mergeVersionsRaw, mergeVersionsRecursive } from '../utils/merge-version-data.js';
|
|
5
|
+
export const mergeContentVersions = asyncHandler(async (req, res, next) => {
|
|
6
|
+
if (req.sanitizedQuery.version &&
|
|
7
|
+
req.collection &&
|
|
8
|
+
(req.singleton || req.params['pk']) &&
|
|
9
|
+
'data' in res.locals['payload']) {
|
|
10
|
+
const originalData = res.locals['payload'].data;
|
|
11
|
+
// only act on single item requests
|
|
12
|
+
if (!isObject(originalData))
|
|
13
|
+
return next();
|
|
14
|
+
const versionsService = new VersionsService({ accountability: req.accountability ?? null, schema: req.schema });
|
|
15
|
+
const versionData = await versionsService.getVersionSaves(req.sanitizedQuery.version, req.collection, req.params['pk']);
|
|
16
|
+
if (!versionData || versionData.length === 0)
|
|
17
|
+
return next();
|
|
18
|
+
if (req.sanitizedQuery.versionRaw) {
|
|
19
|
+
res.locals['payload'].data = mergeVersionsRaw(originalData, versionData);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
res.locals['payload'].data = mergeVersionsRecursive(originalData, versionData, req.collection, req.schema);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return next();
|
|
26
|
+
});
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { useEnv } from '@directus/env';
|
|
2
2
|
import { parse as parseBytesConfiguration } from 'bytes';
|
|
3
|
-
import { assign } from 'lodash-es';
|
|
4
3
|
import { getCache, setCacheValue } from '../cache.js';
|
|
5
4
|
import { useLogger } from '../logger.js';
|
|
6
5
|
import { ExportService } from '../services/import-export.js';
|
|
7
|
-
import { VersionsService } from '../services/versions.js';
|
|
8
6
|
import asyncHandler from '../utils/async-handler.js';
|
|
9
7
|
import { getCacheControlHeader } from '../utils/get-cache-headers.js';
|
|
10
8
|
import { getCacheKey } from '../utils/get-cache-key.js';
|
|
@@ -21,16 +19,6 @@ export const respond = asyncHandler(async (req, res) => {
|
|
|
21
19
|
const maxSize = parseBytesConfiguration(env['CACHE_VALUE_MAX_SIZE']);
|
|
22
20
|
exceedsMaxSize = valueSize > maxSize;
|
|
23
21
|
}
|
|
24
|
-
if (req.sanitizedQuery.version &&
|
|
25
|
-
req.collection &&
|
|
26
|
-
(req.singleton || req.params['pk']) &&
|
|
27
|
-
'data' in res.locals['payload']) {
|
|
28
|
-
const versionsService = new VersionsService({ accountability: req.accountability ?? null, schema: req.schema });
|
|
29
|
-
const saves = await versionsService.getVersionSaves(req.sanitizedQuery.version, req.collection, req.params['pk']);
|
|
30
|
-
if (saves) {
|
|
31
|
-
assign(res.locals['payload'].data, ...saves);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
22
|
if ((req.method.toLowerCase() === 'get' || req.originalUrl?.startsWith('/graphql')) &&
|
|
35
23
|
env['CACHE_ENABLED'] === true &&
|
|
36
24
|
cache &&
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/// <reference types="qs" />
|
|
2
2
|
/// <reference types="express" />
|
|
3
|
+
/// <reference types="cookie-parser" />
|
|
3
4
|
export declare const validateBatch: (scope: 'read' | 'update' | 'delete') => (req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>, res: import("express").Response<any, Record<string, any>>, next: import("express").NextFunction) => Promise<void>;
|
|
@@ -32,7 +32,10 @@ export default defineOperationApi({
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
let result;
|
|
35
|
-
if (
|
|
35
|
+
if (Array.isArray(payloadObject)) {
|
|
36
|
+
result = await itemsService.updateBatch(payloadObject, { emitEvents: !!emitEvents });
|
|
37
|
+
}
|
|
38
|
+
else if (!key || (Array.isArray(key) && key.length === 0)) {
|
|
36
39
|
result = await itemsService.updateByQuery(sanitizedQueryObject, payloadObject, { emitEvents: !!emitEvents });
|
|
37
40
|
}
|
|
38
41
|
else {
|
|
@@ -5,7 +5,7 @@ import type { Agent, ClientRequestArgs } from 'node:http';
|
|
|
5
5
|
* https://github.com/nodejs/node/blob/8a41d9b636be86350cd32847c3f89d327c4f6ff7/lib/_http_agent.js#L215
|
|
6
6
|
*/
|
|
7
7
|
export type _Agent = Agent & {
|
|
8
|
-
createConnection:
|
|
8
|
+
createConnection: ClientRequestArgs['createConnection'];
|
|
9
9
|
};
|
|
10
10
|
/** Extends a HTTP agent with IP validation */
|
|
11
11
|
export declare const agentWithIpValidation: (agent: Agent) => Agent;
|
|
@@ -21,7 +21,11 @@ export const agentWithIpValidation = (agent) => {
|
|
|
21
21
|
*/
|
|
22
22
|
if (isIP(host) !== 0 && isDeniedIp(host))
|
|
23
23
|
throw deniedError(host);
|
|
24
|
-
const socket = createConnection
|
|
24
|
+
const socket = createConnection?.call(this, options, oncreate);
|
|
25
|
+
// Unexpected, but in that case the request is denied to be on the safe side
|
|
26
|
+
if (!socket) {
|
|
27
|
+
throw new Error('Request cannot be verified due to lost socket');
|
|
28
|
+
}
|
|
25
29
|
// Emitted after resolving the host name but before connecting.
|
|
26
30
|
socket.on('lookup', (error, address) => {
|
|
27
31
|
if (error || !isDeniedIp(address))
|
|
@@ -2,9 +2,9 @@ import { Action } from '@directus/constants';
|
|
|
2
2
|
import { useEnv } from '@directus/env';
|
|
3
3
|
import { ErrorCode, isDirectusError } from '@directus/errors';
|
|
4
4
|
import { uniq } from 'lodash-es';
|
|
5
|
-
import validateUUID from 'uuid-validate';
|
|
6
5
|
import { useLogger } from '../logger.js';
|
|
7
6
|
import { getPermissions } from '../utils/get-permissions.js';
|
|
7
|
+
import { isValidUuid } from '../utils/is-valid-uuid.js';
|
|
8
8
|
import { Url } from '../utils/url.js';
|
|
9
9
|
import { userName } from '../utils/user-name.js';
|
|
10
10
|
import { AuthorizationService } from './authorization.js';
|
|
@@ -55,8 +55,8 @@ export class ActivityService extends ItemsService {
|
|
|
55
55
|
let comment = data['comment'];
|
|
56
56
|
for (const mention of mentions) {
|
|
57
57
|
const uuid = mention.substring(1);
|
|
58
|
-
// We only match on UUIDs in the first place. This is just an extra sanity check
|
|
59
|
-
if (
|
|
58
|
+
// We only match on UUIDs in the first place. This is just an extra sanity check.
|
|
59
|
+
if (isValidUuid(uuid) === false)
|
|
60
60
|
continue;
|
|
61
61
|
comment = comment.replace(new RegExp(mention, 'gm'), userPreviews[uuid] ?? '@Unknown User');
|
|
62
62
|
}
|
package/dist/services/assets.js
CHANGED
|
@@ -5,12 +5,12 @@ import { contentType } from 'mime-types';
|
|
|
5
5
|
import hash from 'object-hash';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import sharp from 'sharp';
|
|
8
|
-
import validateUUID from 'uuid-validate';
|
|
9
8
|
import { SUPPORTED_IMAGE_TRANSFORM_FORMATS } from '../constants.js';
|
|
10
9
|
import getDatabase from '../database/index.js';
|
|
11
10
|
import { useLogger } from '../logger.js';
|
|
12
11
|
import { getStorage } from '../storage/index.js';
|
|
13
12
|
import { getMilliseconds } from '../utils/get-milliseconds.js';
|
|
13
|
+
import { isValidUuid } from '../utils/is-valid-uuid.js';
|
|
14
14
|
import * as TransformationUtils from '../utils/transformations.js';
|
|
15
15
|
import { AuthorizationService } from './authorization.js';
|
|
16
16
|
import { FilesService } from './files.js';
|
|
@@ -39,8 +39,7 @@ export class AssetsService {
|
|
|
39
39
|
* with a wrong type. In case of directus_files where id is a uuid, we'll have to verify the
|
|
40
40
|
* validity of the uuid ahead of time.
|
|
41
41
|
*/
|
|
42
|
-
|
|
43
|
-
if (isValidUUID === false)
|
|
42
|
+
if (!isValidUuid(id))
|
|
44
43
|
throw new ForbiddenError();
|
|
45
44
|
if (systemPublicKeys.includes(id) === false && this.accountability?.admin !== true) {
|
|
46
45
|
await this.authorizationService.checkAccess('read', 'directus_files', id);
|
|
@@ -14,8 +14,13 @@ export declare class AuthenticationService {
|
|
|
14
14
|
* Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
|
|
15
15
|
* to handle password existence checks elsewhere
|
|
16
16
|
*/
|
|
17
|
-
login(providerName: string | undefined, payload: Record<string, any>,
|
|
18
|
-
|
|
17
|
+
login(providerName: string | undefined, payload: Record<string, any>, options?: Partial<{
|
|
18
|
+
otp: string;
|
|
19
|
+
session: boolean;
|
|
20
|
+
}>): Promise<LoginResult>;
|
|
21
|
+
refresh(refreshToken: string, options?: Partial<{
|
|
22
|
+
session: boolean;
|
|
23
|
+
}>): Promise<LoginResult>;
|
|
19
24
|
logout(refreshToken: string): Promise<void>;
|
|
20
25
|
verifyPassword(userID: string, password: string): Promise<void>;
|
|
21
26
|
}
|
|
@@ -33,7 +33,7 @@ export class AuthenticationService {
|
|
|
33
33
|
* Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
|
|
34
34
|
* to handle password existence checks elsewhere
|
|
35
35
|
*/
|
|
36
|
-
async login(providerName = DEFAULT_AUTH_PROVIDER, payload,
|
|
36
|
+
async login(providerName = DEFAULT_AUTH_PROVIDER, payload, options) {
|
|
37
37
|
const { nanoid } = await import('nanoid');
|
|
38
38
|
const STALL_TIME = env['LOGIN_STALL_TIME'];
|
|
39
39
|
const timeStart = performance.now();
|
|
@@ -123,14 +123,14 @@ export class AuthenticationService {
|
|
|
123
123
|
await stall(STALL_TIME, timeStart);
|
|
124
124
|
throw e;
|
|
125
125
|
}
|
|
126
|
-
if (user.tfa_secret && !otp) {
|
|
126
|
+
if (user.tfa_secret && !options?.otp) {
|
|
127
127
|
emitStatus('fail');
|
|
128
128
|
await stall(STALL_TIME, timeStart);
|
|
129
129
|
throw new InvalidOtpError();
|
|
130
130
|
}
|
|
131
|
-
if (user.tfa_secret && otp) {
|
|
131
|
+
if (user.tfa_secret && options?.otp) {
|
|
132
132
|
const tfaService = new TFAService({ knex: this.knex, schema: this.schema });
|
|
133
|
-
const otpValid = await tfaService.verifyOTP(user.id, otp);
|
|
133
|
+
const otpValid = await tfaService.verifyOTP(user.id, options?.otp);
|
|
134
134
|
if (otpValid === false) {
|
|
135
135
|
emitStatus('fail');
|
|
136
136
|
await stall(STALL_TIME, timeStart);
|
|
@@ -143,6 +143,11 @@ export class AuthenticationService {
|
|
|
143
143
|
app_access: user.app_access,
|
|
144
144
|
admin_access: user.admin_access,
|
|
145
145
|
};
|
|
146
|
+
const refreshToken = nanoid(64);
|
|
147
|
+
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
148
|
+
if (options?.session) {
|
|
149
|
+
tokenPayload.session = refreshToken;
|
|
150
|
+
}
|
|
146
151
|
const customClaims = await emitter.emitFilter('auth.jwt', tokenPayload, {
|
|
147
152
|
status: 'pending',
|
|
148
153
|
user: user?.id,
|
|
@@ -153,12 +158,11 @@ export class AuthenticationService {
|
|
|
153
158
|
schema: this.schema,
|
|
154
159
|
accountability: this.accountability,
|
|
155
160
|
});
|
|
161
|
+
const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
|
|
156
162
|
const accessToken = jwt.sign(customClaims, env['SECRET'], {
|
|
157
|
-
expiresIn:
|
|
163
|
+
expiresIn: TTL,
|
|
158
164
|
issuer: 'directus',
|
|
159
165
|
});
|
|
160
|
-
const refreshToken = nanoid(64);
|
|
161
|
-
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
162
166
|
await this.knex('directus_sessions').insert({
|
|
163
167
|
token: refreshToken,
|
|
164
168
|
user: user.id,
|
|
@@ -188,11 +192,11 @@ export class AuthenticationService {
|
|
|
188
192
|
return {
|
|
189
193
|
accessToken,
|
|
190
194
|
refreshToken,
|
|
191
|
-
expires: getMilliseconds(
|
|
195
|
+
expires: getMilliseconds(TTL),
|
|
192
196
|
id: user.id,
|
|
193
197
|
};
|
|
194
198
|
}
|
|
195
|
-
async refresh(refreshToken) {
|
|
199
|
+
async refresh(refreshToken, options) {
|
|
196
200
|
const { nanoid } = await import('nanoid');
|
|
197
201
|
const STALL_TIME = env['LOGIN_STALL_TIME'];
|
|
198
202
|
const timeStart = performance.now();
|
|
@@ -269,12 +273,17 @@ export class AuthenticationService {
|
|
|
269
273
|
admin_access: record.role_admin_access,
|
|
270
274
|
});
|
|
271
275
|
}
|
|
276
|
+
const newRefreshToken = nanoid(64);
|
|
277
|
+
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
272
278
|
const tokenPayload = {
|
|
273
279
|
id: record.user_id,
|
|
274
280
|
role: record.role_id,
|
|
275
281
|
app_access: record.role_app_access,
|
|
276
282
|
admin_access: record.role_admin_access,
|
|
277
283
|
};
|
|
284
|
+
if (options?.session) {
|
|
285
|
+
tokenPayload.session = newRefreshToken;
|
|
286
|
+
}
|
|
278
287
|
if (record.share_id) {
|
|
279
288
|
tokenPayload.share = record.share_id;
|
|
280
289
|
tokenPayload.role = record.share_role;
|
|
@@ -296,12 +305,11 @@ export class AuthenticationService {
|
|
|
296
305
|
schema: this.schema,
|
|
297
306
|
accountability: this.accountability,
|
|
298
307
|
});
|
|
308
|
+
const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
|
|
299
309
|
const accessToken = jwt.sign(customClaims, env['SECRET'], {
|
|
300
|
-
expiresIn:
|
|
310
|
+
expiresIn: TTL,
|
|
301
311
|
issuer: 'directus',
|
|
302
312
|
});
|
|
303
|
-
const newRefreshToken = nanoid(64);
|
|
304
|
-
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
305
313
|
await this.knex('directus_sessions')
|
|
306
314
|
.update({
|
|
307
315
|
token: newRefreshToken,
|
|
@@ -314,7 +322,7 @@ export class AuthenticationService {
|
|
|
314
322
|
return {
|
|
315
323
|
accessToken,
|
|
316
324
|
refreshToken: newRefreshToken,
|
|
317
|
-
expires: getMilliseconds(
|
|
325
|
+
expires: getMilliseconds(TTL),
|
|
318
326
|
id: record.user_id,
|
|
319
327
|
};
|
|
320
328
|
}
|
|
@@ -15,10 +15,11 @@ export declare class ExtensionsService {
|
|
|
15
15
|
extensionsItemService: ItemsService<ExtensionSettings>;
|
|
16
16
|
extensionsManager: ExtensionManager;
|
|
17
17
|
constructor(options: AbstractServiceOptions);
|
|
18
|
+
install(extensionId: string, versionId: string): Promise<void>;
|
|
18
19
|
readAll(): Promise<ApiOutput[]>;
|
|
19
|
-
readOne(
|
|
20
|
-
updateOne(
|
|
21
|
-
|
|
20
|
+
readOne(id: string): Promise<ApiOutput>;
|
|
21
|
+
updateOne(id: string, data: DeepPartial<ApiOutput>): Promise<ApiOutput>;
|
|
22
|
+
deleteOne(id: string): Promise<void>;
|
|
22
23
|
/**
|
|
23
24
|
* Sync a bundles enabled status
|
|
24
25
|
* - If the extension or extensions parent is not a bundle changes are skipped
|
|
@@ -29,9 +30,4 @@ export declare class ExtensionsService {
|
|
|
29
30
|
* - Entry status change resulted in at least one child being enabled then the parent bundle is enabled
|
|
30
31
|
*/
|
|
31
32
|
private checkBundleAndSyncStatus;
|
|
32
|
-
/**
|
|
33
|
-
* Combine the settings stored in the database with the information available from the installed
|
|
34
|
-
* extensions into the standardized extensions api output
|
|
35
|
-
*/
|
|
36
|
-
private stitch;
|
|
37
33
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { ForbiddenError, InvalidPayloadError, LimitExceededError, UnprocessableContentError } from '@directus/errors';
|
|
3
|
+
import { describe } from '@directus/extensions-registry';
|
|
2
4
|
import { isObject } from '@directus/utils';
|
|
3
|
-
import { omit, pick } from 'lodash-es';
|
|
4
5
|
import getDatabase from '../database/index.js';
|
|
5
6
|
import { getExtensionManager } from '../extensions/index.js';
|
|
6
7
|
import { ItemsService } from './items.js';
|
|
@@ -28,21 +29,88 @@ export class ExtensionsService {
|
|
|
28
29
|
accountability: this.accountability,
|
|
29
30
|
});
|
|
30
31
|
}
|
|
32
|
+
async install(extensionId, versionId) {
|
|
33
|
+
const env = useEnv();
|
|
34
|
+
const describeOptions = {};
|
|
35
|
+
if (typeof env['MARKETPLACE_REGISTRY'] === 'string') {
|
|
36
|
+
describeOptions.registry = env['MARKETPLACE_REGISTRY'];
|
|
37
|
+
}
|
|
38
|
+
const extension = await describe(extensionId, describeOptions);
|
|
39
|
+
const version = extension.data.versions.find((version) => version.id === versionId);
|
|
40
|
+
if (!version) {
|
|
41
|
+
throw new ForbiddenError();
|
|
42
|
+
}
|
|
43
|
+
const limit = env['EXTENSIONS_LIMIT'] ? Number(env['EXTENSIONS_LIMIT']) : null;
|
|
44
|
+
if (limit !== null) {
|
|
45
|
+
const currentlyInstalledCount = this.extensionsManager.extensions.length;
|
|
46
|
+
/**
|
|
47
|
+
* Bundle extensions should be counted as the number of nested entries rather than a single
|
|
48
|
+
* extension to avoid a vulnerability where you can get around the technical limit by bundling
|
|
49
|
+
* all extensions you want
|
|
50
|
+
*/
|
|
51
|
+
const points = version.bundled.length ?? 1;
|
|
52
|
+
const afterInstallCount = currentlyInstalledCount + points;
|
|
53
|
+
if (afterInstallCount >= limit) {
|
|
54
|
+
throw new LimitExceededError();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await this.extensionsItemService.createOne({
|
|
58
|
+
id: extensionId,
|
|
59
|
+
enabled: true,
|
|
60
|
+
folder: versionId,
|
|
61
|
+
source: 'registry',
|
|
62
|
+
bundle: null,
|
|
63
|
+
});
|
|
64
|
+
if (extension.data.type === 'bundle' && version.bundled.length > 0) {
|
|
65
|
+
await this.extensionsItemService.createMany(version.bundled.map((entry) => ({
|
|
66
|
+
enabled: true,
|
|
67
|
+
folder: entry.name,
|
|
68
|
+
source: 'registry',
|
|
69
|
+
bundle: extensionId,
|
|
70
|
+
})));
|
|
71
|
+
}
|
|
72
|
+
await this.extensionsManager.install(versionId);
|
|
73
|
+
}
|
|
31
74
|
async readAll() {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
75
|
+
const settings = await this.extensionsItemService.readByQuery({ limit: -1 });
|
|
76
|
+
const regular = settings.filter(({ bundle }) => bundle === null);
|
|
77
|
+
const bundled = settings.filter(({ bundle }) => bundle !== null);
|
|
78
|
+
const output = [];
|
|
79
|
+
for (const meta of regular) {
|
|
80
|
+
output.push({
|
|
81
|
+
id: meta.id,
|
|
82
|
+
bundle: meta.bundle,
|
|
83
|
+
meta: meta,
|
|
84
|
+
schema: this.extensionsManager.getExtension(meta.source, meta.folder) ?? null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
for (const meta of bundled) {
|
|
88
|
+
const parentBundle = output.find((ext) => ext.id === meta.bundle);
|
|
89
|
+
if (!parentBundle)
|
|
90
|
+
continue;
|
|
91
|
+
const schema = parentBundle.schema?.entries.find((entry) => entry.name === meta.folder);
|
|
92
|
+
if (!schema)
|
|
93
|
+
continue;
|
|
94
|
+
output.push({
|
|
95
|
+
id: meta.id,
|
|
96
|
+
bundle: meta.bundle,
|
|
97
|
+
meta: meta,
|
|
98
|
+
schema: schema,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return output;
|
|
35
102
|
}
|
|
36
|
-
async readOne(
|
|
37
|
-
const
|
|
38
|
-
const schema = this.extensionsManager.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
103
|
+
async readOne(id) {
|
|
104
|
+
const meta = await this.extensionsItemService.readOne(id);
|
|
105
|
+
const schema = this.extensionsManager.getExtension(meta.source, meta.folder) ?? null;
|
|
106
|
+
return {
|
|
107
|
+
id: meta.id,
|
|
108
|
+
bundle: meta.bundle,
|
|
109
|
+
schema,
|
|
110
|
+
meta,
|
|
111
|
+
};
|
|
44
112
|
}
|
|
45
|
-
async updateOne(
|
|
113
|
+
async updateOne(id, data) {
|
|
46
114
|
const result = await this.knex.transaction(async (trx) => {
|
|
47
115
|
if (!isObject(data.meta)) {
|
|
48
116
|
throw new InvalidPayloadError({ reason: `"meta" is required` });
|
|
@@ -52,25 +120,32 @@ export class ExtensionsService {
|
|
|
52
120
|
accountability: this.accountability,
|
|
53
121
|
schema: this.schema,
|
|
54
122
|
});
|
|
55
|
-
|
|
56
|
-
await service.extensionsItemService.updateOne(key, data.meta);
|
|
123
|
+
await service.extensionsItemService.updateOne(id, data.meta);
|
|
57
124
|
let extension;
|
|
58
125
|
try {
|
|
59
|
-
extension = await service.readOne(
|
|
126
|
+
extension = await service.readOne(id);
|
|
60
127
|
}
|
|
61
128
|
catch (error) {
|
|
62
129
|
throw new ExtensionReadError(error);
|
|
63
130
|
}
|
|
64
131
|
if ('enabled' in data.meta) {
|
|
65
|
-
await service.checkBundleAndSyncStatus(trx, extension);
|
|
132
|
+
await service.checkBundleAndSyncStatus(trx, id, extension);
|
|
66
133
|
}
|
|
67
134
|
return extension;
|
|
68
135
|
});
|
|
69
136
|
this.extensionsManager.reload();
|
|
70
137
|
return result;
|
|
71
138
|
}
|
|
72
|
-
|
|
73
|
-
|
|
139
|
+
async deleteOne(id) {
|
|
140
|
+
const settings = await this.extensionsItemService.readOne(id);
|
|
141
|
+
if (settings.source !== 'registry') {
|
|
142
|
+
throw new InvalidPayloadError({
|
|
143
|
+
reason: 'Cannot uninstall extensions that were not installed from the marketplace registry',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
await this.extensionsItemService.deleteOne(id);
|
|
147
|
+
await this.extensionsItemService.deleteByQuery({ filter: { bundle: { _eq: id } } });
|
|
148
|
+
await this.extensionsManager.uninstall(settings.folder);
|
|
74
149
|
}
|
|
75
150
|
/**
|
|
76
151
|
* Sync a bundles enabled status
|
|
@@ -81,16 +156,16 @@ export class ExtensionsService {
|
|
|
81
156
|
* - Entry status change resulted in all children being disabled then the parent bundle is disabled
|
|
82
157
|
* - Entry status change resulted in at least one child being enabled then the parent bundle is enabled
|
|
83
158
|
*/
|
|
84
|
-
async checkBundleAndSyncStatus(trx, extension) {
|
|
85
|
-
if (extension.bundle === null) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
159
|
+
async checkBundleAndSyncStatus(trx, bundleId, extension) {
|
|
160
|
+
if (extension.bundle === null && extension.schema?.type === 'bundle') {
|
|
161
|
+
// If extension is the parent bundle, set it and all nested extensions to enabled
|
|
162
|
+
await trx('directus_extensions')
|
|
163
|
+
.update({ enabled: extension.meta.enabled })
|
|
164
|
+
.where({ bundle: bundleId })
|
|
165
|
+
.orWhere({ id: bundleId });
|
|
91
166
|
return;
|
|
92
167
|
}
|
|
93
|
-
const parent = await this.readOne(
|
|
168
|
+
const parent = await this.readOne(bundleId);
|
|
94
169
|
if (parent.schema?.type !== 'bundle') {
|
|
95
170
|
return;
|
|
96
171
|
}
|
|
@@ -99,73 +174,15 @@ export class ExtensionsService {
|
|
|
99
174
|
reason: 'Unable to toggle status of an entry for a bundle marked as non partial',
|
|
100
175
|
});
|
|
101
176
|
}
|
|
102
|
-
const
|
|
103
|
-
.where(
|
|
177
|
+
const hasEnabledChildren = !!(await trx('directus_extensions')
|
|
178
|
+
.where({ bundle: bundleId })
|
|
104
179
|
.where({ enabled: true })
|
|
105
|
-
.first();
|
|
106
|
-
if (
|
|
107
|
-
await trx('directus_extensions').update({ enabled:
|
|
180
|
+
.first());
|
|
181
|
+
if (hasEnabledChildren) {
|
|
182
|
+
await trx('directus_extensions').update({ enabled: true }).where({ id: bundleId });
|
|
108
183
|
}
|
|
109
|
-
else
|
|
110
|
-
await trx('directus_extensions').update({ enabled:
|
|
184
|
+
else {
|
|
185
|
+
await trx('directus_extensions').update({ enabled: false }).where({ id: bundleId });
|
|
111
186
|
}
|
|
112
187
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Combine the settings stored in the database with the information available from the installed
|
|
115
|
-
* extensions into the standardized extensions api output
|
|
116
|
-
*/
|
|
117
|
-
stitch(installed, configured) {
|
|
118
|
-
/**
|
|
119
|
-
* On startup, the extensions manager will automatically create the rows for installed
|
|
120
|
-
* extensions that don't have configured settings yet, so there should always be equal or more
|
|
121
|
-
* settings rows than installed extensions.
|
|
122
|
-
*/
|
|
123
|
-
return configured.map((meta) => {
|
|
124
|
-
let bundleName = null;
|
|
125
|
-
let name = meta.name;
|
|
126
|
-
if (name.includes('/')) {
|
|
127
|
-
const parts = name.split('/');
|
|
128
|
-
// NPM packages can have an optional organization scope in the format
|
|
129
|
-
// `@<org>/<package>`. This is limited to a single `/`.
|
|
130
|
-
//
|
|
131
|
-
// `foo` -> extension
|
|
132
|
-
// `foo/bar` -> bundle
|
|
133
|
-
// `@rijk/foo` -> extension
|
|
134
|
-
// `@rijk/foo/bar -> bundle
|
|
135
|
-
const hasOrg = parts.at(0).startsWith('@');
|
|
136
|
-
if (hasOrg && parts.length > 2) {
|
|
137
|
-
name = parts.pop();
|
|
138
|
-
bundleName = parts.join('/');
|
|
139
|
-
}
|
|
140
|
-
else if (hasOrg === false) {
|
|
141
|
-
[bundleName, name] = parts;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
let schema;
|
|
145
|
-
if (bundleName) {
|
|
146
|
-
const bundle = installed.find((extension) => extension.name === bundleName);
|
|
147
|
-
if (bundle && 'entries' in bundle) {
|
|
148
|
-
const entry = bundle.entries.find((entry) => entry.name === name) ?? null;
|
|
149
|
-
if (entry) {
|
|
150
|
-
schema = {
|
|
151
|
-
type: entry.type,
|
|
152
|
-
local: bundle.local,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
schema = null;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
schema = installed.find((extension) => extension.name === name) ?? null;
|
|
162
|
-
}
|
|
163
|
-
return {
|
|
164
|
-
name,
|
|
165
|
-
bundle: bundleName,
|
|
166
|
-
schema: schema ? pick(schema, 'type', 'local', 'version', 'partial') : null,
|
|
167
|
-
meta: omit(meta, 'name'),
|
|
168
|
-
};
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
188
|
}
|