@directus/api 28.0.3 → 29.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/controllers/extensions.js +3 -3
- package/dist/database/get-ast-from-query/lib/parse-fields.js +3 -1
- package/dist/database/migrations/20250718A-add-direction.d.ts +3 -0
- package/dist/database/migrations/20250718A-add-direction.js +10 -0
- package/dist/database/run-ast/utils/apply-parent-filters.js +2 -0
- package/dist/database/run-ast/utils/get-field-alias.js +2 -0
- package/dist/database/run-ast/utils/remove-temporary-fields.js +8 -2
- package/dist/extensions/manager.d.ts +4 -11
- package/dist/extensions/manager.js +37 -27
- package/dist/permissions/modules/validate-access/lib/validate-item-access.js +2 -1
- package/dist/services/fields.js +2 -8
- package/dist/services/import-export.js +5 -3
- package/dist/types/ast.d.ts +2 -0
- package/dist/utils/get-snapshot.js +8 -3
- package/dist/websocket/authenticate.d.ts +2 -1
- package/dist/websocket/authenticate.js +25 -4
- package/dist/websocket/controllers/base.js +10 -14
- package/dist/websocket/controllers/graphql.js +4 -2
- package/package.json +28 -26
|
@@ -189,10 +189,10 @@ router.get('/sources/:chunk', asyncHandler(async (req, res) => {
|
|
|
189
189
|
const extensionManager = getExtensionManager();
|
|
190
190
|
let source;
|
|
191
191
|
if (chunk === 'index.js') {
|
|
192
|
-
source = extensionManager.
|
|
192
|
+
source = await extensionManager.getAppExtensionChunk();
|
|
193
193
|
}
|
|
194
194
|
else {
|
|
195
|
-
source = extensionManager.getAppExtensionChunk(chunk);
|
|
195
|
+
source = await extensionManager.getAppExtensionChunk(chunk);
|
|
196
196
|
}
|
|
197
197
|
if (source === null) {
|
|
198
198
|
throw new RouteNotFoundError({ path: req.path });
|
|
@@ -200,6 +200,6 @@ router.get('/sources/:chunk', asyncHandler(async (req, res) => {
|
|
|
200
200
|
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
|
|
201
201
|
res.setHeader('Cache-Control', getCacheControlHeader(req, getMilliseconds(env['EXTENSIONS_CACHE_TTL']), false, false));
|
|
202
202
|
res.setHeader('Vary', 'Origin, Cache-Control');
|
|
203
|
-
|
|
203
|
+
source.pipe(res);
|
|
204
204
|
}));
|
|
205
205
|
export default router;
|
|
@@ -27,10 +27,12 @@ export async function parseFields(options, context) {
|
|
|
27
27
|
: null;
|
|
28
28
|
const relationalStructure = Object.create(null);
|
|
29
29
|
for (const fieldKey of fields) {
|
|
30
|
+
let alias = false;
|
|
30
31
|
let name = fieldKey;
|
|
31
32
|
if (options.query.alias) {
|
|
32
33
|
// check for field alias (is one of the key)
|
|
33
34
|
if (name in options.query.alias) {
|
|
35
|
+
alias = true;
|
|
34
36
|
name = options.query.alias[fieldKey];
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -100,7 +102,7 @@ export async function parseFields(options, context) {
|
|
|
100
102
|
}
|
|
101
103
|
continue;
|
|
102
104
|
}
|
|
103
|
-
children.push({ type: 'field', name, fieldKey, whenCase: [] });
|
|
105
|
+
children.push({ type: 'field', name, fieldKey, whenCase: [], alias });
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
for (const [fieldKey, nestedFields] of Object.entries(relationalStructure)) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_users', (table) => {
|
|
3
|
+
table.string('text_direction').defaultTo('auto').notNullable();
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
export async function down(knex) {
|
|
7
|
+
await knex.schema.alterTable('directus_users', (table) => {
|
|
8
|
+
table.dropColumn('text_direction');
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -20,6 +20,7 @@ export function applyParentFilters(schema, nestedCollectionNodes, parentItem) {
|
|
|
20
20
|
name: nestedNode.relation.field,
|
|
21
21
|
fieldKey: nestedNode.relation.field,
|
|
22
22
|
whenCase: [],
|
|
23
|
+
alias: false,
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
26
|
if (nestedNode.relation.meta?.sort_field) {
|
|
@@ -28,6 +29,7 @@ export function applyParentFilters(schema, nestedCollectionNodes, parentItem) {
|
|
|
28
29
|
name: nestedNode.relation.meta.sort_field,
|
|
29
30
|
fieldKey: nestedNode.relation.meta.sort_field,
|
|
30
31
|
whenCase: [],
|
|
32
|
+
alias: false,
|
|
31
33
|
});
|
|
32
34
|
}
|
|
33
35
|
const foreignField = nestedNode.relation.field;
|
|
@@ -38,9 +38,15 @@ export function removeTemporaryFields(schema, rawItem, ast, primaryKeyField, par
|
|
|
38
38
|
}
|
|
39
39
|
else {
|
|
40
40
|
const fields = [];
|
|
41
|
+
const aliasFields = [];
|
|
41
42
|
const nestedCollectionNodes = [];
|
|
42
43
|
for (const child of ast.children) {
|
|
43
|
-
|
|
44
|
+
if ('alias' in child && child.alias === true) {
|
|
45
|
+
aliasFields.push(child.fieldKey);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
fields.push(child.fieldKey);
|
|
49
|
+
}
|
|
44
50
|
if (child.type !== 'field' && child.type !== 'functionField') {
|
|
45
51
|
nestedCollectionNodes.push(child);
|
|
46
52
|
}
|
|
@@ -65,7 +71,7 @@ export function removeTemporaryFields(schema, rawItem, ast, primaryKeyField, par
|
|
|
65
71
|
: schema.collections[nestedNode.relation.collection].primary, item);
|
|
66
72
|
}
|
|
67
73
|
const fieldsWithFunctionsApplied = fields.map((field) => applyFunctionToColumnName(field));
|
|
68
|
-
item = fields.length > 0 ? pick(rawItem, fieldsWithFunctionsApplied) : rawItem[primaryKeyField];
|
|
74
|
+
item = fields.length > 0 ? pick(rawItem, fieldsWithFunctionsApplied, aliasFields) : rawItem[primaryKeyField];
|
|
69
75
|
items.push(item);
|
|
70
76
|
}
|
|
71
77
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Extension } from '@directus/extensions';
|
|
2
2
|
import { Router } from 'express';
|
|
3
3
|
import type { ExtensionManagerOptions } from './types.js';
|
|
4
|
+
import type { ReadStream } from 'node:fs';
|
|
4
5
|
export declare class ExtensionManager {
|
|
5
6
|
private options;
|
|
6
7
|
/**
|
|
@@ -14,11 +15,6 @@ export declare class ExtensionManager {
|
|
|
14
15
|
* Settings for the extensions that are loaded within the current process
|
|
15
16
|
*/
|
|
16
17
|
private extensionsSettings;
|
|
17
|
-
/**
|
|
18
|
-
* App extensions rolled up into a single bundle. Any chunks from the bundle will be available
|
|
19
|
-
* under appExtensionChunks
|
|
20
|
-
*/
|
|
21
|
-
private appExtensionsBundle;
|
|
22
18
|
/**
|
|
23
19
|
* Individual filename chunks from the rollup bundle. Used to improve the performance by allowing
|
|
24
20
|
* extensions to split up their bundle into multiple smaller chunks
|
|
@@ -97,13 +93,10 @@ export declare class ExtensionManager {
|
|
|
97
93
|
forceSync: boolean;
|
|
98
94
|
}): Promise<unknown>;
|
|
99
95
|
/**
|
|
100
|
-
* Return the previously generated app
|
|
101
|
-
|
|
102
|
-
getAppExtensionsBundle(): string | null;
|
|
103
|
-
/**
|
|
104
|
-
* Return the previously generated app extension bundle chunk by name
|
|
96
|
+
* Return the previously generated app extension bundle chunk by name.
|
|
97
|
+
* Providing no name will return the entry bundle.
|
|
105
98
|
*/
|
|
106
|
-
getAppExtensionChunk(name
|
|
99
|
+
getAppExtensionChunk(name?: string): Promise<ReadStream | null>;
|
|
107
100
|
/**
|
|
108
101
|
* Return the scoped router for custom endpoints
|
|
109
102
|
*/
|
|
@@ -12,9 +12,10 @@ import ivm from 'isolated-vm';
|
|
|
12
12
|
import { clone, debounce, isPlainObject } from 'lodash-es';
|
|
13
13
|
import { readFile, readdir } from 'node:fs/promises';
|
|
14
14
|
import os from 'node:os';
|
|
15
|
-
import { dirname } from 'node:path';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import path from 'path';
|
|
18
|
+
import { rolldown } from 'rolldown';
|
|
18
19
|
import { rollup } from 'rollup';
|
|
19
20
|
import { useBus } from '../bus/index.js';
|
|
20
21
|
import getDatabase from '../database/index.js';
|
|
@@ -37,6 +38,7 @@ import { generateApiExtensionsSandboxEntrypoint } from './lib/sandbox/generate-a
|
|
|
37
38
|
import { instantiateSandboxSdk } from './lib/sandbox/sdk/instantiate.js';
|
|
38
39
|
import { syncExtensions } from './lib/sync-extensions.js';
|
|
39
40
|
import { wrapEmbeds } from './lib/wrap-embeds.js';
|
|
41
|
+
import DriverLocal from '@directus/storage-driver-local';
|
|
40
42
|
// Workaround for https://github.com/rollup/plugins/issues/1329
|
|
41
43
|
const virtual = virtualDefault;
|
|
42
44
|
const alias = aliasDefault;
|
|
@@ -63,16 +65,11 @@ export class ExtensionManager {
|
|
|
63
65
|
* Settings for the extensions that are loaded within the current process
|
|
64
66
|
*/
|
|
65
67
|
extensionsSettings = [];
|
|
66
|
-
/**
|
|
67
|
-
* App extensions rolled up into a single bundle. Any chunks from the bundle will be available
|
|
68
|
-
* under appExtensionChunks
|
|
69
|
-
*/
|
|
70
|
-
appExtensionsBundle = null;
|
|
71
68
|
/**
|
|
72
69
|
* Individual filename chunks from the rollup bundle. Used to improve the performance by allowing
|
|
73
70
|
* extensions to split up their bundle into multiple smaller chunks
|
|
74
71
|
*/
|
|
75
|
-
appExtensionChunks =
|
|
72
|
+
appExtensionChunks = [];
|
|
76
73
|
/**
|
|
77
74
|
* Callbacks to be able to unregister extensions
|
|
78
75
|
*/
|
|
@@ -220,7 +217,7 @@ export class ExtensionManager {
|
|
|
220
217
|
}
|
|
221
218
|
await Promise.all([this.registerInternalOperations(), this.registerApiExtensions()]);
|
|
222
219
|
if (env['SERVE_APP']) {
|
|
223
|
-
|
|
220
|
+
await this.generateExtensionBundle();
|
|
224
221
|
}
|
|
225
222
|
this.isLoaded = true;
|
|
226
223
|
emitter.emitAction('extensions.load', {
|
|
@@ -234,7 +231,6 @@ export class ExtensionManager {
|
|
|
234
231
|
async unload() {
|
|
235
232
|
await this.unregisterApiExtensions();
|
|
236
233
|
this.localEmitter.offAll();
|
|
237
|
-
this.appExtensionsBundle = null;
|
|
238
234
|
this.isLoaded = false;
|
|
239
235
|
emitter.emitAction('extensions.unload', {
|
|
240
236
|
extensions: this.extensions,
|
|
@@ -289,16 +285,24 @@ export class ExtensionManager {
|
|
|
289
285
|
return promise;
|
|
290
286
|
}
|
|
291
287
|
/**
|
|
292
|
-
* Return the previously generated app
|
|
293
|
-
|
|
294
|
-
getAppExtensionsBundle() {
|
|
295
|
-
return this.appExtensionsBundle;
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Return the previously generated app extension bundle chunk by name
|
|
288
|
+
* Return the previously generated app extension bundle chunk by name.
|
|
289
|
+
* Providing no name will return the entry bundle.
|
|
299
290
|
*/
|
|
300
|
-
getAppExtensionChunk(name) {
|
|
301
|
-
|
|
291
|
+
async getAppExtensionChunk(name) {
|
|
292
|
+
let file;
|
|
293
|
+
if (!name) {
|
|
294
|
+
file = this.appExtensionChunks[0];
|
|
295
|
+
}
|
|
296
|
+
else if (this.appExtensionChunks.includes(name)) {
|
|
297
|
+
file = name;
|
|
298
|
+
}
|
|
299
|
+
if (!file)
|
|
300
|
+
return null;
|
|
301
|
+
const tempDir = join(env['TEMP_PATH'], 'app-extensions');
|
|
302
|
+
const tmpStorage = new DriverLocal({ root: tempDir });
|
|
303
|
+
if ((await tmpStorage.exists(file)) === false)
|
|
304
|
+
return null;
|
|
305
|
+
return await tmpStorage.read(file);
|
|
302
306
|
}
|
|
303
307
|
/**
|
|
304
308
|
* Return the scoped router for custom endpoints
|
|
@@ -369,6 +373,7 @@ export class ExtensionManager {
|
|
|
369
373
|
*/
|
|
370
374
|
async generateExtensionBundle() {
|
|
371
375
|
const logger = useLogger();
|
|
376
|
+
const env = useEnv();
|
|
372
377
|
const sharedDepsMapping = await getSharedDepsMapping(APP_SHARED_DEPS);
|
|
373
378
|
const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
|
|
374
379
|
find: name,
|
|
@@ -376,26 +381,30 @@ export class ExtensionManager {
|
|
|
376
381
|
}));
|
|
377
382
|
const entrypoint = generateExtensionsEntrypoint({ module: this.moduleExtensions, registry: this.registryExtensions, local: this.localExtensions }, this.extensionsSettings);
|
|
378
383
|
try {
|
|
379
|
-
|
|
384
|
+
/** Opt In for now. Should be @deprecated later to always use rolldown! */
|
|
385
|
+
const rollDirection = env['EXTENSIONS_ROLLDOWN'] ?? false ? rolldown : rollup;
|
|
386
|
+
const bundle = await rollDirection({
|
|
380
387
|
input: 'entry',
|
|
381
388
|
external: Object.values(sharedDepsMapping),
|
|
382
389
|
makeAbsoluteExternalsRelative: false,
|
|
383
390
|
plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports }), nodeResolve({ browser: true })],
|
|
384
391
|
});
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
392
|
+
const tempDir = join(env['TEMP_PATH'], 'app-extensions');
|
|
393
|
+
const { output } = await bundle.write({
|
|
394
|
+
format: 'es',
|
|
395
|
+
dir: tempDir,
|
|
396
|
+
});
|
|
397
|
+
this.appExtensionChunks = output.reduce((acc, chunk) => {
|
|
398
|
+
if (chunk.type === 'chunk')
|
|
399
|
+
acc.push(chunk.fileName);
|
|
400
|
+
return acc;
|
|
401
|
+
}, []);
|
|
391
402
|
await bundle.close();
|
|
392
|
-
return output[0].code;
|
|
393
403
|
}
|
|
394
404
|
catch (error) {
|
|
395
405
|
logger.warn(`Couldn't bundle App extensions`);
|
|
396
406
|
logger.warn(error);
|
|
397
407
|
}
|
|
398
|
-
return null;
|
|
399
408
|
}
|
|
400
409
|
async registerSandboxedApiExtension(extension) {
|
|
401
410
|
const logger = useLogger();
|
|
@@ -412,6 +421,7 @@ export class ExtensionManager {
|
|
|
412
421
|
},
|
|
413
422
|
});
|
|
414
423
|
const context = await isolate.createContext();
|
|
424
|
+
context.global.setSync('process', { env: { NODE_ENV: process.env['NODE_ENV'] ?? 'production' } }, { copy: true });
|
|
415
425
|
const module = await isolate.compileModule(extensionCode, { filename: `file://${entrypointPath}` });
|
|
416
426
|
const sdkModule = await instantiateSandboxSdk(isolate, extension.sandbox?.requestedScopes ?? {});
|
|
417
427
|
await module.instantiate(context, (specifier) => {
|
|
@@ -13,7 +13,8 @@ export async function validateItemAccess(options, context) {
|
|
|
13
13
|
name: options.collection,
|
|
14
14
|
query: { limit: options.primaryKeys.length },
|
|
15
15
|
// Act as if every field was a "normal" field
|
|
16
|
-
children: options.fields?.map((field) => ({ type: 'field', name: field, fieldKey: field, whenCase: [] })) ??
|
|
16
|
+
children: options.fields?.map((field) => ({ type: 'field', name: field, fieldKey: field, whenCase: [], alias: false })) ??
|
|
17
|
+
[],
|
|
17
18
|
cases: [],
|
|
18
19
|
};
|
|
19
20
|
await processAst({ ast, ...options }, context);
|
package/dist/services/fields.js
CHANGED
|
@@ -148,7 +148,7 @@ export class FieldsService {
|
|
|
148
148
|
return data;
|
|
149
149
|
});
|
|
150
150
|
const knownCollections = Object.keys(this.schema.collections);
|
|
151
|
-
|
|
151
|
+
let result = [...columnsWithSystem, ...aliasFieldsAsField].filter((field) => knownCollections.includes(field.collection));
|
|
152
152
|
// Filter the result so we only return the fields you have read access to
|
|
153
153
|
if (this.accountability && this.accountability.admin !== true) {
|
|
154
154
|
const policies = await fetchPolicies(this.accountability, { knex: this.knex, schema: this.schema });
|
|
@@ -176,7 +176,7 @@ export class FieldsService {
|
|
|
176
176
|
if (collection && collection in allowedFieldsInCollection === false) {
|
|
177
177
|
throw new ForbiddenError();
|
|
178
178
|
}
|
|
179
|
-
|
|
179
|
+
result = result.filter((field) => {
|
|
180
180
|
if (field.collection in allowedFieldsInCollection === false)
|
|
181
181
|
return false;
|
|
182
182
|
const allowedFields = allowedFieldsInCollection[field.collection];
|
|
@@ -187,12 +187,6 @@ export class FieldsService {
|
|
|
187
187
|
}
|
|
188
188
|
// Update specific database type overrides
|
|
189
189
|
for (const field of result) {
|
|
190
|
-
if (field.meta?.special?.includes('cast-timestamp')) {
|
|
191
|
-
field.type = 'timestamp';
|
|
192
|
-
}
|
|
193
|
-
else if (field.meta?.special?.includes('cast-datetime')) {
|
|
194
|
-
field.type = 'dateTime';
|
|
195
|
-
}
|
|
196
190
|
field.type = this.helpers.schema.processFieldType(field);
|
|
197
191
|
}
|
|
198
192
|
return result;
|
|
@@ -25,6 +25,7 @@ import { FilesService } from './files.js';
|
|
|
25
25
|
import { NotificationsService } from './notifications.js';
|
|
26
26
|
import { UsersService } from './users.js';
|
|
27
27
|
import { parseFields } from '../database/get-ast-from-query/lib/parse-fields.js';
|
|
28
|
+
import { set } from 'lodash-es';
|
|
28
29
|
const env = useEnv();
|
|
29
30
|
const logger = useLogger();
|
|
30
31
|
export class ImportService {
|
|
@@ -166,13 +167,14 @@ export class ImportService {
|
|
|
166
167
|
fileReadStream
|
|
167
168
|
.pipe(Papa.parse(Papa.NODE_STREAM_INPUT, PapaOptions))
|
|
168
169
|
.on('data', (obj) => {
|
|
170
|
+
const result = {};
|
|
169
171
|
// Filter out all undefined fields
|
|
170
172
|
for (const field in obj) {
|
|
171
|
-
if (obj[field]
|
|
172
|
-
|
|
173
|
+
if (obj[field] !== undefined) {
|
|
174
|
+
set(result, field, obj[field]);
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
|
-
saveQueue.push(
|
|
177
|
+
saveQueue.push(result);
|
|
176
178
|
})
|
|
177
179
|
.on('error', (error) => {
|
|
178
180
|
cleanup();
|
package/dist/types/ast.d.ts
CHANGED
|
@@ -66,6 +66,8 @@ export type FieldNode = {
|
|
|
66
66
|
type: 'field';
|
|
67
67
|
name: string;
|
|
68
68
|
fieldKey: string;
|
|
69
|
+
/** If the field was created through alias query parameters */
|
|
70
|
+
alias: boolean;
|
|
69
71
|
/**
|
|
70
72
|
* Which permission cases have to be met on the current item for this field to return a value
|
|
71
73
|
*/
|
|
@@ -18,9 +18,9 @@ export async function getSnapshot(options) {
|
|
|
18
18
|
fieldsService.readAll(),
|
|
19
19
|
relationsService.readAll(),
|
|
20
20
|
]);
|
|
21
|
-
const collectionsFiltered = collectionsRaw.filter((item) => excludeSystem(item));
|
|
22
|
-
const fieldsFiltered = fieldsRaw.filter((item) => excludeSystem(item));
|
|
23
|
-
const relationsFiltered = relationsRaw.filter((item) => excludeSystem(item));
|
|
21
|
+
const collectionsFiltered = collectionsRaw.filter((item) => excludeSystem(item) && excludeUntracked(item));
|
|
22
|
+
const fieldsFiltered = fieldsRaw.filter((item) => excludeSystem(item) && excludeUntracked(item));
|
|
23
|
+
const relationsFiltered = relationsRaw.filter((item) => excludeSystem(item) && excludeUntracked(item));
|
|
24
24
|
const collectionsSorted = sortBy(mapValues(collectionsFiltered, sortDeep), ['collection']);
|
|
25
25
|
const fieldsSorted = sortBy(mapValues(fieldsFiltered, sortDeep), ['collection', 'meta.id']).map(omitID);
|
|
26
26
|
const relationsSorted = sortBy(mapValues(relationsFiltered, sortDeep), ['collection', 'meta.id']).map(omitID);
|
|
@@ -38,6 +38,11 @@ function excludeSystem(item) {
|
|
|
38
38
|
return false;
|
|
39
39
|
return true;
|
|
40
40
|
}
|
|
41
|
+
function excludeUntracked(item) {
|
|
42
|
+
if (item?.meta === null)
|
|
43
|
+
return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
41
46
|
function omitID(item) {
|
|
42
47
|
return omit(item, 'meta.id');
|
|
43
48
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import type { Accountability } from '@directus/types';
|
|
1
2
|
import type { BasicAuthMessage } from './messages.js';
|
|
2
3
|
import type { AuthenticationState } from './types.js';
|
|
3
|
-
export declare function authenticateConnection(message: BasicAuthMessage & Record<string, any>): Promise<AuthenticationState>;
|
|
4
|
+
export declare function authenticateConnection(message: BasicAuthMessage & Record<string, any>, accountabilityOverrides?: Partial<Accountability>): Promise<AuthenticationState>;
|
|
4
5
|
export declare function authenticationSuccess(uid?: string | number, refresh_token?: string): string;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { isEqual } from 'lodash-es';
|
|
1
2
|
import { DEFAULT_AUTH_PROVIDER } from '../constants.js';
|
|
3
|
+
import getDatabase from '../database/index.js';
|
|
4
|
+
import emitter from '../emitter.js';
|
|
5
|
+
import { createDefaultAccountability } from '../permissions/utils/create-default-accountability.js';
|
|
2
6
|
import { AuthenticationService } from '../services/index.js';
|
|
3
7
|
import { getAccountabilityForToken } from '../utils/get-accountability-for-token.js';
|
|
4
8
|
import { getSchema } from '../utils/get-schema.js';
|
|
5
9
|
import { WebSocketError } from './errors.js';
|
|
6
10
|
import { getExpiresAtForToken } from './utils/get-expires-at-for-token.js';
|
|
7
|
-
export async function authenticateConnection(message) {
|
|
11
|
+
export async function authenticateConnection(message, accountabilityOverrides) {
|
|
8
12
|
let access_token, refresh_token;
|
|
9
13
|
try {
|
|
10
14
|
if ('email' in message && 'password' in message) {
|
|
@@ -24,9 +28,26 @@ export async function authenticateConnection(message) {
|
|
|
24
28
|
}
|
|
25
29
|
if (!access_token)
|
|
26
30
|
throw new Error();
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
31
|
+
const defaultAccountability = createDefaultAccountability(accountabilityOverrides);
|
|
32
|
+
const authenticationState = {
|
|
33
|
+
accountability: defaultAccountability,
|
|
34
|
+
expires_at: getExpiresAtForToken(access_token),
|
|
35
|
+
refresh_token,
|
|
36
|
+
};
|
|
37
|
+
const customAccountability = await emitter.emitFilter('websocket.authenticate', defaultAccountability, {
|
|
38
|
+
message,
|
|
39
|
+
}, {
|
|
40
|
+
database: getDatabase(),
|
|
41
|
+
schema: null,
|
|
42
|
+
accountability: null,
|
|
43
|
+
});
|
|
44
|
+
if (customAccountability && isEqual(customAccountability, defaultAccountability) === false) {
|
|
45
|
+
authenticationState.accountability = customAccountability;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
authenticationState.accountability = await getAccountabilityForToken(access_token, defaultAccountability);
|
|
49
|
+
}
|
|
50
|
+
return authenticationState;
|
|
30
51
|
}
|
|
31
52
|
catch {
|
|
32
53
|
throw new WebSocketError('auth', 'AUTH_FAILED', 'Authentication failed.', message['uid']);
|
|
@@ -10,12 +10,10 @@ import emitter from '../../emitter.js';
|
|
|
10
10
|
import { useLogger } from '../../logger/index.js';
|
|
11
11
|
import { createDefaultAccountability } from '../../permissions/utils/create-default-accountability.js';
|
|
12
12
|
import { createRateLimiter } from '../../rate-limiter.js';
|
|
13
|
-
import { getAccountabilityForToken } from '../../utils/get-accountability-for-token.js';
|
|
14
13
|
import { getIPFromReq } from '../../utils/get-ip-from-req.js';
|
|
15
14
|
import { authenticateConnection, authenticationSuccess } from '../authenticate.js';
|
|
16
15
|
import { WebSocketError, handleWebSocketError } from '../errors.js';
|
|
17
16
|
import { AuthMode, WebSocketAuthMessage, WebSocketMessage } from '../messages.js';
|
|
18
|
-
import { getExpiresAtForToken } from '../utils/get-expires-at-for-token.js';
|
|
19
17
|
import { getMessageType } from '../utils/message.js';
|
|
20
18
|
import { waitForAnyMessage, waitForMessageType } from '../utils/wait-for-message.js';
|
|
21
19
|
const TOKEN_CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes
|
|
@@ -139,8 +137,9 @@ export default class SocketController {
|
|
|
139
137
|
let expires_at = null;
|
|
140
138
|
if (token) {
|
|
141
139
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
const state = await authenticateConnection({ access_token: token }, accountabilityOverrides);
|
|
141
|
+
accountability = state.accountability;
|
|
142
|
+
expires_at = state.expires_at;
|
|
144
143
|
}
|
|
145
144
|
catch {
|
|
146
145
|
accountability = null;
|
|
@@ -162,7 +161,6 @@ export default class SocketController {
|
|
|
162
161
|
socket.destroy();
|
|
163
162
|
return;
|
|
164
163
|
}
|
|
165
|
-
Object.assign(accountability, accountabilityOverrides);
|
|
166
164
|
this.server.handleUpgrade(request, socket, head, async (ws) => {
|
|
167
165
|
this.catchInvalidMessages(ws);
|
|
168
166
|
const state = { accountability, expires_at };
|
|
@@ -176,10 +174,7 @@ export default class SocketController {
|
|
|
176
174
|
const payload = await waitForAnyMessage(ws, this.authentication.timeout);
|
|
177
175
|
if (getMessageType(payload) !== 'auth')
|
|
178
176
|
throw new Error();
|
|
179
|
-
const state = await authenticateConnection(WebSocketAuthMessage.parse(payload));
|
|
180
|
-
if (state.accountability) {
|
|
181
|
-
Object.assign(state.accountability, accountabilityOverrides);
|
|
182
|
-
}
|
|
177
|
+
const state = await authenticateConnection(WebSocketAuthMessage.parse(payload), accountabilityOverrides);
|
|
183
178
|
this.checkUserRequirements(state.accountability);
|
|
184
179
|
ws.send(authenticationSuccess(payload['uid'], state.refresh_token));
|
|
185
180
|
this.server.emit('connection', ws, state);
|
|
@@ -268,19 +263,20 @@ export default class SocketController {
|
|
|
268
263
|
}
|
|
269
264
|
async handleAuthRequest(client, message) {
|
|
270
265
|
try {
|
|
271
|
-
|
|
272
|
-
this.checkUserRequirements(accountability);
|
|
266
|
+
let accountabilityOverrides = {};
|
|
273
267
|
/**
|
|
274
268
|
* Re-use the existing ip, userAgent and origin accountability properties.
|
|
275
269
|
* They are only sent in the original connection request
|
|
276
270
|
*/
|
|
277
|
-
if (
|
|
278
|
-
|
|
271
|
+
if (client.accountability) {
|
|
272
|
+
accountabilityOverrides = {
|
|
279
273
|
ip: client.accountability.ip,
|
|
280
274
|
userAgent: client.accountability.userAgent,
|
|
281
275
|
origin: client.accountability.origin,
|
|
282
|
-
}
|
|
276
|
+
};
|
|
283
277
|
}
|
|
278
|
+
const { accountability, expires_at, refresh_token } = await authenticateConnection(message, accountabilityOverrides);
|
|
279
|
+
this.checkUserRequirements(accountability);
|
|
284
280
|
client.accountability = accountability;
|
|
285
281
|
client.expires_at = expires_at;
|
|
286
282
|
this.setTokenExpireTimer(client);
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { CloseCode, MessageType, makeServer } from 'graphql-ws';
|
|
2
2
|
import { useLogger } from '../../logger/index.js';
|
|
3
|
+
import { createDefaultAccountability } from '../../permissions/utils/create-default-accountability.js';
|
|
3
4
|
import { bindPubSub } from '../../services/graphql/subscription.js';
|
|
4
5
|
import { GraphQLService } from '../../services/index.js';
|
|
5
|
-
import { getSchema } from '../../utils/get-schema.js';
|
|
6
6
|
import { getAddress } from '../../utils/get-address.js';
|
|
7
|
+
import { getSchema } from '../../utils/get-schema.js';
|
|
7
8
|
import { authenticateConnection } from '../authenticate.js';
|
|
8
9
|
import { handleWebSocketError } from '../errors.js';
|
|
9
10
|
import { ConnectionParams, WebSocketMessage } from '../messages.js';
|
|
10
11
|
import { getMessageType } from '../utils/message.js';
|
|
11
12
|
import SocketController from './base.js';
|
|
12
13
|
import { registerWebSocketEvents } from './hooks.js';
|
|
13
|
-
import { createDefaultAccountability } from '../../permissions/utils/create-default-accountability.js';
|
|
14
14
|
const logger = useLogger();
|
|
15
15
|
export class GraphQLSubscriptionController extends SocketController {
|
|
16
16
|
gql;
|
|
@@ -51,6 +51,8 @@ export class GraphQLSubscriptionController extends SocketController {
|
|
|
51
51
|
if (typeof params.access_token === 'string') {
|
|
52
52
|
const { accountability, expires_at } = await authenticateConnection({
|
|
53
53
|
access_token: params.access_token,
|
|
54
|
+
}, {
|
|
55
|
+
ip: client.accountability?.ip ?? null,
|
|
54
56
|
});
|
|
55
57
|
client.accountability = accountability;
|
|
56
58
|
client.expires_at = expires_at;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directus/api",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "29.0.0",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"directus",
|
|
@@ -140,6 +140,7 @@
|
|
|
140
140
|
"qs": "6.14.0",
|
|
141
141
|
"rate-limiter-flexible": "5.0.5",
|
|
142
142
|
"rollup": "4.34.9",
|
|
143
|
+
"rolldown": "1.0.0-beta.28",
|
|
143
144
|
"samlify": "2.10.0",
|
|
144
145
|
"sanitize-html": "2.14.0",
|
|
145
146
|
"sharp": "0.33.5",
|
|
@@ -152,30 +153,30 @@
|
|
|
152
153
|
"ws": "8.18.1",
|
|
153
154
|
"zod": "3.24.2",
|
|
154
155
|
"zod-validation-error": "3.4.0",
|
|
155
|
-
"@directus/app": "13.
|
|
156
|
-
"@directus/env": "5.1.
|
|
157
|
-
"@directus/
|
|
156
|
+
"@directus/app": "13.12.0",
|
|
157
|
+
"@directus/env": "5.1.1",
|
|
158
|
+
"@directus/extensions-registry": "3.0.8",
|
|
159
|
+
"@directus/extensions": "3.0.8",
|
|
158
160
|
"@directus/errors": "2.0.2",
|
|
159
|
-
"@directus/extensions": "
|
|
160
|
-
"@directus/extensions-registry": "3.0.7",
|
|
161
|
+
"@directus/extensions-sdk": "15.0.0",
|
|
161
162
|
"@directus/format-title": "12.0.1",
|
|
162
|
-
"@directus/
|
|
163
|
-
"@directus/memory": "3.0.6",
|
|
164
|
-
"@directus/pressure": "3.0.6",
|
|
163
|
+
"@directus/memory": "3.0.7",
|
|
165
164
|
"@directus/schema": "13.0.1",
|
|
165
|
+
"@directus/pressure": "3.0.7",
|
|
166
166
|
"@directus/schema-builder": "0.0.3",
|
|
167
|
-
"@directus/specs": "11.1.0",
|
|
168
167
|
"@directus/storage": "12.0.0",
|
|
169
|
-
"@directus/
|
|
170
|
-
"@directus/storage-driver-
|
|
171
|
-
"@directus/storage-driver-gcs": "12.0.
|
|
168
|
+
"@directus/specs": "11.1.0",
|
|
169
|
+
"@directus/storage-driver-azure": "12.0.7",
|
|
170
|
+
"@directus/storage-driver-gcs": "12.0.7",
|
|
171
|
+
"@directus/storage-driver-cloudinary": "12.0.7",
|
|
172
|
+
"@directus/storage-driver-s3": "12.0.7",
|
|
172
173
|
"@directus/storage-driver-local": "12.0.0",
|
|
173
|
-
"@directus/storage-driver-supabase": "3.0.
|
|
174
|
-
"@directus/
|
|
175
|
-
"@directus/
|
|
176
|
-
"@directus/
|
|
177
|
-
"directus": "
|
|
178
|
-
"
|
|
174
|
+
"@directus/storage-driver-supabase": "3.0.7",
|
|
175
|
+
"@directus/constants": "13.0.1",
|
|
176
|
+
"@directus/system-data": "3.2.0",
|
|
177
|
+
"@directus/utils": "13.0.8",
|
|
178
|
+
"@directus/validation": "2.0.7",
|
|
179
|
+
"directus": "11.10.0"
|
|
179
180
|
},
|
|
180
181
|
"devDependencies": {
|
|
181
182
|
"@directus/tsconfig": "3.0.0",
|
|
@@ -201,7 +202,7 @@
|
|
|
201
202
|
"@types/lodash-es": "4.17.12",
|
|
202
203
|
"@types/mime-types": "2.1.4",
|
|
203
204
|
"@types/ms": "2.1.0",
|
|
204
|
-
"@types/node": "22.13.
|
|
205
|
+
"@types/node": "22.13.14",
|
|
205
206
|
"@types/node-schedule": "2.1.7",
|
|
206
207
|
"@types/nodemailer": "6.4.17",
|
|
207
208
|
"@types/object-hash": "3.0.6",
|
|
@@ -212,16 +213,16 @@
|
|
|
212
213
|
"@types/stream-json": "1.7.8",
|
|
213
214
|
"@types/wellknown": "0.5.8",
|
|
214
215
|
"@types/ws": "8.5.14",
|
|
215
|
-
"@vitest/coverage-v8": "2.
|
|
216
|
+
"@vitest/coverage-v8": "3.2.4",
|
|
216
217
|
"copyfiles": "2.4.1",
|
|
217
218
|
"form-data": "4.0.2",
|
|
218
219
|
"get-port": "7.1.0",
|
|
219
220
|
"knex-mock-client": "3.0.2",
|
|
220
|
-
"typescript": "5.8.
|
|
221
|
-
"vitest": "2.
|
|
222
|
-
"@directus/random": "2.0.1",
|
|
221
|
+
"typescript": "5.8.3",
|
|
222
|
+
"vitest": "3.2.4",
|
|
223
223
|
"@directus/schema-builder": "0.0.3",
|
|
224
|
-
"@directus/types": "13.2.0"
|
|
224
|
+
"@directus/types": "13.2.0",
|
|
225
|
+
"@directus/random": "2.0.1"
|
|
225
226
|
},
|
|
226
227
|
"optionalDependencies": {
|
|
227
228
|
"@keyv/redis": "3.0.1",
|
|
@@ -240,6 +241,7 @@
|
|
|
240
241
|
"cli": "NODE_ENV=development SERVE_APP=false tsx src/cli/run.ts",
|
|
241
242
|
"dev": "NODE_ENV=development SERVE_APP=true tsx watch --ignore extensions --clear-screen=false src/start.ts",
|
|
242
243
|
"test": "vitest run",
|
|
243
|
-
"test:watch": "vitest"
|
|
244
|
+
"test:watch": "vitest",
|
|
245
|
+
"test:coverage": "vitest run --coverage"
|
|
244
246
|
}
|
|
245
247
|
}
|