@directus/api 19.1.0 → 19.2.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/controllers/users.js +1 -0
- package/dist/controllers/utils.js +7 -5
- package/dist/database/helpers/fn/types.js +9 -1
- package/dist/database/migrations/20240515A-add-session-window.d.ts +3 -0
- package/dist/database/migrations/20240515A-add-session-window.js +10 -0
- package/dist/middleware/authenticate.d.ts +1 -1
- package/dist/middleware/authenticate.js +17 -2
- package/dist/services/authentication.d.ts +1 -0
- package/dist/services/authentication.js +60 -8
- package/dist/services/extensions.js +10 -7
- package/dist/services/graphql/index.js +15 -11
- package/dist/services/graphql/utils/sanitize-gql-schema.d.ts +8 -0
- package/dist/services/graphql/utils/sanitize-gql-schema.js +80 -0
- package/dist/services/users.d.ts +1 -1
- package/dist/services/users.js +22 -14
- package/dist/utils/apply-query.js +7 -6
- package/dist/utils/verify-session-jwt.js +2 -2
- package/package.json +27 -27
|
@@ -358,6 +358,7 @@ router.post('/:pk/tfa/disable', asyncHandler(async (req, _res, next) => {
|
|
|
358
358
|
const registerSchema = Joi.object({
|
|
359
359
|
email: Joi.string().email().required(),
|
|
360
360
|
password: Joi.string().required(),
|
|
361
|
+
verification_url: Joi.string().uri(),
|
|
361
362
|
first_name: Joi.string(),
|
|
362
363
|
last_name: Joi.string(),
|
|
363
364
|
});
|
|
@@ -12,13 +12,15 @@ import asyncHandler from '../utils/async-handler.js';
|
|
|
12
12
|
import { generateHash } from '../utils/generate-hash.js';
|
|
13
13
|
import { sanitizeQuery } from '../utils/sanitize-query.js';
|
|
14
14
|
const router = Router();
|
|
15
|
+
const randomStringSchema = Joi.object({
|
|
16
|
+
length: Joi.number().integer().min(1).max(500).default(32),
|
|
17
|
+
});
|
|
15
18
|
router.get('/random/string', asyncHandler(async (req, res) => {
|
|
16
19
|
const { nanoid } = await import('nanoid');
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return res.json({ data: string });
|
|
20
|
+
const { error, value } = randomStringSchema.validate(req.query, { allowUnknown: true });
|
|
21
|
+
if (error)
|
|
22
|
+
throw new InvalidQueryError({ reason: error.message });
|
|
23
|
+
return res.json({ data: nanoid(value.length) });
|
|
22
24
|
}));
|
|
23
25
|
router.post('/hash/generate', asyncHandler(async (req, res) => {
|
|
24
26
|
if (!req.body?.string) {
|
|
@@ -14,13 +14,21 @@ export class FnHelper extends DatabaseHelper {
|
|
|
14
14
|
if (!relation) {
|
|
15
15
|
throw new Error(`Field ${collectionName}.${column} isn't a nested relational collection`);
|
|
16
16
|
}
|
|
17
|
+
// generate a unique alias for the relation collection, to prevent collisions in self referencing relations
|
|
17
18
|
const alias = generateAlias();
|
|
18
19
|
let countQuery = this.knex
|
|
19
20
|
.count('*')
|
|
20
21
|
.from({ [alias]: relation.collection })
|
|
21
22
|
.where(this.knex.raw(`??.??`, [alias, relation.field]), '=', this.knex.raw(`??.??`, [table, currentPrimary]));
|
|
22
23
|
if (options?.query?.filter) {
|
|
23
|
-
|
|
24
|
+
// set the newly aliased collection in the alias map as the default parent collection, indicated by '', for any nested filters
|
|
25
|
+
const aliasMap = {
|
|
26
|
+
'': {
|
|
27
|
+
alias,
|
|
28
|
+
collection: relation.collection,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
countQuery = applyFilter(this.knex, this.schema, countQuery, options.query.filter, relation.collection, aliasMap).query;
|
|
24
32
|
}
|
|
25
33
|
return this.knex.raw('(' + countQuery.toQuery() + ')');
|
|
26
34
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
3
|
+
table.string('next_token', 64).nullable();
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
export async function down(knex) {
|
|
7
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
8
|
+
table.dropColumn('next_token');
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -4,6 +4,6 @@ import type { NextFunction, Request, Response } from 'express';
|
|
|
4
4
|
/**
|
|
5
5
|
* Verify the passed JWT and assign the user ID and role to `req`
|
|
6
6
|
*/
|
|
7
|
-
export declare const handler: (req: Request,
|
|
7
|
+
export declare const handler: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
8
8
|
declare const _default: (req: Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>, res: Response<any, Record<string, any>>, next: NextFunction) => Promise<void>;
|
|
9
9
|
export default _default;
|
|
@@ -4,10 +4,14 @@ import emitter from '../emitter.js';
|
|
|
4
4
|
import asyncHandler from '../utils/async-handler.js';
|
|
5
5
|
import { getAccountabilityForToken } from '../utils/get-accountability-for-token.js';
|
|
6
6
|
import { getIPFromReq } from '../utils/get-ip-from-req.js';
|
|
7
|
+
import { ErrorCode, isDirectusError } from '@directus/errors';
|
|
8
|
+
import { useEnv } from '@directus/env';
|
|
9
|
+
import { SESSION_COOKIE_OPTIONS } from '../constants.js';
|
|
7
10
|
/**
|
|
8
11
|
* Verify the passed JWT and assign the user ID and role to `req`
|
|
9
12
|
*/
|
|
10
|
-
export const handler = async (req,
|
|
13
|
+
export const handler = async (req, res, next) => {
|
|
14
|
+
const env = useEnv();
|
|
11
15
|
const defaultAccountability = {
|
|
12
16
|
user: null,
|
|
13
17
|
role: null,
|
|
@@ -33,7 +37,18 @@ export const handler = async (req, _res, next) => {
|
|
|
33
37
|
req.accountability = customAccountability;
|
|
34
38
|
return next();
|
|
35
39
|
}
|
|
36
|
-
|
|
40
|
+
try {
|
|
41
|
+
req.accountability = await getAccountabilityForToken(req.token, defaultAccountability);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (isDirectusError(err, ErrorCode.InvalidCredentials) || isDirectusError(err, ErrorCode.InvalidToken)) {
|
|
45
|
+
if (req.cookies[env['SESSION_COOKIE_NAME']] === req.token) {
|
|
46
|
+
// clear the session token if ended up in an invalid state
|
|
47
|
+
res.clearCookie(env['SESSION_COOKIE_NAME'], SESSION_COOKIE_OPTIONS);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
37
52
|
return next();
|
|
38
53
|
};
|
|
39
54
|
export default asyncHandler(handler);
|
|
@@ -21,6 +21,7 @@ export declare class AuthenticationService {
|
|
|
21
21
|
refresh(refreshToken: string, options?: Partial<{
|
|
22
22
|
session: boolean;
|
|
23
23
|
}>): Promise<LoginResult>;
|
|
24
|
+
private updateStatefulSession;
|
|
24
25
|
logout(refreshToken: string): Promise<void>;
|
|
25
26
|
verifyPassword(userID: string, password: string): Promise<void>;
|
|
26
27
|
}
|
|
@@ -207,6 +207,7 @@ export class AuthenticationService {
|
|
|
207
207
|
const record = await this.knex
|
|
208
208
|
.select({
|
|
209
209
|
session_expires: 's.expires',
|
|
210
|
+
session_next_token: 's.next_token',
|
|
210
211
|
user_id: 'u.id',
|
|
211
212
|
user_first_name: 'u.first_name',
|
|
212
213
|
user_last_name: 'u.last_name',
|
|
@@ -274,8 +275,9 @@ export class AuthenticationService {
|
|
|
274
275
|
admin_access: record.role_admin_access,
|
|
275
276
|
});
|
|
276
277
|
}
|
|
277
|
-
|
|
278
|
-
const
|
|
278
|
+
let newRefreshToken = record.session_next_token ?? nanoid(64);
|
|
279
|
+
const sessionDuration = env[options?.session ? 'SESSION_COOKIE_TTL' : 'REFRESH_TOKEN_TTL'];
|
|
280
|
+
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(sessionDuration, 0));
|
|
279
281
|
const tokenPayload = {
|
|
280
282
|
id: record.user_id,
|
|
281
283
|
role: record.role_id,
|
|
@@ -283,8 +285,18 @@ export class AuthenticationService {
|
|
|
283
285
|
admin_access: record.role_admin_access,
|
|
284
286
|
};
|
|
285
287
|
if (options?.session) {
|
|
288
|
+
newRefreshToken = await this.updateStatefulSession(record, refreshToken, newRefreshToken, refreshTokenExpiration);
|
|
286
289
|
tokenPayload.session = newRefreshToken;
|
|
287
290
|
}
|
|
291
|
+
else {
|
|
292
|
+
// Original stateless token behavior
|
|
293
|
+
await this.knex('directus_sessions')
|
|
294
|
+
.update({
|
|
295
|
+
token: newRefreshToken,
|
|
296
|
+
expires: refreshTokenExpiration,
|
|
297
|
+
})
|
|
298
|
+
.where({ token: refreshToken });
|
|
299
|
+
}
|
|
288
300
|
if (record.share_id) {
|
|
289
301
|
tokenPayload.share = record.share_id;
|
|
290
302
|
tokenPayload.role = record.share_role;
|
|
@@ -311,15 +323,14 @@ export class AuthenticationService {
|
|
|
311
323
|
expiresIn: TTL,
|
|
312
324
|
issuer: 'directus',
|
|
313
325
|
});
|
|
314
|
-
await this.knex('directus_sessions')
|
|
315
|
-
.update({
|
|
316
|
-
token: newRefreshToken,
|
|
317
|
-
expires: refreshTokenExpiration,
|
|
318
|
-
})
|
|
319
|
-
.where({ token: refreshToken });
|
|
320
326
|
if (record.user_id) {
|
|
321
327
|
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
|
|
322
328
|
}
|
|
329
|
+
// Clear expired sessions for the current user
|
|
330
|
+
await this.knex('directus_sessions')
|
|
331
|
+
.delete()
|
|
332
|
+
.where('user', '=', record.user_id)
|
|
333
|
+
.andWhere('expires', '<', new Date());
|
|
323
334
|
return {
|
|
324
335
|
accessToken,
|
|
325
336
|
refreshToken: newRefreshToken,
|
|
@@ -327,6 +338,47 @@ export class AuthenticationService {
|
|
|
327
338
|
id: record.user_id,
|
|
328
339
|
};
|
|
329
340
|
}
|
|
341
|
+
async updateStatefulSession(sessionRecord, oldSessionToken, newSessionToken, sessionExpiration) {
|
|
342
|
+
if (sessionRecord['session_next_token']) {
|
|
343
|
+
// The current session token was already refreshed and has a reference
|
|
344
|
+
// to the new session, update the new session timeout for the new refresh
|
|
345
|
+
await this.knex('directus_sessions')
|
|
346
|
+
.update({
|
|
347
|
+
expires: sessionExpiration,
|
|
348
|
+
})
|
|
349
|
+
.where({ token: newSessionToken });
|
|
350
|
+
return newSessionToken;
|
|
351
|
+
}
|
|
352
|
+
// Keep the old session active for a short period of time
|
|
353
|
+
const GRACE_PERIOD = getMilliseconds(env['SESSION_REFRESH_GRACE_PERIOD'], 10_000);
|
|
354
|
+
// Update the existing session record to have a short safety timeout
|
|
355
|
+
// before expiring, and add the reference to the new session token
|
|
356
|
+
const updatedSession = await this.knex('directus_sessions')
|
|
357
|
+
.update({
|
|
358
|
+
next_token: newSessionToken,
|
|
359
|
+
expires: new Date(Date.now() + GRACE_PERIOD),
|
|
360
|
+
}, ['next_token'])
|
|
361
|
+
.where({ token: oldSessionToken, next_token: null });
|
|
362
|
+
if (updatedSession.length === 0) {
|
|
363
|
+
// Don't create a new session record, we already have a "next_token" reference
|
|
364
|
+
const { next_token } = await this.knex('directus_sessions')
|
|
365
|
+
.select('next_token')
|
|
366
|
+
.where({ token: oldSessionToken })
|
|
367
|
+
.first();
|
|
368
|
+
return next_token;
|
|
369
|
+
}
|
|
370
|
+
// Instead of updating the current session record with a new token,
|
|
371
|
+
// create a new copy with the new token
|
|
372
|
+
await this.knex('directus_sessions').insert({
|
|
373
|
+
token: newSessionToken,
|
|
374
|
+
user: sessionRecord['user_id'],
|
|
375
|
+
expires: sessionExpiration,
|
|
376
|
+
ip: this.accountability?.ip,
|
|
377
|
+
user_agent: this.accountability?.userAgent,
|
|
378
|
+
origin: this.accountability?.origin,
|
|
379
|
+
});
|
|
380
|
+
return newSessionToken;
|
|
381
|
+
}
|
|
330
382
|
async logout(refreshToken) {
|
|
331
383
|
const record = await this.knex
|
|
332
384
|
.select('u.id', 'u.first_name', 'u.last_name', 'u.email', 'u.password', 'u.status', 'u.role', 'u.provider', 'u.external_identifier', 'u.auth_data')
|
|
@@ -188,16 +188,19 @@ export class ExtensionsService {
|
|
|
188
188
|
* - Entry status change resulted in all children being disabled then the parent bundle is disabled
|
|
189
189
|
* - Entry status change resulted in at least one child being enabled then the parent bundle is enabled
|
|
190
190
|
*/
|
|
191
|
-
async checkBundleAndSyncStatus(trx,
|
|
191
|
+
async checkBundleAndSyncStatus(trx, extensionId, extension) {
|
|
192
192
|
if (extension.bundle === null && extension.schema?.type === 'bundle') {
|
|
193
193
|
// If extension is the parent bundle, set it and all nested extensions to enabled
|
|
194
194
|
await trx('directus_extensions')
|
|
195
195
|
.update({ enabled: extension.meta.enabled })
|
|
196
|
-
.where({ bundle:
|
|
197
|
-
.orWhere({ id:
|
|
196
|
+
.where({ bundle: extensionId })
|
|
197
|
+
.orWhere({ id: extensionId });
|
|
198
198
|
return;
|
|
199
199
|
}
|
|
200
|
-
const
|
|
200
|
+
const parentId = extension.bundle ?? extension.meta.bundle;
|
|
201
|
+
if (!parentId)
|
|
202
|
+
return;
|
|
203
|
+
const parent = await this.readOne(parentId);
|
|
201
204
|
if (parent.schema?.type !== 'bundle') {
|
|
202
205
|
return;
|
|
203
206
|
}
|
|
@@ -207,14 +210,14 @@ export class ExtensionsService {
|
|
|
207
210
|
});
|
|
208
211
|
}
|
|
209
212
|
const hasEnabledChildren = !!(await trx('directus_extensions')
|
|
210
|
-
.where({ bundle:
|
|
213
|
+
.where({ bundle: parentId })
|
|
211
214
|
.where({ enabled: true })
|
|
212
215
|
.first());
|
|
213
216
|
if (hasEnabledChildren) {
|
|
214
|
-
await trx('directus_extensions').update({ enabled: true }).where({ id:
|
|
217
|
+
await trx('directus_extensions').update({ enabled: true }).where({ id: parentId });
|
|
215
218
|
}
|
|
216
219
|
else {
|
|
217
|
-
await trx('directus_extensions').update({ enabled: false }).where({ id:
|
|
220
|
+
await trx('directus_extensions').update({ enabled: false }).where({ id: parentId });
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
}
|
|
@@ -47,6 +47,7 @@ import { GraphQLStringOrFloat } from './types/string-or-float.js';
|
|
|
47
47
|
import { GraphQLVoid } from './types/void.js';
|
|
48
48
|
import { addPathToValidationError } from './utils/add-path-to-validation-error.js';
|
|
49
49
|
import processError from './utils/process-error.js';
|
|
50
|
+
import { sanitizeGraphqlSchema } from './utils/sanitize-gql-schema.js';
|
|
50
51
|
const env = useEnv();
|
|
51
52
|
const validationRules = Array.from(specifiedRules);
|
|
52
53
|
if (env['GRAPHQL_INTROSPECTION'] === false) {
|
|
@@ -115,19 +116,20 @@ export class GraphQLService {
|
|
|
115
116
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
116
117
|
const self = this;
|
|
117
118
|
const schemaComposer = new SchemaComposer();
|
|
119
|
+
const sanitizedSchema = sanitizeGraphqlSchema(this.schema);
|
|
118
120
|
const schema = {
|
|
119
121
|
read: this.accountability?.admin === true
|
|
120
|
-
?
|
|
121
|
-
: reduceSchema(
|
|
122
|
+
? sanitizedSchema
|
|
123
|
+
: reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['read']),
|
|
122
124
|
create: this.accountability?.admin === true
|
|
123
|
-
?
|
|
124
|
-
: reduceSchema(
|
|
125
|
+
? sanitizedSchema
|
|
126
|
+
: reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['create']),
|
|
125
127
|
update: this.accountability?.admin === true
|
|
126
|
-
?
|
|
127
|
-
: reduceSchema(
|
|
128
|
+
? sanitizedSchema
|
|
129
|
+
: reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['update']),
|
|
128
130
|
delete: this.accountability?.admin === true
|
|
129
|
-
?
|
|
130
|
-
: reduceSchema(
|
|
131
|
+
? sanitizedSchema
|
|
132
|
+
: reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['delete']),
|
|
131
133
|
};
|
|
132
134
|
const subscriptionEventType = schemaComposer.createEnumTC({
|
|
133
135
|
name: 'EventEnum',
|
|
@@ -2074,10 +2076,10 @@ export class GraphQLService {
|
|
|
2074
2076
|
},
|
|
2075
2077
|
resolve: async (_, args) => {
|
|
2076
2078
|
const { nanoid } = await import('nanoid');
|
|
2077
|
-
if (args['length'] &&
|
|
2078
|
-
throw new InvalidPayloadError({ reason: `"length"
|
|
2079
|
+
if (args['length'] !== undefined && (args['length'] < 1 || args['length'] > 500)) {
|
|
2080
|
+
throw new InvalidPayloadError({ reason: `"length" must be between 1 and 500` });
|
|
2079
2081
|
}
|
|
2080
|
-
return nanoid(args['length'] ?
|
|
2082
|
+
return nanoid(args['length'] ? args['length'] : 32);
|
|
2081
2083
|
},
|
|
2082
2084
|
},
|
|
2083
2085
|
utils_hash_generate: {
|
|
@@ -2162,6 +2164,7 @@ export class GraphQLService {
|
|
|
2162
2164
|
args: {
|
|
2163
2165
|
email: new GraphQLNonNull(GraphQLString),
|
|
2164
2166
|
password: new GraphQLNonNull(GraphQLString),
|
|
2167
|
+
verification_url: GraphQLString,
|
|
2165
2168
|
first_name: GraphQLString,
|
|
2166
2169
|
last_name: GraphQLString,
|
|
2167
2170
|
},
|
|
@@ -2174,6 +2177,7 @@ export class GraphQLService {
|
|
|
2174
2177
|
await service.registerUser({
|
|
2175
2178
|
email: args.email,
|
|
2176
2179
|
password: args.password,
|
|
2180
|
+
verification_url: args.verification_url,
|
|
2177
2181
|
first_name: args.first_name,
|
|
2178
2182
|
last_name: args.last_name,
|
|
2179
2183
|
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SchemaOverview } from '@directus/types';
|
|
2
|
+
/**
|
|
3
|
+
* Filters out invalid collections to prevent graphql from errorring on schema generation
|
|
4
|
+
*
|
|
5
|
+
* @param schema
|
|
6
|
+
* @returns sanitized schema
|
|
7
|
+
*/
|
|
8
|
+
export declare function sanitizeGraphqlSchema(schema: SchemaOverview): SchemaOverview;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useLogger } from '../../../logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* Regex was taken from the spec
|
|
4
|
+
* https://spec.graphql.org/June2018/#sec-Names
|
|
5
|
+
*/
|
|
6
|
+
const GRAPHQL_NAME_REGEX = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
7
|
+
/**
|
|
8
|
+
* Manually curated list of GraphQL reserved names to cover the most likely naming footguns.
|
|
9
|
+
* This list is not exhaustive and does not cover generated type names.
|
|
10
|
+
*/
|
|
11
|
+
const GRAPHQL_RESERVED_NAMES = [
|
|
12
|
+
'Subscription',
|
|
13
|
+
'Query',
|
|
14
|
+
'Mutation',
|
|
15
|
+
'Int',
|
|
16
|
+
'Float',
|
|
17
|
+
'String',
|
|
18
|
+
'Boolean',
|
|
19
|
+
'DateTime',
|
|
20
|
+
'ID',
|
|
21
|
+
'uid',
|
|
22
|
+
'Point',
|
|
23
|
+
'PointList',
|
|
24
|
+
'Polygon',
|
|
25
|
+
'MultiPolygon',
|
|
26
|
+
'JSON',
|
|
27
|
+
'Hash',
|
|
28
|
+
'Date',
|
|
29
|
+
'Void',
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Filters out invalid collections to prevent graphql from errorring on schema generation
|
|
33
|
+
*
|
|
34
|
+
* @param schema
|
|
35
|
+
* @returns sanitized schema
|
|
36
|
+
*/
|
|
37
|
+
export function sanitizeGraphqlSchema(schema) {
|
|
38
|
+
const logger = useLogger();
|
|
39
|
+
const collections = Object.entries(schema.collections).filter(([collectionName, _data]) => {
|
|
40
|
+
// double underscore __ is reserved for GraphQL introspection
|
|
41
|
+
if (collectionName.startsWith('__') || !collectionName.match(GRAPHQL_NAME_REGEX)) {
|
|
42
|
+
logger.warn(`GraphQL skipping collection "${collectionName}" because it is not a valid name matching /^[_A-Za-z][_0-9A-Za-z]*$/ or starts with __`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (GRAPHQL_RESERVED_NAMES.includes(collectionName)) {
|
|
46
|
+
logger.warn(`GraphQL skipping collection "${collectionName}" because it is a reserved keyword`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
schema.collections = Object.fromEntries(collections);
|
|
52
|
+
const collectionExists = (collection) => Boolean(schema.collections[collection]);
|
|
53
|
+
const skipRelation = (relation) => {
|
|
54
|
+
const relationName = relation.schema?.constraint_name ?? `${relation.collection}.${relation.field}`;
|
|
55
|
+
logger.warn(`GraphQL skipping relation "${relationName}" because it links to a non-existent or invalid collection.`);
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
58
|
+
schema.relations = schema.relations.filter((relation) => {
|
|
59
|
+
if (relation.collection && !collectionExists(relation.collection)) {
|
|
60
|
+
return skipRelation(relation);
|
|
61
|
+
}
|
|
62
|
+
if (relation.related_collection && !collectionExists(relation.related_collection)) {
|
|
63
|
+
return skipRelation(relation);
|
|
64
|
+
}
|
|
65
|
+
if (relation.meta) {
|
|
66
|
+
if (relation.meta.many_collection && !collectionExists(relation.meta.many_collection)) {
|
|
67
|
+
return skipRelation(relation);
|
|
68
|
+
}
|
|
69
|
+
if (relation.meta.one_collection && !collectionExists(relation.meta.one_collection)) {
|
|
70
|
+
return skipRelation(relation);
|
|
71
|
+
}
|
|
72
|
+
if (relation.meta.one_allowed_collections &&
|
|
73
|
+
relation.meta.one_allowed_collections.some((allowed_collection) => !collectionExists(allowed_collection))) {
|
|
74
|
+
return skipRelation(relation);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
return schema;
|
|
80
|
+
}
|
package/dist/services/users.d.ts
CHANGED
package/dist/services/users.js
CHANGED
|
@@ -126,14 +126,14 @@ export class UsersService extends ItemsService {
|
|
|
126
126
|
.first();
|
|
127
127
|
}
|
|
128
128
|
/**
|
|
129
|
-
* Create
|
|
129
|
+
* Create URL for inviting users
|
|
130
130
|
*/
|
|
131
131
|
inviteUrl(email, url) {
|
|
132
132
|
const payload = { email, scope: 'invite' };
|
|
133
133
|
const token = jwt.sign(payload, getSecret(), { expiresIn: '7d', issuer: 'directus' });
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
return (url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite'))
|
|
135
|
+
.setQuery('token', token)
|
|
136
|
+
.toString();
|
|
137
137
|
}
|
|
138
138
|
/**
|
|
139
139
|
* Validate array of emails. Intended to be used with create/update users
|
|
@@ -314,7 +314,7 @@ export class UsersService extends ItemsService {
|
|
|
314
314
|
const opts = {};
|
|
315
315
|
try {
|
|
316
316
|
if (url && isUrlAllowed(url, env['USER_INVITE_URL_ALLOW_LIST']) === false) {
|
|
317
|
-
throw new InvalidPayloadError({ reason: `
|
|
317
|
+
throw new InvalidPayloadError({ reason: `URL "${url}" can't be used to invite users` });
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
catch (err) {
|
|
@@ -373,6 +373,12 @@ export class UsersService extends ItemsService {
|
|
|
373
373
|
await service.updateOne(user.id, { password, status: 'active' });
|
|
374
374
|
}
|
|
375
375
|
async registerUser(input) {
|
|
376
|
+
if (input.verification_url &&
|
|
377
|
+
isUrlAllowed(input.verification_url, env['USER_REGISTER_URL_ALLOW_LIST']) === false) {
|
|
378
|
+
throw new InvalidPayloadError({
|
|
379
|
+
reason: `URL "${input.verification_url}" can't be used to verify registered users`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
376
382
|
const STALL_TIME = env['REGISTER_STALL_TIME'];
|
|
377
383
|
const timeStart = performance.now();
|
|
378
384
|
const serviceOptions = { accountability: this.accountability, schema: this.schema };
|
|
@@ -424,9 +430,11 @@ export class UsersService extends ItemsService {
|
|
|
424
430
|
expiresIn: env['EMAIL_VERIFICATION_TOKEN_TTL'],
|
|
425
431
|
issuer: 'directus',
|
|
426
432
|
});
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
.
|
|
433
|
+
const verificationUrl = (input.verification_url
|
|
434
|
+
? new Url(input.verification_url)
|
|
435
|
+
: new Url(env['PUBLIC_URL']).addPath('users', 'register', 'verify-email'))
|
|
436
|
+
.setQuery('token', token)
|
|
437
|
+
.toString();
|
|
430
438
|
mailService
|
|
431
439
|
.send({
|
|
432
440
|
to: input.email,
|
|
@@ -434,7 +442,7 @@ export class UsersService extends ItemsService {
|
|
|
434
442
|
template: {
|
|
435
443
|
name: 'user-registration',
|
|
436
444
|
data: {
|
|
437
|
-
url:
|
|
445
|
+
url: verificationUrl,
|
|
438
446
|
email: input.email,
|
|
439
447
|
first_name,
|
|
440
448
|
last_name,
|
|
@@ -467,7 +475,7 @@ export class UsersService extends ItemsService {
|
|
|
467
475
|
throw new ForbiddenError();
|
|
468
476
|
}
|
|
469
477
|
if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
|
|
470
|
-
throw new InvalidPayloadError({ reason: `
|
|
478
|
+
throw new InvalidPayloadError({ reason: `URL "${url}" can't be used to reset passwords` });
|
|
471
479
|
}
|
|
472
480
|
const mailService = new MailService({
|
|
473
481
|
schema: this.schema,
|
|
@@ -476,9 +484,9 @@ export class UsersService extends ItemsService {
|
|
|
476
484
|
});
|
|
477
485
|
const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
|
|
478
486
|
const token = jwt.sign(payload, getSecret(), { expiresIn: '1d', issuer: 'directus' });
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
487
|
+
const acceptUrl = (url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password'))
|
|
488
|
+
.setQuery('token', token)
|
|
489
|
+
.toString();
|
|
482
490
|
const subjectLine = subject ? subject : 'Password Reset Request';
|
|
483
491
|
mailService
|
|
484
492
|
.send({
|
|
@@ -487,7 +495,7 @@ export class UsersService extends ItemsService {
|
|
|
487
495
|
template: {
|
|
488
496
|
name: 'password-reset',
|
|
489
497
|
data: {
|
|
490
|
-
url:
|
|
498
|
+
url: acceptUrl,
|
|
491
499
|
email: user.email,
|
|
492
500
|
},
|
|
493
501
|
},
|
|
@@ -347,7 +347,8 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
347
347
|
else {
|
|
348
348
|
const { type, special } = getFilterType(schema.collections[collection].fields, filterPath[0], collection);
|
|
349
349
|
validateFilterOperator(type, filterOperator, special);
|
|
350
|
-
|
|
350
|
+
const aliasedCollection = aliasMap['']?.alias || collection;
|
|
351
|
+
applyFilterToQuery(`${aliasedCollection}.${filterPath[0]}`, filterOperator, filterValue, logical, collection);
|
|
351
352
|
}
|
|
352
353
|
}
|
|
353
354
|
function getFilterType(fields, key, collection = 'unknown') {
|
|
@@ -422,7 +423,7 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
422
423
|
const functionName = column.split('(')[0];
|
|
423
424
|
const type = getOutputTypeForFunction(functionName);
|
|
424
425
|
if (['integer', 'float', 'decimal'].includes(type)) {
|
|
425
|
-
compareValue = Number(compareValue);
|
|
426
|
+
compareValue = Array.isArray(compareValue) ? compareValue.map(Number) : Number(compareValue);
|
|
426
427
|
}
|
|
427
428
|
}
|
|
428
429
|
// Cast filter value (compareValue) based on type of field being filtered against
|
|
@@ -520,19 +521,19 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
520
521
|
dbQuery[logical].whereNotIn(selectionRaw, value);
|
|
521
522
|
}
|
|
522
523
|
if (operator === '_between') {
|
|
523
|
-
if (compareValue.length !== 2)
|
|
524
|
-
return;
|
|
525
524
|
let value = compareValue;
|
|
526
525
|
if (typeof value === 'string')
|
|
527
526
|
value = value.split(',');
|
|
527
|
+
if (value.length !== 2)
|
|
528
|
+
return;
|
|
528
529
|
dbQuery[logical].whereBetween(selectionRaw, value);
|
|
529
530
|
}
|
|
530
531
|
if (operator === '_nbetween') {
|
|
531
|
-
if (compareValue.length !== 2)
|
|
532
|
-
return;
|
|
533
532
|
let value = compareValue;
|
|
534
533
|
if (typeof value === 'string')
|
|
535
534
|
value = value.split(',');
|
|
535
|
+
if (value.length !== 2)
|
|
536
|
+
return;
|
|
536
537
|
dbQuery[logical].whereNotBetween(selectionRaw, value);
|
|
537
538
|
}
|
|
538
539
|
if (operator == '_intersects') {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import getDatabase from '../database/index.js';
|
|
2
|
-
import {
|
|
2
|
+
import { InvalidCredentialsError } from '@directus/errors';
|
|
3
3
|
/**
|
|
4
4
|
* Verifies the associated session is still available and valid.
|
|
5
5
|
*
|
|
@@ -17,6 +17,6 @@ export async function verifySessionJWT(payload) {
|
|
|
17
17
|
.andWhere('expires', '>=', new Date())
|
|
18
18
|
.first();
|
|
19
19
|
if (!session) {
|
|
20
|
-
throw new
|
|
20
|
+
throw new InvalidCredentialsError();
|
|
21
21
|
}
|
|
22
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directus/api",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.2.0",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"directus",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"@types/cookie": "0.6.0",
|
|
68
68
|
"argon2": "0.40.1",
|
|
69
69
|
"async": "3.2.5",
|
|
70
|
-
"axios": "1.
|
|
70
|
+
"axios": "1.7.2",
|
|
71
71
|
"busboy": "1.6.0",
|
|
72
72
|
"bytes": "3.1.2",
|
|
73
73
|
"camelcase": "8.0.0",
|
|
@@ -92,11 +92,11 @@
|
|
|
92
92
|
"fs-extra": "11.2.0",
|
|
93
93
|
"glob-to-regexp": "0.4.1",
|
|
94
94
|
"graphql": "16.8.1",
|
|
95
|
-
"graphql-compose": "9.0.
|
|
95
|
+
"graphql-compose": "9.0.11",
|
|
96
96
|
"graphql-ws": "5.16.0",
|
|
97
97
|
"helmet": "7.1.0",
|
|
98
98
|
"icc": "3.0.0",
|
|
99
|
-
"inquirer": "9.2.
|
|
99
|
+
"inquirer": "9.2.22",
|
|
100
100
|
"ioredis": "5.4.1",
|
|
101
101
|
"ip-matching": "2.1.2",
|
|
102
102
|
"isolated-vm": "4.7.2",
|
|
@@ -140,35 +140,35 @@
|
|
|
140
140
|
"sharp": "0.33.3",
|
|
141
141
|
"snappy": "7.2.2",
|
|
142
142
|
"stream-json": "1.8.0",
|
|
143
|
-
"tar": "7.0
|
|
143
|
+
"tar": "7.1.0",
|
|
144
144
|
"tsx": "4.9.3",
|
|
145
145
|
"wellknown": "0.5.0",
|
|
146
146
|
"ws": "8.17.0",
|
|
147
|
-
"zod": "3.23.
|
|
147
|
+
"zod": "3.23.8",
|
|
148
148
|
"zod-validation-error": "3.2.0",
|
|
149
|
-
"@directus/app": "12.1.
|
|
150
|
-
"@directus/env": "1.1.3",
|
|
151
|
-
"@directus/errors": "0.3.0",
|
|
152
|
-
"@directus/extensions": "1.0.4",
|
|
149
|
+
"@directus/app": "12.1.2",
|
|
153
150
|
"@directus/constants": "11.0.4",
|
|
154
|
-
"@directus/
|
|
155
|
-
"@directus/
|
|
156
|
-
"@directus/
|
|
157
|
-
"@directus/
|
|
151
|
+
"@directus/env": "1.1.5",
|
|
152
|
+
"@directus/errors": "0.3.1",
|
|
153
|
+
"@directus/extensions": "1.0.6",
|
|
154
|
+
"@directus/extensions-registry": "1.0.6",
|
|
155
|
+
"@directus/extensions-sdk": "11.0.6",
|
|
156
|
+
"@directus/memory": "1.0.8",
|
|
158
157
|
"@directus/pressure": "1.0.19",
|
|
159
|
-
"@directus/specs": "10.2.9",
|
|
160
158
|
"@directus/schema": "11.0.2",
|
|
161
|
-
"@directus/
|
|
162
|
-
"@directus/
|
|
163
|
-
"@directus/storage
|
|
164
|
-
"@directus/storage-driver-
|
|
165
|
-
"@directus/storage-driver-
|
|
166
|
-
"@directus/storage-driver-
|
|
167
|
-
"@directus/storage-driver-
|
|
159
|
+
"@directus/specs": "10.2.9",
|
|
160
|
+
"@directus/format-title": "10.1.2",
|
|
161
|
+
"@directus/storage": "10.0.13",
|
|
162
|
+
"@directus/storage-driver-cloudinary": "10.0.21",
|
|
163
|
+
"@directus/storage-driver-azure": "10.0.21",
|
|
164
|
+
"@directus/storage-driver-gcs": "10.0.22",
|
|
165
|
+
"@directus/storage-driver-local": "10.0.20",
|
|
166
|
+
"@directus/storage-driver-supabase": "1.0.13",
|
|
168
167
|
"@directus/system-data": "1.0.3",
|
|
168
|
+
"@directus/storage-driver-s3": "10.0.22",
|
|
169
169
|
"@directus/utils": "11.0.8",
|
|
170
|
-
"
|
|
171
|
-
"directus": "
|
|
170
|
+
"directus": "10.11.2",
|
|
171
|
+
"@directus/validation": "0.0.16"
|
|
172
172
|
},
|
|
173
173
|
"devDependencies": {
|
|
174
174
|
"@ngneat/falso": "7.2.0",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"@types/lodash-es": "4.17.12",
|
|
194
194
|
"@types/mime-types": "2.1.4",
|
|
195
195
|
"@types/ms": "0.7.34",
|
|
196
|
-
"@types/node": "18.19.
|
|
196
|
+
"@types/node": "18.19.33",
|
|
197
197
|
"@types/node-schedule": "2.1.7",
|
|
198
198
|
"@types/nodemailer": "6.4.15",
|
|
199
199
|
"@types/object-hash": "3.0.6",
|
|
@@ -210,8 +210,8 @@
|
|
|
210
210
|
"typescript": "5.4.5",
|
|
211
211
|
"vitest": "1.5.3",
|
|
212
212
|
"@directus/random": "0.2.8",
|
|
213
|
-
"@directus/
|
|
214
|
-
"@directus/
|
|
213
|
+
"@directus/types": "11.1.2",
|
|
214
|
+
"@directus/tsconfig": "1.0.1"
|
|
215
215
|
},
|
|
216
216
|
"optionalDependencies": {
|
|
217
217
|
"@keyv/redis": "2.8.4",
|