@directus/api 11.0.1 → 12.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.
Files changed (283) hide show
  1. package/dist/app.js +3 -4
  2. package/dist/auth/auth.d.ts +4 -4
  3. package/dist/auth/auth.js +2 -2
  4. package/dist/auth/drivers/ldap.js +20 -17
  5. package/dist/auth/drivers/local.js +5 -5
  6. package/dist/auth/drivers/oauth2.js +16 -16
  7. package/dist/auth/drivers/openid.js +18 -17
  8. package/dist/auth/drivers/saml.js +6 -7
  9. package/dist/auth.js +3 -2
  10. package/dist/cache.js +3 -13
  11. package/dist/cli/utils/create-env/env-stub.liquid +5 -7
  12. package/dist/controllers/activity.js +7 -6
  13. package/dist/controllers/assets.js +25 -12
  14. package/dist/controllers/auth.js +8 -7
  15. package/dist/controllers/collections.js +4 -3
  16. package/dist/controllers/dashboards.js +5 -4
  17. package/dist/controllers/extensions.js +3 -3
  18. package/dist/controllers/fields.js +9 -8
  19. package/dist/controllers/files.js +11 -11
  20. package/dist/controllers/flows.js +5 -4
  21. package/dist/controllers/folders.js +5 -4
  22. package/dist/controllers/items.js +14 -13
  23. package/dist/controllers/not-found.js +2 -2
  24. package/dist/controllers/notifications.js +5 -4
  25. package/dist/controllers/operations.js +5 -4
  26. package/dist/controllers/panels.js +5 -4
  27. package/dist/controllers/permissions.js +5 -4
  28. package/dist/controllers/presets.js +5 -4
  29. package/dist/controllers/relations.js +6 -5
  30. package/dist/controllers/roles.js +5 -4
  31. package/dist/controllers/schema.js +8 -8
  32. package/dist/controllers/server.js +2 -2
  33. package/dist/controllers/settings.js +3 -2
  34. package/dist/controllers/shares.js +7 -6
  35. package/dist/controllers/translations.js +6 -5
  36. package/dist/controllers/users.js +22 -21
  37. package/dist/controllers/utils.js +10 -10
  38. package/dist/controllers/webhooks.js +5 -4
  39. package/dist/{exceptions/database → database/errors}/dialects/mssql.js +8 -18
  40. package/dist/{exceptions/database → database/errors}/dialects/mysql.js +9 -19
  41. package/dist/{exceptions/database → database/errors}/dialects/oracle.js +2 -2
  42. package/dist/{exceptions/database → database/errors}/dialects/postgres.js +7 -18
  43. package/dist/{exceptions/database → database/errors}/dialects/sqlite.js +7 -10
  44. package/dist/{exceptions/database → database/errors}/translate.js +1 -1
  45. package/dist/database/migrations/run.js +10 -1
  46. package/dist/emitter.d.ts +3 -2
  47. package/dist/emitter.js +12 -4
  48. package/dist/env.js +21 -17
  49. package/dist/errors/codes.d.ts +29 -0
  50. package/dist/errors/codes.js +30 -0
  51. package/dist/errors/contains-null-values.d.ts +7 -0
  52. package/dist/errors/contains-null-values.js +4 -0
  53. package/dist/errors/content-too-large.d.ts +1 -0
  54. package/dist/errors/content-too-large.js +3 -0
  55. package/dist/errors/forbidden.d.ts +1 -0
  56. package/dist/errors/forbidden.js +3 -0
  57. package/dist/errors/hit-rate-limit.d.ts +6 -0
  58. package/dist/errors/hit-rate-limit.js +8 -0
  59. package/dist/errors/illegal-asset-transformation.d.ts +4 -0
  60. package/dist/errors/illegal-asset-transformation.js +3 -0
  61. package/dist/errors/index.d.ts +28 -0
  62. package/dist/errors/index.js +28 -0
  63. package/dist/errors/invalid-credentials.d.ts +1 -0
  64. package/dist/errors/invalid-credentials.js +3 -0
  65. package/dist/errors/invalid-foreign-key.d.ts +6 -0
  66. package/dist/errors/invalid-foreign-key.js +14 -0
  67. package/dist/errors/invalid-ip.d.ts +1 -0
  68. package/dist/errors/invalid-ip.js +3 -0
  69. package/dist/errors/invalid-otp.d.ts +1 -0
  70. package/dist/errors/invalid-otp.js +3 -0
  71. package/dist/errors/invalid-payload.d.ts +5 -0
  72. package/dist/errors/invalid-payload.js +4 -0
  73. package/dist/errors/invalid-provider-config.d.ts +5 -0
  74. package/dist/errors/invalid-provider-config.js +3 -0
  75. package/dist/errors/invalid-provider.d.ts +1 -0
  76. package/dist/errors/invalid-provider.js +3 -0
  77. package/dist/errors/invalid-query.d.ts +5 -0
  78. package/dist/errors/invalid-query.js +4 -0
  79. package/dist/errors/invalid-token.d.ts +1 -0
  80. package/dist/errors/invalid-token.js +3 -0
  81. package/dist/errors/method-not-allowed.d.ts +6 -0
  82. package/dist/errors/method-not-allowed.js +6 -0
  83. package/dist/errors/not-null-violation.d.ts +6 -0
  84. package/dist/errors/not-null-violation.js +14 -0
  85. package/dist/errors/range-not-satisfiable.d.ts +7 -0
  86. package/dist/errors/range-not-satisfiable.js +7 -0
  87. package/dist/errors/record-not-unique.d.ts +6 -0
  88. package/dist/errors/record-not-unique.js +14 -0
  89. package/dist/errors/route-not-found.d.ts +5 -0
  90. package/dist/errors/route-not-found.js +4 -0
  91. package/dist/errors/service-unavailable.d.ts +7 -0
  92. package/dist/errors/service-unavailable.js +4 -0
  93. package/dist/errors/token-expired.d.ts +1 -0
  94. package/dist/errors/token-expired.js +3 -0
  95. package/dist/errors/unexpected-response.d.ts +1 -0
  96. package/dist/errors/unexpected-response.js +3 -0
  97. package/dist/errors/unprocessable-content.d.ts +5 -0
  98. package/dist/errors/unprocessable-content.js +4 -0
  99. package/dist/errors/unsupported-media-type.d.ts +6 -0
  100. package/dist/errors/unsupported-media-type.js +4 -0
  101. package/dist/errors/user-suspended.d.ts +1 -0
  102. package/dist/errors/user-suspended.js +3 -0
  103. package/dist/errors/value-out-of-range.d.ts +6 -0
  104. package/dist/errors/value-out-of-range.js +14 -0
  105. package/dist/errors/value-too-long.d.ts +6 -0
  106. package/dist/errors/value-too-long.js +14 -0
  107. package/dist/extensions.js +0 -4
  108. package/dist/flows.js +6 -8
  109. package/dist/index.d.ts +0 -2
  110. package/dist/index.js +0 -2
  111. package/dist/messenger.d.ts +3 -3
  112. package/dist/messenger.js +18 -9
  113. package/dist/middleware/authenticate.js +2 -38
  114. package/dist/middleware/check-ip.js +2 -2
  115. package/dist/middleware/collection-exists.js +2 -2
  116. package/dist/middleware/error-handler.js +7 -7
  117. package/dist/middleware/graphql.js +11 -9
  118. package/dist/middleware/rate-limiter-global.d.ts +2 -2
  119. package/dist/middleware/rate-limiter-global.js +2 -3
  120. package/dist/middleware/rate-limiter-ip.d.ts +2 -2
  121. package/dist/middleware/rate-limiter-ip.js +2 -3
  122. package/dist/middleware/validate-batch.js +3 -4
  123. package/dist/rate-limiter.js +2 -9
  124. package/dist/server.js +10 -0
  125. package/dist/services/activity.js +3 -2
  126. package/dist/services/assets.js +9 -10
  127. package/dist/services/authentication.js +12 -11
  128. package/dist/services/authorization.d.ts +1 -1
  129. package/dist/services/authorization.js +16 -16
  130. package/dist/services/collections.js +17 -16
  131. package/dist/services/fields.js +16 -14
  132. package/dist/services/files.js +7 -6
  133. package/dist/services/graphql/errors/execution.d.ts +6 -0
  134. package/dist/services/graphql/errors/execution.js +2 -0
  135. package/dist/services/graphql/errors/index.d.ts +2 -0
  136. package/dist/services/graphql/errors/index.js +2 -0
  137. package/dist/services/graphql/errors/validation.d.ts +6 -0
  138. package/dist/services/graphql/errors/validation.js +2 -0
  139. package/dist/services/graphql/index.d.ts +2 -8
  140. package/dist/services/graphql/index.js +125 -66
  141. package/dist/services/graphql/subscription.d.ts +16 -0
  142. package/dist/services/graphql/subscription.js +77 -0
  143. package/dist/services/graphql/utils/process-error.js +3 -3
  144. package/dist/services/import-export.js +7 -7
  145. package/dist/services/index.d.ts +1 -0
  146. package/dist/services/index.js +1 -0
  147. package/dist/services/items.js +14 -13
  148. package/dist/services/mail/index.js +3 -3
  149. package/dist/services/meta.js +3 -3
  150. package/dist/services/payload.js +11 -7
  151. package/dist/services/relations.js +32 -22
  152. package/dist/services/revisions.js +3 -3
  153. package/dist/services/roles.js +10 -9
  154. package/dist/services/schema.js +5 -5
  155. package/dist/services/server.js +24 -0
  156. package/dist/services/shares.js +4 -4
  157. package/dist/services/tfa.js +6 -6
  158. package/dist/services/translations.d.ts +2 -2
  159. package/dist/services/translations.js +4 -4
  160. package/dist/services/users.js +26 -29
  161. package/dist/services/utils.js +4 -4
  162. package/dist/services/websocket.d.ts +14 -0
  163. package/dist/services/websocket.js +26 -0
  164. package/dist/synchronization.js +3 -3
  165. package/dist/types/items.d.ts +2 -2
  166. package/dist/utils/apply-diff.js +13 -4
  167. package/dist/utils/apply-query.js +22 -13
  168. package/dist/utils/get-accountability-for-role.js +1 -2
  169. package/dist/utils/get-accountability-for-token.d.ts +2 -0
  170. package/dist/utils/get-accountability-for-token.js +50 -0
  171. package/dist/utils/get-column-path.js +5 -3
  172. package/dist/utils/get-column.js +3 -3
  173. package/dist/utils/get-service.d.ts +7 -0
  174. package/dist/utils/get-service.js +49 -0
  175. package/dist/utils/jwt.js +5 -5
  176. package/dist/utils/redact.d.ts +4 -0
  177. package/dist/utils/redact.js +15 -1
  178. package/dist/utils/to-boolean.d.ts +4 -0
  179. package/dist/utils/to-boolean.js +6 -0
  180. package/dist/utils/validate-diff.js +23 -9
  181. package/dist/utils/validate-keys.js +3 -3
  182. package/dist/utils/validate-query.d.ts +2 -0
  183. package/dist/utils/validate-query.js +27 -21
  184. package/dist/utils/validate-snapshot.js +11 -5
  185. package/dist/websocket/authenticate.d.ts +6 -0
  186. package/dist/websocket/authenticate.js +59 -0
  187. package/dist/websocket/controllers/base.d.ts +42 -0
  188. package/dist/websocket/controllers/base.js +279 -0
  189. package/dist/websocket/controllers/graphql.d.ts +12 -0
  190. package/dist/websocket/controllers/graphql.js +102 -0
  191. package/dist/websocket/controllers/hooks.d.ts +1 -0
  192. package/dist/websocket/controllers/hooks.js +122 -0
  193. package/dist/websocket/controllers/index.d.ts +10 -0
  194. package/dist/websocket/controllers/index.js +31 -0
  195. package/dist/websocket/controllers/rest.d.ts +9 -0
  196. package/dist/websocket/controllers/rest.js +47 -0
  197. package/dist/websocket/errors.d.ts +16 -0
  198. package/dist/websocket/errors.js +55 -0
  199. package/dist/websocket/handlers/heartbeat.d.ts +11 -0
  200. package/dist/websocket/handlers/heartbeat.js +72 -0
  201. package/dist/websocket/handlers/index.d.ts +4 -0
  202. package/dist/websocket/handlers/index.js +11 -0
  203. package/dist/websocket/handlers/items.d.ts +6 -0
  204. package/dist/websocket/handlers/items.js +103 -0
  205. package/dist/websocket/handlers/subscribe.d.ts +43 -0
  206. package/dist/websocket/handlers/subscribe.js +278 -0
  207. package/dist/websocket/messages.d.ts +311 -0
  208. package/dist/websocket/messages.js +96 -0
  209. package/dist/websocket/types.d.ts +34 -0
  210. package/dist/websocket/types.js +1 -0
  211. package/dist/websocket/utils/get-expires-at-for-token.d.ts +1 -0
  212. package/dist/websocket/utils/get-expires-at-for-token.js +8 -0
  213. package/dist/websocket/utils/message.d.ts +4 -0
  214. package/dist/websocket/utils/message.js +27 -0
  215. package/dist/websocket/utils/wait-for-message.d.ts +4 -0
  216. package/dist/websocket/utils/wait-for-message.js +45 -0
  217. package/package.json +21 -16
  218. package/dist/exceptions/content-too-large.d.ts +0 -4
  219. package/dist/exceptions/content-too-large.js +0 -6
  220. package/dist/exceptions/database/contains-null-values.d.ts +0 -9
  221. package/dist/exceptions/database/contains-null-values.js +0 -6
  222. package/dist/exceptions/database/invalid-foreign-key.d.ts +0 -10
  223. package/dist/exceptions/database/invalid-foreign-key.js +0 -11
  224. package/dist/exceptions/database/not-null-violation.d.ts +0 -9
  225. package/dist/exceptions/database/not-null-violation.js +0 -6
  226. package/dist/exceptions/database/record-not-unique.d.ts +0 -10
  227. package/dist/exceptions/database/record-not-unique.js +0 -11
  228. package/dist/exceptions/database/value-out-of-range.d.ts +0 -10
  229. package/dist/exceptions/database/value-out-of-range.js +0 -11
  230. package/dist/exceptions/database/value-too-long.d.ts +0 -9
  231. package/dist/exceptions/database/value-too-long.js +0 -11
  232. package/dist/exceptions/forbidden.d.ts +0 -6
  233. package/dist/exceptions/forbidden.js +0 -13
  234. package/dist/exceptions/graphql-validation.d.ts +0 -4
  235. package/dist/exceptions/graphql-validation.js +0 -6
  236. package/dist/exceptions/hit-rate-limit.d.ts +0 -9
  237. package/dist/exceptions/hit-rate-limit.js +0 -6
  238. package/dist/exceptions/illegal-asset-transformation.d.ts +0 -4
  239. package/dist/exceptions/illegal-asset-transformation.js +0 -6
  240. package/dist/exceptions/index.d.ts +0 -21
  241. package/dist/exceptions/index.js +0 -21
  242. package/dist/exceptions/invalid-config.d.ts +0 -4
  243. package/dist/exceptions/invalid-config.js +0 -6
  244. package/dist/exceptions/invalid-credentials.d.ts +0 -4
  245. package/dist/exceptions/invalid-credentials.js +0 -6
  246. package/dist/exceptions/invalid-ip.d.ts +0 -4
  247. package/dist/exceptions/invalid-ip.js +0 -6
  248. package/dist/exceptions/invalid-otp.d.ts +0 -4
  249. package/dist/exceptions/invalid-otp.js +0 -6
  250. package/dist/exceptions/invalid-payload.d.ts +0 -4
  251. package/dist/exceptions/invalid-payload.js +0 -6
  252. package/dist/exceptions/invalid-provider.d.ts +0 -4
  253. package/dist/exceptions/invalid-provider.js +0 -6
  254. package/dist/exceptions/invalid-query.d.ts +0 -4
  255. package/dist/exceptions/invalid-query.js +0 -6
  256. package/dist/exceptions/invalid-token.d.ts +0 -4
  257. package/dist/exceptions/invalid-token.js +0 -6
  258. package/dist/exceptions/method-not-allowed.d.ts +0 -8
  259. package/dist/exceptions/method-not-allowed.js +0 -6
  260. package/dist/exceptions/range-not-satisfiable.d.ts +0 -5
  261. package/dist/exceptions/range-not-satisfiable.js +0 -9
  262. package/dist/exceptions/route-not-found.d.ts +0 -4
  263. package/dist/exceptions/route-not-found.js +0 -6
  264. package/dist/exceptions/service-unavailable.d.ts +0 -9
  265. package/dist/exceptions/service-unavailable.js +0 -6
  266. package/dist/exceptions/token-expired.d.ts +0 -4
  267. package/dist/exceptions/token-expired.js +0 -6
  268. package/dist/exceptions/unexpected-response.d.ts +0 -4
  269. package/dist/exceptions/unexpected-response.js +0 -6
  270. package/dist/exceptions/unprocessable-entity.d.ts +0 -4
  271. package/dist/exceptions/unprocessable-entity.js +0 -6
  272. package/dist/exceptions/unsupported-media-type.d.ts +0 -4
  273. package/dist/exceptions/unsupported-media-type.js +0 -6
  274. package/dist/exceptions/user-suspended.d.ts +0 -4
  275. package/dist/exceptions/user-suspended.js +0 -6
  276. /package/dist/{exceptions/database → database/errors}/dialects/mssql.d.ts +0 -0
  277. /package/dist/{exceptions/database → database/errors}/dialects/mysql.d.ts +0 -0
  278. /package/dist/{exceptions/database → database/errors}/dialects/oracle.d.ts +0 -0
  279. /package/dist/{exceptions/database → database/errors}/dialects/postgres.d.ts +0 -0
  280. /package/dist/{exceptions/database → database/errors}/dialects/sqlite.d.ts +0 -0
  281. /package/dist/{exceptions/database → database/errors}/dialects/types.d.ts +0 -0
  282. /package/dist/{exceptions/database → database/errors}/dialects/types.js +0 -0
  283. /package/dist/{exceptions/database → database/errors}/translate.d.ts +0 -0
@@ -0,0 +1,279 @@
1
+ import { parseJSON } from '@directus/utils';
2
+ import { parse } from 'url';
3
+ import { v4 as uuid } from 'uuid';
4
+ import WebSocket, { WebSocketServer } from 'ws';
5
+ import { fromZodError } from 'zod-validation-error';
6
+ import emitter from '../../emitter.js';
7
+ import env from '../../env.js';
8
+ import { InvalidProviderConfigError, TokenExpiredError } from '../../errors/index.js';
9
+ import logger from '../../logger.js';
10
+ import { createRateLimiter } from '../../rate-limiter.js';
11
+ import { getAccountabilityForToken } from '../../utils/get-accountability-for-token.js';
12
+ import { toBoolean } from '../../utils/to-boolean.js';
13
+ import { authenticateConnection, authenticationSuccess } from '../authenticate.js';
14
+ import { WebSocketError, handleWebSocketError } from '../errors.js';
15
+ import { AuthMode, WebSocketAuthMessage, WebSocketMessage } from '../messages.js';
16
+ import { getExpiresAtForToken } from '../utils/get-expires-at-for-token.js';
17
+ import { getMessageType } from '../utils/message.js';
18
+ import { waitForAnyMessage, waitForMessageType } from '../utils/wait-for-message.js';
19
+ import { registerWebSocketEvents } from './hooks.js';
20
+ const TOKEN_CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes
21
+ export default class SocketController {
22
+ server;
23
+ clients;
24
+ authentication;
25
+ endpoint;
26
+ maxConnections;
27
+ rateLimiter;
28
+ authInterval;
29
+ constructor(httpServer, configPrefix) {
30
+ this.server = new WebSocketServer({ noServer: true });
31
+ this.clients = new Set();
32
+ this.authInterval = null;
33
+ const { endpoint, authentication, maxConnections } = this.getEnvironmentConfig(configPrefix);
34
+ this.endpoint = endpoint;
35
+ this.authentication = authentication;
36
+ this.maxConnections = maxConnections;
37
+ this.rateLimiter = this.getRateLimiter();
38
+ httpServer.on('upgrade', this.handleUpgrade.bind(this));
39
+ this.checkClientTokens();
40
+ registerWebSocketEvents();
41
+ }
42
+ getEnvironmentConfig(configPrefix) {
43
+ const endpoint = String(env[`${configPrefix}_PATH`]);
44
+ const authMode = AuthMode.safeParse(String(env[`${configPrefix}_AUTH`]).toLowerCase());
45
+ const authTimeout = Number(env[`${configPrefix}_AUTH_TIMEOUT`]) * 1000;
46
+ const maxConnections = `${configPrefix}_CONN_LIMIT` in env ? Number(env[`${configPrefix}_CONN_LIMIT`]) : Number.POSITIVE_INFINITY;
47
+ if (!authMode.success) {
48
+ throw new InvalidProviderConfigError({
49
+ provider: 'ws',
50
+ reason: fromZodError(authMode.error, { prefix: `${configPrefix}_AUTH` }).message,
51
+ });
52
+ }
53
+ return {
54
+ endpoint,
55
+ maxConnections,
56
+ authentication: {
57
+ mode: authMode.data,
58
+ timeout: authTimeout,
59
+ },
60
+ };
61
+ }
62
+ getRateLimiter() {
63
+ if (toBoolean(env['RATE_LIMITER_ENABLED']) === true) {
64
+ return createRateLimiter('RATE_LIMITER', {
65
+ keyPrefix: 'websocket',
66
+ });
67
+ }
68
+ return null;
69
+ }
70
+ async handleUpgrade(request, socket, head) {
71
+ const { pathname, query } = parse(request.url, true);
72
+ if (pathname !== this.endpoint)
73
+ return;
74
+ if (this.clients.size >= this.maxConnections) {
75
+ logger.debug('WebSocket upgrade denied - max connections reached');
76
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
77
+ socket.destroy();
78
+ return;
79
+ }
80
+ const context = { request, socket, head };
81
+ if (this.authentication.mode === 'strict') {
82
+ await this.handleStrictUpgrade(context, query);
83
+ return;
84
+ }
85
+ if (this.authentication.mode === 'handshake') {
86
+ await this.handleHandshakeUpgrade(context);
87
+ return;
88
+ }
89
+ this.server.handleUpgrade(request, socket, head, async (ws) => {
90
+ const state = { accountability: null, expires_at: null };
91
+ this.server.emit('connection', ws, state);
92
+ });
93
+ }
94
+ async handleStrictUpgrade({ request, socket, head }, query) {
95
+ let accountability, expires_at;
96
+ try {
97
+ const token = query['access_token'];
98
+ accountability = await getAccountabilityForToken(token);
99
+ expires_at = getExpiresAtForToken(token);
100
+ }
101
+ catch {
102
+ accountability = null;
103
+ expires_at = null;
104
+ }
105
+ if (!accountability || !accountability.user) {
106
+ logger.debug('WebSocket upgrade denied - ' + JSON.stringify(accountability || 'invalid'));
107
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
108
+ socket.destroy();
109
+ return;
110
+ }
111
+ this.server.handleUpgrade(request, socket, head, async (ws) => {
112
+ const state = { accountability, expires_at };
113
+ this.server.emit('connection', ws, state);
114
+ });
115
+ }
116
+ async handleHandshakeUpgrade({ request, socket, head }) {
117
+ this.server.handleUpgrade(request, socket, head, async (ws) => {
118
+ try {
119
+ const payload = await waitForAnyMessage(ws, this.authentication.timeout);
120
+ if (getMessageType(payload) !== 'auth')
121
+ throw new Error();
122
+ const state = await authenticateConnection(WebSocketAuthMessage.parse(payload));
123
+ ws.send(authenticationSuccess(payload['uid'], state.refresh_token));
124
+ this.server.emit('connection', ws, state);
125
+ }
126
+ catch {
127
+ logger.debug('WebSocket authentication handshake failed');
128
+ const error = new WebSocketError('auth', 'AUTH_FAILED', 'Authentication handshake failed.');
129
+ handleWebSocketError(ws, error, 'auth');
130
+ ws.close();
131
+ }
132
+ });
133
+ }
134
+ createClient(ws, { accountability, expires_at }) {
135
+ const client = ws;
136
+ client.accountability = accountability;
137
+ client.expires_at = expires_at;
138
+ client.uid = uuid();
139
+ client.auth_timer = null;
140
+ ws.on('message', async (data) => {
141
+ if (this.rateLimiter !== null) {
142
+ try {
143
+ await this.rateLimiter.consume(client.uid);
144
+ }
145
+ catch (limit) {
146
+ const timeout = limit?.msBeforeNext ?? this.rateLimiter.msDuration;
147
+ const error = new WebSocketError('server', 'REQUESTS_EXCEEDED', `Too many messages, retry after ${timeout}ms.`);
148
+ handleWebSocketError(client, error, 'server');
149
+ logger.debug(`WebSocket#${client.uid} is rate limited`);
150
+ return;
151
+ }
152
+ }
153
+ let message;
154
+ try {
155
+ message = this.parseMessage(data.toString());
156
+ }
157
+ catch (err) {
158
+ handleWebSocketError(client, err, 'server');
159
+ return;
160
+ }
161
+ if (getMessageType(message) === 'auth') {
162
+ try {
163
+ await this.handleAuthRequest(client, WebSocketAuthMessage.parse(message));
164
+ }
165
+ catch {
166
+ // ignore errors
167
+ }
168
+ return;
169
+ }
170
+ // this log cannot be higher in the function or it will leak credentials
171
+ logger.trace(`WebSocket#${client.uid} - ${JSON.stringify(message)}`);
172
+ ws.emit('parsed-message', message);
173
+ });
174
+ ws.on('error', () => {
175
+ logger.debug(`WebSocket#${client.uid} connection errored`);
176
+ if (client.auth_timer) {
177
+ clearTimeout(client.auth_timer);
178
+ client.auth_timer = null;
179
+ }
180
+ this.clients.delete(client);
181
+ });
182
+ ws.on('close', () => {
183
+ logger.debug(`WebSocket#${client.uid} connection closed`);
184
+ if (client.auth_timer) {
185
+ clearTimeout(client.auth_timer);
186
+ client.auth_timer = null;
187
+ }
188
+ this.clients.delete(client);
189
+ });
190
+ logger.debug(`WebSocket#${client.uid} connected`);
191
+ if (accountability) {
192
+ logger.trace(`WebSocket#${client.uid} authenticated as ${JSON.stringify(accountability)}`);
193
+ }
194
+ this.setTokenExpireTimer(client);
195
+ this.clients.add(client);
196
+ return client;
197
+ }
198
+ parseMessage(data) {
199
+ let message;
200
+ try {
201
+ message = WebSocketMessage.parse(parseJSON(data));
202
+ }
203
+ catch (err) {
204
+ throw new WebSocketError('server', 'INVALID_PAYLOAD', 'Unable to parse the incoming message.');
205
+ }
206
+ return message;
207
+ }
208
+ async handleAuthRequest(client, message) {
209
+ try {
210
+ const { accountability, expires_at, refresh_token } = await authenticateConnection(message);
211
+ client.accountability = accountability;
212
+ client.expires_at = expires_at;
213
+ this.setTokenExpireTimer(client);
214
+ emitter.emitAction('websocket.auth.success', { client });
215
+ client.send(authenticationSuccess(message.uid, refresh_token));
216
+ logger.trace(`WebSocket#${client.uid} authenticated as ${JSON.stringify(client.accountability)}`);
217
+ }
218
+ catch (error) {
219
+ logger.trace(`WebSocket#${client.uid} failed authentication`);
220
+ emitter.emitAction('websocket.auth.failure', { client });
221
+ client.accountability = null;
222
+ client.expires_at = null;
223
+ const _error = error instanceof WebSocketError
224
+ ? error
225
+ : new WebSocketError('auth', 'AUTH_FAILED', 'Authentication failed.', message.uid);
226
+ handleWebSocketError(client, _error, 'auth');
227
+ if (this.authentication.mode !== 'public') {
228
+ client.close();
229
+ }
230
+ }
231
+ }
232
+ setTokenExpireTimer(client) {
233
+ if (client.auth_timer !== null) {
234
+ // clear up old timeouts if needed
235
+ clearTimeout(client.auth_timer);
236
+ client.auth_timer = null;
237
+ }
238
+ if (!client.expires_at)
239
+ return;
240
+ const expiresIn = client.expires_at * 1000 - Date.now();
241
+ if (expiresIn > TOKEN_CHECK_INTERVAL)
242
+ return;
243
+ client.auth_timer = setTimeout(() => {
244
+ client.accountability = null;
245
+ client.expires_at = null;
246
+ handleWebSocketError(client, new TokenExpiredError(), 'auth');
247
+ waitForMessageType(client, 'auth', this.authentication.timeout).catch((msg) => {
248
+ const error = new WebSocketError('auth', 'AUTH_TIMEOUT', 'Authentication timed out.', msg?.uid);
249
+ handleWebSocketError(client, error, 'auth');
250
+ if (this.authentication.mode !== 'public') {
251
+ client.close();
252
+ }
253
+ });
254
+ }, expiresIn);
255
+ }
256
+ checkClientTokens() {
257
+ this.authInterval = setInterval(() => {
258
+ if (this.clients.size === 0)
259
+ return;
260
+ // check the clients and set shorter timeouts if needed
261
+ for (const client of this.clients) {
262
+ if (client.expires_at === null || client.auth_timer !== null)
263
+ continue;
264
+ this.setTokenExpireTimer(client);
265
+ }
266
+ }, TOKEN_CHECK_INTERVAL);
267
+ }
268
+ terminate() {
269
+ if (this.authInterval)
270
+ clearInterval(this.authInterval);
271
+ this.clients.forEach((client) => {
272
+ if (client.auth_timer)
273
+ clearTimeout(client.auth_timer);
274
+ });
275
+ this.server.clients.forEach((ws) => {
276
+ ws.terminate();
277
+ });
278
+ }
279
+ }
@@ -0,0 +1,12 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import type { Server } from 'graphql-ws';
3
+ import type { Server as httpServer } from 'http';
4
+ import type { GraphQLSocket, UpgradeContext, WebSocketClient } from '../types.js';
5
+ import SocketController from './base.js';
6
+ export declare class GraphQLSubscriptionController extends SocketController {
7
+ gql: Server<GraphQLSocket>;
8
+ constructor(httpServer: httpServer);
9
+ private bindEvents;
10
+ setTokenExpireTimer(client: WebSocketClient): void;
11
+ protected handleHandshakeUpgrade({ request, socket, head }: UpgradeContext): Promise<void>;
12
+ }
@@ -0,0 +1,102 @@
1
+ import { CloseCode, MessageType, makeServer } from 'graphql-ws';
2
+ import env from '../../env.js';
3
+ import logger from '../../logger.js';
4
+ import { bindPubSub } from '../../services/graphql/subscription.js';
5
+ import { GraphQLService } from '../../services/index.js';
6
+ import { getSchema } from '../../utils/get-schema.js';
7
+ import { authenticateConnection, refreshAccountability } from '../authenticate.js';
8
+ import { handleWebSocketError } from '../errors.js';
9
+ import { ConnectionParams, WebSocketMessage } from '../messages.js';
10
+ import { getMessageType } from '../utils/message.js';
11
+ import SocketController from './base.js';
12
+ export class GraphQLSubscriptionController extends SocketController {
13
+ gql;
14
+ constructor(httpServer) {
15
+ super(httpServer, 'WEBSOCKETS_GRAPHQL');
16
+ this.server.on('connection', (ws, auth) => {
17
+ this.bindEvents(this.createClient(ws, auth));
18
+ });
19
+ this.gql = makeServer({
20
+ schema: async (ctx) => {
21
+ const accountability = ctx.extra.client.accountability;
22
+ // for now only the items will be watched, system events tbd
23
+ const service = new GraphQLService({
24
+ schema: await getSchema(),
25
+ scope: 'items',
26
+ accountability,
27
+ });
28
+ return service.getSchema();
29
+ },
30
+ });
31
+ bindPubSub();
32
+ logger.info(`GraphQL Subscriptions started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
33
+ }
34
+ bindEvents(client) {
35
+ const closedHandler = this.gql.opened({
36
+ protocol: client.protocol,
37
+ send: (data) => new Promise((resolve, reject) => {
38
+ client.send(data, (err) => (err ? reject(err) : resolve()));
39
+ }),
40
+ close: (code, reason) => client.close(code, reason),
41
+ onMessage: (cb) => {
42
+ client.on('parsed-message', async (message) => {
43
+ try {
44
+ if (getMessageType(message) === 'connection_init' && this.authentication.mode !== 'strict') {
45
+ const params = ConnectionParams.parse(message['payload'] ?? {});
46
+ if (this.authentication.mode === 'handshake') {
47
+ if (typeof params.access_token === 'string') {
48
+ const { accountability, expires_at } = await authenticateConnection({
49
+ access_token: params.access_token,
50
+ });
51
+ client.accountability = accountability;
52
+ client.expires_at = expires_at;
53
+ }
54
+ else {
55
+ client.close(CloseCode.Forbidden, 'Forbidden');
56
+ return;
57
+ }
58
+ }
59
+ }
60
+ else if (this.authentication.mode === 'handshake' && !client.accountability?.user) {
61
+ // the first message should authenticate successfully in this mode
62
+ client.close(CloseCode.Forbidden, 'Forbidden');
63
+ return;
64
+ }
65
+ else {
66
+ client.accountability = await refreshAccountability(client.accountability);
67
+ }
68
+ await cb(JSON.stringify(message));
69
+ }
70
+ catch (error) {
71
+ handleWebSocketError(client, error, MessageType.Error);
72
+ }
73
+ });
74
+ },
75
+ }, { client });
76
+ // notify server that the socket closed
77
+ client.once('close', (code, reason) => closedHandler(code, reason.toString()));
78
+ // check strict authentication status
79
+ if (this.authentication.mode === 'strict' && !client.accountability?.user) {
80
+ client.close(CloseCode.Forbidden, 'Forbidden');
81
+ }
82
+ }
83
+ setTokenExpireTimer(client) {
84
+ if (client.auth_timer !== null) {
85
+ clearTimeout(client.auth_timer);
86
+ client.auth_timer = null;
87
+ }
88
+ if (this.authentication.mode !== 'handshake')
89
+ return;
90
+ client.auth_timer = setTimeout(() => {
91
+ if (!client.accountability?.user) {
92
+ client.close(CloseCode.Forbidden, 'Forbidden');
93
+ }
94
+ }, this.authentication.timeout);
95
+ }
96
+ async handleHandshakeUpgrade({ request, socket, head }) {
97
+ this.server.handleUpgrade(request, socket, head, async (ws) => {
98
+ this.server.emit('connection', ws, { accountability: null, expires_at: null });
99
+ // actual enforcement is handled by the setTokenExpireTimer function
100
+ });
101
+ }
102
+ }
@@ -0,0 +1 @@
1
+ export declare function registerWebSocketEvents(): void;
@@ -0,0 +1,122 @@
1
+ import emitter from '../../emitter.js';
2
+ import { getMessenger } from '../../messenger.js';
3
+ let actionsRegistered = false;
4
+ export function registerWebSocketEvents() {
5
+ if (actionsRegistered)
6
+ return;
7
+ actionsRegistered = true;
8
+ registerActionHooks([
9
+ 'items',
10
+ 'activity',
11
+ 'collections',
12
+ 'folders',
13
+ 'permissions',
14
+ 'presets',
15
+ 'revisions',
16
+ 'roles',
17
+ 'settings',
18
+ 'users',
19
+ 'webhooks',
20
+ ]);
21
+ registerFieldsHooks();
22
+ registerFilesHooks();
23
+ registerRelationsHooks();
24
+ }
25
+ function registerActionHooks(modules) {
26
+ // register event hooks that can be handled in an uniform manner
27
+ for (const module of modules) {
28
+ registerAction(module + '.create', ({ key, collection, payload = {} }) => ({
29
+ collection,
30
+ action: 'create',
31
+ key,
32
+ payload,
33
+ }));
34
+ registerAction(module + '.update', ({ keys, collection, payload = {} }) => ({
35
+ collection,
36
+ action: 'update',
37
+ keys,
38
+ payload,
39
+ }));
40
+ registerAction(module + '.delete', ({ keys, collection, payload = [] }) => ({
41
+ collection,
42
+ action: 'delete',
43
+ keys,
44
+ payload,
45
+ }));
46
+ }
47
+ }
48
+ function registerFieldsHooks() {
49
+ // exception for field hooks that don't report `directus_fields` as being the collection
50
+ registerAction('fields.create', ({ key, payload = {} }) => ({
51
+ collection: 'directus_fields',
52
+ action: 'create',
53
+ key,
54
+ payload,
55
+ }));
56
+ registerAction('fields.update', ({ keys, payload = {} }) => ({
57
+ collection: 'directus_fields',
58
+ action: 'update',
59
+ keys,
60
+ payload,
61
+ }));
62
+ registerAction('fields.delete', ({ keys, payload = [] }) => ({
63
+ collection: 'directus_fields',
64
+ action: 'delete',
65
+ keys,
66
+ payload,
67
+ }));
68
+ }
69
+ function registerFilesHooks() {
70
+ // extra event for file uploads that doubles as create event
71
+ registerAction('files.upload', ({ key, collection, payload = {} }) => ({
72
+ collection,
73
+ action: 'create',
74
+ key,
75
+ payload,
76
+ }));
77
+ registerAction('files.update', ({ keys, collection, payload = {} }) => ({
78
+ collection,
79
+ action: 'update',
80
+ keys,
81
+ payload,
82
+ }));
83
+ registerAction('files.delete', ({ keys, collection, payload = [] }) => ({
84
+ collection,
85
+ action: 'delete',
86
+ keys,
87
+ payload,
88
+ }));
89
+ }
90
+ function registerRelationsHooks() {
91
+ // exception for relation hooks that don't report `directus_relations` as being the collection
92
+ registerAction('relations.create', ({ key, payload = {} }) => ({
93
+ collection: 'directus_relations',
94
+ action: 'create',
95
+ key,
96
+ payload: { ...payload, key },
97
+ }));
98
+ registerAction('relations.update', ({ keys, payload = {} }) => ({
99
+ collection: 'directus_relations',
100
+ action: 'update',
101
+ keys,
102
+ payload,
103
+ }));
104
+ registerAction('relations.delete', ({ collection, payload = [] }) => ({
105
+ collection: 'directus_relations',
106
+ action: 'delete',
107
+ keys: payload,
108
+ payload: { collection, fields: payload },
109
+ }));
110
+ }
111
+ /**
112
+ * Wrapper for emitter.onAction to hook into system events
113
+ * @param event The action event to watch
114
+ * @param transform Transformer function
115
+ */
116
+ function registerAction(event, transform) {
117
+ const messenger = getMessenger();
118
+ emitter.onAction(event, async (data) => {
119
+ // push the event through the Redis pub/sub
120
+ messenger.publish('websocket.event', transform(data));
121
+ });
122
+ }
@@ -0,0 +1,10 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import type { Server as httpServer } from 'http';
3
+ import { GraphQLSubscriptionController } from './graphql.js';
4
+ import { WebSocketController } from './rest.js';
5
+ export declare function createWebSocketController(server: httpServer): void;
6
+ export declare function getWebSocketController(): WebSocketController;
7
+ export declare function createSubscriptionController(server: httpServer): void;
8
+ export declare function getSubscriptionController(): GraphQLSubscriptionController | undefined;
9
+ export * from './graphql.js';
10
+ export * from './rest.js';
@@ -0,0 +1,31 @@
1
+ import env from '../../env.js';
2
+ import { ServiceUnavailableError } from '../../errors/index.js';
3
+ import { toBoolean } from '../../utils/to-boolean.js';
4
+ import { GraphQLSubscriptionController } from './graphql.js';
5
+ import { WebSocketController } from './rest.js';
6
+ let websocketController;
7
+ let subscriptionController;
8
+ export function createWebSocketController(server) {
9
+ if (toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
10
+ websocketController = new WebSocketController(server);
11
+ }
12
+ }
13
+ export function getWebSocketController() {
14
+ if (!toBoolean(env['WEBSOCKETS_ENABLED']) || !toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
15
+ throw new ServiceUnavailableError({ service: 'ws', reason: 'WebSocket server is disabled' });
16
+ }
17
+ if (!websocketController) {
18
+ throw new ServiceUnavailableError({ service: 'ws', reason: 'WebSocket server is not initialized' });
19
+ }
20
+ return websocketController;
21
+ }
22
+ export function createSubscriptionController(server) {
23
+ if (toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])) {
24
+ subscriptionController = new GraphQLSubscriptionController(server);
25
+ }
26
+ }
27
+ export function getSubscriptionController() {
28
+ return subscriptionController;
29
+ }
30
+ export * from './graphql.js';
31
+ export * from './rest.js';
@@ -0,0 +1,9 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import type { Server as httpServer } from 'http';
3
+ import { WebSocketMessage } from '../messages.js';
4
+ import SocketController from './base.js';
5
+ export declare class WebSocketController extends SocketController {
6
+ constructor(httpServer: httpServer);
7
+ private bindEvents;
8
+ protected parseMessage(data: string): WebSocketMessage;
9
+ }
@@ -0,0 +1,47 @@
1
+ import { parseJSON } from '@directus/utils';
2
+ import emitter from '../../emitter.js';
3
+ import env from '../../env.js';
4
+ import logger from '../../logger.js';
5
+ import { refreshAccountability } from '../authenticate.js';
6
+ import { WebSocketError, handleWebSocketError } from '../errors.js';
7
+ import { WebSocketMessage } from '../messages.js';
8
+ import SocketController from './base.js';
9
+ export class WebSocketController extends SocketController {
10
+ constructor(httpServer) {
11
+ super(httpServer, 'WEBSOCKETS_REST');
12
+ this.server.on('connection', (ws, auth) => {
13
+ this.bindEvents(this.createClient(ws, auth));
14
+ });
15
+ logger.info(`WebSocket Server started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
16
+ }
17
+ bindEvents(client) {
18
+ client.on('parsed-message', async (message) => {
19
+ try {
20
+ message = WebSocketMessage.parse(await emitter.emitFilter('websocket.message', message, { client }));
21
+ client.accountability = await refreshAccountability(client.accountability);
22
+ emitter.emitAction('websocket.message', { message, client });
23
+ }
24
+ catch (error) {
25
+ handleWebSocketError(client, error, 'server');
26
+ return;
27
+ }
28
+ });
29
+ client.on('error', (event) => {
30
+ emitter.emitAction('websocket.error', { client, event });
31
+ });
32
+ client.on('close', (event) => {
33
+ emitter.emitAction('websocket.close', { client, event });
34
+ });
35
+ emitter.emitAction('websocket.connect', { client });
36
+ }
37
+ parseMessage(data) {
38
+ let message;
39
+ try {
40
+ message = parseJSON(data);
41
+ }
42
+ catch (err) {
43
+ throw new WebSocketError('server', 'INVALID_PAYLOAD', 'Unable to parse the incoming message.');
44
+ }
45
+ return message;
46
+ }
47
+ }
@@ -0,0 +1,16 @@
1
+ import type { DirectusError } from '@directus/errors';
2
+ import type { WebSocket } from 'ws';
3
+ import { ZodError } from 'zod';
4
+ import type { WebSocketResponse } from './messages.js';
5
+ import type { WebSocketClient } from './types.js';
6
+ export declare class WebSocketError extends Error {
7
+ type: string;
8
+ code: string;
9
+ uid: string | number | undefined;
10
+ constructor(type: string, code: string, message: string, uid?: string | number);
11
+ toJSON(): WebSocketResponse;
12
+ toMessage(): string;
13
+ static fromError(error: DirectusError<unknown>, type?: string): WebSocketError;
14
+ static fromZodError(error: ZodError, type?: string): WebSocketError;
15
+ }
16
+ export declare function handleWebSocketError(client: WebSocketClient | WebSocket, error: unknown, type?: string): void;