@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.
- 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/database/helpers/fn/types.js +13 -4
- 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/migrations/20240515A-add-session-window.d.ts +3 -0
- package/dist/database/migrations/20240515A-add-session-window.js +10 -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/authenticate.d.ts +1 -1
- package/dist/middleware/authenticate.js +17 -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.d.ts +1 -0
- package/dist/services/authentication.js +63 -10
- 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/utils/apply-query.d.ts +1 -1
- package/dist/utils/apply-query.js +61 -34
- package/dist/utils/get-accountability-for-token.js +6 -3
- 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
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 = {};
|
|
@@ -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;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { NUMERIC_TYPES } from '@directus/constants';
|
|
1
2
|
import { InvalidQueryError } from '@directus/errors';
|
|
2
|
-
import { getFilterOperatorsForType, getOutputTypeForFunction } from '@directus/utils';
|
|
3
|
+
import { getFilterOperatorsForType, getFunctionsForType, getOutputTypeForFunction, isIn } from '@directus/utils';
|
|
3
4
|
import { clone, isPlainObject } from 'lodash-es';
|
|
4
5
|
import { customAlphabet } from 'nanoid/non-secure';
|
|
5
6
|
import { getHelpers } from '../database/helpers/index.js';
|
|
@@ -7,7 +8,8 @@ import { getColumnPath } from './get-column-path.js';
|
|
|
7
8
|
import { getColumn } from './get-column.js';
|
|
8
9
|
import { getRelationInfo } from './get-relation-info.js';
|
|
9
10
|
import { isValidUuid } from './is-valid-uuid.js';
|
|
10
|
-
import {
|
|
11
|
+
import { parseFilterKey } from './parse-filter-key.js';
|
|
12
|
+
import { parseNumericString } from './parse-numeric-string.js';
|
|
11
13
|
export const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
|
|
12
14
|
/**
|
|
13
15
|
* Apply the Query to a given Knex query builder instance
|
|
@@ -30,7 +32,7 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, opt
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
if (query.search) {
|
|
33
|
-
applySearch(schema, dbQuery, query.search, collection);
|
|
35
|
+
applySearch(knex, schema, dbQuery, query.search, collection);
|
|
34
36
|
}
|
|
35
37
|
if (query.group) {
|
|
36
38
|
dbQuery.groupBy(query.group.map((column) => getColumn(knex, collection, column, false, schema)));
|
|
@@ -84,7 +86,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
|
|
|
84
86
|
}
|
|
85
87
|
rootQuery.leftJoin({ [alias]: pathScope }, (joinClause) => {
|
|
86
88
|
joinClause
|
|
87
|
-
.onVal(relation.meta.one_collection_field
|
|
89
|
+
.onVal(`${aliasedParentCollection}.${relation.meta.one_collection_field}`, '=', pathScope)
|
|
88
90
|
.andOn(`${aliasedParentCollection}.${relation.field}`, '=', knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), `${alias}.${schema.collections[pathScope].primary}`));
|
|
89
91
|
});
|
|
90
92
|
aliasMap[aliasKey].collection = pathScope;
|
|
@@ -93,7 +95,7 @@ function addJoin({ path, collection, aliasMap, rootQuery, schema, relations, kne
|
|
|
93
95
|
else if (relationType === 'o2a') {
|
|
94
96
|
rootQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => {
|
|
95
97
|
joinClause
|
|
96
|
-
.onVal(relation.meta.one_collection_field
|
|
98
|
+
.onVal(`${alias}.${relation.meta.one_collection_field}`, '=', parentCollection)
|
|
97
99
|
.andOn(`${alias}.${relation.field}`, '=', knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), `${aliasedParentCollection}.${schema.collections[parentCollection].primary}`));
|
|
98
100
|
});
|
|
99
101
|
aliasMap[aliasKey].collection = relation.collection;
|
|
@@ -288,7 +290,10 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
288
290
|
*/
|
|
289
291
|
const pathRoot = filterPath[0].split(':')[0];
|
|
290
292
|
const { relation, relationType } = getRelationInfo(relations, collection, pathRoot);
|
|
291
|
-
const
|
|
293
|
+
const operation = getOperation(key, value);
|
|
294
|
+
if (!operation)
|
|
295
|
+
continue;
|
|
296
|
+
const { operator: filterOperator, value: filterValue } = operation;
|
|
292
297
|
if (filterPath.length > 1 ||
|
|
293
298
|
(!(key.includes('(') && key.includes(')')) && schema.collections[collection]?.fields[key]?.type === 'alias')) {
|
|
294
299
|
if (!relation)
|
|
@@ -335,21 +340,33 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
335
340
|
}
|
|
336
341
|
if (!columnPath)
|
|
337
342
|
continue;
|
|
338
|
-
const { type, special } =
|
|
343
|
+
const { type, special } = getFilterType(schema.collections[targetCollection].fields, filterPath.at(-1), targetCollection);
|
|
339
344
|
validateFilterOperator(type, filterOperator, special);
|
|
340
345
|
applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection);
|
|
341
346
|
}
|
|
342
347
|
else {
|
|
343
|
-
const { type, special } =
|
|
348
|
+
const { type, special } = getFilterType(schema.collections[collection].fields, filterPath[0], collection);
|
|
344
349
|
validateFilterOperator(type, filterOperator, special);
|
|
345
|
-
|
|
350
|
+
const aliasedCollection = aliasMap['']?.alias || collection;
|
|
351
|
+
applyFilterToQuery(`${aliasedCollection}.${filterPath[0]}`, filterOperator, filterValue, logical, collection);
|
|
346
352
|
}
|
|
347
353
|
}
|
|
348
|
-
function
|
|
349
|
-
|
|
354
|
+
function getFilterType(fields, key, collection = 'unknown') {
|
|
355
|
+
const { fieldName, functionName } = parseFilterKey(key);
|
|
356
|
+
const field = fields[fieldName];
|
|
357
|
+
if (!field) {
|
|
350
358
|
throw new InvalidQueryError({ reason: `Invalid filter key "${key}" on "${collection}"` });
|
|
351
359
|
}
|
|
352
|
-
|
|
360
|
+
const { type } = field;
|
|
361
|
+
if (functionName) {
|
|
362
|
+
const availableFunctions = getFunctionsForType(type);
|
|
363
|
+
if (!availableFunctions.includes(functionName)) {
|
|
364
|
+
throw new InvalidQueryError({ reason: `Invalid filter key "${key}" on "${collection}"` });
|
|
365
|
+
}
|
|
366
|
+
const functionType = getOutputTypeForFunction(functionName);
|
|
367
|
+
return { type: functionType };
|
|
368
|
+
}
|
|
369
|
+
return { type, special: field.special };
|
|
353
370
|
}
|
|
354
371
|
function validateFilterOperator(type, filterOperator, special) {
|
|
355
372
|
if (filterOperator.startsWith('_')) {
|
|
@@ -360,7 +377,7 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
360
377
|
reason: `"${type}" field type does not contain the "_${filterOperator}" filter operator`,
|
|
361
378
|
});
|
|
362
379
|
}
|
|
363
|
-
if (special
|
|
380
|
+
if (special?.includes('conceal') &&
|
|
364
381
|
!getFilterOperatorsForType('hash').includes(filterOperator)) {
|
|
365
382
|
throw new InvalidQueryError({
|
|
366
383
|
reason: `Field with "conceal" special does not allow the "_${filterOperator}" filter operator`,
|
|
@@ -406,7 +423,7 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
406
423
|
const functionName = column.split('(')[0];
|
|
407
424
|
const type = getOutputTypeForFunction(functionName);
|
|
408
425
|
if (['integer', 'float', 'decimal'].includes(type)) {
|
|
409
|
-
compareValue = Number(compareValue);
|
|
426
|
+
compareValue = Array.isArray(compareValue) ? compareValue.map(Number) : Number(compareValue);
|
|
410
427
|
}
|
|
411
428
|
}
|
|
412
429
|
// Cast filter value (compareValue) based on type of field being filtered against
|
|
@@ -504,19 +521,19 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
504
521
|
dbQuery[logical].whereNotIn(selectionRaw, value);
|
|
505
522
|
}
|
|
506
523
|
if (operator === '_between') {
|
|
507
|
-
if (compareValue.length !== 2)
|
|
508
|
-
return;
|
|
509
524
|
let value = compareValue;
|
|
510
525
|
if (typeof value === 'string')
|
|
511
526
|
value = value.split(',');
|
|
527
|
+
if (value.length !== 2)
|
|
528
|
+
return;
|
|
512
529
|
dbQuery[logical].whereBetween(selectionRaw, value);
|
|
513
530
|
}
|
|
514
531
|
if (operator === '_nbetween') {
|
|
515
|
-
if (compareValue.length !== 2)
|
|
516
|
-
return;
|
|
517
532
|
let value = compareValue;
|
|
518
533
|
if (typeof value === 'string')
|
|
519
534
|
value = value.split(',');
|
|
535
|
+
if (value.length !== 2)
|
|
536
|
+
return;
|
|
520
537
|
dbQuery[logical].whereNotBetween(selectionRaw, value);
|
|
521
538
|
}
|
|
522
539
|
if (operator == '_intersects') {
|
|
@@ -534,33 +551,36 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
|
|
|
534
551
|
}
|
|
535
552
|
}
|
|
536
553
|
}
|
|
537
|
-
export async function applySearch(schema, dbQuery, searchQuery, collection) {
|
|
554
|
+
export async function applySearch(knex, schema, dbQuery, searchQuery, collection) {
|
|
555
|
+
const { number: numberHelper } = getHelpers(knex);
|
|
538
556
|
const fields = Object.entries(schema.collections[collection].fields);
|
|
539
557
|
dbQuery.andWhere(function () {
|
|
558
|
+
let needsFallbackCondition = true;
|
|
540
559
|
fields.forEach(([name, field]) => {
|
|
541
560
|
if (['text', 'string'].includes(field.type)) {
|
|
542
561
|
this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
|
|
562
|
+
needsFallbackCondition = false;
|
|
543
563
|
}
|
|
544
|
-
else if (
|
|
545
|
-
const number =
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
564
|
+
else if (isNumericField(field)) {
|
|
565
|
+
const number = parseNumericString(searchQuery);
|
|
566
|
+
if (number === null) {
|
|
567
|
+
return; // unable to parse
|
|
568
|
+
}
|
|
569
|
+
if (numberHelper.isNumberValid(number, field)) {
|
|
570
|
+
numberHelper.addSearchCondition(this, collection, name, number);
|
|
571
|
+
needsFallbackCondition = false;
|
|
549
572
|
}
|
|
550
573
|
}
|
|
551
574
|
else if (field.type === 'uuid' && isValidUuid(searchQuery)) {
|
|
552
575
|
this.orWhere({ [`${collection}.${name}`]: searchQuery });
|
|
576
|
+
needsFallbackCondition = false;
|
|
553
577
|
}
|
|
554
578
|
});
|
|
579
|
+
if (needsFallbackCondition) {
|
|
580
|
+
this.orWhereRaw('1 = 0');
|
|
581
|
+
}
|
|
555
582
|
});
|
|
556
583
|
}
|
|
557
|
-
function validateNumber(value, parsed) {
|
|
558
|
-
if (isNaN(parsed) || !Number.isFinite(parsed))
|
|
559
|
-
return false;
|
|
560
|
-
// casting parsed value back to string should be equal the original value
|
|
561
|
-
// (prevent unintended number parsing, e.g. String(7) !== "ob111")
|
|
562
|
-
return String(parsed) === value;
|
|
563
|
-
}
|
|
564
584
|
export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins) {
|
|
565
585
|
for (const [operation, fields] of Object.entries(aggregate)) {
|
|
566
586
|
if (!fields)
|
|
@@ -610,7 +630,7 @@ export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins)
|
|
|
610
630
|
function getFilterPath(key, value) {
|
|
611
631
|
const path = [key];
|
|
612
632
|
const childKey = Object.keys(value)[0];
|
|
613
|
-
if (
|
|
633
|
+
if (!childKey || (childKey.startsWith('_') === true && !['_none', '_some'].includes(childKey))) {
|
|
614
634
|
return path;
|
|
615
635
|
}
|
|
616
636
|
if (isPlainObject(value)) {
|
|
@@ -622,8 +642,15 @@ function getOperation(key, value) {
|
|
|
622
642
|
if (key.startsWith('_') && !['_and', '_or', '_none', '_some'].includes(key)) {
|
|
623
643
|
return { operator: key, value };
|
|
624
644
|
}
|
|
625
|
-
else if (isPlainObject(value)
|
|
645
|
+
else if (!isPlainObject(value)) {
|
|
626
646
|
return { operator: '_eq', value };
|
|
627
647
|
}
|
|
628
|
-
|
|
648
|
+
const childKey = Object.keys(value)[0];
|
|
649
|
+
if (childKey) {
|
|
650
|
+
return getOperation(childKey, Object.values(value)[0]);
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
function isNumericField(field) {
|
|
655
|
+
return isIn(field.type, NUMERIC_TYPES);
|
|
629
656
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useEnv } from '@directus/env';
|
|
2
1
|
import { InvalidCredentialsError } from '@directus/errors';
|
|
3
2
|
import getDatabase from '../database/index.js';
|
|
3
|
+
import { getSecret } from './get-secret.js';
|
|
4
4
|
import isDirectusJWT from './is-directus-jwt.js';
|
|
5
|
+
import { verifySessionJWT } from './verify-session-jwt.js';
|
|
5
6
|
import { verifyAccessJWT } from './jwt.js';
|
|
6
7
|
export async function getAccountabilityForToken(token, accountability) {
|
|
7
|
-
const env = useEnv();
|
|
8
8
|
if (!accountability) {
|
|
9
9
|
accountability = {
|
|
10
10
|
user: null,
|
|
@@ -15,7 +15,10 @@ export async function getAccountabilityForToken(token, accountability) {
|
|
|
15
15
|
}
|
|
16
16
|
if (token) {
|
|
17
17
|
if (isDirectusJWT(token)) {
|
|
18
|
-
const payload = verifyAccessJWT(token,
|
|
18
|
+
const payload = verifyAccessJWT(token, getSecret());
|
|
19
|
+
if ('session' in payload) {
|
|
20
|
+
await verifySessionJWT(payload);
|
|
21
|
+
}
|
|
19
22
|
accountability.role = payload.role;
|
|
20
23
|
accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
|
21
24
|
accountability.app = payload.app_access === true || payload.app_access == 1;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
export const _cache = { secret: null };
|
|
4
|
+
export const getSecret = () => {
|
|
5
|
+
if (_cache.secret) {
|
|
6
|
+
return _cache.secret;
|
|
7
|
+
}
|
|
8
|
+
const env = useEnv();
|
|
9
|
+
if (env['SECRET']) {
|
|
10
|
+
return env['SECRET'];
|
|
11
|
+
}
|
|
12
|
+
_cache.secret = nanoid(32);
|
|
13
|
+
return _cache.secret;
|
|
14
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result for keys with a function (e.g. `year(date_created)`):
|
|
3
|
+
* - Group 1: Function (`year`)
|
|
4
|
+
* - Group 3: Field (`date_created`)
|
|
5
|
+
*
|
|
6
|
+
* If group 3 is undefined, it is a key without a function.
|
|
7
|
+
*/
|
|
8
|
+
const FILTER_KEY_REGEX = /^([^()]+)(\(([^)]+)\))?/;
|
|
9
|
+
/**
|
|
10
|
+
* Parses a filter key, returning its field name and function name (if defined) separately.
|
|
11
|
+
*/
|
|
12
|
+
export function parseFilterKey(key) {
|
|
13
|
+
const match = key.match(FILTER_KEY_REGEX);
|
|
14
|
+
const fieldNameWithFunction = match?.[3]?.trim();
|
|
15
|
+
const fieldName = fieldNameWithFunction || key.trim();
|
|
16
|
+
let functionName;
|
|
17
|
+
if (fieldNameWithFunction) {
|
|
18
|
+
functionName = match?.[1]?.trim();
|
|
19
|
+
return { fieldName, functionName };
|
|
20
|
+
}
|
|
21
|
+
return { fieldName, functionName };
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function parseNumericString(stringValue) {
|
|
2
|
+
let number = Number(stringValue);
|
|
3
|
+
if (isNaN(number) || !Number.isFinite(number)) {
|
|
4
|
+
return null; // invalid numbers
|
|
5
|
+
}
|
|
6
|
+
if (number > Number.MAX_SAFE_INTEGER || number < Number.MIN_SAFE_INTEGER) {
|
|
7
|
+
try {
|
|
8
|
+
number = BigInt(stringValue);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// BigInt parsing failed, e.g. it was a float larger than MAX_SAFE_INTEGER
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// casting parsed value back to string should be equal the original value
|
|
16
|
+
// (prevent unintended number parsing, e.g. String(7) !== "ob111")
|
|
17
|
+
if (String(number) !== stringValue) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return number;
|
|
21
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEnv } from '@directus/env';
|
|
2
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
2
3
|
import { parseFilter, parseJSON } from '@directus/utils';
|
|
3
4
|
import { flatten, get, isPlainObject, merge, set } from 'lodash-es';
|
|
4
5
|
import { useLogger } from '../logger.js';
|
|
@@ -106,17 +107,21 @@ function sanitizeAggregate(rawAggregate) {
|
|
|
106
107
|
return aggregate;
|
|
107
108
|
}
|
|
108
109
|
function sanitizeFilter(rawFilter, accountability) {
|
|
109
|
-
const logger = useLogger();
|
|
110
110
|
let filters = rawFilter;
|
|
111
|
-
if (typeof
|
|
111
|
+
if (typeof filters === 'string') {
|
|
112
112
|
try {
|
|
113
|
-
filters = parseJSON(
|
|
113
|
+
filters = parseJSON(filters);
|
|
114
114
|
}
|
|
115
115
|
catch {
|
|
116
|
-
|
|
116
|
+
throw new InvalidQueryError({ reason: 'Invalid JSON for filter object' });
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
try {
|
|
120
|
+
return parseFilter(filters, accountability);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new InvalidQueryError({ reason: 'Invalid filter object' });
|
|
124
|
+
}
|
|
120
125
|
}
|
|
121
126
|
function sanitizeLimit(rawLimit) {
|
|
122
127
|
if (rawLimit === undefined || rawLimit === null)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {} from 'knex';
|
|
2
|
+
import { getDatabaseClient } from '../database/index.js';
|
|
3
|
+
import { useLogger } from '../logger.js';
|
|
1
4
|
/**
|
|
2
5
|
* Execute the given handler within the current transaction or a newly created one
|
|
3
6
|
* if the current knex state isn't a transaction yet.
|
|
@@ -5,11 +8,45 @@
|
|
|
5
8
|
* Can be used to ensure the handler is run within a transaction,
|
|
6
9
|
* while preventing nested transactions.
|
|
7
10
|
*/
|
|
8
|
-
export const transaction = (knex, handler) => {
|
|
11
|
+
export const transaction = async (knex, handler) => {
|
|
9
12
|
if (knex.isTransaction) {
|
|
10
13
|
return handler(knex);
|
|
11
14
|
}
|
|
12
15
|
else {
|
|
13
|
-
|
|
16
|
+
try {
|
|
17
|
+
return await knex.transaction((trx) => handler(trx));
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const client = getDatabaseClient(knex);
|
|
21
|
+
/**
|
|
22
|
+
* This error code indicates that the transaction failed due to another
|
|
23
|
+
* concurrent or recent transaction attempting to write to the same data.
|
|
24
|
+
* This can usually be solved by restarting the transaction on client-side
|
|
25
|
+
* after a short delay, so that it is executed against the latest state.
|
|
26
|
+
*
|
|
27
|
+
* @link https://www.cockroachlabs.com/docs/stable/transaction-retry-error-reference
|
|
28
|
+
*/
|
|
29
|
+
const COCKROACH_RETRY_ERROR_CODE = '40001';
|
|
30
|
+
if (client !== 'cockroachdb' || error?.code !== COCKROACH_RETRY_ERROR_CODE)
|
|
31
|
+
throw error;
|
|
32
|
+
const MAX_ATTEMPTS = 3;
|
|
33
|
+
const BASE_DELAY = 100;
|
|
34
|
+
const logger = useLogger();
|
|
35
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) {
|
|
36
|
+
const delay = 2 ** attempt * BASE_DELAY;
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
38
|
+
logger.trace(`Restarting failed transaction (attempt ${attempt + 1}/${MAX_ATTEMPTS})`);
|
|
39
|
+
try {
|
|
40
|
+
return await knex.transaction((trx) => handler(trx));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error?.code !== COCKROACH_RETRY_ERROR_CODE)
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Initial execution + additional attempts */
|
|
48
|
+
const attempts = 1 + MAX_ATTEMPTS;
|
|
49
|
+
throw new Error(`Transaction failed after ${attempts} attempts`, { cause: error });
|
|
50
|
+
}
|
|
14
51
|
}
|
|
15
52
|
};
|
|
@@ -42,8 +42,6 @@ export function validateQuery(query) {
|
|
|
42
42
|
return query;
|
|
43
43
|
}
|
|
44
44
|
function validateFilter(filter) {
|
|
45
|
-
if (!filter)
|
|
46
|
-
throw new InvalidQueryError({ reason: 'Invalid filter object' });
|
|
47
45
|
for (const [key, nested] of Object.entries(filter)) {
|
|
48
46
|
if (key === '_and' || key === '_or') {
|
|
49
47
|
nested.forEach(validateFilter);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import getDatabase from '../database/index.js';
|
|
2
|
+
import { InvalidCredentialsError } from '@directus/errors';
|
|
3
|
+
/**
|
|
4
|
+
* Verifies the associated session is still available and valid.
|
|
5
|
+
*
|
|
6
|
+
* @throws If session not found.
|
|
7
|
+
*/
|
|
8
|
+
export async function verifySessionJWT(payload) {
|
|
9
|
+
const database = getDatabase();
|
|
10
|
+
const session = await database
|
|
11
|
+
.select(1)
|
|
12
|
+
.from('directus_sessions')
|
|
13
|
+
.where({
|
|
14
|
+
token: payload['session'],
|
|
15
|
+
user: payload['id'],
|
|
16
|
+
})
|
|
17
|
+
.andWhere('expires', '>=', new Date())
|
|
18
|
+
.first();
|
|
19
|
+
if (!session) {
|
|
20
|
+
throw new InvalidCredentialsError();
|
|
21
|
+
}
|
|
22
|
+
}
|