@directus/api 22.1.0 → 22.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/cache.d.ts +2 -2
  3. package/dist/cache.js +2 -2
  4. package/dist/constants.d.ts +1 -0
  5. package/dist/constants.js +1 -0
  6. package/dist/database/get-ast-from-query/get-ast-from-query.js +2 -31
  7. package/dist/database/get-ast-from-query/lib/parse-fields.d.ts +2 -1
  8. package/dist/database/get-ast-from-query/lib/parse-fields.js +21 -3
  9. package/dist/database/get-ast-from-query/utils/get-allowed-sort.d.ts +9 -0
  10. package/dist/database/get-ast-from-query/utils/get-allowed-sort.js +35 -0
  11. package/dist/database/helpers/fn/types.d.ts +6 -3
  12. package/dist/database/helpers/fn/types.js +2 -2
  13. package/dist/database/helpers/index.d.ts +2 -0
  14. package/dist/database/helpers/index.js +2 -0
  15. package/dist/database/helpers/nullable-update/dialects/default.d.ts +3 -0
  16. package/dist/database/helpers/nullable-update/dialects/default.js +3 -0
  17. package/dist/database/helpers/nullable-update/dialects/oracle.d.ts +12 -0
  18. package/dist/database/helpers/nullable-update/dialects/oracle.js +16 -0
  19. package/dist/database/helpers/nullable-update/index.d.ts +7 -0
  20. package/dist/database/helpers/nullable-update/index.js +7 -0
  21. package/dist/database/helpers/nullable-update/types.d.ts +7 -0
  22. package/dist/database/helpers/nullable-update/types.js +12 -0
  23. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +3 -1
  24. package/dist/database/helpers/schema/dialects/cockroachdb.js +17 -0
  25. package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
  26. package/dist/database/helpers/schema/dialects/mssql.js +20 -0
  27. package/dist/database/helpers/schema/dialects/mysql.d.ts +2 -1
  28. package/dist/database/helpers/schema/dialects/mysql.js +33 -0
  29. package/dist/database/helpers/schema/dialects/oracle.d.ts +3 -1
  30. package/dist/database/helpers/schema/dialects/oracle.js +21 -0
  31. package/dist/database/helpers/schema/dialects/postgres.d.ts +3 -1
  32. package/dist/database/helpers/schema/dialects/postgres.js +23 -0
  33. package/dist/database/helpers/schema/dialects/sqlite.d.ts +1 -0
  34. package/dist/database/helpers/schema/dialects/sqlite.js +3 -0
  35. package/dist/database/helpers/schema/types.d.ts +5 -0
  36. package/dist/database/helpers/schema/types.js +3 -0
  37. package/dist/database/helpers/schema/utils/preprocess-bindings.d.ts +5 -1
  38. package/dist/database/helpers/schema/utils/preprocess-bindings.js +23 -17
  39. package/dist/database/index.d.ts +1 -1
  40. package/dist/database/index.js +2 -2
  41. package/dist/database/migrations/20240806A-permissions-policies.js +3 -2
  42. package/dist/database/migrations/20240817A-update-icon-fields-length.d.ts +3 -0
  43. package/dist/database/migrations/20240817A-update-icon-fields-length.js +55 -0
  44. package/dist/database/run-ast/lib/get-db-query.d.ts +2 -2
  45. package/dist/database/run-ast/lib/get-db-query.js +23 -13
  46. package/dist/database/run-ast/run-ast.d.ts +2 -2
  47. package/dist/database/run-ast/run-ast.js +14 -7
  48. package/dist/database/run-ast/utils/apply-case-when.d.ts +3 -2
  49. package/dist/database/run-ast/utils/apply-case-when.js +2 -2
  50. package/dist/database/run-ast/utils/get-column-pre-processor.d.ts +2 -2
  51. package/dist/database/run-ast/utils/get-column-pre-processor.js +3 -1
  52. package/dist/database/run-ast/utils/get-inner-query-column-pre-processor.d.ts +2 -2
  53. package/dist/database/run-ast/utils/get-inner-query-column-pre-processor.js +2 -1
  54. package/dist/extensions/manager.js +2 -2
  55. package/dist/logger/index.d.ts +6 -0
  56. package/dist/logger/index.js +79 -28
  57. package/dist/logger/logs-stream.d.ts +11 -0
  58. package/dist/logger/logs-stream.js +41 -0
  59. package/dist/middleware/respond.js +1 -0
  60. package/dist/permissions/lib/fetch-permissions.d.ts +2 -3
  61. package/dist/permissions/lib/fetch-permissions.js +5 -39
  62. package/dist/permissions/modules/fetch-allowed-collections/fetch-allowed-collections.d.ts +1 -2
  63. package/dist/permissions/modules/fetch-allowed-collections/fetch-allowed-collections.js +1 -13
  64. package/dist/permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.d.ts +1 -2
  65. package/dist/permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.js +1 -6
  66. package/dist/permissions/modules/fetch-allowed-fields/fetch-allowed-fields.d.ts +1 -2
  67. package/dist/permissions/modules/fetch-allowed-fields/fetch-allowed-fields.js +1 -7
  68. package/dist/permissions/modules/fetch-inconsistent-field-map/fetch-inconsistent-field-map.d.ts +1 -2
  69. package/dist/permissions/modules/fetch-inconsistent-field-map/fetch-inconsistent-field-map.js +2 -7
  70. package/dist/permissions/modules/process-ast/lib/get-cases.d.ts +6 -0
  71. package/dist/permissions/modules/process-ast/lib/get-cases.js +40 -0
  72. package/dist/permissions/modules/process-ast/lib/inject-cases.js +1 -40
  73. package/dist/permissions/modules/process-payload/process-payload.js +4 -5
  74. package/dist/permissions/modules/validate-access/lib/validate-item-access.js +7 -6
  75. package/dist/permissions/utils/fetch-dynamic-variable-context.d.ts +1 -2
  76. package/dist/permissions/utils/fetch-dynamic-variable-context.js +44 -24
  77. package/dist/permissions/utils/fetch-raw-permissions.d.ts +11 -0
  78. package/dist/permissions/utils/fetch-raw-permissions.js +39 -0
  79. package/dist/request/is-denied-ip.js +7 -1
  80. package/dist/server.js +4 -2
  81. package/dist/services/fields.d.ts +1 -1
  82. package/dist/services/fields.js +66 -25
  83. package/dist/services/import-export.js +2 -2
  84. package/dist/services/items.js +1 -1
  85. package/dist/services/mail/index.js +1 -5
  86. package/dist/services/meta.js +8 -7
  87. package/dist/services/notifications.d.ts +0 -4
  88. package/dist/services/notifications.js +8 -6
  89. package/dist/services/permissions.js +19 -19
  90. package/dist/services/server.js +8 -1
  91. package/dist/services/specifications.js +7 -7
  92. package/dist/services/users.js +4 -1
  93. package/dist/utils/apply-query.d.ts +3 -3
  94. package/dist/utils/apply-query.js +25 -20
  95. package/dist/utils/get-address.d.ts +1 -1
  96. package/dist/utils/get-address.js +6 -1
  97. package/dist/utils/get-allowed-log-levels.d.ts +3 -0
  98. package/dist/utils/get-allowed-log-levels.js +11 -0
  99. package/dist/utils/get-column.d.ts +8 -4
  100. package/dist/utils/get-column.js +10 -2
  101. package/dist/utils/get-schema.js +19 -24
  102. package/dist/utils/parse-filter-key.js +1 -5
  103. package/dist/utils/sanitize-query.js +1 -1
  104. package/dist/utils/sanitize-schema.d.ts +1 -1
  105. package/dist/websocket/controllers/base.d.ts +10 -10
  106. package/dist/websocket/controllers/base.js +22 -3
  107. package/dist/websocket/controllers/graphql.js +3 -1
  108. package/dist/websocket/controllers/index.d.ts +4 -0
  109. package/dist/websocket/controllers/index.js +12 -0
  110. package/dist/websocket/controllers/logs.d.ts +18 -0
  111. package/dist/websocket/controllers/logs.js +50 -0
  112. package/dist/websocket/controllers/rest.js +3 -1
  113. package/dist/websocket/handlers/index.d.ts +1 -0
  114. package/dist/websocket/handlers/index.js +21 -3
  115. package/dist/websocket/handlers/logs.d.ts +31 -0
  116. package/dist/websocket/handlers/logs.js +121 -0
  117. package/dist/websocket/messages.d.ts +26 -0
  118. package/dist/websocket/messages.js +9 -0
  119. package/dist/websocket/types.d.ts +7 -0
  120. package/package.json +27 -26
@@ -4,7 +4,7 @@ import { systemCollectionRows } from '@directus/system-data';
4
4
  import { parseJSON, toArray } from '@directus/utils';
5
5
  import { mapValues } from 'lodash-es';
6
6
  import { useBus } from '../bus/index.js';
7
- import { getSchemaCache, setSchemaCache } from '../cache.js';
7
+ import { getLocalSchemaCache, setLocalSchemaCache } from '../cache.js';
8
8
  import { ALIAS_TYPES } from '../constants.js';
9
9
  import getDatabase from '../database/index.js';
10
10
  import { useLock } from '../lock/index.js';
@@ -22,7 +22,7 @@ export async function getSchema(options, attempt = 0) {
22
22
  const schemaInspector = createInspector(database);
23
23
  return await getDatabaseSchema(database, schemaInspector);
24
24
  }
25
- const cached = await getSchemaCache();
25
+ const cached = await getLocalSchemaCache();
26
26
  if (cached) {
27
27
  return cached;
28
28
  }
@@ -40,39 +40,34 @@ export async function getSchema(options, attempt = 0) {
40
40
  const currentProcessShouldHandleOperation = processId === 1;
41
41
  if (currentProcessShouldHandleOperation === false) {
42
42
  logger.trace('Schema cache is prepared in another process, waiting for result.');
43
- return new Promise((resolve, reject) => {
44
- const TIMEOUT = 10000;
45
- const timeout = setTimeout(() => {
46
- logger.trace('Did not receive schema callback message in time. Pulling schema...');
47
- callback().catch(reject);
48
- }, TIMEOUT);
49
- bus.subscribe(messageKey, callback);
50
- async function callback() {
51
- try {
52
- if (timeout)
53
- clearTimeout(timeout);
54
- const schema = await getSchema(options, attempt + 1);
55
- resolve(schema);
56
- }
57
- catch (error) {
58
- reject(error);
59
- }
60
- finally {
61
- bus.unsubscribe(messageKey, callback);
43
+ const timeout = new Promise((_, reject) => setTimeout(reject, env['CACHE_SCHEMA_SYNC_TIMEOUT']));
44
+ const subscription = new Promise((resolve, reject) => {
45
+ bus.subscribe(messageKey, busListener).catch(reject);
46
+ function busListener(options) {
47
+ if (options.schema === null) {
48
+ return reject();
62
49
  }
50
+ cleanup();
51
+ setLocalSchemaCache(options.schema).catch(reject);
52
+ resolve(options.schema);
53
+ }
54
+ function cleanup() {
55
+ bus.unsubscribe(messageKey, busListener).catch(reject);
63
56
  }
64
57
  });
58
+ return Promise.race([timeout, subscription]).catch(() => getSchema(options, attempt + 1));
65
59
  }
60
+ let schema = null;
66
61
  try {
67
62
  const database = options?.database || getDatabase();
68
63
  const schemaInspector = createInspector(database);
69
- const schema = await getDatabaseSchema(database, schemaInspector);
70
- await setSchemaCache(schema);
64
+ schema = await getDatabaseSchema(database, schemaInspector);
65
+ await setLocalSchemaCache(schema);
71
66
  return schema;
72
67
  }
73
68
  finally {
69
+ await bus.publish(messageKey, { schema });
74
70
  await lock.delete(lockKey);
75
- bus.publish(messageKey, { ready: true });
76
71
  }
77
72
  }
78
73
  async function getDatabaseSchema(database, schemaInspector) {
@@ -13,10 +13,6 @@ export function parseFilterKey(key) {
13
13
  const match = key.match(FILTER_KEY_REGEX);
14
14
  const fieldNameWithFunction = match?.[3]?.trim();
15
15
  const fieldName = fieldNameWithFunction || key.trim();
16
- let functionName;
17
- if (fieldNameWithFunction) {
18
- functionName = match?.[1]?.trim();
19
- return { fieldName, functionName };
20
- }
16
+ const functionName = fieldNameWithFunction ? match?.[1]?.trim() : undefined;
21
17
  return { fieldName, functionName };
22
18
  }
@@ -95,7 +95,7 @@ function sanitizeAggregate(rawAggregate) {
95
95
  aggregate = parseJSON(rawAggregate);
96
96
  }
97
97
  catch {
98
- logger.warn('Invalid value passed for filter query parameter.');
98
+ logger.warn('Invalid value passed for aggregate query parameter.');
99
99
  }
100
100
  }
101
101
  for (const [operation, fields] of Object.entries(aggregate)) {
@@ -16,7 +16,7 @@ export declare function sanitizeCollection(collection: Collection | undefined):
16
16
  * @returns sanitized field
17
17
  */
18
18
  export declare function sanitizeField(field: Field | undefined, sanitizeAllSchema?: boolean): Partial<Field> | undefined;
19
- export declare function sanitizeColumn(column: Column): Pick<Column, "table" | "name" | "numeric_precision" | "numeric_scale" | "data_type" | "default_value" | "max_length" | "is_nullable" | "is_unique" | "is_primary_key" | "is_generated" | "generation_expression" | "has_auto_increment" | "foreign_key_table" | "foreign_key_column">;
19
+ export declare function sanitizeColumn(column: Column): Pick<Column, "table" | "name" | "numeric_precision" | "data_type" | "default_value" | "max_length" | "numeric_scale" | "is_nullable" | "is_unique" | "is_primary_key" | "is_generated" | "generation_expression" | "has_auto_increment" | "foreign_key_table" | "foreign_key_column">;
20
20
  /**
21
21
  * Pick certain database vendor specific relation properties that should be compared when performing diff
22
22
  *
@@ -3,19 +3,17 @@
3
3
  /// <reference types="node" resolution-mode="require"/>
4
4
  /// <reference types="node/http.js" />
5
5
  /// <reference types="pino-http" />
6
+ import type { Accountability } from '@directus/types';
6
7
  import type { IncomingMessage, Server as httpServer } from 'http';
7
8
  import type { RateLimiterAbstract } from 'rate-limiter-flexible';
8
9
  import type internal from 'stream';
9
10
  import WebSocket from 'ws';
10
- import { AuthMode, WebSocketAuthMessage, WebSocketMessage } from '../messages.js';
11
- import type { AuthenticationState, UpgradeContext, WebSocketClient } from '../types.js';
11
+ import { WebSocketAuthMessage, WebSocketMessage } from '../messages.js';
12
+ import type { AuthenticationState, UpgradeContext, WebSocketAuthentication, WebSocketClient } from '../types.js';
12
13
  export default abstract class SocketController {
13
14
  server: WebSocket.Server;
14
15
  clients: Set<WebSocketClient>;
15
- authentication: {
16
- mode: AuthMode;
17
- timeout: number;
18
- };
16
+ authentication: WebSocketAuthentication;
19
17
  endpoint: string;
20
18
  maxConnections: number;
21
19
  private rateLimiter;
@@ -23,10 +21,7 @@ export default abstract class SocketController {
23
21
  constructor(httpServer: httpServer, configPrefix: string);
24
22
  protected getEnvironmentConfig(configPrefix: string): {
25
23
  endpoint: string;
26
- authentication: {
27
- mode: AuthMode;
28
- timeout: number;
29
- };
24
+ authentication: WebSocketAuthentication;
30
25
  maxConnections: number;
31
26
  };
32
27
  protected getRateLimiter(): RateLimiterAbstract | null;
@@ -39,5 +34,10 @@ export default abstract class SocketController {
39
34
  protected handleAuthRequest(client: WebSocketClient, message: WebSocketAuthMessage): Promise<void>;
40
35
  setTokenExpireTimer(client: WebSocketClient): void;
41
36
  checkClientTokens(): void;
37
+ meetsAdminRequirement({ socket, client, accountability, }: {
38
+ socket?: UpgradeContext['socket'];
39
+ client?: WebSocketClient | WebSocket;
40
+ accountability: Accountability | null;
41
+ }): boolean;
42
42
  terminate(): void;
43
43
  }
@@ -1,6 +1,7 @@
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 cookie from 'cookie';
4
5
  import { randomUUID } from 'node:crypto';
5
6
  import { parse } from 'url';
6
7
  import WebSocket, { WebSocketServer } from 'ws';
@@ -15,8 +16,6 @@ import { AuthMode, WebSocketAuthMessage, WebSocketMessage } from '../messages.js
15
16
  import { getExpiresAtForToken } from '../utils/get-expires-at-for-token.js';
16
17
  import { getMessageType } from '../utils/message.js';
17
18
  import { waitForAnyMessage, waitForMessageType } from '../utils/wait-for-message.js';
18
- import { registerWebSocketEvents } from './hooks.js';
19
- import cookie from 'cookie';
20
19
  const TOKEN_CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes
21
20
  const logger = useLogger();
22
21
  export default class SocketController {
@@ -42,7 +41,6 @@ export default class SocketController {
42
41
  this.rateLimiter = this.getRateLimiter();
43
42
  httpServer.on('upgrade', this.handleUpgrade.bind(this));
44
43
  this.checkClientTokens();
45
- registerWebSocketEvents();
46
44
  }
47
45
  getEnvironmentConfig(configPrefix) {
48
46
  const env = useEnv();
@@ -62,6 +60,7 @@ export default class SocketController {
62
60
  authentication: {
63
61
  mode: authMode.data,
64
62
  timeout: authTimeout,
63
+ requireAdmin: false,
65
64
  },
66
65
  };
67
66
  }
@@ -141,6 +140,8 @@ export default class SocketController {
141
140
  socket.destroy();
142
141
  return;
143
142
  }
143
+ if (!this.meetsAdminRequirement({ socket, accountability }))
144
+ return;
144
145
  this.server.handleUpgrade(request, socket, head, async (ws) => {
145
146
  this.catchInvalidMessages(ws);
146
147
  const state = { accountability, expires_at };
@@ -155,6 +156,8 @@ export default class SocketController {
155
156
  if (getMessageType(payload) !== 'auth')
156
157
  throw new Error();
157
158
  const state = await authenticateConnection(WebSocketAuthMessage.parse(payload));
159
+ if (this.meetsAdminRequirement({ client: ws, accountability: state.accountability }))
160
+ return;
158
161
  ws.send(authenticationSuccess(payload['uid'], state.refresh_token));
159
162
  this.server.emit('connection', ws, state);
160
163
  }
@@ -243,6 +246,8 @@ export default class SocketController {
243
246
  async handleAuthRequest(client, message) {
244
247
  try {
245
248
  const { accountability, expires_at, refresh_token } = await authenticateConnection(message);
249
+ if (!this.meetsAdminRequirement({ client, accountability }))
250
+ return;
246
251
  client.accountability = accountability;
247
252
  client.expires_at = expires_at;
248
253
  this.setTokenExpireTimer(client);
@@ -300,6 +305,20 @@ export default class SocketController {
300
305
  }
301
306
  }, TOKEN_CHECK_INTERVAL);
302
307
  }
308
+ meetsAdminRequirement({ socket, client, accountability, }) {
309
+ if (!this.authentication.requireAdmin || accountability?.admin)
310
+ return true;
311
+ logger.debug('WebSocket connection denied - ' + JSON.stringify(accountability || 'invalid'));
312
+ if (socket) {
313
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
314
+ socket.destroy();
315
+ }
316
+ else if (client) {
317
+ handleWebSocketError(client, new WebSocketError('auth', 'UNAUTHORIZED', 'Unauthorized.'), 'auth');
318
+ client.close();
319
+ }
320
+ return false;
321
+ }
303
322
  terminate() {
304
323
  if (this.authInterval)
305
324
  clearInterval(this.authInterval);
@@ -9,11 +9,13 @@ import { handleWebSocketError } from '../errors.js';
9
9
  import { ConnectionParams, WebSocketMessage } from '../messages.js';
10
10
  import { getMessageType } from '../utils/message.js';
11
11
  import SocketController from './base.js';
12
+ import { registerWebSocketEvents } from './hooks.js';
12
13
  const logger = useLogger();
13
14
  export class GraphQLSubscriptionController extends SocketController {
14
15
  gql;
15
16
  constructor(httpServer) {
16
17
  super(httpServer, 'WEBSOCKETS_GRAPHQL');
18
+ registerWebSocketEvents();
17
19
  this.server.on('connection', (ws, auth) => {
18
20
  this.bindEvents(this.createClient(ws, auth));
19
21
  });
@@ -30,7 +32,7 @@ export class GraphQLSubscriptionController extends SocketController {
30
32
  },
31
33
  });
32
34
  bindPubSub();
33
- logger.info(`GraphQL Subscriptions started at ws://${getAddress(httpServer)}${this.endpoint}`);
35
+ logger.info(`GraphQL Subscriptions started at ${getAddress(httpServer)}${this.endpoint}`);
34
36
  }
35
37
  bindEvents(client) {
36
38
  const closedHandler = this.gql.opened({
@@ -3,10 +3,14 @@
3
3
  /// <reference types="pino-http" />
4
4
  import type { Server as httpServer } from 'http';
5
5
  import { GraphQLSubscriptionController } from './graphql.js';
6
+ import { LogsController } from './logs.js';
6
7
  import { WebSocketController } from './rest.js';
7
8
  export declare function createWebSocketController(server: httpServer): void;
8
9
  export declare function getWebSocketController(): WebSocketController | undefined;
9
10
  export declare function createSubscriptionController(server: httpServer): void;
10
11
  export declare function getSubscriptionController(): GraphQLSubscriptionController | undefined;
12
+ export declare function createLogsController(server: httpServer): void;
13
+ export declare function getLogsController(): LogsController | undefined;
11
14
  export * from './graphql.js';
15
+ export * from './logs.js';
12
16
  export * from './rest.js';
@@ -1,9 +1,11 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { toBoolean } from '@directus/utils';
3
3
  import { GraphQLSubscriptionController } from './graphql.js';
4
+ import { LogsController } from './logs.js';
4
5
  import { WebSocketController } from './rest.js';
5
6
  let websocketController;
6
7
  let subscriptionController;
8
+ let logsController;
7
9
  export function createWebSocketController(server) {
8
10
  const env = useEnv();
9
11
  if (toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
@@ -22,5 +24,15 @@ export function createSubscriptionController(server) {
22
24
  export function getSubscriptionController() {
23
25
  return subscriptionController;
24
26
  }
27
+ export function createLogsController(server) {
28
+ const env = useEnv();
29
+ if (toBoolean(env['WEBSOCKETS_LOGS_ENABLED'])) {
30
+ logsController = new LogsController(server);
31
+ }
32
+ }
33
+ export function getLogsController() {
34
+ return logsController;
35
+ }
25
36
  export * from './graphql.js';
37
+ export * from './logs.js';
26
38
  export * from './rest.js';
@@ -0,0 +1,18 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node/http.js" />
3
+ /// <reference types="pino-http" />
4
+ import type { Server as httpServer } from 'http';
5
+ import SocketController from './base.js';
6
+ export declare class LogsController extends SocketController {
7
+ constructor(httpServer: httpServer);
8
+ getEnvironmentConfig(configPrefix: string): {
9
+ endpoint: string;
10
+ maxConnections: number;
11
+ authentication: {
12
+ mode: "strict" | "public" | "handshake";
13
+ timeout: number;
14
+ requireAdmin: boolean;
15
+ };
16
+ };
17
+ private bindEvents;
18
+ }
@@ -0,0 +1,50 @@
1
+ import { useEnv } from '@directus/env';
2
+ import emitter from '../../emitter.js';
3
+ import { useLogger } from '../../logger/index.js';
4
+ import { handleWebSocketError } from '../errors.js';
5
+ import { AuthMode, WebSocketMessage } from '../messages.js';
6
+ import SocketController from './base.js';
7
+ const logger = useLogger();
8
+ export class LogsController extends SocketController {
9
+ constructor(httpServer) {
10
+ super(httpServer, 'WEBSOCKETS_LOGS');
11
+ const env = useEnv();
12
+ this.server.on('connection', (ws, auth) => {
13
+ this.bindEvents(this.createClient(ws, auth));
14
+ });
15
+ logger.info(`Logs WebSocket Server started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
16
+ }
17
+ getEnvironmentConfig(configPrefix) {
18
+ const env = useEnv();
19
+ const endpoint = String(env[`${configPrefix}_PATH`]);
20
+ const maxConnections = `${configPrefix}_CONN_LIMIT` in env ? Number(env[`${configPrefix}_CONN_LIMIT`]) : Number.POSITIVE_INFINITY;
21
+ return {
22
+ endpoint,
23
+ maxConnections,
24
+ // require strict auth
25
+ authentication: {
26
+ mode: 'strict',
27
+ timeout: 0,
28
+ requireAdmin: true,
29
+ },
30
+ };
31
+ }
32
+ bindEvents(client) {
33
+ client.on('parsed-message', async (message) => {
34
+ try {
35
+ emitter.emitAction('websocket.logs', { message, client });
36
+ }
37
+ catch (error) {
38
+ handleWebSocketError(client, error, 'server');
39
+ return;
40
+ }
41
+ });
42
+ client.on('error', (event) => {
43
+ emitter.emitAction('websocket.error', { client, event });
44
+ });
45
+ client.on('close', (event) => {
46
+ emitter.emitAction('websocket.close', { client, event });
47
+ });
48
+ emitter.emitAction('websocket.connect', { client });
49
+ }
50
+ }
@@ -5,14 +5,16 @@ import { getAddress } from '../../utils/get-address.js';
5
5
  import { WebSocketError, handleWebSocketError } from '../errors.js';
6
6
  import { WebSocketMessage } from '../messages.js';
7
7
  import SocketController from './base.js';
8
+ import { registerWebSocketEvents } from './hooks.js';
8
9
  const logger = useLogger();
9
10
  export class WebSocketController extends SocketController {
10
11
  constructor(httpServer) {
11
12
  super(httpServer, 'WEBSOCKETS_REST');
13
+ registerWebSocketEvents();
12
14
  this.server.on('connection', (ws, auth) => {
13
15
  this.bindEvents(this.createClient(ws, auth));
14
16
  });
15
- logger.info(`WebSocket Server started at ws://${getAddress(httpServer)}${this.endpoint}`);
17
+ logger.info(`WebSocket Server started at ${getAddress(httpServer)}${this.endpoint}`);
16
18
  }
17
19
  bindEvents(client) {
18
20
  client.on('parsed-message', async (message) => {
@@ -1,4 +1,5 @@
1
1
  export declare function startWebSocketHandlers(): void;
2
2
  export * from './heartbeat.js';
3
3
  export * from './items.js';
4
+ export * from './logs.js';
4
5
  export * from './subscribe.js';
@@ -1,11 +1,29 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { toBoolean } from '@directus/utils';
1
3
  import { HeartbeatHandler } from './heartbeat.js';
2
4
  import { ItemsHandler } from './items.js';
5
+ import { LogsHandler } from './logs.js';
3
6
  import { SubscribeHandler } from './subscribe.js';
4
7
  export function startWebSocketHandlers() {
5
- new HeartbeatHandler();
6
- new ItemsHandler();
7
- new SubscribeHandler();
8
+ const env = useEnv();
9
+ const heartbeatEnabled = toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']);
10
+ const restEnabled = toBoolean(env['WEBSOCKETS_REST_ENABLED']);
11
+ const graphqlEnabled = toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED']);
12
+ const logsEnabled = toBoolean(env['WEBSOCKETS_LOGS_ENABLED']);
13
+ if (heartbeatEnabled) {
14
+ new HeartbeatHandler();
15
+ }
16
+ if (restEnabled || graphqlEnabled) {
17
+ new ItemsHandler();
18
+ }
19
+ if (restEnabled) {
20
+ new SubscribeHandler();
21
+ }
22
+ if (logsEnabled) {
23
+ new LogsHandler();
24
+ }
8
25
  }
9
26
  export * from './heartbeat.js';
10
27
  export * from './items.js';
28
+ export * from './logs.js';
11
29
  export * from './subscribe.js';
@@ -0,0 +1,31 @@
1
+ import type { Bus } from '@directus/memory';
2
+ import { LogsController } from '../controllers/index.js';
3
+ import { WebSocketLogsMessage } from '../messages.js';
4
+ import type { LogsSubscription, WebSocketClient } from '../types.js';
5
+ export declare class LogsHandler {
6
+ controller: LogsController;
7
+ messenger: Bus;
8
+ availableLogLevels: string[];
9
+ logLevelValueMap: Record<string, string>;
10
+ subscriptions: LogsSubscription;
11
+ constructor(controller?: LogsController);
12
+ /**
13
+ * Hook into websocket client lifecycle events
14
+ */
15
+ bindWebSocket(): void;
16
+ /**
17
+ * Register a logs subscription
18
+ * @param logLevel
19
+ * @param client
20
+ */
21
+ subscribe(logLevel: string, client: WebSocketClient): void;
22
+ /**
23
+ * Remove a logs subscription
24
+ * @param client WebSocketClient
25
+ */
26
+ unsubscribe(client: WebSocketClient): void;
27
+ /**
28
+ * Handle incoming (un)subscribe requests
29
+ */
30
+ onMessage(client: WebSocketClient, message: WebSocketLogsMessage): Promise<void>;
31
+ }
@@ -0,0 +1,121 @@
1
+ import { ErrorCode, ServiceUnavailableError } from '@directus/errors';
2
+ import { useBus } from '../../bus/index.js';
3
+ import emitter from '../../emitter.js';
4
+ import { useLogger } from '../../logger/index.js';
5
+ import { getAllowedLogLevels } from '../../utils/get-allowed-log-levels.js';
6
+ import { getLogsController, LogsController } from '../controllers/index.js';
7
+ import { handleWebSocketError, WebSocketError } from '../errors.js';
8
+ import { WebSocketLogsMessage } from '../messages.js';
9
+ import { fmtMessage, getMessageType } from '../utils/message.js';
10
+ const logger = useLogger();
11
+ export class LogsHandler {
12
+ controller;
13
+ messenger;
14
+ availableLogLevels;
15
+ logLevelValueMap;
16
+ subscriptions;
17
+ constructor(controller) {
18
+ controller = controller ?? getLogsController();
19
+ if (!controller) {
20
+ throw new ServiceUnavailableError({ service: 'ws', reason: 'WebSocket server is not initialized' });
21
+ }
22
+ this.controller = controller;
23
+ this.messenger = useBus();
24
+ this.availableLogLevels = Object.keys(logger.levels.values);
25
+ this.logLevelValueMap = Object.fromEntries(Object.entries(logger.levels.values).map(([key, value]) => [value, key]));
26
+ this.subscriptions = this.availableLogLevels.reduce((acc, logLevel) => {
27
+ acc[logLevel] = new Set();
28
+ return acc;
29
+ }, {});
30
+ this.bindWebSocket();
31
+ this.messenger.subscribe('logs', (message) => {
32
+ const { log, nodeId } = JSON.parse(message);
33
+ const logLevel = this.logLevelValueMap[log['level']];
34
+ if (logLevel) {
35
+ this.subscriptions[logLevel]?.forEach((subscription) => subscription.send(fmtMessage('logs', { data: log }, nodeId)));
36
+ }
37
+ });
38
+ }
39
+ /**
40
+ * Hook into websocket client lifecycle events
41
+ */
42
+ bindWebSocket() {
43
+ // listen to incoming messages on the connected websockets
44
+ emitter.onAction('websocket.logs', ({ client, message }) => {
45
+ if (!['subscribe', 'unsubscribe'].includes(getMessageType(message)))
46
+ return;
47
+ try {
48
+ const parsedMessage = WebSocketLogsMessage.parse(message);
49
+ this.onMessage(client, parsedMessage).catch((error) => {
50
+ // this catch is required because the async onMessage function is not awaited
51
+ handleWebSocketError(client, error, 'logs');
52
+ });
53
+ }
54
+ catch (error) {
55
+ handleWebSocketError(client, error, 'logs');
56
+ }
57
+ });
58
+ // unsubscribe when a connection drops
59
+ emitter.onAction('websocket.error', ({ client }) => this.unsubscribe(client));
60
+ emitter.onAction('websocket.close', ({ client }) => this.unsubscribe(client));
61
+ }
62
+ /**
63
+ * Register a logs subscription
64
+ * @param logLevel
65
+ * @param client
66
+ */
67
+ subscribe(logLevel, client) {
68
+ let allowedLogLevelNames = [];
69
+ try {
70
+ allowedLogLevelNames = Object.keys(getAllowedLogLevels(logLevel));
71
+ }
72
+ catch (error) {
73
+ throw new WebSocketError('logs', ErrorCode.InvalidPayload, error.message);
74
+ }
75
+ for (const availableLogLevel of this.availableLogLevels) {
76
+ if (allowedLogLevelNames.includes(availableLogLevel)) {
77
+ this.subscriptions[availableLogLevel]?.add(client);
78
+ }
79
+ else {
80
+ this.subscriptions[availableLogLevel]?.delete(client);
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * Remove a logs subscription
86
+ * @param client WebSocketClient
87
+ */
88
+ unsubscribe(client) {
89
+ for (const availableLogLevel of this.availableLogLevels) {
90
+ this.subscriptions[availableLogLevel]?.delete(client);
91
+ }
92
+ }
93
+ /**
94
+ * Handle incoming (un)subscribe requests
95
+ */
96
+ async onMessage(client, message) {
97
+ const accountability = client.accountability;
98
+ if (!accountability?.admin) {
99
+ throw new WebSocketError('logs', ErrorCode.Forbidden, `You don't have permission to access this.`);
100
+ }
101
+ if (message.type === 'subscribe') {
102
+ try {
103
+ const logLevel = message.log_level;
104
+ this.subscribe(logLevel, client);
105
+ client.send(fmtMessage('logs', { event: 'subscribe', log_level: logLevel }));
106
+ }
107
+ catch (err) {
108
+ handleWebSocketError(client, err, 'subscribe');
109
+ }
110
+ }
111
+ else if (message.type === 'unsubscribe') {
112
+ try {
113
+ this.unsubscribe(client);
114
+ client.send(fmtMessage('logs', { event: 'unsubscribe' }));
115
+ }
116
+ catch (err) {
117
+ handleWebSocketError(client, err, 'unsubscribe');
118
+ }
119
+ }
120
+ }
121
+ }
@@ -186,6 +186,32 @@ export declare const WebSocketSubscribeMessage: z.ZodDiscriminatedUnion<"type",
186
186
  type: z.ZodLiteral<"unsubscribe">;
187
187
  }>, z.ZodTypeAny, "passthrough">>]>;
188
188
  export type WebSocketSubscribeMessage = z.infer<typeof WebSocketSubscribeMessage>;
189
+ export declare const WebSocketLogsMessage: z.ZodUnion<[z.ZodObject<{
190
+ type: z.ZodLiteral<"subscribe">;
191
+ log_level: z.ZodString;
192
+ }, "strip", z.ZodTypeAny, {
193
+ type: "subscribe";
194
+ log_level: string;
195
+ }, {
196
+ type: "subscribe";
197
+ log_level: string;
198
+ }>, z.ZodObject<z.objectUtil.extendShape<{
199
+ type: z.ZodString;
200
+ uid: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
201
+ }, {
202
+ type: z.ZodLiteral<"unsubscribe">;
203
+ }>, "passthrough", z.ZodTypeAny, z.objectOutputType<z.objectUtil.extendShape<{
204
+ type: z.ZodString;
205
+ uid: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
206
+ }, {
207
+ type: z.ZodLiteral<"unsubscribe">;
208
+ }>, z.ZodTypeAny, "passthrough">, z.objectInputType<z.objectUtil.extendShape<{
209
+ type: z.ZodString;
210
+ uid: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
211
+ }, {
212
+ type: z.ZodLiteral<"unsubscribe">;
213
+ }>, z.ZodTypeAny, "passthrough">>]>;
214
+ export type WebSocketLogsMessage = z.infer<typeof WebSocketLogsMessage>;
189
215
  export declare const WebSocketItemsMessage: z.ZodUnion<[z.ZodObject<z.objectUtil.extendShape<{
190
216
  uid: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
191
217
  type: z.ZodLiteral<"items">;
@@ -41,6 +41,15 @@ export const WebSocketSubscribeMessage = z.discriminatedUnion('type', [
41
41
  type: z.literal('unsubscribe'),
42
42
  }),
43
43
  ]);
44
+ export const WebSocketLogsMessage = z.union([
45
+ z.object({
46
+ type: z.literal('subscribe'),
47
+ log_level: z.string(),
48
+ }),
49
+ WebSocketMessage.extend({
50
+ type: z.literal('unsubscribe'),
51
+ }),
52
+ ]);
44
53
  const ZodItem = z.custom();
45
54
  const PartialItemsMessage = z.object({
46
55
  uid: zodStringOrNumber.optional(),
@@ -7,6 +7,7 @@ import type { Accountability, Query } from '@directus/types';
7
7
  import type { IncomingMessage } from 'http';
8
8
  import type internal from 'stream';
9
9
  import type { WebSocket } from 'ws';
10
+ import type { AuthMode } from './messages.js';
10
11
  export type AuthenticationState = {
11
12
  accountability: Accountability | null;
12
13
  expires_at: number | null;
@@ -17,6 +18,11 @@ export type WebSocketClient = WebSocket & AuthenticationState & {
17
18
  auth_timer: NodeJS.Timeout | null;
18
19
  };
19
20
  export type UpgradeRequest = IncomingMessage & AuthenticationState;
21
+ export type WebSocketAuthentication = {
22
+ mode: AuthMode;
23
+ timeout: number;
24
+ requireAdmin: boolean;
25
+ };
20
26
  export type SubscriptionEvent = 'create' | 'update' | 'delete';
21
27
  export type Subscription = {
22
28
  uid?: string | number;
@@ -34,3 +40,4 @@ export type UpgradeContext = {
34
40
  export type GraphQLSocket = {
35
41
  client: WebSocketClient;
36
42
  };
43
+ export type LogsSubscription = Record<string, Set<WebSocketClient>>;