@directus/api 19.0.2 → 19.1.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.
Files changed (75) hide show
  1. package/dist/app.js +8 -7
  2. package/dist/auth/drivers/oauth2.js +3 -2
  3. package/dist/auth/drivers/openid.js +3 -2
  4. package/dist/cli/utils/create-env/env-stub.liquid +0 -3
  5. package/dist/cli/utils/create-env/index.js +0 -2
  6. package/dist/controllers/auth.js +3 -2
  7. package/dist/controllers/extensions.js +30 -19
  8. package/dist/controllers/users.js +25 -0
  9. package/dist/database/helpers/fn/types.js +13 -4
  10. package/dist/database/helpers/index.d.ts +2 -0
  11. package/dist/database/helpers/index.js +2 -0
  12. package/dist/database/helpers/number/dialects/default.d.ts +3 -0
  13. package/dist/database/helpers/number/dialects/default.js +3 -0
  14. package/dist/database/helpers/number/dialects/mssql.d.ts +7 -0
  15. package/dist/database/helpers/number/dialects/mssql.js +11 -0
  16. package/dist/database/helpers/number/dialects/oracle.d.ts +6 -0
  17. package/dist/database/helpers/number/dialects/oracle.js +7 -0
  18. package/dist/database/helpers/number/dialects/postgres.d.ts +5 -0
  19. package/dist/database/helpers/number/dialects/postgres.js +15 -0
  20. package/dist/database/helpers/number/dialects/sqlite.d.ts +6 -0
  21. package/dist/database/helpers/number/dialects/sqlite.js +7 -0
  22. package/dist/database/helpers/number/index.d.ts +7 -0
  23. package/dist/database/helpers/number/index.js +7 -0
  24. package/dist/database/helpers/number/types.d.ts +12 -0
  25. package/dist/database/helpers/number/types.js +9 -0
  26. package/dist/database/helpers/number/utils/decimal-limit.d.ts +4 -0
  27. package/dist/database/helpers/number/utils/decimal-limit.js +10 -0
  28. package/dist/database/helpers/number/utils/maybe-stringify-big-int.d.ts +1 -0
  29. package/dist/database/helpers/number/utils/maybe-stringify-big-int.js +6 -0
  30. package/dist/database/helpers/number/utils/number-in-range.d.ts +3 -0
  31. package/dist/database/helpers/number/utils/number-in-range.js +20 -0
  32. package/dist/database/migrations/20240422A-public-registration.d.ts +3 -0
  33. package/dist/database/migrations/20240422A-public-registration.js +14 -0
  34. package/dist/database/migrations/20240515A-add-session-window.d.ts +3 -0
  35. package/dist/database/migrations/20240515A-add-session-window.js +10 -0
  36. package/dist/database/run-ast.js +5 -4
  37. package/dist/extensions/lib/get-extensions-settings.js +48 -11
  38. package/dist/extensions/lib/installation/manager.js +2 -2
  39. package/dist/middleware/authenticate.d.ts +1 -1
  40. package/dist/middleware/authenticate.js +17 -2
  41. package/dist/middleware/rate-limiter-global.js +1 -1
  42. package/dist/middleware/rate-limiter-registration.d.ts +5 -0
  43. package/dist/middleware/rate-limiter-registration.js +32 -0
  44. package/dist/services/authentication.d.ts +1 -0
  45. package/dist/services/authentication.js +63 -10
  46. package/dist/services/authorization.js +4 -4
  47. package/dist/services/fields.js +2 -2
  48. package/dist/services/graphql/index.js +41 -2
  49. package/dist/services/mail/templates/user-registration.liquid +37 -0
  50. package/dist/services/meta.js +1 -1
  51. package/dist/services/payload.d.ts +2 -0
  52. package/dist/services/payload.js +16 -4
  53. package/dist/services/server.js +3 -1
  54. package/dist/services/shares.js +2 -1
  55. package/dist/services/users.d.ts +3 -1
  56. package/dist/services/users.js +92 -5
  57. package/dist/utils/apply-query.d.ts +1 -1
  58. package/dist/utils/apply-query.js +61 -34
  59. package/dist/utils/get-accountability-for-token.js +6 -3
  60. package/dist/utils/get-secret.d.ts +4 -0
  61. package/dist/utils/get-secret.js +14 -0
  62. package/dist/utils/parse-filter-key.d.ts +7 -0
  63. package/dist/utils/parse-filter-key.js +22 -0
  64. package/dist/utils/parse-numeric-string.d.ts +2 -0
  65. package/dist/utils/parse-numeric-string.js +21 -0
  66. package/dist/utils/sanitize-query.js +10 -5
  67. package/dist/utils/transaction.d.ts +1 -1
  68. package/dist/utils/transaction.js +39 -2
  69. package/dist/utils/validate-query.js +0 -2
  70. package/dist/utils/verify-session-jwt.d.ts +7 -0
  71. package/dist/utils/verify-session-jwt.js +22 -0
  72. package/dist/websocket/messages.d.ts +78 -50
  73. package/package.json +60 -61
  74. package/dist/utils/strip-function.d.ts +0 -4
  75. package/dist/utils/strip-function.js +0 -12
@@ -15,9 +15,33 @@ export const getExtensionsSettings = async ({ local, module, registry, }) => {
15
15
  });
16
16
  const existingSettings = await service.extensionsItemService.readByQuery({ limit: -1 });
17
17
  const newSettings = [];
18
+ const removedSettingIds = [];
18
19
  const localSettings = existingSettings.filter(({ source }) => source === 'local');
19
20
  const registrySettings = existingSettings.filter(({ source }) => source === 'registry');
20
21
  const moduleSettings = existingSettings.filter(({ source }) => source === 'module');
22
+ const updateBundleEntriesSettings = (bundle, bundleSettings, allSettings) => {
23
+ const bundleEntriesSettings = allSettings.filter(({ bundle }) => bundle === bundleSettings.id);
24
+ // Remove settings of removed bundle entries from the DB
25
+ for (const entry of bundleEntriesSettings) {
26
+ const entryInBundle = bundle.entries.some(({ name }) => name === entry.folder);
27
+ if (entryInBundle)
28
+ continue;
29
+ removedSettingIds.push(entry.id);
30
+ }
31
+ // Add new bundle entries to the settings
32
+ for (const entry of bundle.entries) {
33
+ const settingsExist = bundleEntriesSettings.some(({ folder }) => folder === entry.name);
34
+ if (settingsExist)
35
+ continue;
36
+ newSettings.push({
37
+ id: randomUUID(),
38
+ enabled: bundleSettings.enabled,
39
+ source: bundleSettings.source,
40
+ bundle: bundleSettings.id,
41
+ folder: entry.name,
42
+ });
43
+ }
44
+ };
21
45
  const generateSettingsEntry = (folder, extension, source) => {
22
46
  if (extension.type === 'bundle') {
23
47
  const bundleId = randomUUID();
@@ -49,9 +73,13 @@ export const getExtensionsSettings = async ({ local, module, registry, }) => {
49
73
  }
50
74
  };
51
75
  for (const [folder, extension] of local.entries()) {
52
- const settingsExist = localSettings.some((settings) => settings.folder === folder);
53
- if (settingsExist)
76
+ const existingSettings = localSettings.find((settings) => settings.folder === folder);
77
+ if (existingSettings) {
78
+ if (extension.type === 'bundle') {
79
+ updateBundleEntriesSettings(extension, existingSettings, localSettings);
80
+ }
54
81
  continue;
82
+ }
55
83
  const settingsForName = localSettings.find((settings) => settings.folder === extension.name);
56
84
  /*
57
85
  * TODO: Consider removing this in follow-up versions after v10.10.0
@@ -66,22 +94,31 @@ export const getExtensionsSettings = async ({ local, module, registry, }) => {
66
94
  await service.extensionsItemService.updateOne(settingsForName.id, { folder });
67
95
  continue;
68
96
  }
69
- if (!settingsExist)
70
- generateSettingsEntry(folder, extension, 'local');
97
+ generateSettingsEntry(folder, extension, 'local');
71
98
  }
72
99
  for (const [folder, extension] of module.entries()) {
73
- const settingsExist = moduleSettings.some((settings) => settings.folder === folder);
74
- if (!settingsExist)
100
+ const existingSettings = moduleSettings.find((settings) => settings.folder === folder);
101
+ if (!existingSettings) {
75
102
  generateSettingsEntry(folder, extension, 'module');
103
+ }
104
+ else if (extension.type === 'bundle') {
105
+ updateBundleEntriesSettings(extension, existingSettings, moduleSettings);
106
+ }
76
107
  }
77
108
  for (const [folder, extension] of registry.entries()) {
78
- const settingsExist = registrySettings.some((settings) => settings.folder === folder);
79
- if (!settingsExist)
109
+ const existingSettings = registrySettings.find((settings) => settings.folder === folder);
110
+ if (!existingSettings) {
80
111
  generateSettingsEntry(folder, extension, 'registry');
112
+ }
113
+ else if (extension.type === 'bundle') {
114
+ updateBundleEntriesSettings(extension, existingSettings, registrySettings);
115
+ }
116
+ }
117
+ if (removedSettingIds.length > 0) {
118
+ await service.extensionsItemService.deleteMany(removedSettingIds);
81
119
  }
82
120
  if (newSettings.length > 0) {
83
- await database.insert(newSettings).into('directus_extensions');
121
+ await service.extensionsItemService.createMany(newSettings);
84
122
  }
85
- const settings = [...existingSettings, ...newSettings];
86
- return settings;
123
+ return [...existingSettings.filter(({ id }) => !removedSettingIds.includes(id)), ...newSettings];
87
124
  };
@@ -8,7 +8,7 @@ import { mkdir, readFile, rm } from 'node:fs/promises';
8
8
  import { Readable } from 'node:stream';
9
9
  import Queue from 'p-queue';
10
10
  import { join } from 'path';
11
- import tar from 'tar';
11
+ import { extract } from 'tar';
12
12
  import { useLogger } from '../../../logger.js';
13
13
  import { getStorage } from '../../../storage/index.js';
14
14
  import { getExtensionsPath } from '../get-extensions-path.js';
@@ -36,7 +36,7 @@ export class InstallationManager {
36
36
  * NPM modules that are packed are always tarballed in a folder called "package"
37
37
  */
38
38
  const extractedPath = 'package';
39
- await tar.extract({
39
+ await extract({
40
40
  file: tarPath,
41
41
  cwd: tempDir,
42
42
  });
@@ -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);
@@ -10,7 +10,7 @@ const logger = useLogger();
10
10
  let checkRateLimit = (_req, _res, next) => next();
11
11
  export let rateLimiterGlobal;
12
12
  if (env['RATE_LIMITER_GLOBAL_ENABLED'] === true) {
13
- validateEnv(['RATE_LIMITER_GLOBAL_STORE', 'RATE_LIMITER_GLOBAL_DURATION', 'RATE_LIMITER_GLOBAL_POINTS']);
13
+ validateEnv(['RATE_LIMITER_GLOBAL_DURATION', 'RATE_LIMITER_GLOBAL_POINTS']);
14
14
  validateConfiguration();
15
15
  rateLimiterGlobal = createRateLimiter('RATE_LIMITER_GLOBAL');
16
16
  checkRateLimit = asyncHandler(async (_req, res, next) => {
@@ -0,0 +1,5 @@
1
+ import type { RequestHandler } from 'express';
2
+ import type { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
3
+ declare let checkRateLimit: RequestHandler;
4
+ export declare let rateLimiter: RateLimiterRedis | RateLimiterMemory;
5
+ export default checkRateLimit;
@@ -0,0 +1,32 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { HitRateLimitError } from '@directus/errors';
3
+ import { createRateLimiter } from '../rate-limiter.js';
4
+ import asyncHandler from '../utils/async-handler.js';
5
+ import { getIPFromReq } from '../utils/get-ip-from-req.js';
6
+ import { validateEnv } from '../utils/validate-env.js';
7
+ let checkRateLimit = (_req, _res, next) => next();
8
+ export let rateLimiter;
9
+ const env = useEnv();
10
+ if (env['RATE_LIMITER_REGISTRATION_ENABLED'] === true) {
11
+ validateEnv(['RATE_LIMITER_REGISTRATION_DURATION', 'RATE_LIMITER_REGISTRATION_POINTS']);
12
+ rateLimiter = createRateLimiter('RATE_LIMITER_REGISTRATION');
13
+ checkRateLimit = asyncHandler(async (req, res, next) => {
14
+ const ip = getIPFromReq(req);
15
+ if (ip) {
16
+ try {
17
+ await rateLimiter.consume(ip, 1);
18
+ }
19
+ catch (rateLimiterRes) {
20
+ if (rateLimiterRes instanceof Error)
21
+ throw rateLimiterRes;
22
+ res.set('Retry-After', String(Math.round(rateLimiterRes.msBeforeNext / 1000)));
23
+ throw new HitRateLimitError({
24
+ limit: +env['RATE_LIMITER_REGISTRATION_POINTS'],
25
+ reset: new Date(Date.now() + rateLimiterRes.msBeforeNext),
26
+ });
27
+ }
28
+ }
29
+ next();
30
+ });
31
+ }
32
+ export default checkRateLimit;
@@ -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
  }
@@ -10,6 +10,7 @@ import getDatabase from '../database/index.js';
10
10
  import emitter from '../emitter.js';
11
11
  import { RateLimiterRes, createRateLimiter } from '../rate-limiter.js';
12
12
  import { getMilliseconds } from '../utils/get-milliseconds.js';
13
+ import { getSecret } from '../utils/get-secret.js';
13
14
  import { stall } from '../utils/stall.js';
14
15
  import { ActivityService } from './activity.js';
15
16
  import { SettingsService } from './settings.js';
@@ -159,7 +160,7 @@ export class AuthenticationService {
159
160
  accountability: this.accountability,
160
161
  });
161
162
  const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
162
- const accessToken = jwt.sign(customClaims, env['SECRET'], {
163
+ const accessToken = jwt.sign(customClaims, getSecret(), {
163
164
  expiresIn: TTL,
164
165
  issuer: 'directus',
165
166
  });
@@ -206,6 +207,7 @@ export class AuthenticationService {
206
207
  const record = await this.knex
207
208
  .select({
208
209
  session_expires: 's.expires',
210
+ session_next_token: 's.next_token',
209
211
  user_id: 'u.id',
210
212
  user_first_name: 'u.first_name',
211
213
  user_last_name: 'u.last_name',
@@ -273,8 +275,9 @@ export class AuthenticationService {
273
275
  admin_access: record.role_admin_access,
274
276
  });
275
277
  }
276
- const newRefreshToken = nanoid(64);
277
- 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));
278
281
  const tokenPayload = {
279
282
  id: record.user_id,
280
283
  role: record.role_id,
@@ -282,8 +285,18 @@ export class AuthenticationService {
282
285
  admin_access: record.role_admin_access,
283
286
  };
284
287
  if (options?.session) {
288
+ newRefreshToken = await this.updateStatefulSession(record, refreshToken, newRefreshToken, refreshTokenExpiration);
285
289
  tokenPayload.session = newRefreshToken;
286
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
+ }
287
300
  if (record.share_id) {
288
301
  tokenPayload.share = record.share_id;
289
302
  tokenPayload.role = record.share_role;
@@ -306,19 +319,18 @@ export class AuthenticationService {
306
319
  accountability: this.accountability,
307
320
  });
308
321
  const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
309
- const accessToken = jwt.sign(customClaims, env['SECRET'], {
322
+ const accessToken = jwt.sign(customClaims, getSecret(), {
310
323
  expiresIn: TTL,
311
324
  issuer: 'directus',
312
325
  });
313
- await this.knex('directus_sessions')
314
- .update({
315
- token: newRefreshToken,
316
- expires: refreshTokenExpiration,
317
- })
318
- .where({ token: refreshToken });
319
326
  if (record.user_id) {
320
327
  await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
321
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());
322
334
  return {
323
335
  accessToken,
324
336
  refreshToken: newRefreshToken,
@@ -326,6 +338,47 @@ export class AuthenticationService {
326
338
  id: record.user_id,
327
339
  };
328
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
+ }
329
382
  async logout(refreshToken) {
330
383
  const record = await this.knex
331
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')
@@ -5,7 +5,7 @@ import { cloneDeep, flatten, isArray, isNil, merge, reduce, uniq, uniqWith } fro
5
5
  import { GENERATE_SPECIAL } from '../constants.js';
6
6
  import getDatabase from '../database/index.js';
7
7
  import { getRelationInfo } from '../utils/get-relation-info.js';
8
- import { stripFunction } from '../utils/strip-function.js';
8
+ import { parseFilterKey } from '../utils/parse-filter-key.js';
9
9
  import { ItemsService } from './items.js';
10
10
  import { PayloadService } from './payload.js';
11
11
  export class AuthorizationService {
@@ -105,8 +105,8 @@ export class AuthorizationService {
105
105
  }
106
106
  if (allowedFields.includes('*'))
107
107
  continue;
108
- const fieldKey = stripFunction(childNode.name);
109
- if (allowedFields.includes(fieldKey) === false) {
108
+ const { fieldName } = parseFilterKey(childNode.name);
109
+ if (allowedFields.includes(fieldName) === false) {
110
110
  throw new ForbiddenError();
111
111
  }
112
112
  }
@@ -282,7 +282,7 @@ export class AuthorizationService {
282
282
  for (const field of requiredPermissions[collection]) {
283
283
  if (field.startsWith('$FOLLOW'))
284
284
  continue;
285
- const fieldName = stripFunction(field);
285
+ const { fieldName } = parseFilterKey(field);
286
286
  let originalFieldName = fieldName;
287
287
  if (collection === rootCollection && aliasMap?.[fieldName]) {
288
288
  originalFieldName = aliasMap[fieldName];
@@ -1,4 +1,4 @@
1
- import { KNEX_TYPES, REGEX_BETWEEN_PARENS } from '@directus/constants';
1
+ import { KNEX_TYPES, REGEX_BETWEEN_PARENS, DEFAULT_NUMERIC_PRECISION, DEFAULT_NUMERIC_SCALE, } from '@directus/constants';
2
2
  import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
3
3
  import { createInspector } from '@directus/schema';
4
4
  import { addFieldFlag, toArray } from '@directus/utils';
@@ -586,7 +586,7 @@ export class FieldsService {
586
586
  }
587
587
  else if (['float', 'decimal'].includes(field.type)) {
588
588
  const type = field.type;
589
- column = table[type](field.field, field.schema?.numeric_precision ?? 10, field.schema?.numeric_scale ?? 5);
589
+ column = table[type](field.field, field.schema?.numeric_precision ?? DEFAULT_NUMERIC_PRECISION, field.schema?.numeric_scale ?? DEFAULT_NUMERIC_SCALE);
590
590
  }
591
591
  else if (field.type === 'csv') {
592
592
  column = table.text(field.field);
@@ -10,8 +10,11 @@ import { flatten, get, mapKeys, merge, omit, pick, set, transform, uniq } from '
10
10
  import { clearSystemCache, getCache } from '../../cache.js';
11
11
  import { DEFAULT_AUTH_PROVIDER, GENERATE_SPECIAL, REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS, } from '../../constants.js';
12
12
  import getDatabase from '../../database/index.js';
13
+ import { rateLimiter } from '../../middleware/rate-limiter-registration.js';
13
14
  import { generateHash } from '../../utils/generate-hash.js';
14
15
  import { getGraphQLType } from '../../utils/get-graphql-type.js';
16
+ import { getIPFromReq } from '../../utils/get-ip-from-req.js';
17
+ import { getSecret } from '../../utils/get-secret.js';
15
18
  import { getService } from '../../utils/get-service.js';
16
19
  import isDirectusJWT from '../../utils/is-directus-jwt.js';
17
20
  import { verifyAccessJWT } from '../../utils/jwt.js';
@@ -1637,6 +1640,8 @@ export class GraphQLService {
1637
1640
  public_background: { type: GraphQLString },
1638
1641
  public_note: { type: GraphQLString },
1639
1642
  custom_css: { type: GraphQLString },
1643
+ public_registration: { type: GraphQLBoolean },
1644
+ public_registration_verify_email: { type: GraphQLBoolean },
1640
1645
  },
1641
1646
  }),
1642
1647
  },
@@ -1872,7 +1877,7 @@ export class GraphQLService {
1872
1877
  else if (mode === 'session') {
1873
1878
  const token = req?.cookies[env['SESSION_COOKIE_NAME']];
1874
1879
  if (isDirectusJWT(token)) {
1875
- const payload = verifyAccessJWT(token, env['SECRET']);
1880
+ const payload = verifyAccessJWT(token, getSecret());
1876
1881
  currentRefreshToken = payload.session;
1877
1882
  }
1878
1883
  }
@@ -1930,7 +1935,7 @@ export class GraphQLService {
1930
1935
  else if (mode === 'session') {
1931
1936
  const token = req?.cookies[env['SESSION_COOKIE_NAME']];
1932
1937
  if (isDirectusJWT(token)) {
1933
- const payload = verifyAccessJWT(token, env['SECRET']);
1938
+ const payload = verifyAccessJWT(token, getSecret());
1934
1939
  currentRefreshToken = payload.session;
1935
1940
  }
1936
1941
  }
@@ -2152,6 +2157,40 @@ export class GraphQLService {
2152
2157
  return true;
2153
2158
  },
2154
2159
  },
2160
+ users_register: {
2161
+ type: GraphQLBoolean,
2162
+ args: {
2163
+ email: new GraphQLNonNull(GraphQLString),
2164
+ password: new GraphQLNonNull(GraphQLString),
2165
+ first_name: GraphQLString,
2166
+ last_name: GraphQLString,
2167
+ },
2168
+ resolve: async (_, args, { req }) => {
2169
+ const service = new UsersService({ accountability: null, schema: this.schema });
2170
+ const ip = req ? getIPFromReq(req) : null;
2171
+ if (ip) {
2172
+ await rateLimiter.consume(ip);
2173
+ }
2174
+ await service.registerUser({
2175
+ email: args.email,
2176
+ password: args.password,
2177
+ first_name: args.first_name,
2178
+ last_name: args.last_name,
2179
+ });
2180
+ return true;
2181
+ },
2182
+ },
2183
+ users_register_verify: {
2184
+ type: GraphQLBoolean,
2185
+ args: {
2186
+ token: new GraphQLNonNull(GraphQLString),
2187
+ },
2188
+ resolve: async (_, args) => {
2189
+ const service = new UsersService({ accountability: null, schema: this.schema });
2190
+ await service.verifyRegistration(args.token);
2191
+ return true;
2192
+ },
2193
+ },
2155
2194
  });
2156
2195
  if ('directus_collections' in schema.read.collections) {
2157
2196
  Collection.addFields({
@@ -0,0 +1,37 @@
1
+ {% layout 'base' %} {% block content %}
2
+
3
+ <h1>Verify your email address</h1>
4
+
5
+ <p style='padding-bottom: 30px'>
6
+ Thanks for registering at <i>{{ projectName }}</i>.
7
+ To complete your registration you need to verify your email address by opening the following verification-link.
8
+ Please feel free to ignore this email if you have not personally initiated the registration.
9
+ </p>
10
+
11
+ <a
12
+ class='button'
13
+ rel='noopener'
14
+ target='_blank'
15
+ href='{{url}}'
16
+ style='
17
+ font-size: 16px;
18
+ font-weight: 600;
19
+ color: #ffffff;
20
+ text-decoration: none;
21
+ display: inline-block;
22
+ padding: 11px 24px;
23
+ border-radius: 8px;
24
+ background: #171717;
25
+ border: 1px solid #ffffff;
26
+ '
27
+ >
28
+ <!--[if mso]>
29
+ <i style="letter-spacing: 25px; mso-font-width: -100%; mso-text-raise: 30pt"
30
+ >&nbsp;</i
31
+ >
32
+ <![endif]-->
33
+ <span style='mso-text-raise: 15pt'>Verify email</span>
34
+ <!--[if mso]> <i style="letter-spacing: 25px; mso-font-width: -100%">&nbsp;</i> <![endif]-->
35
+ </a>
36
+
37
+ {% endblock %}
@@ -63,7 +63,7 @@ export class MetaService {
63
63
  ({ hasJoins } = applyFilter(this.knex, this.schema, dbQuery, filter, collection, {}));
64
64
  }
65
65
  if (query.search) {
66
- applySearch(this.schema, dbQuery, query.search, collection);
66
+ applySearch(this.knex, this.schema, dbQuery, query.search, collection);
67
67
  }
68
68
  if (hasJoins) {
69
69
  const primaryKeyName = this.schema.collections[collection].primary;
@@ -27,6 +27,8 @@ export declare class PayloadService {
27
27
  transformers: Transformers;
28
28
  processValues(action: Action, payloads: Partial<Item>[]): Promise<Partial<Item>[]>;
29
29
  processValues(action: Action, payload: Partial<Item>): Promise<Partial<Item>>;
30
+ processValues(action: Action, payloads: Partial<Item>[], aliasMap: Record<string, string>): Promise<Partial<Item>[]>;
31
+ processValues(action: Action, payload: Partial<Item>, aliasMap: Record<string, string>): Promise<Partial<Item>>;
30
32
  processAggregates(payload: Partial<Item>[]): void;
31
33
  processField(field: SchemaOverview['collections'][string]['fields'][string], payload: Partial<Item>, action: Action, accountability: Accountability | null): Promise<any>;
32
34
  /**
@@ -121,19 +121,31 @@ export class PayloadService {
121
121
  return value;
122
122
  },
123
123
  };
124
- async processValues(action, payload) {
124
+ async processValues(action, payload, aliasMap = {}) {
125
125
  const processedPayload = toArray(payload);
126
126
  if (processedPayload.length === 0)
127
127
  return [];
128
128
  const fieldsInPayload = Object.keys(processedPayload[0]);
129
- let specialFieldsInCollection = Object.entries(this.schema.collections[this.collection].fields).filter(([_name, field]) => field.special && field.special.length > 0);
129
+ const fieldEntries = Object.entries(this.schema.collections[this.collection].fields);
130
+ const aliasEntries = Object.entries(aliasMap);
131
+ let specialFields = [];
132
+ for (const [name, field] of fieldEntries) {
133
+ if (field.special && field.special.length > 0) {
134
+ specialFields.push([name, field]);
135
+ for (const [aliasName, fieldName] of aliasEntries) {
136
+ if (fieldName === name) {
137
+ specialFields.push([aliasName, { ...field, field: aliasName }]);
138
+ }
139
+ }
140
+ }
141
+ }
130
142
  if (action === 'read') {
131
- specialFieldsInCollection = specialFieldsInCollection.filter(([name]) => {
143
+ specialFields = specialFields.filter(([name]) => {
132
144
  return fieldsInPayload.includes(name);
133
145
  });
134
146
  }
135
147
  await Promise.all(processedPayload.map(async (record) => {
136
- await Promise.all(specialFieldsInCollection.map(async ([name, field]) => {
148
+ await Promise.all(specialFields.map(async ([name, field]) => {
137
149
  const newValue = await this.processField(field, record, action, this.accountability);
138
150
  if (newValue !== undefined)
139
151
  record[name] = newValue;
@@ -46,6 +46,8 @@ export class ServerService {
46
46
  'public_favicon',
47
47
  'public_note',
48
48
  'custom_css',
49
+ 'public_registration',
50
+ 'public_registration_verify_email',
49
51
  ],
50
52
  });
51
53
  info['project'] = projectInfo;
@@ -106,7 +108,7 @@ export class ServerService {
106
108
  const data = {
107
109
  status: 'ok',
108
110
  releaseId: version,
109
- serviceId: env['KEY'],
111
+ serviceId: env['PUBLIC_URL'],
110
112
  checks: merge(...(await Promise.all([
111
113
  testDatabase(),
112
114
  testCache(),
@@ -4,6 +4,7 @@ import argon2 from 'argon2';
4
4
  import jwt from 'jsonwebtoken';
5
5
  import { useLogger } from '../logger.js';
6
6
  import { getMilliseconds } from '../utils/get-milliseconds.js';
7
+ import { getSecret } from '../utils/get-secret.js';
7
8
  import { md } from '../utils/md.js';
8
9
  import { Url } from '../utils/url.js';
9
10
  import { userName } from '../utils/user-name.js';
@@ -78,7 +79,7 @@ export class SharesService extends ItemsService {
78
79
  tokenPayload.session = refreshToken;
79
80
  }
80
81
  const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
81
- const accessToken = jwt.sign(tokenPayload, env['SECRET'], {
82
+ const accessToken = jwt.sign(tokenPayload, getSecret(), {
82
83
  expiresIn: TTL,
83
84
  issuer: 'directus',
84
85
  });
@@ -1,4 +1,4 @@
1
- import type { Item, PrimaryKey, Query } from '@directus/types';
1
+ import type { Item, PrimaryKey, Query, RegisterUserInput } from '@directus/types';
2
2
  import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
3
3
  import { ItemsService } from './items.js';
4
4
  export declare class UsersService extends ItemsService {
@@ -62,6 +62,8 @@ export declare class UsersService extends ItemsService {
62
62
  deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]>;
63
63
  inviteUser(email: string | string[], role: string, url: string | null, subject?: string | null): Promise<void>;
64
64
  acceptInvite(token: string, password: string): Promise<void>;
65
+ registerUser(input: RegisterUserInput): Promise<void>;
66
+ verifyRegistration(token: string): Promise<string>;
65
67
  requestPasswordReset(email: string, url: string | null, subject?: string | null): Promise<void>;
66
68
  resetPassword(token: string, password: string): Promise<void>;
67
69
  }