@directus/api 34.0.0 → 34.0.1
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/ai/tools/fields/index.d.ts +3 -3
- package/dist/ai/tools/fields/index.js +9 -3
- package/dist/auth/drivers/oauth2.js +10 -4
- package/dist/auth/drivers/openid.js +10 -4
- package/dist/auth/drivers/saml.js +20 -10
- package/dist/auth/utils/resolve-login-redirect.d.ts +11 -0
- package/dist/auth/utils/resolve-login-redirect.js +62 -0
- package/dist/controllers/server.js +32 -26
- package/dist/controllers/tus.js +33 -2
- package/dist/controllers/utils.js +18 -0
- package/dist/database/run-ast/lib/apply-query/aggregate.js +4 -4
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
- package/dist/permissions/modules/validate-access/lib/validate-item-access.js +1 -1
- package/dist/services/graphql/resolvers/system.js +35 -27
- package/dist/test-utils/README.md +112 -0
- package/dist/test-utils/controllers.d.ts +65 -0
- package/dist/test-utils/controllers.js +100 -0
- package/dist/test-utils/database.d.ts +1 -1
- package/dist/test-utils/database.js +3 -1
- package/package.json +29 -29
- package/dist/auth/utils/is-login-redirect-allowed.d.ts +0 -7
- package/dist/auth/utils/is-login-redirect-allowed.js +0 -39
|
@@ -29,7 +29,7 @@ export declare const FieldsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
29
29
|
action: z.ZodLiteral<"update">;
|
|
30
30
|
data: z.ZodArray<z.ZodObject<{
|
|
31
31
|
field: z.ZodString;
|
|
32
|
-
type: z.ZodString
|
|
32
|
+
type: z.ZodOptional<z.ZodString>;
|
|
33
33
|
name: z.ZodOptional<z.ZodString>;
|
|
34
34
|
children: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodRecord<z.ZodString, z.ZodAny>>, z.ZodNull]>>;
|
|
35
35
|
collection: z.ZodOptional<z.ZodString>;
|
|
@@ -51,7 +51,7 @@ export declare const FieldsInputSchema: z.ZodObject<{
|
|
|
51
51
|
collection: z.ZodOptional<z.ZodString>;
|
|
52
52
|
field: z.ZodOptional<z.ZodString>;
|
|
53
53
|
data: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
54
|
-
field: z.ZodOptional<z.ZodString
|
|
54
|
+
field: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
|
|
55
55
|
type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
56
56
|
name: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
57
57
|
collection: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
@@ -87,7 +87,7 @@ export declare const fields: import("../types.js").ToolConfig<{
|
|
|
87
87
|
action: "update";
|
|
88
88
|
data: {
|
|
89
89
|
field: string;
|
|
90
|
-
type
|
|
90
|
+
type?: string | undefined;
|
|
91
91
|
name?: string | undefined;
|
|
92
92
|
children?: Record<string, any>[] | null | undefined;
|
|
93
93
|
collection?: string | undefined;
|
|
@@ -28,7 +28,7 @@ export const FieldsValidateSchema = z.discriminatedUnion('action', [
|
|
|
28
28
|
}),
|
|
29
29
|
FieldsBaseValidateSchema.extend({
|
|
30
30
|
action: z.literal('update'),
|
|
31
|
-
data: z.array(RawFieldItemValidateSchema),
|
|
31
|
+
data: z.array(RawFieldItemValidateSchema.partial({ type: true })),
|
|
32
32
|
}),
|
|
33
33
|
FieldsBaseValidateSchema.extend({
|
|
34
34
|
action: z.literal('delete'),
|
|
@@ -38,11 +38,17 @@ export const FieldsValidateSchema = z.discriminatedUnion('action', [
|
|
|
38
38
|
export const FieldsInputSchema = z.object({
|
|
39
39
|
action: z.enum(['read', 'create', 'update', 'delete']).describe('The operation to perform'),
|
|
40
40
|
collection: z.string().describe('The name of the collection').optional(),
|
|
41
|
-
field: z
|
|
41
|
+
field: z
|
|
42
|
+
.string()
|
|
43
|
+
.describe('The name of the field. Required for delete. Optional for read (omit to read all fields). Do not use for create or update.')
|
|
44
|
+
.optional(),
|
|
42
45
|
data: z
|
|
43
46
|
.array(FieldItemInputSchema.extend({
|
|
44
47
|
children: RawFieldItemInputSchema.shape.children,
|
|
45
|
-
})
|
|
48
|
+
})
|
|
49
|
+
.partial()
|
|
50
|
+
.required({ field: true }))
|
|
51
|
+
.describe('Array of field objects for create/update actions. Each object must include "field" (the field name).')
|
|
46
52
|
.optional(),
|
|
47
53
|
});
|
|
48
54
|
export const fields = defineTool({
|
|
@@ -21,7 +21,7 @@ import { getSecret } from '../../utils/get-secret.js';
|
|
|
21
21
|
import { verifyJWT } from '../../utils/jwt.js';
|
|
22
22
|
import { Url } from '../../utils/url.js';
|
|
23
23
|
import { generateCallbackUrl } from '../utils/generate-callback-url.js';
|
|
24
|
-
import {
|
|
24
|
+
import { resolveLoginRedirect } from '../utils/resolve-login-redirect.js';
|
|
25
25
|
import { LocalAuthDriver } from './local.js';
|
|
26
26
|
export class OAuth2AuthDriver extends LocalAuthDriver {
|
|
27
27
|
client;
|
|
@@ -273,8 +273,12 @@ export function createOAuth2AuthRouter(providerName) {
|
|
|
273
273
|
const codeVerifier = provider.generateCodeVerifier();
|
|
274
274
|
const prompt = !!req.query['prompt'];
|
|
275
275
|
const otp = req.query['otp'];
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
let redirect = req.query['redirect'];
|
|
277
|
+
try {
|
|
278
|
+
redirect = resolveLoginRedirect(redirect, { provider: providerName });
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
useLogger().error(e);
|
|
278
282
|
throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
|
|
279
283
|
}
|
|
280
284
|
const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get('host')}`);
|
|
@@ -356,8 +360,10 @@ export function createOAuth2AuthRouter(providerName) {
|
|
|
356
360
|
const claims = verifyJWT(accessToken, getSecret());
|
|
357
361
|
if (claims?.enforce_tfa === true) {
|
|
358
362
|
const url = new Url(env['PUBLIC_URL']).addPath('admin', 'tfa-setup');
|
|
359
|
-
if (redirect)
|
|
363
|
+
if (redirect) {
|
|
360
364
|
url.setQuery('redirect', redirect);
|
|
365
|
+
url.setQuery('provider', providerName);
|
|
366
|
+
}
|
|
361
367
|
redirect = url.toString();
|
|
362
368
|
}
|
|
363
369
|
}
|
|
@@ -21,7 +21,7 @@ import { getSecret } from '../../utils/get-secret.js';
|
|
|
21
21
|
import { verifyJWT } from '../../utils/jwt.js';
|
|
22
22
|
import { Url } from '../../utils/url.js';
|
|
23
23
|
import { generateCallbackUrl } from '../utils/generate-callback-url.js';
|
|
24
|
-
import {
|
|
24
|
+
import { resolveLoginRedirect } from '../utils/resolve-login-redirect.js';
|
|
25
25
|
import { LocalAuthDriver } from './local.js';
|
|
26
26
|
export class OpenIDAuthDriver extends LocalAuthDriver {
|
|
27
27
|
client;
|
|
@@ -324,9 +324,13 @@ export function createOpenIDAuthRouter(providerName) {
|
|
|
324
324
|
const provider = getAuthProvider(providerName);
|
|
325
325
|
const codeVerifier = provider.generateCodeVerifier();
|
|
326
326
|
const prompt = !!req.query['prompt'];
|
|
327
|
-
|
|
327
|
+
let redirect = req.query['redirect'];
|
|
328
328
|
const otp = req.query['otp'];
|
|
329
|
-
|
|
329
|
+
try {
|
|
330
|
+
redirect = resolveLoginRedirect(redirect, { provider: providerName });
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
useLogger().error(e);
|
|
330
334
|
throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
|
|
331
335
|
}
|
|
332
336
|
const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get('host')}`);
|
|
@@ -418,8 +422,10 @@ export function createOpenIDAuthRouter(providerName) {
|
|
|
418
422
|
const claims = verifyJWT(accessToken, getSecret());
|
|
419
423
|
if (claims?.enforce_tfa === true) {
|
|
420
424
|
const url = new Url(env['PUBLIC_URL']).addPath('admin', 'tfa-setup');
|
|
421
|
-
if (redirect)
|
|
425
|
+
if (redirect) {
|
|
422
426
|
url.setQuery('redirect', redirect);
|
|
427
|
+
url.setQuery('provider', providerName);
|
|
428
|
+
}
|
|
423
429
|
redirect = url.toString();
|
|
424
430
|
}
|
|
425
431
|
}
|
|
@@ -13,7 +13,7 @@ import { AuthenticationService } from '../../services/authentication.js';
|
|
|
13
13
|
import asyncHandler from '../../utils/async-handler.js';
|
|
14
14
|
import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
|
|
15
15
|
import { getSchema } from '../../utils/get-schema.js';
|
|
16
|
-
import {
|
|
16
|
+
import { resolveLoginRedirect } from '../utils/resolve-login-redirect.js';
|
|
17
17
|
import { LocalAuthDriver } from './local.js';
|
|
18
18
|
// Register the samlify schema validator
|
|
19
19
|
samlify.setSchemaValidator(validator);
|
|
@@ -94,8 +94,12 @@ export function createSAMLAuthRouter(providerName) {
|
|
|
94
94
|
const { context: url } = sp.createLoginRequest(idp, 'redirect');
|
|
95
95
|
const parsedUrl = new URL(url);
|
|
96
96
|
if (req.query['redirect']) {
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
let redirect = req.query['redirect'];
|
|
98
|
+
try {
|
|
99
|
+
redirect = resolveLoginRedirect(redirect, { provider: providerName });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
useLogger().error(e);
|
|
99
103
|
throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
|
|
100
104
|
}
|
|
101
105
|
parsedUrl.searchParams.append('RelayState', redirect);
|
|
@@ -115,10 +119,16 @@ export function createSAMLAuthRouter(providerName) {
|
|
|
115
119
|
}));
|
|
116
120
|
router.post('/acs', express.urlencoded({ extended: false }), asyncHandler(async (req, res, next) => {
|
|
117
121
|
const logger = useLogger();
|
|
118
|
-
|
|
122
|
+
let redirect = req.body?.RelayState;
|
|
119
123
|
const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session');
|
|
120
|
-
if (
|
|
121
|
-
|
|
124
|
+
if (redirect) {
|
|
125
|
+
try {
|
|
126
|
+
redirect = resolveLoginRedirect(redirect, { provider: providerName });
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
useLogger().error(e);
|
|
130
|
+
throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
|
|
131
|
+
}
|
|
122
132
|
}
|
|
123
133
|
try {
|
|
124
134
|
const { sp, idp } = getAuthProvider(providerName);
|
|
@@ -134,19 +144,19 @@ export function createSAMLAuthRouter(providerName) {
|
|
|
134
144
|
expires,
|
|
135
145
|
},
|
|
136
146
|
};
|
|
137
|
-
if (
|
|
147
|
+
if (redirect) {
|
|
138
148
|
if (authMode === 'session') {
|
|
139
149
|
res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
|
|
140
150
|
}
|
|
141
151
|
else {
|
|
142
152
|
res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
|
|
143
153
|
}
|
|
144
|
-
return res.redirect(
|
|
154
|
+
return res.redirect(redirect);
|
|
145
155
|
}
|
|
146
156
|
return next();
|
|
147
157
|
}
|
|
148
158
|
catch (error) {
|
|
149
|
-
if (
|
|
159
|
+
if (redirect) {
|
|
150
160
|
let reason = 'UNKNOWN_EXCEPTION';
|
|
151
161
|
if (isDirectusError(error)) {
|
|
152
162
|
reason = error.code;
|
|
@@ -154,7 +164,7 @@ export function createSAMLAuthRouter(providerName) {
|
|
|
154
164
|
else {
|
|
155
165
|
logger.warn(error, `[SAML] Unexpected error during SAML login`);
|
|
156
166
|
}
|
|
157
|
-
return res.redirect(`${
|
|
167
|
+
return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);
|
|
158
168
|
}
|
|
159
169
|
logger.warn(error, `[SAML] Unexpected error during SAML login`);
|
|
160
170
|
throw error;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves and validates the redirect URL after a successful SSO login.
|
|
3
|
+
* Returns a safe redirect path or URL, or throws if the redirect is invalid or not allowed.
|
|
4
|
+
* @param redirect URL or relative path to redirect to after login
|
|
5
|
+
* @param opts.provider SSO provider name, used to check provider-specific allow lists
|
|
6
|
+
* @returns Resolved redirect path or URL string
|
|
7
|
+
* @throws If the redirect is not a string, PUBLIC_URL is not defined, or the redirect is not allowed
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveLoginRedirect(redirect: unknown, opts?: {
|
|
10
|
+
provider?: string | undefined;
|
|
11
|
+
}): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { toArray } from '@directus/utils';
|
|
3
|
+
import isUrlAllowed from '../../utils/is-url-allowed.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves and validates the redirect URL after a successful SSO login.
|
|
6
|
+
* Returns a safe redirect path or URL, or throws if the redirect is invalid or not allowed.
|
|
7
|
+
* @param redirect URL or relative path to redirect to after login
|
|
8
|
+
* @param opts.provider SSO provider name, used to check provider-specific allow lists
|
|
9
|
+
* @returns Resolved redirect path or URL string
|
|
10
|
+
* @throws If the redirect is not a string, PUBLIC_URL is not defined, or the redirect is not allowed
|
|
11
|
+
*/
|
|
12
|
+
export function resolveLoginRedirect(redirect, opts = {}) {
|
|
13
|
+
const env = useEnv();
|
|
14
|
+
const publicURL = env['PUBLIC_URL'];
|
|
15
|
+
// Default empty redirect to root
|
|
16
|
+
if (!redirect)
|
|
17
|
+
return '/';
|
|
18
|
+
if (typeof redirect !== 'string')
|
|
19
|
+
throw new Error('"redirect" must be a string');
|
|
20
|
+
if (!publicURL)
|
|
21
|
+
throw new Error('"PUBLIC_URL" must be defined');
|
|
22
|
+
// Relative URL
|
|
23
|
+
if (URL.canParse(redirect) === false) {
|
|
24
|
+
try {
|
|
25
|
+
const dummyDomain = 'http://dummy.local';
|
|
26
|
+
const { protocol: dummyProtocol, host: dummyHost } = new URL(dummyDomain);
|
|
27
|
+
const parsedRelativeURL = new URL(redirect, dummyDomain);
|
|
28
|
+
if (dummyProtocol !== parsedRelativeURL.protocol || dummyHost !== parsedRelativeURL.host) {
|
|
29
|
+
throw new Error('Relative URL mismatch');
|
|
30
|
+
}
|
|
31
|
+
// If the protocol & host match then it is a safe relative path
|
|
32
|
+
return parsedRelativeURL.toString().replace(dummyDomain, '');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Unparsable URL
|
|
36
|
+
throw new Error('Invalid relative URL');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Absolute redirect
|
|
40
|
+
const parsedAbsoluteURL = new URL(redirect);
|
|
41
|
+
if (!['http:', 'https:'].includes(parsedAbsoluteURL.protocol)) {
|
|
42
|
+
throw new Error('Only http/https redirect protocols are allowed');
|
|
43
|
+
}
|
|
44
|
+
if (opts.provider) {
|
|
45
|
+
const envKey = `AUTH_${opts.provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
|
|
46
|
+
if (envKey in env) {
|
|
47
|
+
const allowedList = toArray(String(env[envKey]));
|
|
48
|
+
allowedList.push(publicURL);
|
|
49
|
+
if (isUrlAllowed(redirect, allowedList)) {
|
|
50
|
+
return parsedAbsoluteURL.toString();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (URL.canParse(publicURL) === false)
|
|
55
|
+
throw new Error('PUBLIC_URL must be a valid URL');
|
|
56
|
+
const { protocol: publicProtocol, host: publicHost } = new URL(publicURL);
|
|
57
|
+
// Reject "app" redirects not matching PUBLIC_URL
|
|
58
|
+
if (publicProtocol !== parsedAbsoluteURL.protocol || publicHost !== parsedAbsoluteURL.host) {
|
|
59
|
+
throw new Error('App "redirect" must match PUBLIC_URL');
|
|
60
|
+
}
|
|
61
|
+
return parsedAbsoluteURL.toString();
|
|
62
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
1
2
|
import { ErrorCode, ForbiddenError, isDirectusError, RouteNotFoundError } from '@directus/errors';
|
|
2
3
|
import { format } from 'date-fns';
|
|
3
4
|
import { Router } from 'express';
|
|
@@ -8,32 +9,37 @@ import { SpecificationService } from '../services/specifications.js';
|
|
|
8
9
|
import asyncHandler from '../utils/async-handler.js';
|
|
9
10
|
import { createAdmin } from '../utils/create-admin.js';
|
|
10
11
|
const router = Router();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
12
|
+
const env = useEnv();
|
|
13
|
+
if (env['OPENAPI_ENABLED'] !== false) {
|
|
14
|
+
router.get('/specs/oas', asyncHandler(async (req, res, next) => {
|
|
15
|
+
const service = new SpecificationService({
|
|
16
|
+
accountability: req.accountability,
|
|
17
|
+
schema: req.schema,
|
|
18
|
+
});
|
|
19
|
+
res.locals['payload'] = await service.oas.generate(req.headers.host);
|
|
20
|
+
return next();
|
|
21
|
+
}), respond);
|
|
22
|
+
}
|
|
23
|
+
if (env['GRAPHQL_INTROSPECTION'] !== false) {
|
|
24
|
+
router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
|
|
25
|
+
const service = new SpecificationService({
|
|
26
|
+
accountability: req.accountability,
|
|
27
|
+
schema: req.schema,
|
|
28
|
+
});
|
|
29
|
+
const serverService = new ServerService({
|
|
30
|
+
accountability: req.accountability,
|
|
31
|
+
schema: req.schema,
|
|
32
|
+
});
|
|
33
|
+
const scope = req.params['scope'] || 'items';
|
|
34
|
+
if (['items', 'system'].includes(scope) === false)
|
|
35
|
+
throw new RouteNotFoundError({ path: req.path });
|
|
36
|
+
const info = await serverService.serverInfo();
|
|
37
|
+
const result = await service.graphql.generate(scope);
|
|
38
|
+
const filename = info['project'].project_name + '_' + format(new Date(), 'yyyy-MM-dd') + '.graphql';
|
|
39
|
+
res.attachment(filename);
|
|
40
|
+
res.send(result);
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
37
43
|
router.get('/info', asyncHandler(async (req, res, next) => {
|
|
38
44
|
const service = new ServerService({
|
|
39
45
|
accountability: req.accountability,
|
package/dist/controllers/tus.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createError } from '@directus/errors';
|
|
2
|
+
import { ERRORS, Metadata } from '@tus/utils';
|
|
1
3
|
import { Router } from 'express';
|
|
2
4
|
import getDatabase from '../database/index.js';
|
|
3
5
|
import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
|
|
@@ -18,11 +20,40 @@ const mapAction = (method) => {
|
|
|
18
20
|
const checkFileAccess = asyncHandler(async (req, _res, next) => {
|
|
19
21
|
if (req.accountability) {
|
|
20
22
|
const action = mapAction(req.method);
|
|
21
|
-
|
|
23
|
+
const validateAccessOptions = {
|
|
22
24
|
action,
|
|
23
25
|
collection: 'directus_files',
|
|
24
26
|
accountability: req.accountability,
|
|
25
|
-
}
|
|
27
|
+
};
|
|
28
|
+
if (req.method === 'POST' && req.header('upload-metadata')) {
|
|
29
|
+
let metadata;
|
|
30
|
+
try {
|
|
31
|
+
metadata = Metadata.parse(req.header('upload-metadata'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new (createError('INVALID_METADATA', ERRORS.INVALID_METADATA.body, ERRORS.INVALID_METADATA.status_code))();
|
|
35
|
+
}
|
|
36
|
+
// On replacement ensure update for that record
|
|
37
|
+
if (metadata['id']) {
|
|
38
|
+
validateAccessOptions.action = 'update';
|
|
39
|
+
validateAccessOptions.primaryKeys = [metadata['id']];
|
|
40
|
+
}
|
|
41
|
+
// Validate permissions for any payload fields
|
|
42
|
+
const fields = [];
|
|
43
|
+
for (const field of Object.keys(req.schema.collections['directus_files'].fields)) {
|
|
44
|
+
// PK is not mutable, access to record is already checked via `primaryKeys` for updates
|
|
45
|
+
if (field === 'id') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (field in metadata) {
|
|
49
|
+
fields.push(field);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (fields.length > 0) {
|
|
53
|
+
validateAccessOptions.fields = fields;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await validateAccess(validateAccessOptions, {
|
|
26
57
|
schema: req.schema,
|
|
27
58
|
knex: getDatabase(),
|
|
28
59
|
});
|
|
@@ -3,6 +3,7 @@ import argon2 from 'argon2';
|
|
|
3
3
|
import Busboy from 'busboy';
|
|
4
4
|
import { Router } from 'express';
|
|
5
5
|
import Joi from 'joi';
|
|
6
|
+
import { resolveLoginRedirect } from '../auth/utils/resolve-login-redirect.js';
|
|
6
7
|
import collectionExists from '../middleware/collection-exists.js';
|
|
7
8
|
import { respond } from '../middleware/respond.js';
|
|
8
9
|
import { ExportService, ImportService } from '../services/import-export.js';
|
|
@@ -125,4 +126,21 @@ router.post('/cache/clear', asyncHandler(async (req, res) => {
|
|
|
125
126
|
await service.clearCache({ system: clearSystemCache });
|
|
126
127
|
res.status(200).end();
|
|
127
128
|
}));
|
|
129
|
+
router.post('/resolve-redirect', asyncHandler(async (req, res) => {
|
|
130
|
+
if (!req.body?.redirect) {
|
|
131
|
+
throw new InvalidPayloadError({ reason: `"redirect" is required` });
|
|
132
|
+
}
|
|
133
|
+
if (req.body?.provider && typeof req.body.provider !== 'string') {
|
|
134
|
+
throw new InvalidPayloadError({ reason: `"provider" must be a string` });
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const resolved = resolveLoginRedirect(req.body.redirect, {
|
|
138
|
+
provider: req.body.provider,
|
|
139
|
+
});
|
|
140
|
+
return res.json({ data: resolved });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new InvalidPayloadError({ reason: `Invalid "redirect" provided` });
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
128
146
|
export default router;
|
|
@@ -2,11 +2,11 @@ export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins)
|
|
|
2
2
|
for (const [operation, fields] of Object.entries(aggregate)) {
|
|
3
3
|
if (!fields)
|
|
4
4
|
continue;
|
|
5
|
+
if (operation === 'countAll') {
|
|
6
|
+
dbQuery.count('*', { as: 'countAll' });
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
5
9
|
for (const field of fields) {
|
|
6
|
-
if (operation === 'countAll') {
|
|
7
|
-
dbQuery.count('*', { as: 'countAll' });
|
|
8
|
-
continue;
|
|
9
|
-
}
|
|
10
10
|
if (operation === 'count' && field === '*') {
|
|
11
11
|
dbQuery.count('*', { as: 'count' });
|
|
12
12
|
continue;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { FieldOverview } from '@directus/types';
|
|
2
2
|
export declare function getFilterType(fields: Record<string, FieldOverview>, key: string, collection?: string): {
|
|
3
|
-
type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "
|
|
3
|
+
type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "float" | "alias" | "uuid" | "json" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
|
|
4
4
|
special?: never;
|
|
5
5
|
} | {
|
|
6
|
-
type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "
|
|
6
|
+
type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "float" | "alias" | "uuid" | "json" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
|
|
7
7
|
special: string[];
|
|
8
8
|
};
|
|
@@ -15,7 +15,7 @@ export async function validateItemAccess(options, context) {
|
|
|
15
15
|
const hasPrimaryKeys = options.primaryKeys && options.primaryKeys.length > 0;
|
|
16
16
|
// For non-singletons, we must have PKs to validate against
|
|
17
17
|
if (!isSingleton && !hasPrimaryKeys) {
|
|
18
|
-
|
|
18
|
+
return { accessAllowed: false };
|
|
19
19
|
}
|
|
20
20
|
// When we're looking up access to specific items, we have to read them from the database to
|
|
21
21
|
// make sure you are allowed to access them.
|
|
@@ -135,35 +135,43 @@ export function injectSystemResolvers(gql, schemaComposer, { CreateCollectionTyp
|
|
|
135
135
|
},
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
},
|
|
147
|
-
server_specs_graphql: {
|
|
148
|
-
type: GraphQLString,
|
|
149
|
-
args: {
|
|
150
|
-
scope: new GraphQLEnumType({
|
|
151
|
-
name: 'graphql_sdl_scope',
|
|
152
|
-
values: {
|
|
153
|
-
items: { value: 'items' },
|
|
154
|
-
system: { value: 'system' },
|
|
155
|
-
},
|
|
156
|
-
}),
|
|
138
|
+
if (env['OPENAPI_ENABLED'] !== false) {
|
|
139
|
+
schemaComposer.Query.addFields({
|
|
140
|
+
server_specs_oas: {
|
|
141
|
+
type: GraphQLJSON,
|
|
142
|
+
resolve: async () => {
|
|
143
|
+
const service = new SpecificationService({ schema: gql.schema, accountability: gql.accountability });
|
|
144
|
+
return await service.oas.generate();
|
|
145
|
+
},
|
|
157
146
|
},
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/** Globally available query */
|
|
150
|
+
if (env['GRAPHQL_INTROSPECTION'] !== false) {
|
|
151
|
+
schemaComposer.Query.addFields({
|
|
152
|
+
server_specs_graphql: {
|
|
153
|
+
type: GraphQLString,
|
|
154
|
+
args: {
|
|
155
|
+
scope: new GraphQLEnumType({
|
|
156
|
+
name: 'graphql_sdl_scope',
|
|
157
|
+
values: {
|
|
158
|
+
items: { value: 'items' },
|
|
159
|
+
system: { value: 'system' },
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
},
|
|
163
|
+
resolve: async (_, args) => {
|
|
164
|
+
const service = new GraphQLService({
|
|
165
|
+
schema: gql.schema,
|
|
166
|
+
accountability: gql.accountability,
|
|
167
|
+
scope: args['scope'] ?? 'items',
|
|
168
|
+
});
|
|
169
|
+
return await generateSchema(service, 'sdl');
|
|
170
|
+
},
|
|
165
171
|
},
|
|
166
|
-
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
schemaComposer.Query.addFields({
|
|
167
175
|
server_ping: {
|
|
168
176
|
type: GraphQLString,
|
|
169
177
|
resolve: () => 'pong',
|
|
@@ -17,6 +17,7 @@ This directory contains mock implementations for commonly used modules in servic
|
|
|
17
17
|
- **[files-service.ts](#files-servicets)** - FilesService mocks
|
|
18
18
|
- **[folders-service.ts](#folders-servicets)** - FoldersService mocks
|
|
19
19
|
- **[test-helpers.ts](#test-helpersts)** - Test data factory functions
|
|
20
|
+
- **[controllers.ts](#controllersts)** - Controller/router testing helpers
|
|
20
21
|
|
|
21
22
|
## Quick Start
|
|
22
23
|
|
|
@@ -521,6 +522,116 @@ const buildTreeSpy = vi.spyOn(FoldersService.prototype, 'buildTree').mockResolve
|
|
|
521
522
|
|
|
522
523
|
---
|
|
523
524
|
|
|
525
|
+
### controllers.ts
|
|
526
|
+
|
|
527
|
+
Provides helpers for extracting route handlers from Express routers and creating mock Express request/response objects
|
|
528
|
+
for controller tests.
|
|
529
|
+
|
|
530
|
+
#### `getRouteHandler(router, method, path)`
|
|
531
|
+
|
|
532
|
+
Extracts the middleware/handler stack for a specific route from an Express router.
|
|
533
|
+
|
|
534
|
+
**Parameters:**
|
|
535
|
+
|
|
536
|
+
- `router`: The Express `Router` instance
|
|
537
|
+
- `method`: HTTP method (`'GET'`, `'POST'`, `'PATCH'`, `'DELETE'`, etc.)
|
|
538
|
+
- `path`: Route path (e.g. `'/'`, `'/:id'`)
|
|
539
|
+
|
|
540
|
+
**Returns:** Array of `{ handle: (...args) => any }` layers for the matched route
|
|
541
|
+
|
|
542
|
+
**Throws:** If no matching route is found
|
|
543
|
+
|
|
544
|
+
**Example:**
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
import { default as router } from './tus.js';
|
|
548
|
+
import { getRouteHandler } from '../test-utils/controllers.js';
|
|
549
|
+
|
|
550
|
+
const [checkAccess, handler] = getRouteHandler(router, 'POST', '/');
|
|
551
|
+
await checkAccess?.handle(req, res, next);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
#### `createMockRequest(overrides?)`
|
|
555
|
+
|
|
556
|
+
Creates a mock Express Request pre-populated with common Directus properties (`accountability`, `schema`,
|
|
557
|
+
`sanitizedQuery`, etc.).
|
|
558
|
+
|
|
559
|
+
**Parameters:**
|
|
560
|
+
|
|
561
|
+
- `overrides` (optional): Properties to merge into the mock request
|
|
562
|
+
|
|
563
|
+
**Returns:** Mock `Request` object
|
|
564
|
+
|
|
565
|
+
**Example:**
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
import { createMockRequest } from '../test-utils/controllers.js';
|
|
569
|
+
|
|
570
|
+
// Minimal request
|
|
571
|
+
const req = createMockRequest({ schema });
|
|
572
|
+
|
|
573
|
+
// With accountability and custom header
|
|
574
|
+
const req = createMockRequest({
|
|
575
|
+
method: 'POST',
|
|
576
|
+
accountability,
|
|
577
|
+
schema,
|
|
578
|
+
header: vi.fn().mockReturnValue('some-value'),
|
|
579
|
+
});
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
#### `createMockResponse(overrides?)`
|
|
583
|
+
|
|
584
|
+
Creates a mock Express Response with chainable methods (`status`, `json`, `send`, `set`, `end`).
|
|
585
|
+
|
|
586
|
+
**Parameters:**
|
|
587
|
+
|
|
588
|
+
- `overrides` (optional): Properties to merge into the mock response
|
|
589
|
+
|
|
590
|
+
**Returns:** Mock `Response` object
|
|
591
|
+
|
|
592
|
+
**Example:**
|
|
593
|
+
|
|
594
|
+
```typescript
|
|
595
|
+
import { createMockResponse } from '../test-utils/controllers.js';
|
|
596
|
+
|
|
597
|
+
const res = createMockResponse();
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
#### Full Controller Test Example
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
import { SchemaBuilder } from '@directus/schema-builder';
|
|
604
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
605
|
+
import { createMockRequest, createMockResponse, getRouteHandler } from '../test-utils/controllers.js';
|
|
606
|
+
import { default as router } from './controller.js';
|
|
607
|
+
|
|
608
|
+
const schema = new SchemaBuilder()
|
|
609
|
+
.collection('collection', (c) => {
|
|
610
|
+
c.field('id').integer().primary();
|
|
611
|
+
c.field('title').string();
|
|
612
|
+
})
|
|
613
|
+
.build();
|
|
614
|
+
|
|
615
|
+
describe('controller', () => {
|
|
616
|
+
beforeEach(() => {
|
|
617
|
+
vi.clearAllMocks();
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test('validates access on POST', async () => {
|
|
621
|
+
const req = createMockRequest({ method: 'POST', accountability, schema });
|
|
622
|
+
const res = createMockResponse();
|
|
623
|
+
const next = vi.fn();
|
|
624
|
+
|
|
625
|
+
const [firstHandler] = getRouteHandler(router, 'POST', '/');
|
|
626
|
+
await firstHandler?.handle(req, res, next);
|
|
627
|
+
|
|
628
|
+
expect(next).toHaveBeenCalled();
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
524
635
|
## Common Patterns
|
|
525
636
|
|
|
526
637
|
### Full Service Test Setup
|
|
@@ -737,6 +848,7 @@ See these files for complete examples:
|
|
|
737
848
|
|
|
738
849
|
- [collections.test.ts](../services/collections.test.ts) - Full service test with schema operations
|
|
739
850
|
- [fields.test.ts](../services/fields.test.ts) - Complex service test with field management
|
|
851
|
+
- [tus.test.ts](../controllers/tus.test.ts) - Controller test with route handler extraction and access validation
|
|
740
852
|
|
|
741
853
|
---
|
|
742
854
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller testing utilities
|
|
3
|
+
* Provides helpers for extracting route handlers and creating mock Express request/response objects
|
|
4
|
+
*/
|
|
5
|
+
import type { Request, Response, Router } from 'express';
|
|
6
|
+
/**
|
|
7
|
+
* Get a route handler stack from an Express router
|
|
8
|
+
*
|
|
9
|
+
* @param router The Express router instance to search
|
|
10
|
+
* @param method HTTP method (GET, POST, PATCH, DELETE, etc.)
|
|
11
|
+
* @param path Route path (e.g. '/', '/:id')
|
|
12
|
+
* @returns Array of middleware/handler layers for the matched route
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { default as router } from './controller.js';
|
|
17
|
+
|
|
18
|
+
* const [firstHandler, secondHandler] = getRouteHandler(router, 'POST', '/');
|
|
19
|
+
* await firstHandler?.handle(req, res, next);
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function getRouteHandler(router: Router, method: string, path: string): Array<{
|
|
23
|
+
handle: (...args: any[]) => any;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Creates a mock Express Request with common Directus properties.
|
|
27
|
+
*
|
|
28
|
+
* @param overrides Properties to merge into the mock request
|
|
29
|
+
* @returns Mock Express Request object
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* // Basic usage
|
|
34
|
+
* const req = createMockRequest({ method: 'POST', accountability });
|
|
35
|
+
*
|
|
36
|
+
* // With custom schema
|
|
37
|
+
* const req = createMockRequest({
|
|
38
|
+
* method: 'PATCH',
|
|
39
|
+
* params: { id: 'file-1' },
|
|
40
|
+
* schema: mySchema,
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // With custom header mock
|
|
44
|
+
* const req = createMockRequest({
|
|
45
|
+
* header: vi.fn().mockReturnValue('some-header-value'),
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare function createMockRequest(overrides?: Partial<Request>): Request;
|
|
50
|
+
/**
|
|
51
|
+
* Creates a mock Express Response with chainable methods.
|
|
52
|
+
*
|
|
53
|
+
* @param overrides Properties to merge into the mock response
|
|
54
|
+
* @returns Mock Express Response object
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* // Basic usage
|
|
59
|
+
* const res = createMockResponse();
|
|
60
|
+
*
|
|
61
|
+
* // With custom locals
|
|
62
|
+
* const res = createMockResponse({ locals: { payload: { data: [] } } });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function createMockResponse(overrides?: Partial<Response>): Response;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller testing utilities
|
|
3
|
+
* Provides helpers for extracting route handlers and creating mock Express request/response objects
|
|
4
|
+
*/
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
/**
|
|
7
|
+
* Get a route handler stack from an Express router
|
|
8
|
+
*
|
|
9
|
+
* @param router The Express router instance to search
|
|
10
|
+
* @param method HTTP method (GET, POST, PATCH, DELETE, etc.)
|
|
11
|
+
* @param path Route path (e.g. '/', '/:id')
|
|
12
|
+
* @returns Array of middleware/handler layers for the matched route
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { default as router } from './controller.js';
|
|
17
|
+
|
|
18
|
+
* const [firstHandler, secondHandler] = getRouteHandler(router, 'POST', '/');
|
|
19
|
+
* await firstHandler?.handle(req, res, next);
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function getRouteHandler(router, method, path) {
|
|
23
|
+
const stack = router.stack;
|
|
24
|
+
const layer = stack.find((l) => l.route?.path === path && l.route?.methods[method.toLowerCase()]);
|
|
25
|
+
if (!layer)
|
|
26
|
+
throw new Error(`No route found for ${method} ${path}`);
|
|
27
|
+
return layer.route.stack;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a mock Express Request with common Directus properties.
|
|
31
|
+
*
|
|
32
|
+
* @param overrides Properties to merge into the mock request
|
|
33
|
+
* @returns Mock Express Request object
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* // Basic usage
|
|
38
|
+
* const req = createMockRequest({ method: 'POST', accountability });
|
|
39
|
+
*
|
|
40
|
+
* // With custom schema
|
|
41
|
+
* const req = createMockRequest({
|
|
42
|
+
* method: 'PATCH',
|
|
43
|
+
* params: { id: 'file-1' },
|
|
44
|
+
* schema: mySchema,
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* // With custom header mock
|
|
48
|
+
* const req = createMockRequest({
|
|
49
|
+
* header: vi.fn().mockReturnValue('some-header-value'),
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createMockRequest(overrides = {}) {
|
|
54
|
+
const headerFn = vi.fn().mockReturnValue(undefined);
|
|
55
|
+
return {
|
|
56
|
+
method: 'GET',
|
|
57
|
+
headers: {},
|
|
58
|
+
params: {},
|
|
59
|
+
body: {},
|
|
60
|
+
header: headerFn,
|
|
61
|
+
get: headerFn,
|
|
62
|
+
is: vi.fn().mockReturnValue(false),
|
|
63
|
+
token: null,
|
|
64
|
+
collection: '',
|
|
65
|
+
singleton: false,
|
|
66
|
+
accountability: undefined,
|
|
67
|
+
sanitizedQuery: {},
|
|
68
|
+
schema: {
|
|
69
|
+
collections: {},
|
|
70
|
+
relations: [],
|
|
71
|
+
},
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Creates a mock Express Response with chainable methods.
|
|
77
|
+
*
|
|
78
|
+
* @param overrides Properties to merge into the mock response
|
|
79
|
+
* @returns Mock Express Response object
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* // Basic usage
|
|
84
|
+
* const res = createMockResponse();
|
|
85
|
+
*
|
|
86
|
+
* // With custom locals
|
|
87
|
+
* const res = createMockResponse({ locals: { payload: { data: [] } } });
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function createMockResponse(overrides = {}) {
|
|
91
|
+
return {
|
|
92
|
+
locals: {},
|
|
93
|
+
status: vi.fn().mockReturnThis(),
|
|
94
|
+
json: vi.fn().mockReturnThis(),
|
|
95
|
+
send: vi.fn().mockReturnThis(),
|
|
96
|
+
set: vi.fn().mockReturnThis(),
|
|
97
|
+
end: vi.fn().mockReturnThis(),
|
|
98
|
+
...overrides,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -24,7 +24,7 @@ import type { DatabaseClient } from '@directus/types';
|
|
|
24
24
|
* ```
|
|
25
25
|
*/
|
|
26
26
|
export declare function mockDatabase(client?: DatabaseClient): {
|
|
27
|
-
default: import("vitest").Mock<(
|
|
27
|
+
default: import("vitest").Mock<() => import("vitest").MockedFunction<import("knex").Knex<any, unknown[]>>>;
|
|
28
28
|
getDatabaseClient: import("vitest").Mock<(...args: any[]) => any>;
|
|
29
29
|
getSchemaInspector: import("vitest").Mock<(...args: any[]) => any>;
|
|
30
30
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Provides simplified mocks for src/database/index module used in service testing
|
|
4
4
|
*/
|
|
5
5
|
import { vi } from 'vitest';
|
|
6
|
+
import { createMockKnex } from './knex.js';
|
|
6
7
|
/**
|
|
7
8
|
* Creates a standard database mock for service tests
|
|
8
9
|
* This matches the pattern used across all service test files
|
|
@@ -24,8 +25,9 @@ import { vi } from 'vitest';
|
|
|
24
25
|
* ```
|
|
25
26
|
*/
|
|
26
27
|
export function mockDatabase(client = 'postgres') {
|
|
28
|
+
const { db } = createMockKnex();
|
|
27
29
|
return {
|
|
28
|
-
default: vi.fn(),
|
|
30
|
+
default: vi.fn(() => db),
|
|
29
31
|
getDatabaseClient: vi.fn().mockReturnValue(client),
|
|
30
32
|
getSchemaInspector: vi.fn(),
|
|
31
33
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directus/api",
|
|
3
|
-
"version": "34.0.
|
|
3
|
+
"version": "34.0.1",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"directus",
|
|
@@ -58,10 +58,10 @@
|
|
|
58
58
|
"dist"
|
|
59
59
|
],
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@ai-sdk/anthropic": "3.0.
|
|
62
|
-
"@ai-sdk/google": "3.0.
|
|
63
|
-
"@ai-sdk/openai": "3.0.
|
|
64
|
-
"@ai-sdk/openai-compatible": "2.0.
|
|
61
|
+
"@ai-sdk/anthropic": "3.0.58",
|
|
62
|
+
"@ai-sdk/google": "3.0.43",
|
|
63
|
+
"@ai-sdk/openai": "3.0.41",
|
|
64
|
+
"@ai-sdk/openai-compatible": "2.0.35",
|
|
65
65
|
"@authenio/samlify-node-xmllint": "2.0.0",
|
|
66
66
|
"@aws-sdk/client-sesv2": "3.928.0",
|
|
67
67
|
"@godaddy/terminus": "4.12.1",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@rollup/plugin-virtual": "3.0.2",
|
|
73
73
|
"@tus/server": "2.3.0",
|
|
74
74
|
"@tus/utils": "0.6.0",
|
|
75
|
-
"ai": "6.0.
|
|
75
|
+
"ai": "6.0.116",
|
|
76
76
|
"archiver": "7.0.1",
|
|
77
77
|
"argon2": "0.44.0",
|
|
78
78
|
"async": "3.2.6",
|
|
@@ -118,8 +118,8 @@
|
|
|
118
118
|
"keyv": "5.5.3",
|
|
119
119
|
"knex": "3.1.0",
|
|
120
120
|
"ldapts": "8.1.3",
|
|
121
|
-
"liquidjs": "10.
|
|
122
|
-
"lodash-es": "4.17.
|
|
121
|
+
"liquidjs": "10.25.0",
|
|
122
|
+
"lodash-es": "4.17.23",
|
|
123
123
|
"marked": "16.4.1",
|
|
124
124
|
"micromustache": "8.0.3",
|
|
125
125
|
"mime-types": "3.0.1",
|
|
@@ -161,31 +161,31 @@
|
|
|
161
161
|
"ws": "8.18.3",
|
|
162
162
|
"zod": "4.1.12",
|
|
163
163
|
"zod-validation-error": "4.0.2",
|
|
164
|
-
"@directus/ai": "1.
|
|
165
|
-
"@directus/env": "5.6.0",
|
|
166
|
-
"@directus/errors": "2.2.0",
|
|
164
|
+
"@directus/ai": "1.3.0",
|
|
167
165
|
"@directus/constants": "14.2.0",
|
|
168
|
-
"@directus/
|
|
169
|
-
"@directus/
|
|
170
|
-
"@directus/
|
|
171
|
-
"@directus/
|
|
166
|
+
"@directus/app": "15.5.1",
|
|
167
|
+
"@directus/env": "5.6.1",
|
|
168
|
+
"@directus/errors": "2.2.0",
|
|
169
|
+
"@directus/extensions": "3.0.21",
|
|
170
|
+
"@directus/extensions-registry": "3.0.21",
|
|
172
171
|
"@directus/format-title": "12.1.1",
|
|
172
|
+
"@directus/memory": "3.1.4",
|
|
173
|
+
"@directus/pressure": "3.0.19",
|
|
173
174
|
"@directus/schema": "13.0.5",
|
|
174
|
-
"@directus/
|
|
175
|
-
"@directus/
|
|
175
|
+
"@directus/schema-builder": "0.0.16",
|
|
176
|
+
"@directus/extensions-sdk": "17.0.11",
|
|
176
177
|
"@directus/specs": "12.0.1",
|
|
177
|
-
"@directus/storage-driver-azure": "12.0.18",
|
|
178
178
|
"@directus/storage": "12.0.3",
|
|
179
|
-
"@directus/storage-driver-gcs": "12.0.
|
|
179
|
+
"@directus/storage-driver-gcs": "12.0.19",
|
|
180
|
+
"@directus/storage-driver-cloudinary": "12.0.19",
|
|
181
|
+
"@directus/storage-driver-s3": "12.1.5",
|
|
180
182
|
"@directus/storage-driver-local": "12.0.3",
|
|
181
|
-
"@directus/
|
|
182
|
-
"@directus/storage-driver-
|
|
183
|
-
"@directus/
|
|
184
|
-
"@directus/
|
|
185
|
-
"
|
|
186
|
-
"@directus/
|
|
187
|
-
"@directus/memory": "3.1.3",
|
|
188
|
-
"directus": "11.16.0"
|
|
183
|
+
"@directus/system-data": "4.3.0",
|
|
184
|
+
"@directus/storage-driver-supabase": "3.0.19",
|
|
185
|
+
"@directus/validation": "2.0.19",
|
|
186
|
+
"@directus/utils": "13.3.1",
|
|
187
|
+
"directus": "11.16.1",
|
|
188
|
+
"@directus/storage-driver-azure": "12.0.19"
|
|
189
189
|
},
|
|
190
190
|
"devDependencies": {
|
|
191
191
|
"@directus/tsconfig": "3.0.0",
|
|
@@ -228,8 +228,8 @@
|
|
|
228
228
|
"knex-mock-client": "3.0.2",
|
|
229
229
|
"typescript": "5.9.3",
|
|
230
230
|
"vitest": "3.2.4",
|
|
231
|
-
"@directus/
|
|
232
|
-
"@directus/
|
|
231
|
+
"@directus/schema-builder": "0.0.16",
|
|
232
|
+
"@directus/types": "14.3.1"
|
|
233
233
|
},
|
|
234
234
|
"optionalDependencies": {
|
|
235
235
|
"@keyv/redis": "3.0.1",
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Checks if the defined redirect after successful SSO login is in the allow list
|
|
3
|
-
* @param provider SSO provider name
|
|
4
|
-
* @param redirect URL to redirect to after login
|
|
5
|
-
* @returns True if the redirect is allowed, false otherwise
|
|
6
|
-
*/
|
|
7
|
-
export declare function isLoginRedirectAllowed(provider: string, redirect: unknown): boolean;
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { useEnv } from '@directus/env';
|
|
2
|
-
import { toArray } from '@directus/utils';
|
|
3
|
-
import { useLogger } from '../../logger/index.js';
|
|
4
|
-
import isUrlAllowed from '../../utils/is-url-allowed.js';
|
|
5
|
-
/**
|
|
6
|
-
* Checks if the defined redirect after successful SSO login is in the allow list
|
|
7
|
-
* @param provider SSO provider name
|
|
8
|
-
* @param redirect URL to redirect to after login
|
|
9
|
-
* @returns True if the redirect is allowed, false otherwise
|
|
10
|
-
*/
|
|
11
|
-
export function isLoginRedirectAllowed(provider, redirect) {
|
|
12
|
-
if (!redirect)
|
|
13
|
-
return true; // empty redirect
|
|
14
|
-
if (typeof redirect !== 'string')
|
|
15
|
-
return false; // invalid type
|
|
16
|
-
const env = useEnv();
|
|
17
|
-
const publicUrl = env['PUBLIC_URL'];
|
|
18
|
-
if (!URL.canParse(redirect)) {
|
|
19
|
-
if (!redirect.startsWith('//')) {
|
|
20
|
-
// should be a relative path like `/admin/test`
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
// domain without protocol `//example.com/test`
|
|
24
|
-
return false;
|
|
25
|
-
}
|
|
26
|
-
const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
|
|
27
|
-
if (envKey in env) {
|
|
28
|
-
if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
if (URL.canParse(publicUrl) === false) {
|
|
32
|
-
useLogger().error('Invalid PUBLIC_URL for login redirect');
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
const { protocol: redirectProtocol, host: redirectHost } = new URL(redirect);
|
|
36
|
-
const { protocol: publicProtocol, host: publicHost } = new URL(publicUrl);
|
|
37
|
-
// allow redirects to the defined PUBLIC_URL (protocol + host including port)
|
|
38
|
-
return `${redirectProtocol}//${redirectHost}` === `${publicProtocol}//${publicHost}`;
|
|
39
|
-
}
|