@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.
@@ -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
- if (req.query && req.query['length'] && Number(req.query['length']) > 500) {
18
- throw new InvalidQueryError({ reason: `"length" can't be more than 500 characters` });
19
- }
20
- const string = nanoid(req.query?.['length'] ? Number(req.query['length']) : 32);
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
- countQuery = applyFilter(this.knex, this.schema, countQuery, options.query.filter, relation.collection, {}).query;
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,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -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, _res: Response, next: NextFunction) => Promise<void>;
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, _res, next) => {
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
- req.accountability = await getAccountabilityForToken(req.token, defaultAccountability);
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
- const newRefreshToken = nanoid(64);
278
- const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
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, bundleId, extension) {
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: bundleId })
197
- .orWhere({ id: bundleId });
196
+ .where({ bundle: extensionId })
197
+ .orWhere({ id: extensionId });
198
198
  return;
199
199
  }
200
- const parent = await this.readOne(bundleId);
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: bundleId })
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: bundleId });
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: bundleId });
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
- ? this.schema
121
- : reduceSchema(this.schema, this.accountability?.permissions || null, ['read']),
122
+ ? sanitizedSchema
123
+ : reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['read']),
122
124
  create: this.accountability?.admin === true
123
- ? this.schema
124
- : reduceSchema(this.schema, this.accountability?.permissions || null, ['create']),
125
+ ? sanitizedSchema
126
+ : reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['create']),
125
127
  update: this.accountability?.admin === true
126
- ? this.schema
127
- : reduceSchema(this.schema, this.accountability?.permissions || null, ['update']),
128
+ ? sanitizedSchema
129
+ : reduceSchema(sanitizedSchema, this.accountability?.permissions || null, ['update']),
128
130
  delete: this.accountability?.admin === true
129
- ? this.schema
130
- : reduceSchema(this.schema, this.accountability?.permissions || null, ['delete']),
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'] && Number(args['length']) > 500) {
2078
- throw new InvalidPayloadError({ reason: `"length" can't be more than 500 characters` });
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'] ? Number(args['length']) : 32);
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
+ }
@@ -23,7 +23,7 @@ export declare class UsersService extends ItemsService {
23
23
  */
24
24
  private getUserByEmail;
25
25
  /**
26
- * Create url for inviting users
26
+ * Create URL for inviting users
27
27
  */
28
28
  private inviteUrl;
29
29
  /**
@@ -126,14 +126,14 @@ export class UsersService extends ItemsService {
126
126
  .first();
127
127
  }
128
128
  /**
129
- * Create url for inviting users
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
- const inviteURL = url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite');
135
- inviteURL.setQuery('token', token);
136
- return inviteURL.toString();
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: `Url "${url}" can't be used to invite users` });
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 verificationURL = new Url(env['PUBLIC_URL'])
428
- .addPath('users', 'register', 'verify-email')
429
- .setQuery('token', token);
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: verificationURL.toString(),
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: `Url "${url}" can't be used to reset passwords` });
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 acceptURL = url
480
- ? new Url(url).setQuery('token', token).toString()
481
- : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
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: acceptURL,
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
- applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical);
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 { InvalidTokenError } from '@directus/errors';
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 InvalidTokenError();
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.1.0",
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.6.8",
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.10",
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.20",
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.1",
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.6",
147
+ "zod": "3.23.8",
148
148
  "zod-validation-error": "3.2.0",
149
- "@directus/app": "12.1.0",
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/extensions-registry": "1.0.4",
155
- "@directus/extensions-sdk": "11.0.4",
156
- "@directus/format-title": "10.1.2",
157
- "@directus/memory": "1.0.7",
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/storage": "10.0.12",
162
- "@directus/storage-driver-azure": "10.0.20",
163
- "@directus/storage-driver-cloudinary": "10.0.20",
164
- "@directus/storage-driver-gcs": "10.0.20",
165
- "@directus/storage-driver-local": "10.0.19",
166
- "@directus/storage-driver-supabase": "1.0.12",
167
- "@directus/storage-driver-s3": "10.0.21",
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
- "@directus/validation": "0.0.15",
171
- "directus": "10.11.0"
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.31",
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/tsconfig": "1.0.1",
214
- "@directus/types": "11.1.1"
213
+ "@directus/types": "11.1.2",
214
+ "@directus/tsconfig": "1.0.1"
215
215
  },
216
216
  "optionalDependencies": {
217
217
  "@keyv/redis": "2.8.4",