@directus/api 17.0.1 → 18.0.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 -2
- package/dist/auth/drivers/ldap.js +14 -16
- package/dist/auth/drivers/local.js +16 -10
- package/dist/auth/drivers/oauth2.js +16 -11
- package/dist/auth/drivers/openid.js +16 -11
- package/dist/auth/drivers/saml.js +27 -12
- package/dist/cli/commands/init/index.js +3 -3
- package/dist/cli/commands/security/key.js +2 -2
- package/dist/cli/utils/create-env/env-stub.liquid +19 -4
- package/dist/cli/utils/create-env/index.js +2 -2
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +11 -4
- package/dist/controllers/auth.js +54 -19
- package/dist/controllers/extensions.js +102 -5
- package/dist/controllers/fields.js +0 -3
- package/dist/controllers/items.js +3 -2
- package/dist/controllers/permissions.js +1 -1
- package/dist/controllers/shares.js +19 -4
- package/dist/database/migrations/20220429A-add-flows.js +3 -3
- package/dist/database/migrations/20230526A-migrate-translation-strings.js +2 -2
- package/dist/database/migrations/20240204A-marketplace.d.ts +3 -0
- package/dist/database/migrations/20240204A-marketplace.js +68 -0
- package/dist/database/migrations/run.js +3 -2
- package/dist/extensions/lib/get-extensions-settings.d.ts +6 -2
- package/dist/extensions/lib/get-extensions-settings.js +70 -22
- package/dist/extensions/lib/get-extensions.d.ts +5 -1
- package/dist/extensions/lib/get-extensions.js +7 -31
- package/dist/extensions/lib/installation/index.d.ts +2 -0
- package/dist/extensions/lib/installation/index.js +9 -0
- package/dist/extensions/lib/installation/manager.d.ts +5 -0
- package/dist/extensions/lib/installation/manager.js +90 -0
- package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +1 -1
- package/dist/extensions/lib/sync-extensions.js +11 -10
- package/dist/extensions/manager.d.ts +27 -25
- package/dist/extensions/manager.js +214 -183
- package/dist/middleware/authenticate.d.ts +1 -0
- package/dist/middleware/error-handler.js +22 -18
- package/dist/middleware/extract-token.d.ts +6 -5
- package/dist/middleware/extract-token.js +27 -11
- package/dist/middleware/merge-content-versions.d.ts +2 -0
- package/dist/middleware/merge-content-versions.js +26 -0
- package/dist/middleware/respond.js +0 -12
- package/dist/middleware/validate-batch.d.ts +1 -0
- package/dist/operations/item-update/index.js +4 -1
- package/dist/request/agent-with-ip-validation.d.ts +1 -1
- package/dist/request/agent-with-ip-validation.js +5 -1
- package/dist/services/activity.js +3 -3
- package/dist/services/assets.js +2 -3
- package/dist/services/authentication.d.ts +7 -2
- package/dist/services/authentication.js +21 -13
- package/dist/services/extensions.d.ts +4 -8
- package/dist/services/extensions.js +110 -93
- package/dist/services/fields.js +34 -22
- package/dist/services/graphql/index.js +98 -42
- package/dist/services/import-export.js +61 -26
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/mail/index.d.ts +1 -1
- package/dist/services/mail/index.js +4 -2
- package/dist/services/payload.js +2 -2
- package/dist/services/{permissions.d.ts → permissions/index.d.ts} +3 -4
- package/dist/services/{permissions.js → permissions/index.js} +6 -23
- package/dist/services/permissions/lib/with-app-minimal-permissions.d.ts +2 -0
- package/dist/services/permissions/lib/with-app-minimal-permissions.js +13 -0
- package/dist/services/relations.d.ts +2 -3
- package/dist/services/relations.js +2 -2
- package/dist/services/roles.d.ts +9 -4
- package/dist/services/roles.js +51 -3
- package/dist/services/server.js +3 -0
- package/dist/services/shares.d.ts +3 -1
- package/dist/services/shares.js +9 -5
- package/dist/storage/index.js +5 -4
- package/dist/types/auth.d.ts +6 -4
- package/dist/types/graphql.d.ts +1 -0
- package/dist/utils/apply-query.js +3 -3
- package/dist/utils/filter-items.d.ts +2 -2
- package/dist/utils/filter-items.js +1 -3
- package/dist/utils/get-cache-headers.d.ts +1 -0
- package/dist/utils/get-cache-key.d.ts +1 -0
- package/dist/utils/get-graphql-query-and-variables.d.ts +1 -0
- package/dist/utils/get-ip-from-req.d.ts +1 -0
- package/dist/utils/get-milliseconds.d.ts +1 -1
- package/dist/utils/get-milliseconds.js +4 -1
- package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
- package/dist/utils/is-login-redirect-allowed.js +34 -0
- package/dist/utils/is-url-allowed.d.ts +1 -1
- package/dist/utils/is-url-allowed.js +5 -5
- package/dist/utils/is-valid-uuid.d.ts +3 -0
- package/dist/utils/is-valid-uuid.js +21 -0
- package/dist/utils/jwt.d.ts +1 -1
- package/dist/utils/jwt.js +3 -3
- package/dist/utils/merge-version-data.d.ts +3 -0
- package/dist/utils/merge-version-data.js +134 -0
- package/dist/utils/sanitize-query.js +2 -0
- package/dist/utils/should-skip-cache.d.ts +1 -0
- package/dist/utils/validate-keys.js +2 -2
- package/dist/utils/validate-query.js +1 -0
- package/dist/websocket/controllers/base.js +2 -2
- package/dist/websocket/controllers/hooks.js +1 -1
- package/package.json +50 -51
package/dist/services/roles.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { Query } from '@directus/types';
|
|
2
|
-
import type { AbstractServiceOptions, MutationOptions, PrimaryKey } from '../types/index.js';
|
|
2
|
+
import type { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types/index.js';
|
|
3
3
|
import { ItemsService } from './items.js';
|
|
4
4
|
export declare class RolesService extends ItemsService {
|
|
5
5
|
constructor(options: AbstractServiceOptions);
|
|
6
6
|
private checkForOtherAdminRoles;
|
|
7
7
|
private checkForOtherAdminUsers;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
private isIpAccessValid;
|
|
9
|
+
private assertValidIpAccess;
|
|
10
|
+
createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
|
|
11
|
+
createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
12
|
+
updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
|
|
13
|
+
updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
14
|
+
updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]>;
|
|
15
|
+
updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions | undefined): Promise<PrimaryKey[]>;
|
|
11
16
|
deleteOne(key: PrimaryKey): Promise<PrimaryKey>;
|
|
12
17
|
deleteMany(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
|
13
18
|
deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]>;
|
package/dist/services/roles.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ForbiddenError, UnprocessableContentError } from '@directus/errors';
|
|
1
|
+
import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
|
|
2
|
+
import { getMatch } from 'ip-matching';
|
|
2
3
|
import { ItemsService } from './items.js';
|
|
3
|
-
import { PermissionsService } from './permissions.js';
|
|
4
|
+
import { PermissionsService } from './permissions/index.js';
|
|
4
5
|
import { PresetsService } from './presets.js';
|
|
5
6
|
import { UsersService } from './users.js';
|
|
6
7
|
export class RolesService extends ItemsService {
|
|
@@ -74,7 +75,7 @@ export class RolesService extends ItemsService {
|
|
|
74
75
|
.count('*', { as: 'count' })
|
|
75
76
|
.from('directus_users')
|
|
76
77
|
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
|
77
|
-
.whereNotIn('directus_users.id', usersAdded)
|
|
78
|
+
.whereNotIn('directus_users.id', usersAdded.map((user) => user.id))
|
|
78
79
|
.andWhere({ 'directus_roles.admin_access': true, status: 'active' })
|
|
79
80
|
.first();
|
|
80
81
|
const otherAdminUsersCount = Number(otherAdminUsers?.count ?? 0);
|
|
@@ -121,7 +122,46 @@ export class RolesService extends ItemsService {
|
|
|
121
122
|
}
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
125
|
+
isIpAccessValid(value) {
|
|
126
|
+
if (value === undefined)
|
|
127
|
+
return false;
|
|
128
|
+
if (value === null)
|
|
129
|
+
return true;
|
|
130
|
+
if (Array.isArray(value) && value.length === 0)
|
|
131
|
+
return true;
|
|
132
|
+
for (const ip of value) {
|
|
133
|
+
if (typeof ip !== 'string' || ip.includes('*'))
|
|
134
|
+
return false;
|
|
135
|
+
try {
|
|
136
|
+
const match = getMatch(ip);
|
|
137
|
+
if (match.type == 'IPMask')
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
assertValidIpAccess(partialItem) {
|
|
147
|
+
if ('ip_access' in partialItem && !this.isIpAccessValid(partialItem['ip_access'])) {
|
|
148
|
+
throw new InvalidPayloadError({
|
|
149
|
+
reason: 'IP Access contains an incorrect value. Valid values are: IP addresses, IP ranges and CIDR blocks',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async createOne(data, opts) {
|
|
154
|
+
this.assertValidIpAccess(data);
|
|
155
|
+
return super.createOne(data, opts);
|
|
156
|
+
}
|
|
157
|
+
async createMany(data, opts) {
|
|
158
|
+
for (const partialItem of data) {
|
|
159
|
+
this.assertValidIpAccess(partialItem);
|
|
160
|
+
}
|
|
161
|
+
return super.createMany(data, opts);
|
|
162
|
+
}
|
|
124
163
|
async updateOne(key, data, opts) {
|
|
164
|
+
this.assertValidIpAccess(data);
|
|
125
165
|
try {
|
|
126
166
|
if ('users' in data) {
|
|
127
167
|
await this.checkForOtherAdminUsers(key, data['users']);
|
|
@@ -133,6 +173,9 @@ export class RolesService extends ItemsService {
|
|
|
133
173
|
return super.updateOne(key, data, opts);
|
|
134
174
|
}
|
|
135
175
|
async updateBatch(data, opts) {
|
|
176
|
+
for (const partialItem of data) {
|
|
177
|
+
this.assertValidIpAccess(partialItem);
|
|
178
|
+
}
|
|
136
179
|
const primaryKeyField = this.schema.collections[this.collection].primary;
|
|
137
180
|
const keys = data.map((item) => item[primaryKeyField]);
|
|
138
181
|
const setsToNoAdmin = data.some((item) => item['admin_access'] === false);
|
|
@@ -147,6 +190,7 @@ export class RolesService extends ItemsService {
|
|
|
147
190
|
return super.updateBatch(data, opts);
|
|
148
191
|
}
|
|
149
192
|
async updateMany(keys, data, opts) {
|
|
193
|
+
this.assertValidIpAccess(data);
|
|
150
194
|
try {
|
|
151
195
|
if ('admin_access' in data && data['admin_access'] === false) {
|
|
152
196
|
await this.checkForOtherAdminRoles(keys);
|
|
@@ -157,6 +201,10 @@ export class RolesService extends ItemsService {
|
|
|
157
201
|
}
|
|
158
202
|
return super.updateMany(keys, data, opts);
|
|
159
203
|
}
|
|
204
|
+
async updateByQuery(query, data, opts) {
|
|
205
|
+
this.assertValidIpAccess(data);
|
|
206
|
+
return super.updateByQuery(query, data, opts);
|
|
207
|
+
}
|
|
160
208
|
async deleteOne(key) {
|
|
161
209
|
await this.deleteMany([key]);
|
|
162
210
|
return key;
|
package/dist/services/server.js
CHANGED
|
@@ -68,6 +68,9 @@ export class ServerService {
|
|
|
68
68
|
else {
|
|
69
69
|
info['rateLimitGlobal'] = false;
|
|
70
70
|
}
|
|
71
|
+
info['extensions'] = {
|
|
72
|
+
limit: env['EXTENSIONS_LIMIT'] ?? null,
|
|
73
|
+
};
|
|
71
74
|
info['queryLimit'] = {
|
|
72
75
|
default: env['QUERY_LIMIT_DEFAULT'],
|
|
73
76
|
max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
|
|
@@ -5,7 +5,9 @@ export declare class SharesService extends ItemsService {
|
|
|
5
5
|
authorizationService: AuthorizationService;
|
|
6
6
|
constructor(options: AbstractServiceOptions);
|
|
7
7
|
createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey>;
|
|
8
|
-
login(payload: Record<string, any
|
|
8
|
+
login(payload: Record<string, any>, options?: Partial<{
|
|
9
|
+
session: boolean;
|
|
10
|
+
}>): Promise<Omit<LoginResult, 'id'>>;
|
|
9
11
|
/**
|
|
10
12
|
* Send a link to the given share ID to the given email(s). Note: you can only send a link to a share
|
|
11
13
|
* if you have read access to that particular share
|
package/dist/services/shares.js
CHANGED
|
@@ -25,7 +25,7 @@ export class SharesService extends ItemsService {
|
|
|
25
25
|
await this.authorizationService.checkAccess('share', data['collection'], data['item']);
|
|
26
26
|
return super.createOne(data, opts);
|
|
27
27
|
}
|
|
28
|
-
async login(payload) {
|
|
28
|
+
async login(payload, options) {
|
|
29
29
|
const { nanoid } = await import('nanoid');
|
|
30
30
|
const record = await this.knex
|
|
31
31
|
.select({
|
|
@@ -70,12 +70,16 @@ export class SharesService extends ItemsService {
|
|
|
70
70
|
collection: record.share_collection,
|
|
71
71
|
},
|
|
72
72
|
};
|
|
73
|
+
const refreshToken = nanoid(64);
|
|
74
|
+
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
75
|
+
if (options?.session) {
|
|
76
|
+
tokenPayload.session = refreshToken;
|
|
77
|
+
}
|
|
78
|
+
const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
|
|
73
79
|
const accessToken = jwt.sign(tokenPayload, env['SECRET'], {
|
|
74
|
-
expiresIn:
|
|
80
|
+
expiresIn: TTL,
|
|
75
81
|
issuer: 'directus',
|
|
76
82
|
});
|
|
77
|
-
const refreshToken = nanoid(64);
|
|
78
|
-
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
79
83
|
await this.knex('directus_sessions').insert({
|
|
80
84
|
token: refreshToken,
|
|
81
85
|
expires: refreshTokenExpiration,
|
|
@@ -88,7 +92,7 @@ export class SharesService extends ItemsService {
|
|
|
88
92
|
return {
|
|
89
93
|
accessToken,
|
|
90
94
|
refreshToken,
|
|
91
|
-
expires: getMilliseconds(
|
|
95
|
+
expires: getMilliseconds(TTL),
|
|
92
96
|
};
|
|
93
97
|
}
|
|
94
98
|
/**
|
package/dist/storage/index.js
CHANGED
|
@@ -9,8 +9,9 @@ export const getStorage = async () => {
|
|
|
9
9
|
return _cache.storage;
|
|
10
10
|
const { StorageManager } = await import('@directus/storage');
|
|
11
11
|
validateEnv(['STORAGE_LOCATIONS']);
|
|
12
|
-
|
|
13
|
-
await registerDrivers(
|
|
14
|
-
await registerLocations(
|
|
15
|
-
|
|
12
|
+
const storage = new StorageManager();
|
|
13
|
+
await registerDrivers(storage);
|
|
14
|
+
await registerLocations(storage);
|
|
15
|
+
_cache.storage = storage;
|
|
16
|
+
return storage;
|
|
16
17
|
};
|
package/dist/types/auth.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface Session {
|
|
|
27
27
|
export type DirectusTokenPayload = {
|
|
28
28
|
id?: string;
|
|
29
29
|
role: string | null;
|
|
30
|
+
session?: string;
|
|
30
31
|
app_access: boolean | number;
|
|
31
32
|
admin_access: boolean | number;
|
|
32
33
|
share?: string;
|
|
@@ -47,8 +48,9 @@ export type ShareData = {
|
|
|
47
48
|
share_password?: string;
|
|
48
49
|
};
|
|
49
50
|
export type LoginResult = {
|
|
50
|
-
accessToken:
|
|
51
|
-
refreshToken:
|
|
52
|
-
expires:
|
|
53
|
-
id?:
|
|
51
|
+
accessToken: string;
|
|
52
|
+
refreshToken: string;
|
|
53
|
+
expires: number;
|
|
54
|
+
id?: string;
|
|
54
55
|
};
|
|
56
|
+
export type AuthenticationMode = 'json' | 'cookie' | 'session';
|
package/dist/types/graphql.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
1
2
|
import { getFilterOperatorsForType, getOutputTypeForFunction } from '@directus/utils';
|
|
2
3
|
import { clone, isPlainObject } from 'lodash-es';
|
|
3
4
|
import { customAlphabet } from 'nanoid/non-secure';
|
|
4
|
-
import validate from 'uuid-validate';
|
|
5
5
|
import { getHelpers } from '../database/helpers/index.js';
|
|
6
|
-
import { InvalidQueryError } from '@directus/errors';
|
|
7
6
|
import { getColumnPath } from './get-column-path.js';
|
|
8
7
|
import { getColumn } from './get-column.js';
|
|
9
8
|
import { getRelationInfo } from './get-relation-info.js';
|
|
9
|
+
import { isValidUuid } from './is-valid-uuid.js';
|
|
10
10
|
import { stripFunction } from './strip-function.js';
|
|
11
11
|
export const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
|
|
12
12
|
/**
|
|
@@ -548,7 +548,7 @@ export async function applySearch(schema, dbQuery, searchQuery, collection) {
|
|
|
548
548
|
this.orWhere({ [`${collection}.${name}`]: number });
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
|
-
else if (field.type === 'uuid' &&
|
|
551
|
+
else if (field.type === 'uuid' && isValidUuid(searchQuery)) {
|
|
552
552
|
this.orWhere({ [`${collection}.${name}`]: searchQuery });
|
|
553
553
|
}
|
|
554
554
|
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { Query } from '@directus/types';
|
|
2
|
-
export declare function filterItems(items:
|
|
1
|
+
import type { Item, Query } from '@directus/types';
|
|
2
|
+
export declare function filterItems<T extends Item[]>(items: T, filter: Query['filter']): T;
|
|
@@ -6,9 +6,7 @@ import { generateJoi } from '@directus/utils';
|
|
|
6
6
|
export function filterItems(items, filter) {
|
|
7
7
|
if (!filter)
|
|
8
8
|
return items;
|
|
9
|
-
return items.filter((item) =>
|
|
10
|
-
return passesFilter(item, filter);
|
|
11
|
-
});
|
|
9
|
+
return items.filter((item) => passesFilter(item, filter));
|
|
12
10
|
function passesFilter(item, filter) {
|
|
13
11
|
if (!filter || Object.keys(filter).length === 0)
|
|
14
12
|
return true;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import ms from 'ms';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Safely parse human readable time format into milliseconds
|
|
4
|
+
*/
|
|
5
|
+
export function getMilliseconds(value, fallback) {
|
|
3
6
|
if ((typeof value !== 'string' && typeof value !== 'number') || value === '') {
|
|
4
7
|
return fallback;
|
|
5
8
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { toArray } from '@directus/utils';
|
|
3
|
+
import isUrlAllowed from './is-url-allowed.js';
|
|
4
|
+
/**
|
|
5
|
+
* Checks if the defined redirect after successful SSO login is in the allow list
|
|
6
|
+
*/
|
|
7
|
+
export function isLoginRedirectAllowed(redirect, provider) {
|
|
8
|
+
if (!redirect)
|
|
9
|
+
return true; // empty redirect
|
|
10
|
+
if (typeof redirect !== 'string')
|
|
11
|
+
return false; // invalid type
|
|
12
|
+
const env = useEnv();
|
|
13
|
+
const publicUrl = env['PUBLIC_URL'];
|
|
14
|
+
if (URL.canParse(redirect) === false) {
|
|
15
|
+
if (redirect.startsWith('//') === false) {
|
|
16
|
+
// should be a relative path like `/admin/test`
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
// domain without protocol `//example.com/test`
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);
|
|
23
|
+
const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
|
|
24
|
+
if (envKey in env) {
|
|
25
|
+
if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (URL.canParse(publicUrl) === false) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
// allow redirects to the defined PUBLIC_URL
|
|
32
|
+
const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);
|
|
33
|
+
return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
|
|
34
|
+
}
|
|
@@ -2,7 +2,7 @@ import { toArray } from '@directus/utils';
|
|
|
2
2
|
import { URL } from 'url';
|
|
3
3
|
import { useLogger } from '../logger.js';
|
|
4
4
|
/**
|
|
5
|
-
* Check if
|
|
5
|
+
* Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
|
|
6
6
|
*/
|
|
7
7
|
export default function isUrlAllowed(url, allowList) {
|
|
8
8
|
const logger = useLogger();
|
|
@@ -12,8 +12,8 @@ export default function isUrlAllowed(url, allowList) {
|
|
|
12
12
|
const parsedWhitelist = urlAllowList
|
|
13
13
|
.map((allowedURL) => {
|
|
14
14
|
try {
|
|
15
|
-
const {
|
|
16
|
-
return
|
|
15
|
+
const { origin, pathname } = new URL(allowedURL);
|
|
16
|
+
return origin + pathname;
|
|
17
17
|
}
|
|
18
18
|
catch {
|
|
19
19
|
logger.warn(`Invalid URL used "${url}"`);
|
|
@@ -22,8 +22,8 @@ export default function isUrlAllowed(url, allowList) {
|
|
|
22
22
|
})
|
|
23
23
|
.filter((f) => f);
|
|
24
24
|
try {
|
|
25
|
-
const {
|
|
26
|
-
return parsedWhitelist.includes(
|
|
25
|
+
const { origin, pathname } = new URL(url);
|
|
26
|
+
return parsedWhitelist.includes(origin + pathname);
|
|
27
27
|
}
|
|
28
28
|
catch {
|
|
29
29
|
return false;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Based on the patterns found in the 'uuid' and 'uuid-validate' npm packages, both of which are MIT licensed.
|
|
3
|
+
*
|
|
4
|
+
* The primary difference between this pattern and the patterns found in the referenced packages is that
|
|
5
|
+
* no validation over the version component (the 14th character) is performed, while the
|
|
6
|
+
* packages fail if the version is not a known one (only versions 1 through 5 are accepted).
|
|
7
|
+
*
|
|
8
|
+
* This specification complies with all major database vendors.
|
|
9
|
+
*
|
|
10
|
+
* e22f209d-9e85-4ef5-b1fe-7dc09d2b67cf
|
|
11
|
+
* ^ version
|
|
12
|
+
*
|
|
13
|
+
* @see https://datatracker.ietf.org/doc/html/rfc4122
|
|
14
|
+
* @see https://github.com/uuidjs/uuid/blob/bc46e198ab06311a9d82d3c9c6222062dd27f760/src/regex.js
|
|
15
|
+
* @see https://github.com/microsoft/uuid-validate/blob/06554db1b093aa6bb429156fa8964e1cde2b750c/index.js
|
|
16
|
+
* @see https://github.com/directus/directus/issues/21573
|
|
17
|
+
*/
|
|
18
|
+
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
export function isValidUuid(value) {
|
|
20
|
+
return regex.test(value);
|
|
21
|
+
}
|
package/dist/utils/jwt.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { DirectusTokenPayload } from '../types/index.js';
|
|
2
|
-
export declare function verifyJWT(token: string, secret: string): Record<string,
|
|
2
|
+
export declare function verifyJWT(token: string, secret: string): Record<string, unknown>;
|
|
3
3
|
export declare function verifyAccessJWT(token: string, secret: string): DirectusTokenPayload;
|
package/dist/utils/jwt.js
CHANGED
|
@@ -21,9 +21,9 @@ export function verifyJWT(token, secret) {
|
|
|
21
21
|
return payload;
|
|
22
22
|
}
|
|
23
23
|
export function verifyAccessJWT(token, secret) {
|
|
24
|
-
const
|
|
25
|
-
if (role === undefined || app_access === undefined || admin_access === undefined) {
|
|
24
|
+
const payload = verifyJWT(token, secret);
|
|
25
|
+
if (payload.role === undefined || payload.app_access === undefined || payload.admin_access === undefined) {
|
|
26
26
|
throw new InvalidTokenError();
|
|
27
27
|
}
|
|
28
|
-
return
|
|
28
|
+
return payload;
|
|
29
29
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Item, SchemaOverview } from '@directus/types';
|
|
2
|
+
export declare function mergeVersionsRaw(item: Item, versionData: Partial<Item>[]): Item;
|
|
3
|
+
export declare function mergeVersionsRecursive(item: Item, versionData: Item[], collection: string, schema: SchemaOverview): Item;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import Joi from 'joi';
|
|
2
|
+
import { isObject } from '@directus/utils';
|
|
3
|
+
import { cloneDeep } from 'lodash-es';
|
|
4
|
+
const alterationSchema = Joi.object({
|
|
5
|
+
create: Joi.array().items(Joi.object().unknown()),
|
|
6
|
+
update: Joi.array().items(Joi.object().unknown()),
|
|
7
|
+
delete: Joi.array().items(Joi.string(), Joi.number()),
|
|
8
|
+
});
|
|
9
|
+
export function mergeVersionsRaw(item, versionData) {
|
|
10
|
+
const result = cloneDeep(item);
|
|
11
|
+
for (const versionRecord of versionData) {
|
|
12
|
+
for (const key of Object.keys(versionRecord)) {
|
|
13
|
+
result[key] = versionRecord[key];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
export function mergeVersionsRecursive(item, versionData, collection, schema) {
|
|
19
|
+
if (versionData.length === 0)
|
|
20
|
+
return item;
|
|
21
|
+
return recursiveMerging(item, versionData, collection, schema);
|
|
22
|
+
}
|
|
23
|
+
function recursiveMerging(data, versionData, collection, schema) {
|
|
24
|
+
const result = cloneDeep(data);
|
|
25
|
+
const relations = getRelations(collection, schema);
|
|
26
|
+
for (const versionRecord of versionData) {
|
|
27
|
+
if (!isObject(versionRecord)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (const key of Object.keys(data)) {
|
|
31
|
+
if (key in versionRecord === false) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const currentValue = data[key];
|
|
35
|
+
const newValue = versionRecord[key];
|
|
36
|
+
if (typeof newValue !== 'object' || newValue === null) {
|
|
37
|
+
// primitive type substitution, json and non relational array values are handled in the next check
|
|
38
|
+
result[key] = newValue;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (key in relations === false) {
|
|
42
|
+
// check for m2a exception
|
|
43
|
+
if (isManyToAnyCollection(collection, schema) && key === 'item') {
|
|
44
|
+
const item = addMissingKeys(isObject(currentValue) ? currentValue : {}, newValue);
|
|
45
|
+
result[key] = recursiveMerging(item, [newValue], data['collection'], schema);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// item is not a relation
|
|
49
|
+
result[key] = newValue;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const { error } = alterationSchema.validate(newValue);
|
|
54
|
+
if (error) {
|
|
55
|
+
if (typeof newValue === 'object' && key in relations) {
|
|
56
|
+
const newItem = !currentValue || typeof currentValue !== 'object' ? newValue : currentValue;
|
|
57
|
+
result[key] = recursiveMerging(newItem, [newValue], relations[key], schema);
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const alterations = newValue;
|
|
62
|
+
const currentPrimaryKeyField = schema.collections[collection].primary;
|
|
63
|
+
const relatedPrimaryKeyField = schema.collections[relations[key]].primary;
|
|
64
|
+
const mergedRelation = [];
|
|
65
|
+
if (Array.isArray(currentValue)) {
|
|
66
|
+
if (alterations.delete.length > 0) {
|
|
67
|
+
for (const currentItem of currentValue) {
|
|
68
|
+
const currentId = typeof currentItem === 'object' ? currentItem[currentPrimaryKeyField] : currentItem;
|
|
69
|
+
if (alterations.delete.includes(currentId) === false) {
|
|
70
|
+
mergedRelation.push(currentItem);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
mergedRelation.push(...currentValue);
|
|
76
|
+
}
|
|
77
|
+
if (alterations.update.length > 0) {
|
|
78
|
+
for (const updatedItem of alterations.update) {
|
|
79
|
+
// find existing item to update
|
|
80
|
+
const itemIndex = mergedRelation.findIndex((currentItem) => currentItem[relatedPrimaryKeyField] === updatedItem[currentPrimaryKeyField]);
|
|
81
|
+
if (itemIndex === -1) {
|
|
82
|
+
// check for raw primary keys
|
|
83
|
+
const pkIndex = mergedRelation.findIndex((currentItem) => currentItem === updatedItem[currentPrimaryKeyField]);
|
|
84
|
+
if (pkIndex === -1) {
|
|
85
|
+
// nothing to update so add the item as is
|
|
86
|
+
mergedRelation.push(updatedItem);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
mergedRelation[pkIndex] = updatedItem;
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const item = addMissingKeys(mergedRelation[itemIndex], updatedItem);
|
|
94
|
+
mergedRelation[itemIndex] = recursiveMerging(item, [updatedItem], relations[key], schema);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (alterations.create.length > 0) {
|
|
99
|
+
for (const createdItem of alterations.create) {
|
|
100
|
+
const item = addMissingKeys({}, createdItem);
|
|
101
|
+
mergedRelation.push(recursiveMerging(item, [createdItem], relations[key], schema));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
result[key] = mergedRelation;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function addMissingKeys(item, edits) {
|
|
110
|
+
const result = { ...item };
|
|
111
|
+
for (const key of Object.keys(edits)) {
|
|
112
|
+
if (key in item === false) {
|
|
113
|
+
result[key] = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function isManyToAnyCollection(collection, schema) {
|
|
119
|
+
const relation = schema.relations.find((relation) => relation.collection === collection && relation.meta?.many_collection === collection);
|
|
120
|
+
if (!relation || !relation.meta?.one_field || !relation.related_collection)
|
|
121
|
+
return false;
|
|
122
|
+
return Boolean(schema.collections[relation.related_collection]?.fields[relation.meta.one_field]?.special.includes('m2a'));
|
|
123
|
+
}
|
|
124
|
+
function getRelations(collection, schema) {
|
|
125
|
+
return schema.relations.reduce((result, relation) => {
|
|
126
|
+
if (relation.related_collection === collection && relation.meta?.one_field) {
|
|
127
|
+
result[relation.meta.one_field] = relation.collection;
|
|
128
|
+
}
|
|
129
|
+
if (relation.collection === collection && relation.related_collection) {
|
|
130
|
+
result[relation.field] = relation.related_collection;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}, {});
|
|
134
|
+
}
|
|
@@ -48,6 +48,8 @@ export function sanitizeQuery(rawQuery, accountability) {
|
|
|
48
48
|
}
|
|
49
49
|
if (rawQuery['version']) {
|
|
50
50
|
query.version = rawQuery['version'];
|
|
51
|
+
// whether or not to merge the relational results
|
|
52
|
+
query.versionRaw = Boolean('versionRaw' in rawQuery && (rawQuery['versionRaw'] === '' || rawQuery['versionRaw'] === 'true'));
|
|
51
53
|
}
|
|
52
54
|
if (rawQuery['export']) {
|
|
53
55
|
query.export = rawQuery['export'];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ForbiddenError } from '@directus/errors';
|
|
2
|
-
import
|
|
2
|
+
import { isValidUuid } from './is-valid-uuid.js';
|
|
3
3
|
/**
|
|
4
4
|
* Validate keys based on its type
|
|
5
5
|
*/
|
|
@@ -11,7 +11,7 @@ export function validateKeys(schema, collection, keyField, keys) {
|
|
|
11
11
|
}
|
|
12
12
|
else {
|
|
13
13
|
const primaryKeyFieldType = schema.collections[collection]?.fields[keyField]?.type;
|
|
14
|
-
if (primaryKeyFieldType === 'uuid' && !
|
|
14
|
+
if (primaryKeyFieldType === 'uuid' && !isValidUuid(String(keys))) {
|
|
15
15
|
throw new ForbiddenError();
|
|
16
16
|
}
|
|
17
17
|
else if (primaryKeyFieldType === 'integer' && !Number.isInteger(Number(keys))) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEnv } from '@directus/env';
|
|
2
2
|
import { InvalidProviderConfigError, TokenExpiredError } from '@directus/errors';
|
|
3
3
|
import { parseJSON, toBoolean } from '@directus/utils';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { parse } from 'url';
|
|
5
|
-
import { v4 as uuid } from 'uuid';
|
|
6
6
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
7
7
|
import { fromZodError } from 'zod-validation-error';
|
|
8
8
|
import emitter from '../../emitter.js';
|
|
@@ -157,7 +157,7 @@ export default class SocketController {
|
|
|
157
157
|
const client = ws;
|
|
158
158
|
client.accountability = accountability;
|
|
159
159
|
client.expires_at = expires_at;
|
|
160
|
-
client.uid =
|
|
160
|
+
client.uid = randomUUID();
|
|
161
161
|
client.auth_timer = null;
|
|
162
162
|
ws.on('message', async (data) => {
|
|
163
163
|
if (this.rateLimiter !== null) {
|
|
@@ -130,7 +130,7 @@ function registerSortHooks() {
|
|
|
130
130
|
*/
|
|
131
131
|
function registerAction(event, transform) {
|
|
132
132
|
const messenger = useBus();
|
|
133
|
-
emitter.onAction(event,
|
|
133
|
+
emitter.onAction(event, (data) => {
|
|
134
134
|
// push the event through the Redis pub/sub
|
|
135
135
|
messenger.publish('websocket.event', transform(data));
|
|
136
136
|
});
|