@directus/api 19.0.1 → 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.
- package/dist/app.js +8 -7
- package/dist/auth/drivers/oauth2.js +3 -2
- package/dist/auth/drivers/openid.js +3 -2
- package/dist/cli/utils/create-env/env-stub.liquid +0 -3
- package/dist/cli/utils/create-env/index.js +0 -2
- package/dist/controllers/auth.js +3 -2
- package/dist/controllers/extensions.js +30 -19
- package/dist/controllers/users.js +25 -0
- package/dist/controllers/utils.js +2 -1
- package/dist/database/helpers/fn/types.js +4 -3
- package/dist/database/helpers/index.d.ts +2 -0
- package/dist/database/helpers/index.js +2 -0
- package/dist/database/helpers/number/dialects/default.d.ts +3 -0
- package/dist/database/helpers/number/dialects/default.js +3 -0
- package/dist/database/helpers/number/dialects/mssql.d.ts +7 -0
- package/dist/database/helpers/number/dialects/mssql.js +11 -0
- package/dist/database/helpers/number/dialects/oracle.d.ts +6 -0
- package/dist/database/helpers/number/dialects/oracle.js +7 -0
- package/dist/database/helpers/number/dialects/postgres.d.ts +5 -0
- package/dist/database/helpers/number/dialects/postgres.js +15 -0
- package/dist/database/helpers/number/dialects/sqlite.d.ts +6 -0
- package/dist/database/helpers/number/dialects/sqlite.js +7 -0
- package/dist/database/helpers/number/index.d.ts +7 -0
- package/dist/database/helpers/number/index.js +7 -0
- package/dist/database/helpers/number/types.d.ts +12 -0
- package/dist/database/helpers/number/types.js +9 -0
- package/dist/database/helpers/number/utils/decimal-limit.d.ts +4 -0
- package/dist/database/helpers/number/utils/decimal-limit.js +10 -0
- package/dist/database/helpers/number/utils/maybe-stringify-big-int.d.ts +1 -0
- package/dist/database/helpers/number/utils/maybe-stringify-big-int.js +6 -0
- package/dist/database/helpers/number/utils/number-in-range.d.ts +3 -0
- package/dist/database/helpers/number/utils/number-in-range.js +20 -0
- package/dist/database/migrations/20240422A-public-registration.d.ts +3 -0
- package/dist/database/migrations/20240422A-public-registration.js +14 -0
- package/dist/database/run-ast.js +5 -4
- package/dist/extensions/lib/get-extensions-settings.js +48 -11
- package/dist/extensions/lib/installation/manager.js +2 -2
- package/dist/middleware/rate-limiter-global.js +1 -1
- package/dist/middleware/rate-limiter-registration.d.ts +5 -0
- package/dist/middleware/rate-limiter-registration.js +32 -0
- package/dist/services/authentication.js +3 -2
- package/dist/services/authorization.js +4 -4
- package/dist/services/fields.js +2 -2
- package/dist/services/graphql/index.js +41 -2
- package/dist/services/mail/templates/user-registration.liquid +37 -0
- package/dist/services/meta.js +1 -1
- package/dist/services/payload.d.ts +2 -0
- package/dist/services/payload.js +16 -4
- package/dist/services/server.js +3 -1
- package/dist/services/shares.js +2 -1
- package/dist/services/users.d.ts +3 -1
- package/dist/services/users.js +92 -5
- package/dist/services/utils.d.ts +3 -1
- package/dist/services/utils.js +7 -3
- package/dist/utils/apply-query.d.ts +1 -1
- package/dist/utils/apply-query.js +54 -28
- package/dist/utils/get-accountability-for-token.js +6 -3
- package/dist/utils/get-cache-headers.js +3 -0
- package/dist/utils/get-schema.js +21 -12
- package/dist/utils/get-secret.d.ts +4 -0
- package/dist/utils/get-secret.js +14 -0
- package/dist/utils/parse-filter-key.d.ts +7 -0
- package/dist/utils/parse-filter-key.js +22 -0
- package/dist/utils/parse-numeric-string.d.ts +2 -0
- package/dist/utils/parse-numeric-string.js +21 -0
- package/dist/utils/sanitize-query.js +10 -5
- package/dist/utils/transaction.d.ts +1 -1
- package/dist/utils/transaction.js +39 -2
- package/dist/utils/validate-query.js +0 -2
- package/dist/utils/verify-session-jwt.d.ts +7 -0
- package/dist/utils/verify-session-jwt.js +22 -0
- package/dist/websocket/messages.d.ts +78 -50
- package/package.json +60 -61
- package/dist/utils/strip-function.d.ts +0 -4
- 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
|
|
53
|
-
if (
|
|
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
|
-
|
|
70
|
-
generateSettingsEntry(folder, extension, 'local');
|
|
97
|
+
generateSettingsEntry(folder, extension, 'local');
|
|
71
98
|
}
|
|
72
99
|
for (const [folder, extension] of module.entries()) {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
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
|
|
79
|
-
if (!
|
|
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
|
|
121
|
+
await service.extensionsItemService.createMany(newSettings);
|
|
84
122
|
}
|
|
85
|
-
|
|
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
|
|
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
|
|
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(['
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
|
109
|
-
if (allowedFields.includes(
|
|
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 =
|
|
285
|
+
const { fieldName } = parseFilterKey(field);
|
|
286
286
|
let originalFieldName = fieldName;
|
|
287
287
|
if (collection === rootCollection && aliasMap?.[fieldName]) {
|
|
288
288
|
originalFieldName = aliasMap[fieldName];
|
package/dist/services/fields.js
CHANGED
|
@@ -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 ??
|
|
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,
|
|
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,
|
|
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
|
+
> </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%"> </i> <![endif]-->
|
|
35
|
+
</a>
|
|
36
|
+
|
|
37
|
+
{% endblock %}
|
package/dist/services/meta.js
CHANGED
|
@@ -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
|
/**
|
package/dist/services/payload.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|
package/dist/services/server.js
CHANGED
|
@@ -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['
|
|
111
|
+
serviceId: env['PUBLIC_URL'],
|
|
110
112
|
checks: merge(...(await Promise.all([
|
|
111
113
|
testDatabase(),
|
|
112
114
|
testCache(),
|
package/dist/services/shares.js
CHANGED
|
@@ -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,
|
|
82
|
+
const accessToken = jwt.sign(tokenPayload, getSecret(), {
|
|
82
83
|
expiresIn: TTL,
|
|
83
84
|
issuer: 'directus',
|
|
84
85
|
});
|
package/dist/services/users.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/services/users.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 = {};
|
package/dist/services/utils.d.ts
CHANGED
package/dist/services/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
|
|
2
2
|
import { systemCollectionRows } from '@directus/system-data';
|
|
3
|
-
import {
|
|
3
|
+
import { clearSystemCache, getCache } from '../cache.js';
|
|
4
4
|
import getDatabase from '../database/index.js';
|
|
5
5
|
import emitter from '../emitter.js';
|
|
6
6
|
import { shouldClearCache } from '../utils/should-clear-cache.js';
|
|
@@ -112,10 +112,14 @@ export class UtilsService {
|
|
|
112
112
|
accountability: this.accountability,
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
|
-
async clearCache() {
|
|
115
|
+
async clearCache({ system }) {
|
|
116
116
|
if (this.accountability?.admin !== true) {
|
|
117
117
|
throw new ForbiddenError();
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
const { cache } = getCache();
|
|
120
|
+
if (system) {
|
|
121
|
+
await clearSystemCache({ forced: true });
|
|
122
|
+
}
|
|
123
|
+
return cache?.clear();
|
|
120
124
|
}
|
|
121
125
|
}
|
|
@@ -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;
|