@directus/api 19.0.2 → 19.1.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.
Files changed (70) 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 +4 -3
  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/run-ast.js +5 -4
  35. package/dist/extensions/lib/get-extensions-settings.js +48 -11
  36. package/dist/extensions/lib/installation/manager.js +2 -2
  37. package/dist/middleware/rate-limiter-global.js +1 -1
  38. package/dist/middleware/rate-limiter-registration.d.ts +5 -0
  39. package/dist/middleware/rate-limiter-registration.js +32 -0
  40. package/dist/services/authentication.js +3 -2
  41. package/dist/services/authorization.js +4 -4
  42. package/dist/services/fields.js +2 -2
  43. package/dist/services/graphql/index.js +41 -2
  44. package/dist/services/mail/templates/user-registration.liquid +37 -0
  45. package/dist/services/meta.js +1 -1
  46. package/dist/services/payload.d.ts +2 -0
  47. package/dist/services/payload.js +16 -4
  48. package/dist/services/server.js +3 -1
  49. package/dist/services/shares.js +2 -1
  50. package/dist/services/users.d.ts +3 -1
  51. package/dist/services/users.js +92 -5
  52. package/dist/utils/apply-query.d.ts +1 -1
  53. package/dist/utils/apply-query.js +54 -28
  54. package/dist/utils/get-accountability-for-token.js +6 -3
  55. package/dist/utils/get-secret.d.ts +4 -0
  56. package/dist/utils/get-secret.js +14 -0
  57. package/dist/utils/parse-filter-key.d.ts +7 -0
  58. package/dist/utils/parse-filter-key.js +22 -0
  59. package/dist/utils/parse-numeric-string.d.ts +2 -0
  60. package/dist/utils/parse-numeric-string.js +21 -0
  61. package/dist/utils/sanitize-query.js +10 -5
  62. package/dist/utils/transaction.d.ts +1 -1
  63. package/dist/utils/transaction.js +39 -2
  64. package/dist/utils/validate-query.js +0 -2
  65. package/dist/utils/verify-session-jwt.d.ts +7 -0
  66. package/dist/utils/verify-session-jwt.js +22 -0
  67. package/dist/websocket/messages.d.ts +78 -50
  68. package/package.json +60 -61
  69. package/dist/utils/strip-function.d.ts +0 -4
  70. 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
  });
@@ -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;
@@ -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
  });
@@ -306,7 +307,7 @@ export class AuthenticationService {
306
307
  accountability: this.accountability,
307
308
  });
308
309
  const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
309
- const accessToken = jwt.sign(customClaims, env['SECRET'], {
310
+ const accessToken = jwt.sign(customClaims, getSecret(), {
310
311
  expiresIn: TTL,
311
312
  issuer: 'directus',
312
313
  });
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { ForbiddenError, InvalidPayloadError, RecordNotUniqueError, UnprocessableContentError } from '@directus/errors';
3
- import { getSimpleHash, toArray } from '@directus/utils';
3
+ import { getSimpleHash, toArray, validatePayload } from '@directus/utils';
4
4
  import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from '@directus/validation';
5
5
  import Joi from 'joi';
6
6
  import jwt from 'jsonwebtoken';
@@ -8,6 +8,7 @@ import { cloneDeep, isEmpty } from 'lodash-es';
8
8
  import { performance } from 'perf_hooks';
9
9
  import getDatabase from '../database/index.js';
10
10
  import { useLogger } from '../logger.js';
11
+ import { getSecret } from '../utils/get-secret.js';
11
12
  import isUrlAllowed from '../utils/is-url-allowed.js';
12
13
  import { verifyJWT } from '../utils/jwt.js';
13
14
  import { stall } from '../utils/stall.js';
@@ -129,7 +130,7 @@ export class UsersService extends ItemsService {
129
130
  */
130
131
  inviteUrl(email, url) {
131
132
  const payload = { email, scope: 'invite' };
132
- const token = jwt.sign(payload, env['SECRET'], { expiresIn: '7d', issuer: 'directus' });
133
+ const token = jwt.sign(payload, getSecret(), { expiresIn: '7d', issuer: 'directus' });
133
134
  const inviteURL = url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite');
134
135
  inviteURL.setQuery('token', token);
135
136
  return inviteURL.toString();
@@ -357,7 +358,7 @@ export class UsersService extends ItemsService {
357
358
  }
358
359
  }
359
360
  async acceptInvite(token, password) {
360
- const { email, scope } = verifyJWT(token, env['SECRET']);
361
+ const { email, scope } = verifyJWT(token, getSecret());
361
362
  if (scope !== 'invite')
362
363
  throw new ForbiddenError();
363
364
  const user = await this.getUserByEmail(email);
@@ -371,6 +372,92 @@ export class UsersService extends ItemsService {
371
372
  });
372
373
  await service.updateOne(user.id, { password, status: 'active' });
373
374
  }
375
+ async registerUser(input) {
376
+ const STALL_TIME = env['REGISTER_STALL_TIME'];
377
+ const timeStart = performance.now();
378
+ const serviceOptions = { accountability: this.accountability, schema: this.schema };
379
+ const settingsService = new SettingsService(serviceOptions);
380
+ const settings = await settingsService.readSingleton({
381
+ fields: [
382
+ 'public_registration',
383
+ 'public_registration_verify_email',
384
+ 'public_registration_role',
385
+ 'public_registration_email_filter',
386
+ ],
387
+ });
388
+ if (settings?.['public_registration'] == false) {
389
+ throw new ForbiddenError();
390
+ }
391
+ const publicRegistrationRole = settings?.['public_registration_role'] ?? null;
392
+ const hasEmailVerification = settings?.['public_registration_verify_email'];
393
+ const emailFilter = settings?.['public_registration_email_filter'];
394
+ const first_name = input.first_name ?? null;
395
+ const last_name = input.last_name ?? null;
396
+ const partialUser = {
397
+ // Required fields
398
+ email: input.email,
399
+ password: input.password,
400
+ role: publicRegistrationRole,
401
+ status: hasEmailVerification ? 'unverified' : 'active',
402
+ // Optional fields
403
+ first_name,
404
+ last_name,
405
+ };
406
+ if (emailFilter && validatePayload(emailFilter, { email: input.email }).length !== 0) {
407
+ await stall(STALL_TIME, timeStart);
408
+ throw new ForbiddenError();
409
+ }
410
+ const user = await this.getUserByEmail(input.email);
411
+ if (isEmpty(user)) {
412
+ await this.createOne(partialUser);
413
+ }
414
+ // We want to be able to re-send the verification email
415
+ else if (user.status !== ('unverified')) {
416
+ // To avoid giving attackers infos about registered emails we dont fail for violated unique constraints
417
+ await stall(STALL_TIME, timeStart);
418
+ return;
419
+ }
420
+ if (hasEmailVerification) {
421
+ const mailService = new MailService(serviceOptions);
422
+ const payload = { email: input.email, scope: 'pending-registration' };
423
+ const token = jwt.sign(payload, env['SECRET'], {
424
+ expiresIn: env['EMAIL_VERIFICATION_TOKEN_TTL'],
425
+ issuer: 'directus',
426
+ });
427
+ const verificationURL = new Url(env['PUBLIC_URL'])
428
+ .addPath('users', 'register', 'verify-email')
429
+ .setQuery('token', token);
430
+ mailService
431
+ .send({
432
+ to: input.email,
433
+ subject: 'Verify your email address', // TODO: translate after theres support for internationalized emails
434
+ template: {
435
+ name: 'user-registration',
436
+ data: {
437
+ url: verificationURL.toString(),
438
+ email: input.email,
439
+ first_name,
440
+ last_name,
441
+ },
442
+ },
443
+ })
444
+ .catch((error) => {
445
+ logger.error(error, 'Could not send email verification mail');
446
+ });
447
+ }
448
+ await stall(STALL_TIME, timeStart);
449
+ }
450
+ async verifyRegistration(token) {
451
+ const { email, scope } = verifyJWT(token, env['SECRET']);
452
+ if (scope !== 'pending-registration')
453
+ throw new ForbiddenError();
454
+ const user = await this.getUserByEmail(email);
455
+ if (user?.status !== ('unverified')) {
456
+ throw new InvalidPayloadError({ reason: 'Invalid verification code' });
457
+ }
458
+ await this.updateOne(user.id, { status: 'active' });
459
+ return user.id;
460
+ }
374
461
  async requestPasswordReset(email, url, subject) {
375
462
  const STALL_TIME = 500;
376
463
  const timeStart = performance.now();
@@ -388,7 +475,7 @@ export class UsersService extends ItemsService {
388
475
  accountability: this.accountability,
389
476
  });
390
477
  const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
391
- const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
478
+ const token = jwt.sign(payload, getSecret(), { expiresIn: '1d', issuer: 'directus' });
392
479
  const acceptURL = url
393
480
  ? new Url(url).setQuery('token', token).toString()
394
481
  : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
@@ -411,7 +498,7 @@ export class UsersService extends ItemsService {
411
498
  await stall(STALL_TIME, timeStart);
412
499
  }
413
500
  async resetPassword(token, password) {
414
- const { email, scope, hash } = jwt.verify(token, env['SECRET'], { issuer: 'directus' });
501
+ const { email, scope, hash } = jwt.verify(token, getSecret(), { issuer: 'directus' });
415
502
  if (scope !== 'password-reset' || !hash)
416
503
  throw new ForbiddenError();
417
504
  const opts = {};
@@ -37,5 +37,5 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
37
37
  hasJoins: boolean;
38
38
  hasMultiRelationalFilter: boolean;
39
39
  };
40
- export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
40
+ export declare function applySearch(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
41
41
  export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string, hasJoins: boolean): void;