@rapidmx/meet-plugin 0.14.1 → 0.16.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/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +46 -2
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
|
@@ -22,8 +22,9 @@ import { buildIceServers } from "../util/IceServerUtils.js";
|
|
|
22
22
|
import { hashPassword, verifyPassword as verifyPasswordHash } from "../util/PasswordUtils.js";
|
|
23
23
|
import { buildBaseUrl } from "../util/PublicUrlUtils.js";
|
|
24
24
|
import { RedisRelayBus } from "../util/RedisRelayBus.js";
|
|
25
|
-
import { parseRelayEnabled, RELAY_LARGE_PAYLOAD_BYTES, RELAY_MAX_PAYLOAD_BYTES, RELAY_WS_MAX_BACKPRESSURE_BYTES, RelayHub, } from "../util/RelayHub.js";
|
|
25
|
+
import { parseRelayEnabled, RELAY_LARGE_PAYLOAD_BYTES, RELAY_MAX_PAYLOAD_BYTES, RELAY_WS_MAX_BACKPRESSURE_BYTES, InProcessRelayBus, RelayHub, } from "../util/RelayHub.js";
|
|
26
26
|
import { stripTrustedRoles } from "../util/RouteAccessUtils.js";
|
|
27
|
+
import { SIGNALING_LARGE_PAYLOAD_BYTES, SIGNALING_MAX_PAYLOAD_BYTES, SignalingHub } from "../util/SignalingHub.js";
|
|
27
28
|
import { JOIN_TOKEN_PATTERN, PUBLIC_SLUG_PATTERN, mintJoinToken, mintPublicSlug } from "../util/TokenUtils.js";
|
|
28
29
|
import { VideoMeetingStatus, VideoMeetingVisibility } from "../models/types.js";
|
|
29
30
|
const { Config, Destroy, Init, Inject, Logger } = ObjectDecorators;
|
|
@@ -42,6 +43,12 @@ const RELAY_MESSAGE_LIMIT_BYTES = WS_ROUTE_OPTIONS_SUPPORTED ? RELAY_LARGE_PAYLO
|
|
|
42
43
|
const RELAY_WS_OPTIONS = WS_ROUTE_OPTIONS_SUPPORTED
|
|
43
44
|
? { maxPayloadLength: RELAY_LARGE_PAYLOAD_BYTES, maxBackpressure: RELAY_WS_MAX_BACKPRESSURE_BYTES }
|
|
44
45
|
: undefined;
|
|
46
|
+
/** The largest message a signaling socket may send: what the route asks the framework for and what `ready` tells
|
|
47
|
+
* the client. */
|
|
48
|
+
const SIGNALING_MESSAGE_LIMIT_BYTES = WS_ROUTE_OPTIONS_SUPPORTED ? SIGNALING_LARGE_PAYLOAD_BYTES : SIGNALING_MAX_PAYLOAD_BYTES;
|
|
49
|
+
/** The signaling route's WebSocket options: bigger messages, so a late joiner's whiteboard catch-up is a few messages.
|
|
50
|
+
* `undefined` (the framework's defaults) where it cannot be set. */
|
|
51
|
+
const SIGNALING_WS_OPTIONS = WS_ROUTE_OPTIONS_SUPPORTED ? { maxPayloadLength: SIGNALING_LARGE_PAYLOAD_BYTES } : undefined;
|
|
45
52
|
/** Upper bounds on caller-supplied text, matching the general shape `booking-plugin`'s `BaseBookingRoute` bounds
|
|
46
53
|
* its own free-text fields with. */
|
|
47
54
|
const MAX_TITLE_LENGTH = 200;
|
|
@@ -108,11 +115,11 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
|
|
|
108
115
|
*
|
|
109
116
|
* ## Why `VideoMeeting` gets its own per-record `AccessControlList`
|
|
110
117
|
*
|
|
111
|
-
* Unlike `Booking`, `VideoMeeting.uid` doubles as
|
|
112
|
-
*
|
|
113
|
-
* published
|
|
114
|
-
*
|
|
115
|
-
* `AccessControlList` document keyed by that exact uid - there being no such document at all (the case for
|
|
118
|
+
* Unlike `Booking`, `VideoMeeting.uid` doubles as the meeting's signaling channel: SDP offers/answers and ICE
|
|
119
|
+
* candidates travel over `signal()`'s WebSocket (see "The signaling WebSocket" below; until it replaced them they
|
|
120
|
+
* were published on the server's shared `/push` route), whose room is the meeting's uid. Like a `/push` channel
|
|
121
|
+
* before it, that socket is authorized purely by `ACLUtils.hasPermission(user, meetingUid, action)` against a
|
|
122
|
+
* per-record `AccessControlList` document keyed by that exact uid - there being no such document at all (the case for
|
|
116
123
|
* `Booking`, whose `@Protect` sets `recordACL: false`) makes `hasPermission()` always answer `false`. So
|
|
117
124
|
* `VideoMeetingMongo`/`VideoMeetingSQL` set `recordACL: true`, and `create()` below claims that per-record ACL
|
|
118
125
|
* with `parentUid` set to the meeting's own `mailboxUid` (mirroring `BaseFolderRoute`'s identical `acl: { uid,
|
|
@@ -123,17 +130,17 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
|
|
|
123
130
|
*
|
|
124
131
|
* ## The channel-ACL-grant mechanism
|
|
125
132
|
*
|
|
126
|
-
* An anonymous guest holds no `AccessControlList` grant of their own and has no `JWTUser` to authenticate
|
|
127
|
-
* with in the first place - `join()` therefore does two things together: it mints a short-lived, scope-limited
|
|
133
|
+
* An anonymous guest holds no `AccessControlList` grant of their own and has no `JWTUser` to authenticate the
|
|
134
|
+
* meeting's WebSockets with in the first place - `join()` therefore does two things together: it mints a short-lived, scope-limited
|
|
128
135
|
* guest JWT (`mintGuestToken()`: a synthetic `guest:<random>` uid, no roles, `GUEST_JWT_TTL_SECONDS` expiry, real and
|
|
129
136
|
* verifiable since it's signed with the same `auth` config every other token is), and it adds an explicit
|
|
130
137
|
* `ACLRecord` for that exact uid onto the meeting's own `AccessControlList` (`ensureChannelGrant()`), granting
|
|
131
|
-
* `READ`
|
|
132
|
-
*
|
|
138
|
+
* `READ` and `CREATE` - the pair `signal()` and `relay()` both require (`authorizeMeetingSocket()`), the same pair
|
|
139
|
+
* `/push` demanded to subscribe to and publish on a channel when signaling still went through it. The grant is scoped to exactly
|
|
133
140
|
* that one meeting's uid, nothing else - the same "possession of a link is the credential" pattern
|
|
134
141
|
* `BaseBookingRoute.requireBookingByToken()`/`resolveEffectiveUser()`'s `share:<token>` identity already establish
|
|
135
|
-
* elsewhere in this codebase, extended one step further because signaling needs a
|
|
136
|
-
*
|
|
142
|
+
* elsewhere in this codebase, extended one step further because signaling needs a long-lived socket, not just a
|
|
143
|
+
* stateless REST call.
|
|
137
144
|
*
|
|
138
145
|
* **Known limitation, documented rather than silently assumed away**: because each `join()` call for an anonymous
|
|
139
146
|
* caller mints a *fresh* random guest uid (so simultaneous participants of one shared link are distinguishable from
|
|
@@ -153,16 +160,16 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
|
|
|
153
160
|
* authenticated-optional endpoint in this codebase's family (e.g. `BaseScopedChildRoute.resolveEffectiveUser()`'s
|
|
154
161
|
* "prefer the real authenticated user, fall back to the anonymous token" precedent). This matters because a
|
|
155
162
|
* logged-in RapidMX user's browser already carries a real, `HttpOnly` `jwt` session cookie for this origin - a
|
|
156
|
-
* cookie `apps/shared/
|
|
163
|
+
* cookie `apps/shared/signaling/SignalingClient.ts` cannot overwrite with a guest token even if `join()` minted one
|
|
157
164
|
* (browsers refuse to let a script override an `HttpOnly` cookie of the same name), so a guest-only `join()` would
|
|
158
|
-
* leave that browser's
|
|
159
|
-
* synthetic guest uid - the
|
|
165
|
+
* leave that browser's signaling WebSocket authenticating as the real session while the meeting's ACL only names a
|
|
166
|
+
* synthetic guest uid - the socket is simply, safely refused, and the real user could never actually join.
|
|
160
167
|
*
|
|
161
168
|
* The fix: when `user` is present and is a *real* identity - not a guest uid from a previous `join()` call, told
|
|
162
169
|
* apart by the `GUEST_UID_PREFIX` a guest uid always starts with and a real, mailbox-owning identity never can -
|
|
163
170
|
* `join()` grants `user.uid` itself (not a synthetic one) `READ`/`CREATE` on the meeting's channel via
|
|
164
171
|
* `ensureChannelGrant()`, mints no guest JWT at all, and returns `authenticated: true` with `selfUid: user.uid`. The
|
|
165
|
-
* caller's own already-existing session cookie now already authenticates
|
|
172
|
+
* caller's own already-existing session cookie now already authenticates `signal()` for them with zero new
|
|
166
173
|
* client-side auth handling - the frontend must not write a cookie of its own in this case (see
|
|
167
174
|
* `VideoMeetingJoinResult`'s doc comment). When `user` is absent (the common, true-anonymous case - no existing
|
|
168
175
|
* session at all), behavior is exactly Phase 1's: a fresh guest identity is minted and granted instead.
|
|
@@ -223,6 +230,19 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
|
|
|
223
230
|
* it): a disabled relay closes every socket with 1008 "Relay disabled." and `join()` reports `relayEnabled: false`, so
|
|
224
231
|
* clients don't try it.
|
|
225
232
|
*
|
|
233
|
+
* ## The signaling WebSocket (`signal()`)
|
|
234
|
+
*
|
|
235
|
+
* Every participant's offers, answers, ICE candidates, presence, moderation signals, chat messages and whiteboard
|
|
236
|
+
* strokes travel over this plugin's own WebSocket, `signal()` at `/signal/:id`
|
|
237
|
+
* (`/api/mail/video-meetings/signal/<meetingUid>`), not the server's shared `/push` route: one socket in both
|
|
238
|
+
* directions, so a message is not a whole HTTP request, and a meeting's traffic never mixes with the webmail's own
|
|
239
|
+
* push notifications. `SignalingHub` carries the messages (see it for the protocol and limits) on the same bus as the
|
|
240
|
+
* relay, so it crosses replicas exactly when the relay does. It is authorized exactly as `relay()` is (the same
|
|
241
|
+
* `READ`/`CREATE` grant from `join()`, the same live-meeting check, the same "Not permitted." for every refusal) but
|
|
242
|
+
* is never switched off - without it there is no call. The hub stamps every message with the socket's own uid and
|
|
243
|
+
* peer id, so nobody can speak as someone else, and a point-to-point message (`to`) reaches only that peer. `kick()`
|
|
244
|
+
* closes the removed participant's sockets at once.
|
|
245
|
+
*
|
|
226
246
|
* ## Other known limitations
|
|
227
247
|
*
|
|
228
248
|
* **`VideoMeetingInvitee.joinToken` never expires** and has no GC job - identical tradeoff to `Booking.manageToken`.
|
|
@@ -257,10 +277,6 @@ export class BaseVideoMeetingRoute {
|
|
|
257
277
|
/** The deployment's `events` datastore (`datastores:events`) - the Redis the framework's push system publishes
|
|
258
278
|
* across replicas on; `null` when the deployment has none. Decides how `initRelayBus()` carries relay frames. */
|
|
259
279
|
this.eventsConfig = null;
|
|
260
|
-
/** The rooms of this process's relay sockets. One per route instance. It starts out on an in-process bus, which
|
|
261
|
-
* is what a route that was never initialized (a test's) keeps; `initRelayBus()` swaps in a hub on a Redis bus
|
|
262
|
-
* when the deployment has one, before any socket can attach. Its socket caps are per server replica. */
|
|
263
|
-
this.relayHub = new RelayHub({ maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } });
|
|
264
280
|
/** Every meeting's pending admission requests (`VideoMeeting.waitingRoomEnabled`), keyed by meeting uid then by
|
|
265
281
|
* the requester's own uid (real or freshly minted guest). In-memory and per server instance only - the same
|
|
266
282
|
* accepted tradeoff `relayHub` above already has: a restart drops anyone currently waiting, who simply
|
|
@@ -280,30 +296,53 @@ export class BaseVideoMeetingRoute {
|
|
|
280
296
|
return this.constructor.modelClass;
|
|
281
297
|
}
|
|
282
298
|
/**
|
|
283
|
-
* Picks how relay frames travel between server replicas, once the route is created: over
|
|
284
|
-
* (`RedisRelayBus`) when `datastores:events` is configured, else in-process only with a single
|
|
285
|
-
* participants on different replicas cannot
|
|
299
|
+
* Picks how relay frames and signaling messages travel between server replicas, once the route is created: over
|
|
300
|
+
* Redis pub/sub (`RedisRelayBus`) when `datastores:events` is configured, else in-process only with a single
|
|
301
|
+
* warning that participants on different replicas cannot reach each other. A Redis bus that cannot be created (the `redis`
|
|
286
302
|
* package is missing, an unusable URL) falls back the same way, with the error logged. Runs before the route
|
|
287
303
|
* serves anything, so no socket is attached to the hub it replaces.
|
|
288
304
|
*/
|
|
289
305
|
async initRelayBus() {
|
|
306
|
+
if (!this._objectFactory) {
|
|
307
|
+
throw new Error("objectFactory is not set.");
|
|
308
|
+
}
|
|
309
|
+
if (this.relayHub) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Named after the concrete route, so the two backends' routes never share an instance.
|
|
313
|
+
const name = this.constructor.name;
|
|
314
|
+
let bus;
|
|
290
315
|
const url = this.eventsConfig?.url;
|
|
291
316
|
if (!url) {
|
|
292
|
-
this.logger.warn("The `events` datastore is not configured:
|
|
317
|
+
this.logger.warn("The `events` datastore is not configured: video meeting signaling and the media relay only reach participants " +
|
|
293
318
|
"connected to this server instance, not between server replicas.");
|
|
294
|
-
return;
|
|
295
319
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
320
|
+
else {
|
|
321
|
+
const redisBus = await this._objectFactory.newInstance(RedisRelayBus, {
|
|
322
|
+
name: `${name}RedisRelayBus`,
|
|
323
|
+
args: [{ url, logger: this.logger }],
|
|
324
|
+
});
|
|
325
|
+
try {
|
|
326
|
+
await redisBus.connect();
|
|
327
|
+
this.relayBus = redisBus;
|
|
328
|
+
bus = redisBus;
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
this.logger.error("Could not create the video meeting relay's Redis bus, so signaling and relay only work between participants connected to this " +
|
|
332
|
+
`server instance: ${err?.message ?? err}`);
|
|
333
|
+
}
|
|
299
334
|
}
|
|
300
|
-
|
|
301
|
-
this.
|
|
302
|
-
`server instance: ${err?.message ?? err}`);
|
|
303
|
-
return;
|
|
335
|
+
if (!bus) {
|
|
336
|
+
bus = await this._objectFactory.newInstance(InProcessRelayBus, { name: `${name}InProcessRelayBus`, args: [] });
|
|
304
337
|
}
|
|
305
|
-
this.
|
|
306
|
-
|
|
338
|
+
this.relayHub = await this._objectFactory.newInstance(RelayHub, {
|
|
339
|
+
name: `${name}RelayHub`,
|
|
340
|
+
args: [{ bus, maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } }],
|
|
341
|
+
});
|
|
342
|
+
this.signalingHub = await this._objectFactory.newInstance(SignalingHub, {
|
|
343
|
+
name: `${name}SignalingHub`,
|
|
344
|
+
args: [{ bus, maxPayloadBytes: SIGNALING_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } }],
|
|
345
|
+
});
|
|
307
346
|
}
|
|
308
347
|
/** Disconnects the Redis relay bus, if there is one, when the object factory destroys the route. (The factory
|
|
309
348
|
* runs one destroy method per object: a subclass declaring its own `@Destroy` must call this one.) */
|
|
@@ -311,17 +350,21 @@ export class BaseVideoMeetingRoute {
|
|
|
311
350
|
this.relayBus?.close();
|
|
312
351
|
this.relayBus = undefined;
|
|
313
352
|
}
|
|
314
|
-
|
|
315
|
-
|
|
353
|
+
/** Builds the model repositories once, before the route serves anything. */
|
|
354
|
+
async initialize() {
|
|
355
|
+
if (!this._objectFactory) {
|
|
356
|
+
throw new Error("objectFactory is not set.");
|
|
357
|
+
}
|
|
358
|
+
if (!this.meetingRepo && this.meetingClass) {
|
|
316
359
|
this.meetingRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.meetingClass.name, args: [this.meetingClass] });
|
|
317
360
|
}
|
|
318
|
-
if (!this.inviteeRepo) {
|
|
361
|
+
if (!this.inviteeRepo && this.inviteeClass) {
|
|
319
362
|
this.inviteeRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.inviteeClass.name, args: [this.inviteeClass] });
|
|
320
363
|
}
|
|
321
|
-
if (!this.mailboxRepo) {
|
|
364
|
+
if (!this.mailboxRepo && this.mailboxClass) {
|
|
322
365
|
this.mailboxRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.mailboxClass.name, args: [this.mailboxClass] });
|
|
323
366
|
}
|
|
324
|
-
if (!this.attendeeLinkRepo) {
|
|
367
|
+
if (!this.attendeeLinkRepo && this.attendeeLinkClass) {
|
|
325
368
|
this.attendeeLinkRepo = await this._objectFactory.newInstance(RepoUtils, {
|
|
326
369
|
name: this.attendeeLinkClass.name,
|
|
327
370
|
args: [this.attendeeLinkClass],
|
|
@@ -511,7 +554,6 @@ export class BaseVideoMeetingRoute {
|
|
|
511
554
|
return rest;
|
|
512
555
|
}
|
|
513
556
|
async create(rawBody, req, user) {
|
|
514
|
-
await this.init();
|
|
515
557
|
const { mailboxUid, visibility } = this.validateCreateBody(rawBody);
|
|
516
558
|
const body = rawBody;
|
|
517
559
|
const startTime = this.requireOptionalDate(body.startTime, "startTime");
|
|
@@ -558,7 +600,6 @@ export class BaseVideoMeetingRoute {
|
|
|
558
600
|
return result;
|
|
559
601
|
}
|
|
560
602
|
async find(mailboxUid, limit, page, user) {
|
|
561
|
-
await this.init();
|
|
562
603
|
if (typeof mailboxUid !== "string" || !mailboxUid) {
|
|
563
604
|
throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
|
|
564
605
|
}
|
|
@@ -572,7 +613,6 @@ export class BaseVideoMeetingRoute {
|
|
|
572
613
|
return meetings.map((meeting) => this.withJoinUrls(meeting));
|
|
573
614
|
}
|
|
574
615
|
async personalRoom(user) {
|
|
575
|
-
await this.init();
|
|
576
616
|
if (!user?.uid) {
|
|
577
617
|
throw new ApiError(ApiErrors.AUTH_REQUIRED, 401, ApiErrorMessages.AUTH_REQUIRED);
|
|
578
618
|
}
|
|
@@ -595,12 +635,10 @@ export class BaseVideoMeetingRoute {
|
|
|
595
635
|
throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
|
|
596
636
|
}
|
|
597
637
|
async findById(id, user) {
|
|
598
|
-
await this.init();
|
|
599
638
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.READ);
|
|
600
639
|
return this.withJoinUrls(meeting);
|
|
601
640
|
}
|
|
602
641
|
async update(id, body, user) {
|
|
603
|
-
await this.init();
|
|
604
642
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
|
|
605
643
|
const patch = { uid: meeting.uid, version: meeting.version };
|
|
606
644
|
if (body?.title !== undefined) {
|
|
@@ -653,7 +691,6 @@ export class BaseVideoMeetingRoute {
|
|
|
653
691
|
return this.withoutPasswordHash(updated);
|
|
654
692
|
}
|
|
655
693
|
async delete(id, user) {
|
|
656
|
-
await this.init();
|
|
657
694
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.DELETE);
|
|
658
695
|
const strippedUser = stripTrustedRoles(user, this.trustedRoles);
|
|
659
696
|
const invitees = await this.findInvitees(meeting);
|
|
@@ -664,12 +701,11 @@ export class BaseVideoMeetingRoute {
|
|
|
664
701
|
await this.meetingRepo.delete(meeting.uid, { user: strippedUser, ignoreACL: true });
|
|
665
702
|
}
|
|
666
703
|
async kick(id, uid, user) {
|
|
667
|
-
await this.init();
|
|
668
704
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
|
|
669
705
|
await this.revokeChannelGrant(meeting.uid, uid);
|
|
706
|
+
this.signalingHub?.evict(meeting.uid, uid);
|
|
670
707
|
}
|
|
671
708
|
async listWaiting(id, user) {
|
|
672
|
-
await this.init();
|
|
673
709
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.READ);
|
|
674
710
|
const pending = this.pendingAdmissions.get(meeting.uid);
|
|
675
711
|
if (!pending) {
|
|
@@ -681,7 +717,6 @@ export class BaseVideoMeetingRoute {
|
|
|
681
717
|
.map(([uid, request]) => ({ uid, name: request.name, requestedAt: request.requestedAt.toISOString() }));
|
|
682
718
|
}
|
|
683
719
|
async admit(id, uid, user) {
|
|
684
|
-
await this.init();
|
|
685
720
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
|
|
686
721
|
const request = this.pendingAdmissions.get(meeting.uid)?.get(uid);
|
|
687
722
|
if (request) {
|
|
@@ -689,7 +724,6 @@ export class BaseVideoMeetingRoute {
|
|
|
689
724
|
}
|
|
690
725
|
}
|
|
691
726
|
async deny(id, uid, user) {
|
|
692
|
-
await this.init();
|
|
693
727
|
const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
|
|
694
728
|
const request = this.pendingAdmissions.get(meeting.uid)?.get(uid);
|
|
695
729
|
if (request) {
|
|
@@ -803,9 +837,9 @@ export class BaseVideoMeetingRoute {
|
|
|
803
837
|
}
|
|
804
838
|
/**
|
|
805
839
|
* Removes `uid`'s `ACLRecord` from `meetingUid`'s own `AccessControlList`, if it has one - the mirror image of
|
|
806
|
-
* `ensureChannelGrant()`, used to enforce a kick (see `kick()
|
|
807
|
-
*
|
|
808
|
-
* (`SignalMessage`'s doc comment) cannot keep participating. A no-op when `uid` holds no grant (already kicked,
|
|
840
|
+
* `ensureChannelGrant()`, used to enforce a kick (see `kick()`, which also closes `uid`'s open signaling sockets):
|
|
841
|
+
* once removed, `uid` can no longer open the meeting's signaling (`signal()`) or relay (`relay()`) socket, so even
|
|
842
|
+
* a client that ignores the cooperative `"kicked"` signal (`SignalMessage`'s doc comment) cannot keep participating. A no-op when `uid` holds no grant (already kicked,
|
|
809
843
|
* or never joined) or the meeting has no ACL, rather than an error - the caller wants "not able to rejoin",
|
|
810
844
|
* which is already true either way. Same optimistic-lock retry as `ensureChannelGrant()`, for the same reason:
|
|
811
845
|
* a concurrent joiner or the owner editing the ACL directly may save first.
|
|
@@ -954,7 +988,6 @@ export class BaseVideoMeetingRoute {
|
|
|
954
988
|
pending.set(uid, { name, requestedAt: new Date(), status: "pending" });
|
|
955
989
|
}
|
|
956
990
|
async join(token, user) {
|
|
957
|
-
await this.init();
|
|
958
991
|
const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
|
|
959
992
|
await this.requireOrganizerAuth(meeting, resolvedVia, user);
|
|
960
993
|
const publicMeeting = await this.buildPublicMeeting(meeting);
|
|
@@ -969,7 +1002,6 @@ export class BaseVideoMeetingRoute {
|
|
|
969
1002
|
return this.completeJoin(meeting, publicMeeting, user);
|
|
970
1003
|
}
|
|
971
1004
|
async verifyPassword(token, body, user) {
|
|
972
|
-
await this.init();
|
|
973
1005
|
const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
|
|
974
1006
|
await this.requireOrganizerAuth(meeting, resolvedVia, user);
|
|
975
1007
|
const bypassed = resolvedVia === "organizerSlug";
|
|
@@ -988,7 +1020,6 @@ export class BaseVideoMeetingRoute {
|
|
|
988
1020
|
return this.completeJoin(meeting, publicMeeting, user);
|
|
989
1021
|
}
|
|
990
1022
|
async pollAdmission(token, user) {
|
|
991
|
-
await this.init();
|
|
992
1023
|
const { meeting } = await this.requireMeetingByToken(token);
|
|
993
1024
|
const pending = this.pendingAdmissions.get(meeting.uid)?.get(user.uid);
|
|
994
1025
|
if (!pending) {
|
|
@@ -1015,7 +1046,7 @@ export class BaseVideoMeetingRoute {
|
|
|
1015
1046
|
/**
|
|
1016
1047
|
* Decides whether `user` may open a relay socket to meeting `id`: the relay must be enabled, `user` must hold BOTH
|
|
1017
1048
|
* `READ` and `CREATE` on the meeting's uid - exactly what `ensureChannelGrant()` gives a joined participant, and
|
|
1018
|
-
* what
|
|
1049
|
+
* what `signal()` demands too (`authorizeMeetingSocket()`) - and the meeting must exist and be neither cancelled nor ended.
|
|
1019
1050
|
* Returns the reason to close the socket with, or `undefined` when permitted.
|
|
1020
1051
|
*
|
|
1021
1052
|
* `hasPermission()` gets `user` with its trusted roles stripped (`stripTrustedRoles()`), exactly as
|
|
@@ -1028,11 +1059,16 @@ export class BaseVideoMeetingRoute {
|
|
|
1028
1059
|
if (!this.isRelayEnabled()) {
|
|
1029
1060
|
return "Relay disabled.";
|
|
1030
1061
|
}
|
|
1062
|
+
return this.authorizeMeetingSocket(id, user);
|
|
1063
|
+
}
|
|
1064
|
+
/** Why a meeting WebSocket (`relay()`, `signal()`) of `user` for meeting `id` must be refused, or `undefined` to
|
|
1065
|
+
* accept it - see `authorizeRelay()`'s doc comment for the rules, which are the same for both apart from the
|
|
1066
|
+
* relay's own on/off setting. */
|
|
1067
|
+
async authorizeMeetingSocket(id, user) {
|
|
1031
1068
|
// `hasPermission()` answers `true` for an empty ACL uid, so an empty id must never reach it.
|
|
1032
1069
|
if (!user || !id) {
|
|
1033
1070
|
return RELAY_NOT_PERMITTED;
|
|
1034
1071
|
}
|
|
1035
|
-
await this.init();
|
|
1036
1072
|
const checked = stripTrustedRoles(user, this.trustedRoles);
|
|
1037
1073
|
if (!(await this.aclUtils.hasPermission(checked, id, ACLAction.READ)) || !(await this.aclUtils.hasPermission(checked, id, ACLAction.CREATE))) {
|
|
1038
1074
|
return RELAY_NOT_PERMITTED;
|
|
@@ -1044,14 +1080,14 @@ export class BaseVideoMeetingRoute {
|
|
|
1044
1080
|
return undefined;
|
|
1045
1081
|
}
|
|
1046
1082
|
/**
|
|
1047
|
-
* The meeting uid a relay socket's URL names. `@Param("id")` is filled in by the Bun router, but NOT by the uWS
|
|
1083
|
+
* The meeting uid a relay or signaling socket's URL names. `@Param("id")` is filled in by the Bun router, but NOT by the uWS
|
|
1048
1084
|
* router this framework actually serves with: it never populates `req.params` for a WebSocket upgrade (a
|
|
1049
1085
|
* `@WebSocket()` route has, so far, never had a path parameter - `BasePushRoute.connect()` has none). So when the
|
|
1050
1086
|
* decorated argument is empty this reads the last segment of the request path instead, which the
|
|
1051
|
-
* `/relay/:id`
|
|
1087
|
+
* `/relay/:id` and `/signal/:id` patterns guarantee is the id (the framework also registers the trailing-slash form; the path
|
|
1052
1088
|
* never carries the query string). Returns `undefined` when there is none or it isn't valid percent-encoding.
|
|
1053
1089
|
*/
|
|
1054
|
-
|
|
1090
|
+
socketMeetingId(paramId, req) {
|
|
1055
1091
|
if (typeof paramId === "string" && paramId) {
|
|
1056
1092
|
return paramId;
|
|
1057
1093
|
}
|
|
@@ -1064,7 +1100,7 @@ export class BaseVideoMeetingRoute {
|
|
|
1064
1100
|
}
|
|
1065
1101
|
}
|
|
1066
1102
|
async relay(paramId, req, sock, user) {
|
|
1067
|
-
const id = this.
|
|
1103
|
+
const id = this.socketMeetingId(paramId, req);
|
|
1068
1104
|
// Listeners first, before the first await: the client may send its `hello` the moment the socket opens,
|
|
1069
1105
|
// while the meeting is still being looked up, and an event with no listener is lost. Whatever arrives until
|
|
1070
1106
|
// the socket is admitted is held (a few messages at most) and replayed to it.
|
|
@@ -1105,6 +1141,45 @@ export class BaseVideoMeetingRoute {
|
|
|
1105
1141
|
conn?.message(message.data, message.isBinary);
|
|
1106
1142
|
}
|
|
1107
1143
|
}
|
|
1144
|
+
async signal(paramId, req, sock, user) {
|
|
1145
|
+
const id = this.socketMeetingId(paramId, req);
|
|
1146
|
+
// Listeners first, before the first await - see `relay()`, which this mirrors.
|
|
1147
|
+
let conn;
|
|
1148
|
+
let closed = false;
|
|
1149
|
+
const queued = [];
|
|
1150
|
+
sock.on("message", (data, isBinary) => {
|
|
1151
|
+
if (conn) {
|
|
1152
|
+
conn.message(data, isBinary);
|
|
1153
|
+
}
|
|
1154
|
+
else if (queued.length < RELAY_MAX_QUEUED_MESSAGES) {
|
|
1155
|
+
queued.push({ data, isBinary });
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
sock.on("close", () => {
|
|
1159
|
+
closed = true;
|
|
1160
|
+
conn?.close();
|
|
1161
|
+
});
|
|
1162
|
+
let refusal;
|
|
1163
|
+
try {
|
|
1164
|
+
refusal = await this.authorizeMeetingSocket(id, user);
|
|
1165
|
+
}
|
|
1166
|
+
catch (err) {
|
|
1167
|
+
this.logger.error(`Failed to authorize a signaling socket for meeting ${id}: ${err?.message ?? err}`);
|
|
1168
|
+
sock.close(1011, "Signaling unavailable.");
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (refusal) {
|
|
1172
|
+
sock.close(1008, refusal);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (closed) {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
conn = this.signalingHub.attach(id, user.uid, sock);
|
|
1179
|
+
for (const message of queued) {
|
|
1180
|
+
conn?.message(message.data, message.isBinary);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1108
1183
|
}
|
|
1109
1184
|
__decorate([
|
|
1110
1185
|
Inject(ACLUtils),
|
|
@@ -1166,6 +1241,12 @@ __decorate([
|
|
|
1166
1241
|
__metadata("design:paramtypes", []),
|
|
1167
1242
|
__metadata("design:returntype", void 0)
|
|
1168
1243
|
], BaseVideoMeetingRoute.prototype, "destroyRelayBus", null);
|
|
1244
|
+
__decorate([
|
|
1245
|
+
Init,
|
|
1246
|
+
__metadata("design:type", Function),
|
|
1247
|
+
__metadata("design:paramtypes", []),
|
|
1248
|
+
__metadata("design:returntype", Promise)
|
|
1249
|
+
], BaseVideoMeetingRoute.prototype, "initialize", null);
|
|
1169
1250
|
__decorate([
|
|
1170
1251
|
Transactional(),
|
|
1171
1252
|
__metadata("design:type", Function),
|
|
@@ -1244,7 +1325,7 @@ __decorate([
|
|
|
1244
1325
|
Summary("Deletes a video meeting."),
|
|
1245
1326
|
Description("Deletes the meeting, every one of its invitees and the calendar invite links written for them. Deleting the meeting also removes its own " +
|
|
1246
1327
|
"per-record AccessControlList (RepoUtils.delete()'s standard recordACL cleanup - see the VideoMeeting " +
|
|
1247
|
-
"interface's doc comment), so its uid stops
|
|
1328
|
+
"interface's doc comment), so its uid stops authorizing a signaling or relay socket immediately. Requires DELETE on " +
|
|
1248
1329
|
"the meeting's owning mailbox."),
|
|
1249
1330
|
Delete("/:id"),
|
|
1250
1331
|
__param(0, Param("id")),
|
|
@@ -1255,8 +1336,8 @@ __decorate([
|
|
|
1255
1336
|
], BaseVideoMeetingRoute.prototype, "delete", null);
|
|
1256
1337
|
__decorate([
|
|
1257
1338
|
Summary("Removes a participant from a video meeting."),
|
|
1258
|
-
Description("Revokes 'uid's grant on the meeting
|
|
1259
|
-
"
|
|
1339
|
+
Description("Revokes 'uid's grant on the meeting (revokeChannelGrant()) and closes every signaling socket they hold in it " +
|
|
1340
|
+
"(on every replica the bus reaches), so they can no longer signal, chat, draw or relay media, even if their " +
|
|
1260
1341
|
"client ignores the cooperative 'kicked' signal the host's own UI also sends over the mesh (see " +
|
|
1261
1342
|
"SignalMessage's doc comment for why that signal alone isn't enforcement). A no-op if 'uid' holds no " +
|
|
1262
1343
|
"grant (already removed, or never joined). Requires UPDATE on the meeting's owning mailbox. " +
|
|
@@ -1317,11 +1398,11 @@ __decorate([
|
|
|
1317
1398
|
Summary("Joins a video meeting."),
|
|
1318
1399
|
Description("Resolves an invitee's join token or a public meeting's slug. If the caller already presents a valid " +
|
|
1319
1400
|
"session for a real RapidMX identity (not a returning guest), that identity is granted READ/CREATE " +
|
|
1320
|
-
"on the meeting
|
|
1321
|
-
"guest token at all - the caller's own existing session cookie/header already authenticates
|
|
1322
|
-
"for them. Otherwise (the common anonymous case)
|
|
1323
|
-
"GUEST_JWT_TTL_SECONDS) already granted READ/CREATE on the same
|
|
1324
|
-
"to exchange WebRTC signaling messages. Requires no authentication beyond the token itself; a " +
|
|
1401
|
+
"on the meeting directly and the response carries 'authenticated: true' with no " +
|
|
1402
|
+
"guest token at all - the caller's own existing session cookie/header already authenticates the " +
|
|
1403
|
+
"meeting's signaling WebSocket (/signal/<meetingUid>) for them. Otherwise (the common anonymous case) " +
|
|
1404
|
+
"mints a short-lived guest JWT (see GUEST_JWT_TTL_SECONDS) already granted READ/CREATE on the same " +
|
|
1405
|
+
"meeting, ready to use against /signal/<meetingUid> to exchange WebRTC signaling messages. Requires no authentication beyond the token itself; a " +
|
|
1325
1406
|
"stale/unknown token answers 404. The one exception is a private meeting's organizerSlug, which is " +
|
|
1326
1407
|
"never an anonymous surface: it additionally requires an already-authenticated caller holding READ on " +
|
|
1327
1408
|
"the meeting's owning mailbox, and answers the very same 404 for anyone else. If the meeting requires " +
|
|
@@ -1397,4 +1478,25 @@ __decorate([
|
|
|
1397
1478
|
__metadata("design:paramtypes", [Object, Object, Object, Object]),
|
|
1398
1479
|
__metadata("design:returntype", Promise)
|
|
1399
1480
|
], BaseVideoMeetingRoute.prototype, "relay", null);
|
|
1481
|
+
__decorate([
|
|
1482
|
+
Summary("Carries a video meeting's signaling over a WebSocket."),
|
|
1483
|
+
Description("The meeting's own signaling channel: a WebSocket at /signal/<meetingUid> carrying JSON messages - WebRTC " +
|
|
1484
|
+
"offers, answers and ICE candidates, presence, moderation, chat and whiteboard - between the meeting's " +
|
|
1485
|
+
"participants (see SignalingHub for the protocol: a 'hello' first, answered with 'ready', then 'signal' " +
|
|
1486
|
+
"messages both ways). The server stamps each message with the sender's own uid and peer id, and delivers a " +
|
|
1487
|
+
"message naming a recipient ('to') to that participant only. Requires a JWT (a real user's, or the guest " +
|
|
1488
|
+
"token join() minted) holding READ and CREATE on the meeting - the grant join() gives - and an existing, " +
|
|
1489
|
+
"unended, uncancelled meeting; anything else closes the socket with code 1008. Removing a participant " +
|
|
1490
|
+
"(kick) closes their sockets. With the events datastore (Redis) configured, messages reach participants " +
|
|
1491
|
+
"connected to other server replicas too."),
|
|
1492
|
+
Auth(["jwt"]),
|
|
1493
|
+
WebSocket("/signal/:id", SIGNALING_WS_OPTIONS),
|
|
1494
|
+
__param(0, Param("id")),
|
|
1495
|
+
__param(1, Request),
|
|
1496
|
+
__param(2, Socket),
|
|
1497
|
+
__param(3, AuthUser),
|
|
1498
|
+
__metadata("design:type", Function),
|
|
1499
|
+
__metadata("design:paramtypes", [Object, Object, Object, Object]),
|
|
1500
|
+
__metadata("design:returntype", Promise)
|
|
1501
|
+
], BaseVideoMeetingRoute.prototype, "signal", null);
|
|
1400
1502
|
//# sourceMappingURL=BaseVideoMeetingRoute.js.map
|