From 3fef47e3694209b14a482be198417d83a4ce10f8 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 28 Apr 2021 18:47:58 +0100 Subject: [PATCH 01/13] Add an endpoint for retrieving the widget from Dimension Dummy data for now. --- .../DimensionBigBlueButtonService.ts | 27 ++++++++++++++++++- src/models/WidgetResponses.ts | 22 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index fb2cadf..2d36f4d 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -3,7 +3,7 @@ import * as request from "request"; import { LogService } from "matrix-js-snippets"; import { URL } from "url"; import { BigBlueButtonJoinRequest } from "../../models/Widget"; -import { BigBlueButtonJoinResponse } from "../../models/WidgetResponses"; +import { BigBlueButtonJoinResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; import { AutoWired } from "typescript-ioc/es6"; import { ApiError } from "../ApiError"; @@ -204,4 +204,29 @@ export class DimensionBigBlueButtonService { }); } + @GET + @Path("widget") + public async widget(): Promise { + return { + "widget_id": "1234", + "widget": { + "creatorUserId": "@admin:localhost", + "id": "1234", + "type": "m.custom", + "waitForIframeLoad": true, + "name": "Livestream / Q&A", + "avatar_url": "mxc://fosdem.org/0eea5cb67fbe964399060b10b09a22e45e2226ee", + "url": "https://widgets-fosdem.ems.host/widgets/hybrid.html?widgetId=$matrix_widget_id&roomId=$matrix_room_id#displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&roomId=$matrix_room_id&auth=openidtoken-jwt", + "data": { + "title": "Join the conference thingy to ask questions", + } + }, + "layout": { + "container": "top", + "index": 0, + "width": 65, + "height": 50, + } + } + } } diff --git a/src/models/WidgetResponses.ts b/src/models/WidgetResponses.ts index 854ea31..16670ef 100644 --- a/src/models/WidgetResponses.ts +++ b/src/models/WidgetResponses.ts @@ -2,3 +2,25 @@ export interface BigBlueButtonJoinResponse { // The meeting URL the client should load to join the meeting url: string; } + +export interface BigBlueButtonWidgetResponse { + widget_id: string; + widget: { + creatorUserId: string; + id: string; + type: string; + waitForIframeLoad: boolean; + name: string; + avatar_url: string; + url: string; + data: { + title: string; + } + }; + layout: { + container: string; + index: number; + width: number; + height: number; + }; +} \ No newline at end of file From 5c28ec1d9406aca5abe7cf45d1b287bb5179cbb0 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 4 May 2021 09:38:44 +0100 Subject: [PATCH 02/13] Fleshing out the Dimension API, widget code --- package-lock.json | 27 ++++ package.json | 4 +- .../DimensionBigBlueButtonService.ts | 132 ++++++++++++++++-- src/config.ts | 4 + src/models/Widget.ts | 7 + src/models/WidgetResponses.ts | 6 + src/utils/hashing.ts | 10 +- .../bigbluebutton.widget.component.ts | 2 + web/app/shared/models/integration.ts | 6 + .../integrations/bigbluebutton-api.service.ts | 7 +- .../bigbluebutton/bigbluebutton.component.ts | 90 +++++++++--- 11 files changed, 265 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index e78ea89..0c12123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2110,6 +2110,14 @@ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", "dev": true }, + "bigbluebutton-api-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bigbluebutton-api-js/-/bigbluebutton-api-js-2.2.1.tgz", + "integrity": "sha512-pkLc3tur/5UPLlC7hlCfR0fta3ajbVtO48IzIn10Tkm00Ren1OP89cp1i1dfRUVroGq1K4VwqIOpUPWlHghw/w==", + "requires": { + "jssha": "^3.2.0" + } + }, "binary-extensions": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", @@ -6951,6 +6959,11 @@ "verror": "1.10.0" } }, + "jssha": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", + "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -13962,6 +13975,20 @@ "async-limiter": "~1.0.0" } }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", diff --git a/package.json b/package.json index 0e24a3c..37c6e78 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/body-parser": "^1.17.0", "@types/node": "^12.0.10", "@types/validator": "^10.11.1", + "bigbluebutton-api-js": "^2.2.1", "body-parser": "^1.19.0", "config": "^3.1.0", "dns-then": "^0.1.0", @@ -61,7 +62,8 @@ "typescript-ioc": "^1.2.5", "typescript-rest": "^2.2.0", "umzug": "^2.2.0", - "url": "^0.11.0" + "url": "^0.11.0", + "xml2js": "^0.4.23" }, "devDependencies": { "@angular/animations": "^8.0.3", diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index 2d36f4d..d86d9a3 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -2,10 +2,13 @@ import { GET, Path, QueryParam } from "typescript-rest"; import * as request from "request"; import { LogService } from "matrix-js-snippets"; import { URL } from "url"; -import { BigBlueButtonJoinRequest } from "../../models/Widget"; -import { BigBlueButtonJoinResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; +import { BigBlueButtonJoinRequest, BigBlueButtonCreateAndJoinMeetingRequest } from "../../models/Widget"; +import { BigBlueButtonJoinResponse, BigBlueButtonCreateAndJoinMeetingResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; import { AutoWired } from "typescript-ioc/es6"; import { ApiError } from "../ApiError"; +import { sha1, sha256 } from "../../utils/hashing"; +import config from "../../config"; +import { parseStringPromise } from "xml2js"; /** * API for the BigBlueButton widget. @@ -163,6 +166,15 @@ export class DimensionBigBlueButtonService { return {url: joinUrl}; } + /** + * Perform an HTTP request. + * @param {string} method The HTTP method to use. + * @param {string} url The URL (without query parameters) to request. + * @param {string} qs The query parameters to use with the request. + * @param {string} body The JSON body of the request + * @param {boolean} followRedirect Whether to follow redirect responses automatically. + * @private + */ private async doRequest( method: string, url: string, @@ -180,6 +192,7 @@ export class DimensionBigBlueButtonService { followRedirect: followRedirect, jar: true, // remember cookies between requests json: false, // expect html + }, (err, res, _body) => { try { if (err) { @@ -205,20 +218,40 @@ export class DimensionBigBlueButtonService { } @GET - @Path("widget") - public async widget(): Promise { + @Path("widget_state") + public async widget( + @QueryParam("room_id") roomId: string, + ): Promise { + // Hash the room ID in order to generate a unique widget ID + const widgetId = sha256(roomId + "bigbluebutton"); + + // TODO: Make configurable + const widgetTitle = "BigBlueButton Video Conference"; + const widgetSubTitle = "Join the conference"; + const widgetAvatarUrl = "mxc://fosdem.org/0eea5cb67fbe964399060b10b09a22e45e2226ee"; + + // TODO: What should we put for the creatorUserId? Also make it configurable? + const widgetCreatorUserId = "@bobbb:localhost"; + + // TODO: Set to configured Dimension publicUrl + let widgetUrl = "http://localhost:8082/widgets/bigbluebutton"; + + // Add all necessary client variables to the url when loading the widget + widgetUrl += "?widgetId=$matrix_widget_id&roomId=$matrix_room_id#displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&roomId=$matrix_room_id&auth=openidtoken-jwt"; + return { - "widget_id": "1234", + "widget_id": widgetId, "widget": { - "creatorUserId": "@admin:localhost", - "id": "1234", + "creatorUserId": widgetCreatorUserId, + "id": widgetId, "type": "m.custom", "waitForIframeLoad": true, - "name": "Livestream / Q&A", - "avatar_url": "mxc://fosdem.org/0eea5cb67fbe964399060b10b09a22e45e2226ee", - "url": "https://widgets-fosdem.ems.host/widgets/hybrid.html?widgetId=$matrix_widget_id&roomId=$matrix_room_id#displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&roomId=$matrix_room_id&auth=openidtoken-jwt", + "name": widgetTitle, + "avatar_url": widgetAvatarUrl, + "url": widgetUrl, "data": { - "title": "Join the conference thingy to ask questions", + "title": widgetSubTitle, + "widgetVersion": 2, } }, "layout": { @@ -229,4 +262,81 @@ export class DimensionBigBlueButtonService { } } } + + @GET + @Path("createAndJoinMeeting") + public async createAndJoinMeeting( + @QueryParam("room_id") roomId: string, + ): Promise { + // Check if a meeting already exists for this room... + LogService.info("BigBlueButton", "Got a meeting create and join request for room: " + roomId); + + // Create a new meeting + LogService.info("BigBlueButton", "Using secret: " + config.bigbluebutton.sharedSecret); + + // NOTE: BBB meetings will by default end a minute or two after the last person leaves. + const queryParameters = { + meetingID: roomId + "bigbluebuttondimension", + }; + const response = await this.makeBBBApiCall("GET", "create", queryParameters, null); + LogService.info("BigBlueButton", response); + + return { + url: "https://bla.com", + } + } + + /** + * Make an API call to the configured BBB server instance + * @param {string} method The HTTP method to use for the request. + * @param {string} apiCallName The name of the API (the last bit of the endpoint) to call. e.g 'create', 'join'. + * @param {any} queryParameters The query parameters to use in the request. + * @param {any} body The body of the request. + * @private + * @returns {BigBlueButtonApiResponse} The response to the call. + */ + private async makeBBBApiCall( + method: string, + apiCallName: string, + queryParameters: any, + body: any, + ): Promise { + // Build the URL path from the api name, query parameter string, shared secret and checksum + // Docs: https://docs.bigbluebutton.org/dev/api.html#usage + + LogService.info("BigBlueButton", "given query params: " + queryParameters.meetingID); + // Convert the query parameters map into a string + // We URL encode each value, as doRequest does so as well. If we don't, our resulting checksum will not match + const widgetQueryString = Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k])).join("&"); + LogService.info("BigBlueButton", "queryString: " + widgetQueryString); + + // SHA1 hash the api name and query parameters to get the checksum, and add it to the set of query parameters + queryParameters.checksum = sha1(apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + LogService.info("BigBlueButton", "hashing: " + apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + + // Get the URL host and path using the configured api base and the API call name + const url = `${config.bigbluebutton.apiBaseUrl}/${apiCallName}`; + const qsWithChecksum = Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k])).join("&"); + LogService.info("BigBlueButton", "final url: " + url + "?" + qsWithChecksum); + + // Now make the request! + // TODO: Unfortunately doRequest is URLencoding the query parameters which the checksum stuff doesn't take into account. + // So we need to disable URL encoding here, or do it when calculating the checksum + const response = await this.doRequest(method, url, queryParameters, body); + + // Parse and return the XML from the response + LogService.info("BigBlueButton", response.body); + return await parseStringPromise(response.body); + } + + /** + * Encodes a string in the same fashion browsers do (encoding ! and other characters) + * @param {string} text The text to encode + */ + encodeForUrl(text: string) { + // use + instead of %20 for space to match what the Java tools do. + // encodeURIComponent doesn't escape !'()* but browsers do, so manually escape them. + return encodeURIComponent(text).replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); + } + } diff --git a/src/config.ts b/src/config.ts index 510342b..9c848b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,10 @@ export interface DimensionConfig { telegram: { botToken: string; }; + bigbluebutton: { + apiBaseUrl: string; + sharedSecret: string; + }; stickers: { enabled: boolean; stickerBot: string; diff --git a/src/models/Widget.ts b/src/models/Widget.ts index 3a26ef2..1b34c75 100644 --- a/src/models/Widget.ts +++ b/src/models/Widget.ts @@ -5,3 +5,10 @@ export interface BigBlueButtonJoinRequest { // The name the user wishes to join the meeting with fullName: string; } + +export interface BigBlueButtonCreateAndJoinMeetingRequest { + // The ID of the room that the BBB meeting is a part of + roomId: string; + // The name the user wishes to join the meeting with + fullName: string; +} diff --git a/src/models/WidgetResponses.ts b/src/models/WidgetResponses.ts index 16670ef..0ccf20b 100644 --- a/src/models/WidgetResponses.ts +++ b/src/models/WidgetResponses.ts @@ -3,6 +3,11 @@ export interface BigBlueButtonJoinResponse { url: string; } +export interface BigBlueButtonCreateAndJoinMeetingResponse { + // The meeting URL the client should load to join the meeting + url: string; +} + export interface BigBlueButtonWidgetResponse { widget_id: string; widget: { @@ -15,6 +20,7 @@ export interface BigBlueButtonWidgetResponse { url: string; data: { title: string; + widgetVersion: number; } }; layout: { diff --git a/src/utils/hashing.ts b/src/utils/hashing.ts index 7587743..090a2ab 100644 --- a/src/utils/hashing.ts +++ b/src/utils/hashing.ts @@ -2,4 +2,12 @@ import * as crypto from "crypto"; export function md5(text: string): string { return crypto.createHash("md5").update(text).digest('hex').toLowerCase(); -} \ No newline at end of file +} + +export function sha1(text: string): string { + return crypto.createHash("sha1").update(text).digest('hex').toLowerCase(); +} + +export function sha256(text: string): string { + return crypto.createHash("sha256").update(text).digest('hex').toLowerCase(); +} diff --git a/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts b/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts index c00da9e..1eb9c62 100644 --- a/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts +++ b/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts @@ -27,6 +27,7 @@ export class BigBlueButtonConfigComponent extends WidgetComponent { protected OnNewWidgetPrepared(widget: EditableWidget): void { widget.dimension.newData["conferenceUrl"] = this.bigBlueButtonWidget.options.conferenceUrl; + widget.dimension.newData["widgetVersion"] = this.bigBlueButtonWidget.options.widgetVersion; } protected OnWidgetBeforeAdd(widget: EditableWidget) { @@ -50,5 +51,6 @@ export class BigBlueButtonConfigComponent extends WidgetComponent { }); widgetQueryString = this.decodeParams(widgetQueryString, Object.keys(widget.dimension.newData).map(k => "$" + k)); widget.dimension.newUrl = window.location.origin + "/widgets/bigbluebutton" + widgetQueryString; + console.log("URL ended up as:", widget.dimension.newUrl); } } diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index 519cb32..81e6444 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -69,6 +69,11 @@ export interface FE_BigBlueButtonJoin { url: string; } +export interface FE_BigBlueButtonCreateAndJoinMeeting { + // The meeting URL the client should load to join the meeting + url: string; +} + export interface FE_StickerConfig { enabled: boolean; stickerBot: string; @@ -96,6 +101,7 @@ export interface FE_JitsiWidget extends FE_Widget { export interface FE_BigBlueButtonWidget extends FE_Widget { options: { conferenceUrl: string; + widgetVersion: number; }; } diff --git a/web/app/shared/services/integrations/bigbluebutton-api.service.ts b/web/app/shared/services/integrations/bigbluebutton-api.service.ts index 4a23cfe..271d904 100644 --- a/web/app/shared/services/integrations/bigbluebutton-api.service.ts +++ b/web/app/shared/services/integrations/bigbluebutton-api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; import { AuthedApi } from "../authed-api"; -import { FE_BigBlueButtonJoin } from "../../models/integration" +import { FE_BigBlueButtonJoin, FE_BigBlueButtonCreateAndJoinMeeting } from "../../models/integration" import { HttpClient } from "@angular/common/http"; import { ApiError } from "../../../../../src/api/ApiError"; @@ -13,4 +13,9 @@ export class BigBlueButtonApiService extends AuthedApi { public joinMeeting(url: string, name: string): Promise { return this.authedGet("/api/v1/dimension/bigbluebutton/join", {greenlightUrl: url, fullName: name}).toPromise(); } + + public createAndJoinMeeting(roomId: string): Promise { + return this.authedGet("/api/v1/dimension/bigbluebutton/join_meeting", {roomId: roomId}).toPromise(); + } + } \ No newline at end of file diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts index fdd679d..5b3adab 100644 --- a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts @@ -6,7 +6,7 @@ import { ScalarWidgetApi } from "../../shared/services/scalar/scalar-widget.api" import { CapableWidget } from "../capable-widget"; import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; import { BigBlueButtonApiService } from "../../shared/services/integrations/bigbluebutton-api.service"; -import { FE_BigBlueButtonJoin } from "../../shared/models/integration"; +import { FE_BigBlueButtonCreateAndJoinMeeting, FE_BigBlueButtonJoin } from "../../shared/models/integration"; import { TranslateService } from "@ngx-translate/core"; @Component({ @@ -25,6 +25,19 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement private displayName: string; private userId: string; + /** + * Whether we expect the meeting to be created on command. + * + * True if we'd like the meeting to be created, false if we have a greenlight URL leading to an existing meeting + * and would like Dimension to translate that to a BigBlueButton meeting URL. + */ + private createMeeting: boolean; + + /** + * The ID of the room, required if createMeeting is true. + */ + private roomId: string; + /** * The poll period in ms while waiting for a meeting to start */ @@ -60,12 +73,15 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement let params: any = activatedRoute.snapshot.queryParams; - console.log("BigBlueButton: Given greenlight url: " + params.conferenceUrl); - + this.roomId = params.roomId; + this.createMeeting = params.createMeeting; this.conferenceUrl = params.conferenceUrl; this.displayName = params.displayName; this.userId = params.userId || params.email; // Element uses `email` when placing a conference call + console.log("BigBlueButton: should create meeting: " + this.createMeeting); + console.log("BigBlueButton: got room ID: " + this.roomId); + // Set the widget ID if we have it ScalarWidgetApi.widgetId = params.widgetId; } @@ -105,6 +121,44 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement const joinName = `${this.displayName} (${this.userId})`; // Make a request to Dimension requesting the join URL + if (this.createMeeting === true) { + // Ask Dimension to create the meeting for us and return the URL + this.createAndJoinMeeting(joinName); + } else { + // Provide Dimension with a Greenlight URL, which it will transform into + // a BBB meeting URL + this.joinThroughGreenlightUrl(joinName); + } + } + + // Ask Dimension to create a meeting (or use an existing one) for this room and return the embeddable meeting URL + private createAndJoinMeeting(joinName: string) { + console.log("BigBlueButton: joining and creating meeting if it doesn't already exist, with fullname:", joinName); + + this.bigBlueButtonApi.createAndJoinMeeting(this.roomId).then((response) => { + if ("errorCode" in response) { + // This is an instance of ApiError + // if (response.errorCode === "WAITING_FOR_MEETING_START") { + // // The meeting hasn't started yet + // this.statusMessage = "Waiting for conference to start..."; + // + // // Poll until it has + // setTimeout(this.joinConference.bind(this), this.pollIntervalMillis, false); + // return; + // } + + // Otherwise this is a generic error + this.statusMessage = "An error occurred while loading the meeting"; + } + + // Retrieve and embed the meeting URL + const joinUrl = (response as FE_BigBlueButtonCreateAndJoinMeeting).url; + this.embedMeetingWithUrl(joinUrl); + }); + } + + // Hand Dimension a Greenlight URL and receive a translated, embeddable meeting URL in response + private joinThroughGreenlightUrl(joinName: string) { console.log("BigBlueButton: joining via greenlight url:", this.conferenceUrl); this.bigBlueButtonApi.joinMeeting(this.conferenceUrl, joinName).then((response) => { if ("errorCode" in response) { @@ -122,23 +176,27 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement this.statusMessage = "An error occurred while loading the meeting"; } + // Retrieve and embed the meeting URL const joinUrl = (response as FE_BigBlueButtonJoin).url; + this.embedMeetingWithUrl(joinUrl); + }); + } - // Check if the given URL is embeddable - this.widgetApi.isEmbeddable(joinUrl).then(result => { - this.canEmbed = result.canEmbed; - this.statusMessage = null; + private embedMeetingWithUrl(url: string) { + // Check if the given URL is embeddable + this.widgetApi.isEmbeddable(url).then(result => { + this.canEmbed = result.canEmbed; + this.statusMessage = null; - // Embed the return meeting URL, joining the meeting - this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(joinUrl); + // Embed the return meeting URL, joining the meeting + this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); - // Inform the client that we would like the meeting to remain visible for its duration - ScalarWidgetApi.sendSetAlwaysOnScreen(true); - }).catch(err => { - console.error(err); - this.canEmbed = false; - this.statusMessage = "Unable to embed meeting"; - }); + // Inform the client that we would like the meeting to remain visible for its duration + ScalarWidgetApi.sendSetAlwaysOnScreen(true); + }).catch(err => { + console.error(err); + this.canEmbed = false; + this.statusMessage = "Unable to embed meeting"; }); } From 32d0bd3aec26380285e2622d2ed46005f078a650 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 5 May 2021 20:37:19 +0100 Subject: [PATCH 03/13] Fix communication with BBB, fix widget query parameters etc. --- config/default.yaml | 21 +++ package.json | 1 - .../DimensionBigBlueButtonService.ts | 136 ++++++++++++------ src/config.ts | 3 + src/models/WidgetResponses.ts | 1 - .../bigbluebutton.widget.component.ts | 3 +- web/app/shared/models/integration.ts | 2 +- .../integrations/bigbluebutton-api.service.ts | 6 +- .../bigbluebutton/bigbluebutton.component.ts | 36 +++-- 9 files changed, 145 insertions(+), 64 deletions(-) diff --git a/config/default.yaml b/config/default.yaml index e77735d..70637a3 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -91,6 +91,27 @@ dimension: # to your own Dimension instance. publicUrl: "https://dimension.example.org" +bigbluebutton: + # The full base URL of the API of your BigBlueButton instance. The API is + # used to create and join meetings. + apiBaseUrl: "https://bbb.example.org/bigbluebutton/api" + + # The "shared secret" of your BigBlueButton instance. This is used to + # authenticate to the API above. + sharedSecret: "YourSharedSecretHere" + + # The title for BigBlueButton widgets that are generated by Dimension. + widgetName: "BigBlueButton Conference" + + # The subtitle for BigBlueButton widgets that are generated by Dimension. + widgetTitle: "Join the conference" + + # The avatar for BigBlueButton widgets that are generated by Dimension. + # Usually this doen't need to be changed, however if your homeserver + # is not able to reach t2bot.io then you should specify your own here. + # TODO: Need a t2bot.io MXC URL. + widgetAvatarUrl: "mxc://t2bot.io/ineedamxcurlplstravis" + # Settings for controlling how logging works logging: file: logs/dimension.log diff --git a/package.json b/package.json index 37c6e78..d5fdb8b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@types/body-parser": "^1.17.0", "@types/node": "^12.0.10", "@types/validator": "^10.11.1", - "bigbluebutton-api-js": "^2.2.1", "body-parser": "^1.19.0", "config": "^3.1.0", "dns-then": "^0.1.0", diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index d86d9a3..6b76a5e 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -2,7 +2,7 @@ import { GET, Path, QueryParam } from "typescript-rest"; import * as request from "request"; import { LogService } from "matrix-js-snippets"; import { URL } from "url"; -import { BigBlueButtonJoinRequest, BigBlueButtonCreateAndJoinMeetingRequest } from "../../models/Widget"; +import { BigBlueButtonJoinRequest } from "../../models/Widget"; import { BigBlueButtonJoinResponse, BigBlueButtonCreateAndJoinMeetingResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; import { AutoWired } from "typescript-ioc/es6"; import { ApiError } from "../ApiError"; @@ -173,7 +173,6 @@ export class DimensionBigBlueButtonService { * @param {string} qs The query parameters to use with the request. * @param {string} body The JSON body of the request * @param {boolean} followRedirect Whether to follow redirect responses automatically. - * @private */ private async doRequest( method: string, @@ -225,19 +224,17 @@ export class DimensionBigBlueButtonService { // Hash the room ID in order to generate a unique widget ID const widgetId = sha256(roomId + "bigbluebutton"); - // TODO: Make configurable - const widgetTitle = "BigBlueButton Video Conference"; - const widgetSubTitle = "Join the conference"; - const widgetAvatarUrl = "mxc://fosdem.org/0eea5cb67fbe964399060b10b09a22e45e2226ee"; + const widgetName = config.bigbluebutton.widgetName; + const widgetTitle = config.bigbluebutton.widgetTitle; + const widgetAvatarUrl = config.bigbluebutton.widgetAvatarUrl; // TODO: What should we put for the creatorUserId? Also make it configurable? - const widgetCreatorUserId = "@bobbb:localhost"; - - // TODO: Set to configured Dimension publicUrl - let widgetUrl = "http://localhost:8082/widgets/bigbluebutton"; + const widgetCreatorUserId = "@bbb:localhost"; // Add all necessary client variables to the url when loading the widget - widgetUrl += "?widgetId=$matrix_widget_id&roomId=$matrix_room_id#displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&roomId=$matrix_room_id&auth=openidtoken-jwt"; + const widgetUrl = config.dimension.publicUrl + + "/widgets/bigbluebutton" + + "?widgetId=$matrix_widget_id&roomId=$matrix_room_id&createMeeting=true&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&auth=openidtoken-jwt"; return { "widget_id": widgetId, @@ -246,12 +243,11 @@ export class DimensionBigBlueButtonService { "id": widgetId, "type": "m.custom", "waitForIframeLoad": true, - "name": widgetTitle, + "name": widgetName, "avatar_url": widgetAvatarUrl, "url": widgetUrl, "data": { - "title": widgetSubTitle, - "widgetVersion": 2, + "title": widgetTitle, } }, "layout": { @@ -264,9 +260,10 @@ export class DimensionBigBlueButtonService { } @GET - @Path("createAndJoinMeeting") + @Path("create") public async createAndJoinMeeting( - @QueryParam("room_id") roomId: string, + @QueryParam("roomId") roomId: string, + @QueryParam("fullName") fullName: string, ): Promise { // Check if a meeting already exists for this room... LogService.info("BigBlueButton", "Got a meeting create and join request for room: " + roomId); @@ -275,15 +272,36 @@ export class DimensionBigBlueButtonService { LogService.info("BigBlueButton", "Using secret: " + config.bigbluebutton.sharedSecret); // NOTE: BBB meetings will by default end a minute or two after the last person leaves. - const queryParameters = { + const createQueryParameters = { meetingID: roomId + "bigbluebuttondimension", + attendeePW: "a", + moderatorPW: "b", }; - const response = await this.makeBBBApiCall("GET", "create", queryParameters, null); - LogService.info("BigBlueButton", response); + + // TODO: Contrary to the documentation, one needs to provide a meeting ID, attendee and moderator password in order + // for creating meeting to be idempotent. For now we use dummy passwords, though we may want to consider generating + // some once we actually start authenticating meetings. + const createResponse = await this.makeBBBApiCall("GET", "create", createQueryParameters, null); + LogService.info("BigBlueButton", createResponse); + + // Grab the meeting ID and password from the create response + const returnedMeetingId = createResponse.meetingID[0]; + const returnedAttendeePassword = createResponse.attendeePW[0]; + const joinQueryParameters = { + meetingID: returnedMeetingId, + password: returnedAttendeePassword, + fullName: fullName, + } + + // Calculate the checksum for the join URL. We need to do so as a browser would as we're passing this back to a browser + const checksum = this.bbbChecksumFromCallNameAndQueryParamaters("join", joinQueryParameters, true); + + // Construct the join URL, which we'll give back to the client, who can then add additional parameters to (or we just do it) + const url = `${config.bigbluebutton.apiBaseUrl}/join?${this.queryStringFromObject(joinQueryParameters, true)}&checksum=${checksum}`; return { - url: "https://bla.com", - } + url: url, + }; } /** @@ -292,7 +310,6 @@ export class DimensionBigBlueButtonService { * @param {string} apiCallName The name of the API (the last bit of the endpoint) to call. e.g 'create', 'join'. * @param {any} queryParameters The query parameters to use in the request. * @param {any} body The body of the request. - * @private * @returns {BigBlueButtonApiResponse} The response to the call. */ private async makeBBBApiCall( @@ -301,42 +318,69 @@ export class DimensionBigBlueButtonService { queryParameters: any, body: any, ): Promise { - // Build the URL path from the api name, query parameter string, shared secret and checksum - // Docs: https://docs.bigbluebutton.org/dev/api.html#usage - - LogService.info("BigBlueButton", "given query params: " + queryParameters.meetingID); - // Convert the query parameters map into a string - // We URL encode each value, as doRequest does so as well. If we don't, our resulting checksum will not match - const widgetQueryString = Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k])).join("&"); - LogService.info("BigBlueButton", "queryString: " + widgetQueryString); - - // SHA1 hash the api name and query parameters to get the checksum, and add it to the set of query parameters - queryParameters.checksum = sha1(apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); - LogService.info("BigBlueButton", "hashing: " + apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + // Compute the checksum needed to authenticate the request (as derived from the configured shared secret) + queryParameters.checksum = this.bbbChecksumFromCallNameAndQueryParamaters(apiCallName, queryParameters, false); // Get the URL host and path using the configured api base and the API call name const url = `${config.bigbluebutton.apiBaseUrl}/${apiCallName}`; - const qsWithChecksum = Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k])).join("&"); - LogService.info("BigBlueButton", "final url: " + url + "?" + qsWithChecksum); // Now make the request! - // TODO: Unfortunately doRequest is URLencoding the query parameters which the checksum stuff doesn't take into account. - // So we need to disable URL encoding here, or do it when calculating the checksum const response = await this.doRequest(method, url, queryParameters, body); // Parse and return the XML from the response - LogService.info("BigBlueButton", response.body); - return await parseStringPromise(response.body); + // TODO: XML parsing error handling + const parsedResponse = await parseStringPromise(response.body); + + // Extract the "response" object + return parsedResponse.response; } /** - * Encodes a string in the same fashion browsers do (encoding ! and other characters) - * @param {string} text The text to encode + * Converts an object representing a query string into a checksum suitable for appending to a BBB API call. + * Docs: https://docs.bigbluebutton.org/dev/api.html#usage + * @param {string} apiCallName The name of the API to call, e.g "create", "join". + * @param {any} queryParameters An object representing a set of query parameters represented by keys and values. + * @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would. + * @returns {string} The checksum for the request. */ - encodeForUrl(text: string) { - // use + instead of %20 for space to match what the Java tools do. - // encodeURIComponent doesn't escape !'()* but browsers do, so manually escape them. - return encodeURIComponent(text).replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); + private bbbChecksumFromCallNameAndQueryParamaters(apiCallName: string, queryParameters: any, encodeAsBrowser: boolean): string { + // Convert the query parameters object into a string + // We URL encode each value as a browser would. If we don't, our resulting checksum will not match. + const widgetQueryString = this.queryStringFromObject(queryParameters, encodeAsBrowser); + + LogService.info("BigBlueButton", "Built widget string:" + widgetQueryString); + LogService.info("BigBlueButton", "Hashing:" + apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + + // SHA1 hash the api name and query parameters to get the checksum, and add it to the set of query parameters + // TODO: Try Sha256 + return sha1(apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + } + + /** + * A + * @param queryParameters + * @param encodeAsBrowser + * @private + */ + private queryStringFromObject(queryParameters: any, encodeAsBrowser: boolean): string { + return Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k], encodeAsBrowser)).join("&"); + } + + /** + * Encodes a string in the same fashion browsers do (encoding ! and other characters). + * @param {string} text The text to encode. + * @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would. + * @returns {string} The encoded text. + */ + private encodeForUrl(text: string, encodeAsBrowser: boolean): string { + let encodedText = encodeURIComponent(text); + if (!encodeAsBrowser) { + // use + instead of %20 for space to match what the 'request' JavaScript library does do. + // encodeURIComponent doesn't escape !'()*, so manually escape them. + encodedText = encodedText.replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); + } + + return encodedText; } } diff --git a/src/config.ts b/src/config.ts index 9c848b4..d7577ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,9 @@ export interface DimensionConfig { bigbluebutton: { apiBaseUrl: string; sharedSecret: string; + widgetName: string; + widgetTitle: string; + widgetAvatarUrl: string; }; stickers: { enabled: boolean; diff --git a/src/models/WidgetResponses.ts b/src/models/WidgetResponses.ts index 0ccf20b..364ae96 100644 --- a/src/models/WidgetResponses.ts +++ b/src/models/WidgetResponses.ts @@ -20,7 +20,6 @@ export interface BigBlueButtonWidgetResponse { url: string; data: { title: string; - widgetVersion: number; } }; layout: { diff --git a/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts b/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts index 1eb9c62..4fb3823 100644 --- a/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts +++ b/web/app/configs/widget/bigbluebutton/bigbluebutton.widget.component.ts @@ -27,7 +27,7 @@ export class BigBlueButtonConfigComponent extends WidgetComponent { protected OnNewWidgetPrepared(widget: EditableWidget): void { widget.dimension.newData["conferenceUrl"] = this.bigBlueButtonWidget.options.conferenceUrl; - widget.dimension.newData["widgetVersion"] = this.bigBlueButtonWidget.options.widgetVersion; + widget.dimension.newData["createMeeting"] = this.bigBlueButtonWidget.options.createMeeting; } protected OnWidgetBeforeAdd(widget: EditableWidget) { @@ -44,6 +44,7 @@ export class BigBlueButtonConfigComponent extends WidgetComponent { let widgetQueryString = url.format({ query: { "conferenceUrl": "$conferenceUrl", + "createMeeting": "$createMeeting", "displayName": "$matrix_display_name", "avatarUrl": "$matrix_avatar_url", "userId": "$matrix_user_id", diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index 81e6444..202fbd1 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -101,7 +101,7 @@ export interface FE_JitsiWidget extends FE_Widget { export interface FE_BigBlueButtonWidget extends FE_Widget { options: { conferenceUrl: string; - widgetVersion: number; + createMeeting: boolean; }; } diff --git a/web/app/shared/services/integrations/bigbluebutton-api.service.ts b/web/app/shared/services/integrations/bigbluebutton-api.service.ts index 271d904..2dd727b 100644 --- a/web/app/shared/services/integrations/bigbluebutton-api.service.ts +++ b/web/app/shared/services/integrations/bigbluebutton-api.service.ts @@ -10,12 +10,12 @@ export class BigBlueButtonApiService extends AuthedApi { super(http); } - public joinMeeting(url: string, name: string): Promise { + public joinMeetingWithGreenlightUrl(url: string, name: string): Promise { return this.authedGet("/api/v1/dimension/bigbluebutton/join", {greenlightUrl: url, fullName: name}).toPromise(); } - public createAndJoinMeeting(roomId: string): Promise { - return this.authedGet("/api/v1/dimension/bigbluebutton/join_meeting", {roomId: roomId}).toPromise(); + public createAndJoinMeeting(roomId: string, name: string): Promise { + return this.authedGet("/api/v1/dimension/bigbluebutton/create", {roomId: roomId, fullName: name}).toPromise(); } } \ No newline at end of file diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts index 5b3adab..b7893ad 100644 --- a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts @@ -25,6 +25,12 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement private displayName: string; private userId: string; + /** + * + * The name to join the BigBlueButton meeting with. Made up of metadata the client passes to us. + */ + private joinName: string; + /** * Whether we expect the meeting to be created on command. * @@ -79,7 +85,14 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement this.displayName = params.displayName; this.userId = params.userId || params.email; // Element uses `email` when placing a conference call + // Create a nick to display in the meeting + this.joinName = `${this.displayName} (${this.userId})`; + + // TODO: As of BigBlueButton 2.3, Avatar URLs are supported in /join, which would allow us to set the + // user's avatar in BigBlueButton to that of their Matrix ID. + console.log("BigBlueButton: should create meeting: " + this.createMeeting); + console.log("BigBlueButton: will join as: " + this.joinName); console.log("BigBlueButton: got room ID: " + this.roomId); // Set the widget ID if we have it @@ -117,25 +130,22 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement this.statusMessage = "Joining conference..."; } - // Generate a nick to display in the meeting - const joinName = `${this.displayName} (${this.userId})`; - // Make a request to Dimension requesting the join URL - if (this.createMeeting === true) { + if (this.createMeeting) { // Ask Dimension to create the meeting for us and return the URL - this.createAndJoinMeeting(joinName); + this.createAndJoinMeeting(); } else { // Provide Dimension with a Greenlight URL, which it will transform into // a BBB meeting URL - this.joinThroughGreenlightUrl(joinName); + this.joinThroughGreenlightUrl(); } } // Ask Dimension to create a meeting (or use an existing one) for this room and return the embeddable meeting URL - private createAndJoinMeeting(joinName: string) { - console.log("BigBlueButton: joining and creating meeting if it doesn't already exist, with fullname:", joinName); + private createAndJoinMeeting() { + console.log("BigBlueButton: joining and creating meeting if it doesn't already exist, with fullname:", this.joinName); - this.bigBlueButtonApi.createAndJoinMeeting(this.roomId).then((response) => { + this.bigBlueButtonApi.createAndJoinMeeting(this.roomId, this.joinName).then((response) => { if ("errorCode" in response) { // This is an instance of ApiError // if (response.errorCode === "WAITING_FOR_MEETING_START") { @@ -158,9 +168,9 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement } // Hand Dimension a Greenlight URL and receive a translated, embeddable meeting URL in response - private joinThroughGreenlightUrl(joinName: string) { + private joinThroughGreenlightUrl() { console.log("BigBlueButton: joining via greenlight url:", this.conferenceUrl); - this.bigBlueButtonApi.joinMeeting(this.conferenceUrl, joinName).then((response) => { + this.bigBlueButtonApi.joinMeetingWithGreenlightUrl(this.conferenceUrl, this.joinName).then((response) => { if ("errorCode" in response) { // This is an instance of ApiError if (response.errorCode === "WAITING_FOR_MEETING_START") { @@ -183,6 +193,10 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement } private embedMeetingWithUrl(url: string) { + this.canEmbed = true; + this.statusMessage = null; + this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); + return // Check if the given URL is embeddable this.widgetApi.isEmbeddable(url).then(result => { this.canEmbed = result.canEmbed; From a1e12f353a6f2cdceee44a00d179cfcba3b34d03 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Thu, 6 May 2021 19:30:47 +0100 Subject: [PATCH 04/13] Create meeting on /widget_state, deal with dead/unknown meetings --- .../DimensionBigBlueButtonService.ts | 387 ++++++++++-------- src/utils/hashing.ts | 4 - .../integrations/bigbluebutton-api.service.ts | 7 +- .../bigbluebutton/bigbluebutton.component.ts | 61 ++- 4 files changed, 254 insertions(+), 205 deletions(-) diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index 6b76a5e..862a268 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -6,9 +6,10 @@ import { BigBlueButtonJoinRequest } from "../../models/Widget"; import { BigBlueButtonJoinResponse, BigBlueButtonCreateAndJoinMeetingResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; import { AutoWired } from "typescript-ioc/es6"; import { ApiError } from "../ApiError"; -import { sha1, sha256 } from "../../utils/hashing"; +import { sha256 } from "../../utils/hashing"; import config from "../../config"; import { parseStringPromise } from "xml2js"; +import * as randomString from "random-string"; /** * API for the BigBlueButton widget. @@ -24,6 +25,9 @@ export class DimensionBigBlueButtonService { private authenticityTokenRegexp = new RegExp(`name="authenticity_token" value="([^"]+)".*`); // join handles the request from a client to join a BigBlueButton meeting + // via a Greenlight URL. Note that this is no longer the only way to join a + // BigBlueButton meeting. See xxx below for the API that bypasses Greenlight + // and instead calls the BigBlueButton API directly. // // The client is expected to send a link created by greenlight, the nice UI // that's recommended to be installed on top of BBB, which is itself a BBB @@ -166,6 +170,217 @@ export class DimensionBigBlueButtonService { return {url: joinUrl}; } + /** + * Clients can call this endpoint in order to retrieve the contents of the widget room state. + * This endpoint will create a BigBlueButton meeting and place the returned ID and password in the room state. + * @param {string} roomId The ID of the room that the widget will live in. + */ + @GET + @Path("widget_state") + public async widget( + @QueryParam("roomId") roomId: string, + ): Promise { + // Hash the room ID in order to generate a unique widget ID + const widgetId = sha256(roomId + "bigbluebutton"); + + const widgetName = config.bigbluebutton.widgetName; + const widgetTitle = config.bigbluebutton.widgetTitle; + const widgetAvatarUrl = config.bigbluebutton.widgetAvatarUrl; + + LogService.info("BigBlueButton", "Got a meeting create request for room: " + roomId); + + // NOTE: BBB meetings will by default end a minute or two after the last person leaves. + const createQueryParameters = { + meetingID: randomString(20), + // To help admins link meeting IDs to rooms + meta_MatrixRoomID: roomId, + }; + + // Create a new meeting. + const createResponse = await this.makeBBBApiCall("GET", "create", createQueryParameters, null); + LogService.info("BigBlueButton", createResponse); + + // The password users will join with. + // TODO: We could give users access to moderate the meeting if we returned createResponse.moderatorPW, but it's + // unclear how we pass this to the user without also leaking it to others in the room. + // We could have the client request the password and depending on their power level in the room, return either + // the attendee or moderator one. + const attendeePassword = createResponse.attendeePW[0]; + + // TODO: How do we get the user dimension is actually running as? + const widgetCreatorUserId = "@dimension:" + config.homeserver.name; + + // Add all necessary client variables to the url when loading the widget + const widgetUrl = config.dimension.publicUrl + + "/widgets/bigbluebutton" + + "?widgetId=$matrix_widget_id" + + "&roomId=$matrix_room_id" + + "&createMeeting=true" + + "&displayName=$matrix_display_name" + + "&avatarUrl=$matrix_avatar_url" + + "&userId=$matrix_user_id" + + `&meetingId=${createResponse.meetingID[0]}` + + `&meetingPassword=${attendeePassword}` + + "&auth=$openidtoken-jwt"; + + return { + "widget_id": widgetId, + "widget": { + "creatorUserId": widgetCreatorUserId, + "id": widgetId, + "type": "m.custom", + "waitForIframeLoad": true, + "name": widgetName, + "avatar_url": widgetAvatarUrl, + "url": widgetUrl, + "data": { + "title": widgetTitle, + } + }, + "layout": { + "container": "top", + "index": 0, + "width": 65, + "height": 50, + } + } + } + + /** + * Clients can call this endpoint in order to retrieve a URL that leads to the BigBlueButton API that they can + * use to join the meeting with. They will need to provide the meeting ID and password which are only available + * from the widget room state event. + * @param {string} displayName The displayname of the user. + * @param {string} userId The Matrix User ID of the user. + * @param {string} avatarUrl The avatar of the user (mxc://...). + * @param {string} meetingId The meeting ID to join. + * @param {string} password The password to attempt to join the meeting with. + */ + @GET + @Path("getJoinUrl") + public async createAndJoinMeeting( + @QueryParam("displayName") displayName: string, + @QueryParam("userId") userId: string, + @QueryParam("avatarUrl") avatarUrl: string, + @QueryParam("meetingId") meetingId: string, + @QueryParam("meetingPassword") password: string, + ): Promise { + // Check if the meeting is actually running. If not, return an error + let isMeetingRunningParameters = { + meetingID: meetingId, + } + + const isMeetingRunningResponse = await this.makeBBBApiCall("GET", "isMeetingRunning", isMeetingRunningParameters, null); + if (isMeetingRunningResponse.running[0].toLowerCase() !== "true") { + // This meeting is not running, inform the user + return new ApiError( + 400, + {error: "This meeting does not exist or has ended."}, + "UNKNOWN_MEETING_ID", + ); + } + + let joinQueryParameters = { + meetingID: meetingId, + password: password, + fullName: `${displayName} (${userId})`, + userID: userId, + } + + // Add an avatar to the join request if the user provided one + if (avatarUrl.startsWith("mxc")) { + joinQueryParameters["avatarURL"] = this.getHTTPAvatarUrlFromMXCUrl(avatarUrl); + } + + // Calculate the checksum for the join URL. We need to do so as a browser would as we're passing this back to a browser + const checksum = this.bbbChecksumFromCallNameAndQueryParamaters("join", joinQueryParameters, true); + + // Construct the join URL, which we'll give back to the client, who can then add additional parameters to (or we just do it) + const url = `${config.bigbluebutton.apiBaseUrl}/join?${this.queryStringFromObject(joinQueryParameters, true)}&checksum=${checksum}`; + + return { + url: url, + }; + } + + /** + * Make an API call to the configured BBB server instance. + * @param {string} method The HTTP method to use for the request. + * @param {string} apiCallName The name of the API (the last bit of the endpoint) to call. e.g 'create', 'join'. + * @param {any} queryParameters The query parameters to use in the request. + * @param {any} body The body of the request. + * @returns {any} The response to the call. + */ + private async makeBBBApiCall( + method: string, + apiCallName: string, + queryParameters: any, + body: any, + ): Promise { + // Compute the checksum needed to authenticate the request (as derived from the configured shared secret) + queryParameters.checksum = this.bbbChecksumFromCallNameAndQueryParamaters(apiCallName, queryParameters, false); + + // Get the URL host and path using the configured api base and the API call name + const url = `${config.bigbluebutton.apiBaseUrl}/${apiCallName}`; + + // Now make the request! + const response = await this.doRequest(method, url, queryParameters, body); + + // Parse and return the XML from the response + // TODO: XML parsing error handling + const parsedResponse = await parseStringPromise(response.body); + + // Extract the "response" object + return parsedResponse.response; + } + + /** + * Converts an object representing a query string into a checksum suitable for appending to a BBB API call. + * Docs: https://docs.bigbluebutton.org/dev/api.html#usage + * @param {string} apiCallName The name of the API to call, e.g "create", "join". + * @param {any} queryParameters An object representing a set of query parameters represented by keys and values. + * @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would. + * @returns {string} The checksum for the request. + */ + private bbbChecksumFromCallNameAndQueryParamaters(apiCallName: string, queryParameters: any, encodeAsBrowser: boolean): string { + // Convert the query parameters object into a string + // We URL encode each value as a browser would. If we don't, our resulting checksum will not match. + const widgetQueryString = this.queryStringFromObject(queryParameters, encodeAsBrowser); + + LogService.info("BigBlueButton", "Built widget string:" + widgetQueryString); + LogService.info("BigBlueButton", "Hashing:" + apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + + // Hash the api name and query parameters to get the checksum, and add it to the set of query parameters + return sha256(apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); + } + + /** + * Converts an object containing keys and values as strings into a string representing URL query parameters. + * @param queryParameters + * @param encodeAsBrowser + * @returns {string} The query parameter object as a string. + */ + private queryStringFromObject(queryParameters: any, encodeAsBrowser: boolean): string { + return Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k], encodeAsBrowser)).join("&"); + } + + /** + * Encodes a string in the same fashion browsers do (encoding ! and other characters). + * @param {string} text The text to encode. + * @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would. + * @returns {string} The encoded text. + */ + private encodeForUrl(text: string, encodeAsBrowser: boolean): string { + let encodedText = encodeURIComponent(text); + if (!encodeAsBrowser) { + // use + instead of %20 for space to match what the 'request' JavaScript library does do. + // encodeURIComponent doesn't escape !'()*, so manually escape them. + encodedText = encodedText.replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); + } + + return encodedText; + } + /** * Perform an HTTP request. * @param {string} method The HTTP method to use. @@ -216,171 +431,13 @@ export class DimensionBigBlueButtonService { }); } - @GET - @Path("widget_state") - public async widget( - @QueryParam("room_id") roomId: string, - ): Promise { - // Hash the room ID in order to generate a unique widget ID - const widgetId = sha256(roomId + "bigbluebutton"); + private getHTTPAvatarUrlFromMXCUrl(mxc: string): string { + const width = 64; + const height = 64; + const method = "scale"; - const widgetName = config.bigbluebutton.widgetName; - const widgetTitle = config.bigbluebutton.widgetTitle; - const widgetAvatarUrl = config.bigbluebutton.widgetAvatarUrl; - - // TODO: What should we put for the creatorUserId? Also make it configurable? - const widgetCreatorUserId = "@bbb:localhost"; - - // Add all necessary client variables to the url when loading the widget - const widgetUrl = config.dimension.publicUrl + - "/widgets/bigbluebutton" + - "?widgetId=$matrix_widget_id&roomId=$matrix_room_id&createMeeting=true&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&auth=openidtoken-jwt"; - - return { - "widget_id": widgetId, - "widget": { - "creatorUserId": widgetCreatorUserId, - "id": widgetId, - "type": "m.custom", - "waitForIframeLoad": true, - "name": widgetName, - "avatar_url": widgetAvatarUrl, - "url": widgetUrl, - "data": { - "title": widgetTitle, - } - }, - "layout": { - "container": "top", - "index": 0, - "width": 65, - "height": 50, - } - } - } - - @GET - @Path("create") - public async createAndJoinMeeting( - @QueryParam("roomId") roomId: string, - @QueryParam("fullName") fullName: string, - ): Promise { - // Check if a meeting already exists for this room... - LogService.info("BigBlueButton", "Got a meeting create and join request for room: " + roomId); - - // Create a new meeting - LogService.info("BigBlueButton", "Using secret: " + config.bigbluebutton.sharedSecret); - - // NOTE: BBB meetings will by default end a minute or two after the last person leaves. - const createQueryParameters = { - meetingID: roomId + "bigbluebuttondimension", - attendeePW: "a", - moderatorPW: "b", - }; - - // TODO: Contrary to the documentation, one needs to provide a meeting ID, attendee and moderator password in order - // for creating meeting to be idempotent. For now we use dummy passwords, though we may want to consider generating - // some once we actually start authenticating meetings. - const createResponse = await this.makeBBBApiCall("GET", "create", createQueryParameters, null); - LogService.info("BigBlueButton", createResponse); - - // Grab the meeting ID and password from the create response - const returnedMeetingId = createResponse.meetingID[0]; - const returnedAttendeePassword = createResponse.attendeePW[0]; - const joinQueryParameters = { - meetingID: returnedMeetingId, - password: returnedAttendeePassword, - fullName: fullName, - } - - // Calculate the checksum for the join URL. We need to do so as a browser would as we're passing this back to a browser - const checksum = this.bbbChecksumFromCallNameAndQueryParamaters("join", joinQueryParameters, true); - - // Construct the join URL, which we'll give back to the client, who can then add additional parameters to (or we just do it) - const url = `${config.bigbluebutton.apiBaseUrl}/join?${this.queryStringFromObject(joinQueryParameters, true)}&checksum=${checksum}`; - - return { - url: url, - }; - } - - /** - * Make an API call to the configured BBB server instance - * @param {string} method The HTTP method to use for the request. - * @param {string} apiCallName The name of the API (the last bit of the endpoint) to call. e.g 'create', 'join'. - * @param {any} queryParameters The query parameters to use in the request. - * @param {any} body The body of the request. - * @returns {BigBlueButtonApiResponse} The response to the call. - */ - private async makeBBBApiCall( - method: string, - apiCallName: string, - queryParameters: any, - body: any, - ): Promise { - // Compute the checksum needed to authenticate the request (as derived from the configured shared secret) - queryParameters.checksum = this.bbbChecksumFromCallNameAndQueryParamaters(apiCallName, queryParameters, false); - - // Get the URL host and path using the configured api base and the API call name - const url = `${config.bigbluebutton.apiBaseUrl}/${apiCallName}`; - - // Now make the request! - const response = await this.doRequest(method, url, queryParameters, body); - - // Parse and return the XML from the response - // TODO: XML parsing error handling - const parsedResponse = await parseStringPromise(response.body); - - // Extract the "response" object - return parsedResponse.response; - } - - /** - * Converts an object representing a query string into a checksum suitable for appending to a BBB API call. - * Docs: https://docs.bigbluebutton.org/dev/api.html#usage - * @param {string} apiCallName The name of the API to call, e.g "create", "join". - * @param {any} queryParameters An object representing a set of query parameters represented by keys and values. - * @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would. - * @returns {string} The checksum for the request. - */ - private bbbChecksumFromCallNameAndQueryParamaters(apiCallName: string, queryParameters: any, encodeAsBrowser: boolean): string { - // Convert the query parameters object into a string - // We URL encode each value as a browser would. If we don't, our resulting checksum will not match. - const widgetQueryString = this.queryStringFromObject(queryParameters, encodeAsBrowser); - - LogService.info("BigBlueButton", "Built widget string:" + widgetQueryString); - LogService.info("BigBlueButton", "Hashing:" + apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); - - // SHA1 hash the api name and query parameters to get the checksum, and add it to the set of query parameters - // TODO: Try Sha256 - return sha1(apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret); - } - - /** - * A - * @param queryParameters - * @param encodeAsBrowser - * @private - */ - private queryStringFromObject(queryParameters: any, encodeAsBrowser: boolean): string { - return Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k], encodeAsBrowser)).join("&"); - } - - /** - * Encodes a string in the same fashion browsers do (encoding ! and other characters). - * @param {string} text The text to encode. - * @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would. - * @returns {string} The encoded text. - */ - private encodeForUrl(text: string, encodeAsBrowser: boolean): string { - let encodedText = encodeURIComponent(text); - if (!encodeAsBrowser) { - // use + instead of %20 for space to match what the 'request' JavaScript library does do. - // encodeURIComponent doesn't escape !'()*, so manually escape them. - encodedText = encodedText.replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); - } - - return encodedText; + mxc = mxc.substring("mxc://".length).split('?')[0]; + return `${config.dimension.publicUrl}/api/v1/dimension/media/thumbnail/${mxc}?width=${width}&height=${height}&method=${method}&animated=false`; } } diff --git a/src/utils/hashing.ts b/src/utils/hashing.ts index 090a2ab..94c9c69 100644 --- a/src/utils/hashing.ts +++ b/src/utils/hashing.ts @@ -4,10 +4,6 @@ export function md5(text: string): string { return crypto.createHash("md5").update(text).digest('hex').toLowerCase(); } -export function sha1(text: string): string { - return crypto.createHash("sha1").update(text).digest('hex').toLowerCase(); -} - export function sha256(text: string): string { return crypto.createHash("sha256").update(text).digest('hex').toLowerCase(); } diff --git a/web/app/shared/services/integrations/bigbluebutton-api.service.ts b/web/app/shared/services/integrations/bigbluebutton-api.service.ts index 2dd727b..fcc861d 100644 --- a/web/app/shared/services/integrations/bigbluebutton-api.service.ts +++ b/web/app/shared/services/integrations/bigbluebutton-api.service.ts @@ -14,8 +14,11 @@ export class BigBlueButtonApiService extends AuthedApi { return this.authedGet("/api/v1/dimension/bigbluebutton/join", {greenlightUrl: url, fullName: name}).toPromise(); } - public createAndJoinMeeting(roomId: string, name: string): Promise { - return this.authedGet("/api/v1/dimension/bigbluebutton/create", {roomId: roomId, fullName: name}).toPromise(); + public getJoinUrl(displayName: string, userId: string, avatarUrl: string, meetingId: string, meetingPassword: string): Promise { + return this.authedGet( + "/api/v1/dimension/bigbluebutton/getJoinUrl", + {displayName: displayName, userId: userId, avatarUrl: avatarUrl, meetingId: meetingId, meetingPassword: meetingPassword}, + ).toPromise(); } } \ No newline at end of file diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts index b7893ad..5b00d73 100644 --- a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts @@ -1,6 +1,5 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { WidgetApiService } from "../../shared/services/integrations/widget-api.service"; import { Subscription } from "rxjs/Subscription"; import { ScalarWidgetApi } from "../../shared/services/scalar/scalar-widget.api"; import { CapableWidget } from "../capable-widget"; @@ -24,6 +23,9 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement private conferenceUrl: string; private displayName: string; private userId: string; + private avatarUrl: string; + private meetingId: string; + private meetingPassword: string; /** * @@ -71,7 +73,6 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement constructor(activatedRoute: ActivatedRoute, private bigBlueButtonApi: BigBlueButtonApiService, - private widgetApi: WidgetApiService, private sanitizer: DomSanitizer, public translate: TranslateService) { super(); @@ -83,14 +84,14 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement this.createMeeting = params.createMeeting; this.conferenceUrl = params.conferenceUrl; this.displayName = params.displayName; + this.avatarUrl = params.avatarUrl; + this.meetingId = params.meetingId; + this.meetingPassword = params.meetingPassword; this.userId = params.userId || params.email; // Element uses `email` when placing a conference call // Create a nick to display in the meeting this.joinName = `${this.displayName} (${this.userId})`; - // TODO: As of BigBlueButton 2.3, Avatar URLs are supported in /join, which would allow us to set the - // user's avatar in BigBlueButton to that of their Matrix ID. - console.log("BigBlueButton: should create meeting: " + this.createMeeting); console.log("BigBlueButton: will join as: " + this.joinName); console.log("BigBlueButton: got room ID: " + this.roomId); @@ -132,8 +133,8 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement // Make a request to Dimension requesting the join URL if (this.createMeeting) { - // Ask Dimension to create the meeting for us and return the URL - this.createAndJoinMeeting(); + // Ask Dimension to return a URL for joining a meeting that it created + this.joinThroughDimension(); } else { // Provide Dimension with a Greenlight URL, which it will transform into // a BBB meeting URL @@ -142,20 +143,20 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement } // Ask Dimension to create a meeting (or use an existing one) for this room and return the embeddable meeting URL - private createAndJoinMeeting() { - console.log("BigBlueButton: joining and creating meeting if it doesn't already exist, with fullname:", this.joinName); + private async joinThroughDimension() { + console.log("BigBlueButton: Joining meeting created by Dimension with meeting ID: " + this.meetingId); - this.bigBlueButtonApi.createAndJoinMeeting(this.roomId, this.joinName).then((response) => { + this.bigBlueButtonApi.getJoinUrl(this.displayName, this.userId, this.avatarUrl, this.meetingId, this.meetingPassword).then((response) => { + console.log("The response"); + console.log(response); if ("errorCode" in response) { // This is an instance of ApiError - // if (response.errorCode === "WAITING_FOR_MEETING_START") { - // // The meeting hasn't started yet - // this.statusMessage = "Waiting for conference to start..."; - // - // // Poll until it has - // setTimeout(this.joinConference.bind(this), this.pollIntervalMillis, false); - // return; - // } + if (response.errorCode === "UNKNOWN_MEETING_ID") { + // It's likely that everyone has left the meeting, and it's been garbage collected. + // Inform the user that they should try and start a new meeting + this.statusMessage = "This meeting has ended or otherwise does not exist.
Please start a new meeting."; + return; + } // Otherwise this is a generic error this.statusMessage = "An error occurred while loading the meeting"; @@ -193,25 +194,15 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement } private embedMeetingWithUrl(url: string) { - this.canEmbed = true; + // Hide widget-related UI this.statusMessage = null; + + // Embed the return meeting URL, joining the meeting + this.canEmbed = true; this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); - return - // Check if the given URL is embeddable - this.widgetApi.isEmbeddable(url).then(result => { - this.canEmbed = result.canEmbed; - this.statusMessage = null; - // Embed the return meeting URL, joining the meeting - this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); - - // Inform the client that we would like the meeting to remain visible for its duration - ScalarWidgetApi.sendSetAlwaysOnScreen(true); - }).catch(err => { - console.error(err); - this.canEmbed = false; - this.statusMessage = "Unable to embed meeting"; - }); + // Inform the client that we would like the meeting to remain visible for its duration + ScalarWidgetApi.sendSetAlwaysOnScreen(true); } public ngOnDestroy() { @@ -220,6 +211,8 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement protected onCapabilitiesSent(): void { super.onCapabilitiesSent(); + + // Don't set alwaysOnScreen until we start a meeting ScalarWidgetApi.sendSetAlwaysOnScreen(false); } From 5c4927cd3773a05230d9af95e979b4a103ae98ad Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Thu, 6 May 2021 19:32:54 +0100 Subject: [PATCH 05/13] Add a t2bot MXC URL for the widget --- config/default.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/default.yaml b/config/default.yaml index 70637a3..9ba7cb3 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -109,8 +109,7 @@ bigbluebutton: # The avatar for BigBlueButton widgets that are generated by Dimension. # Usually this doen't need to be changed, however if your homeserver # is not able to reach t2bot.io then you should specify your own here. - # TODO: Need a t2bot.io MXC URL. - widgetAvatarUrl: "mxc://t2bot.io/ineedamxcurlplstravis" + widgetAvatarUrl: "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c" # Settings for controlling how logging works logging: From a06e002c28c27b8dfc92d74f5debc9e6fb70690f Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Thu, 6 May 2021 20:09:25 +0100 Subject: [PATCH 06/13] Distinguish between an unknown meeting, and an meeting that's ended --- .../DimensionBigBlueButtonService.ts | 20 +++++++++++++------ .../bigbluebutton/bigbluebutton.component.ts | 8 +++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index 862a268..9b6b675 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -265,19 +265,27 @@ export class DimensionBigBlueButtonService { @QueryParam("meetingId") meetingId: string, @QueryParam("meetingPassword") password: string, ): Promise { - // Check if the meeting is actually running. If not, return an error - let isMeetingRunningParameters = { + // Check if the meeting exists and is running. If not, return an error for each case + let getMeetingInfoParameters = { meetingID: meetingId, } - const isMeetingRunningResponse = await this.makeBBBApiCall("GET", "isMeetingRunning", isMeetingRunningParameters, null); - if (isMeetingRunningResponse.running[0].toLowerCase() !== "true") { - // This meeting is not running, inform the user + const getMeetingInfoResponse = await this.makeBBBApiCall("GET", "getMeetingInfo", getMeetingInfoParameters, null); + LogService.info("BigBlueButton", getMeetingInfoResponse) + if (getMeetingInfoResponse.returncode[0] === "FAILED") { + // This meeting does not exist, inform the user return new ApiError( 400, - {error: "This meeting does not exist or has ended."}, + {error: "This meeting does not exist."}, "UNKNOWN_MEETING_ID", ); + } else if (getMeetingInfoResponse.running[0] === "false" && getMeetingInfoResponse.endTime[0] !== "0") { + // This meeting did exist, but has ended. Inform the user + return new ApiError( + 400, + {error: "This meeting has ended."}, + "MEETING_HAS_ENDED", + ); } let joinQueryParameters = { diff --git a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts index 5b00d73..b50aecf 100644 --- a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts @@ -152,12 +152,18 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement if ("errorCode" in response) { // This is an instance of ApiError if (response.errorCode === "UNKNOWN_MEETING_ID") { - // It's likely that everyone has left the meeting, and it's been garbage collected. + // This meeting ID is invalid. // Inform the user that they should try and start a new meeting this.statusMessage = "This meeting has ended or otherwise does not exist.
Please start a new meeting."; return; } + if (response.errorCode === "MEETING_HAS_ENDED") { + // It's likely that everyone has left the meeting, and it's been garbage collected. + // Inform the user that they should try and start a new meeting + this.statusMessage = "This meeting has ended.
Please start a new meeting."; + return; + } // Otherwise this is a generic error this.statusMessage = "An error occurred while loading the meeting"; } From 70608c2a96a11953e0bf3bf197bb81d852df801d Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 7 May 2021 11:49:46 +0100 Subject: [PATCH 07/13] Use MatrixStickerBot.getUserId --- package-lock.json | 13 ------------- src/api/dimension/DimensionBigBlueButtonService.ts | 8 ++++++-- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c12123..6b89a30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2110,14 +2110,6 @@ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", "dev": true }, - "bigbluebutton-api-js": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bigbluebutton-api-js/-/bigbluebutton-api-js-2.2.1.tgz", - "integrity": "sha512-pkLc3tur/5UPLlC7hlCfR0fta3ajbVtO48IzIn10Tkm00Ren1OP89cp1i1dfRUVroGq1K4VwqIOpUPWlHghw/w==", - "requires": { - "jssha": "^3.2.0" - } - }, "binary-extensions": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", @@ -6959,11 +6951,6 @@ "verror": "1.10.0" } }, - "jssha": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", - "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==" - }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index 9b6b675..2a71d42 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -10,6 +10,7 @@ import { sha256 } from "../../utils/hashing"; import config from "../../config"; import { parseStringPromise } from "xml2js"; import * as randomString from "random-string"; +import { MatrixStickerBot } from "../../matrix/MatrixStickerBot"; /** * API for the BigBlueButton widget. @@ -207,18 +208,21 @@ export class DimensionBigBlueButtonService { // the attendee or moderator one. const attendeePassword = createResponse.attendeePW[0]; - // TODO: How do we get the user dimension is actually running as? - const widgetCreatorUserId = "@dimension:" + config.homeserver.name; + // Retrieve the user ID that dimension is running as + const widgetCreatorUserId = await MatrixStickerBot.getUserId(); // Add all necessary client variables to the url when loading the widget const widgetUrl = config.dimension.publicUrl + "/widgets/bigbluebutton" + "?widgetId=$matrix_widget_id" + "&roomId=$matrix_room_id" + + // Indicate that we would like to join a meeting created by Dimension, rather than doing so via + // a greenlight URL "&createMeeting=true" + "&displayName=$matrix_display_name" + "&avatarUrl=$matrix_avatar_url" + "&userId=$matrix_user_id" + + // Provide the meeting details in the state event `&meetingId=${createResponse.meetingID[0]}` + `&meetingPassword=${attendeePassword}` + "&auth=$openidtoken-jwt"; From 85bcab3df77de0b8163388d4b03fcae0ca22149a Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 7 May 2021 12:03:06 +0100 Subject: [PATCH 08/13] Fix translation of BigBlueButton conference --- web/public/assets/i18n/de.json | 2 +- web/public/assets/i18n/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/public/assets/i18n/de.json b/web/public/assets/i18n/de.json index 1f8a93d..7d7fa9a 100644 --- a/web/public/assets/i18n/de.json +++ b/web/public/assets/i18n/de.json @@ -293,7 +293,7 @@ "There are currently no integrations which support encrypted rooms. Sorry about that!": "Derzeit gibt es keine Integrationen, die verschlüsselte Räume unterstützen. Das tut mir leid!", "No integrations available": "Keine Integrationen verfügbar", "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "Dieser Raum hat keine kompatiblen Integrationen. Bitte wenden Sie sich an den Server-Administrator, wenn diese Meldung angezeigt wird.", - "BigBlueButton Conference": "", + "BigBlueButton Conference": "BigBlueButton Konferenz", "Join Conference": "An der Konferenz teilnehmen", "Sorry, this content cannot be embedded": "Dieser Inhalt kann leider nicht eingebettet werden", "Start camera:": "", diff --git a/web/public/assets/i18n/en.json b/web/public/assets/i18n/en.json index 9be17d1..c80b6ca 100644 --- a/web/public/assets/i18n/en.json +++ b/web/public/assets/i18n/en.json @@ -293,7 +293,7 @@ "There are currently no integrations which support encrypted rooms. Sorry about that!": "There are currently no integrations which support encrypted rooms. Sorry about that!", "No integrations available": "No integrations available", "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.", - "BigBlueButton Conference": "", + "BigBlueButton Conference": "BigBlueButton Conference", "Join Conference": "Join Conference", "Sorry, this content cannot be embedded": "Sorry, this content cannot be embedded", "Start camera:": "", From 191f30819b935c6de8d59840816c8ca35b0a7145 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 7 May 2021 13:05:46 +0100 Subject: [PATCH 09/13] Add BigBlueButton usage documentation --- docs/bigbluebutton.md | 102 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/bigbluebutton.md diff --git a/docs/bigbluebutton.md b/docs/bigbluebutton.md new file mode 100644 index 0000000..f2a9ba2 --- /dev/null +++ b/docs/bigbluebutton.md @@ -0,0 +1,102 @@ +# BigBlueButton + +[BigBlueButton](https://bigbluebutton.org/) is open-source video calling software aimed primarily at educators and has +useful features such as screen and PDF sharing, collaborative document editing, polls and more. + +Dimension supports embedding BigBlueButton meetings into a Matrix room via widgets, and can do so in two ways: + +* [Greenlight](https://docs.bigbluebutton.org/greenlight/gl-overview.html) is a frontend for BigBlueButton meeting + administration that allows for creating/ending meetings, inviting people via link or email etc. Dimension supports + taking a Greenlight URL and turning it into a widget in a Matrix room that room occupants can use to join the meeting. + In this instance, Greenlight is acting as a client of BigBlueButton's server-side API, and Dimension is relying on a + Greenlight URL to allow Matrix users to join a meeting. +* Alternatively, Dimension can be configured to connect directly to BigBlueButton and create meetings itself. This does + require running your own BigBlueButton server which Dimension will have authorization to control. This configuration is + useful as meetings can be created without needing to leave your Matrix client, as well as sidesteps the need to set up + Greenlight on your deployment. This method can also be used to allow the 'Video Call' button in Element to start a + meeting. + +Note that with the first method, Dimension can be given a Greenlight URL pointing to any public instance of +Greenlight/BigBlueButton. With the second, Dimension will only talk to, and create meetings on, the BigBlueButton server +it has been configured to talk to. + +## Usage Guide + +### Using a Greenlight URL + +Dimension does not require any extra configuration to allow creating meetings using a Greenlight URL. + +* Open the integration manager window and add a BigBlueButton widget. +* Enter the URL you received from Greenlight. It should be in the form: `https://bbb.example.com/abc-def-ghi`. +* Click the 'Add Widget' button. + +Members in the room will see a BigBlueButton widget appear. They can click "Join Conference" inside the widget to join. + +### Have Dimension Create Meetings (with Element's Video Call button) + +Fill out the `bigbluebutton` section of Dimension's config file. Both the `apiBaseUrl` and `sharedSecret` fields must be +set to the values corresponding to your own BigBlueButton server. + +Note: Matrix clients can create widgets by sending a widget state event in the room with the appropriate fields. To retrieve +the necessary content of the state event, clients can +call `/api/v1/dimension/bigbluebutton/widget_state?roomId=!room:domain` +on Dimension to retrieve the necessary state. The contents of the `widget` field from the response is what should be +placed in the `content` of the widget state event. Note that while servicing this call, Dimension will create the +BigBlueButton meeting. The contents of the state event will then contain all necessary information for clients to join +the meeting. + +Element can be configured to do the above automatically without any client-side changes. Simply add the following +information to the [Client well-known](https://matrix.org/docs/spec/client_server/r0.6.1#get-well-known-matrix-client) +file of your homeserver: + +```json +"io.element.call_behaviour": { + "widget_build_url": "https://dimension.example.com/api/v1/dimension/bigbluebutton/widget_state", +} +``` + +then close and reopen Element so that it picks up the new behaviour. + +Now pressing the Video Call button in a room with more than two users in it should spawn a BigBlueButton meeting! Note +that BigBlueButton will automatically end meetings that all users have left (after a minute or two). Thus if all users +have left a meeting, you will need to recreate the widget in order to spawn a new meeting. + +Created meetings are protected by a generated password that is included in the widget room state event. Thus one will +only be able to gain access to the meeting if they are in the room, or if someone in the room shares that information +with them. All users that join will have moderator permissions in the meeting, though this may change in the future to +allow meeting permissions based of room power level. + +### Troubleshooting + +This code was last tested with the [BigBlueButton v2.3 docker image](https://github.com/bigbluebutton/docker/). + +#### When using a Greenlight URL + +Let us know if you run into any problems! + +#### When having Dimension create meetings + +*My users are seeing 401 Unknown Session errors when trying to join a meeting!* + +BigBlueButton (or at least the [docker image](https://github.com/bigbluebutton/docker)) has a strange bug where +calling the [`/join`](https://docs.bigbluebutton.org/dev/api.html#join) API ends up failing to verify the session ID +*which it just passed to you*. There's discussion on the +issue [here](https://github.com/bigbluebutton/bigbluebutton/issues/6343) +and [here](https://groups.google.com/forum/#!topic/bigbluebutton-dev/TkjyUZP_gO8), but **TL;DR if you're getting +invalid session ID errors**, a workaround is to set `allowRequestsWithoutSession=true` +in `/usr/share/bbb-web/WEB-INF/classes/bigbluebutton.properties` (located in the `bbb-docker_bbb-web` container for +docker deployments). + +*I'm using the BigBlueButton docker image and it's taking about 20-30 seconds for a user to join a meeting. Why is it so slow?* + +This appears to be due to the BigBlueButton nginx container attempting to contact multiple html5-frontend instances, of +which you may only have configured one or two to start. nginx will wait for every request to timeout before returning +the result to yours though, hence the long wait. It's possible to edit the nginx configuration inside the containers to +remove the extra upstream directives and eliminate the timeout. But this is something that really needs to be fixed +upstream. + +*It sometimes says that my meeting does not exist! Where'd it go?* + +As mentioned above, BigBlueButton server will automatically remove meetings that everyone has left. Unfortunately we don't +currently have a way to remove the widget when this happens. Someone will permissions in the room will need to recreate +the widget and thus create a new meeting. \ No newline at end of file From 688442ed31d60477af82e06c8ca04c4dcfd6ba81 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 7 May 2021 14:59:03 +0100 Subject: [PATCH 10/13] Switch getJoinUrl to a POST request as it contains meeting join details --- .../DimensionBigBlueButtonService.ts | 45 +++++++++---------- src/models/Widget.ts | 27 +++++------ .../integrations/bigbluebutton-api.service.ts | 2 +- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index 2a71d42..0316774 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -1,8 +1,8 @@ -import { GET, Path, QueryParam } from "typescript-rest"; +import { GET, POST, Path, QueryParam } from "typescript-rest"; import * as request from "request"; import { LogService } from "matrix-js-snippets"; import { URL } from "url"; -import { BigBlueButtonJoinRequest } from "../../models/Widget"; +import { BigBlueButtonGetJoinUrlRequest } from "../../models/Widget"; import { BigBlueButtonJoinResponse, BigBlueButtonCreateAndJoinMeetingResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; import { AutoWired } from "typescript-ioc/es6"; import { ApiError } from "../ApiError"; @@ -112,7 +112,6 @@ export class DimensionBigBlueButtonService { @GET @Path("join") public async join( - joinRequest: BigBlueButtonJoinRequest, @QueryParam("greenlightUrl") greenlightURL: string, @QueryParam("fullName") fullName: string, ): Promise { @@ -122,7 +121,6 @@ export class DimensionBigBlueButtonService { LogService.info("BigBlueButton", "URL from client: " + greenlightURL); LogService.info("BigBlueButton", "MeetingID: " + greenlightMeetingID); LogService.info("BigBlueButton", "Name given from client: " + fullName); - LogService.info("BigBlueButton", joinRequest); // Query the URL the user has given us let response = await this.doRequest("GET", greenlightURL); @@ -254,24 +252,16 @@ export class DimensionBigBlueButtonService { * Clients can call this endpoint in order to retrieve a URL that leads to the BigBlueButton API that they can * use to join the meeting with. They will need to provide the meeting ID and password which are only available * from the widget room state event. - * @param {string} displayName The displayname of the user. - * @param {string} userId The Matrix User ID of the user. - * @param {string} avatarUrl The avatar of the user (mxc://...). - * @param {string} meetingId The meeting ID to join. - * @param {string} password The password to attempt to join the meeting with. + * @param {BigBlueButtonGetJoinUrlRequest} getJoinUrlRequest The body of the request. */ - @GET + @POST @Path("getJoinUrl") - public async createAndJoinMeeting( - @QueryParam("displayName") displayName: string, - @QueryParam("userId") userId: string, - @QueryParam("avatarUrl") avatarUrl: string, - @QueryParam("meetingId") meetingId: string, - @QueryParam("meetingPassword") password: string, + public async getJoinUrl( + getJoinUrlRequest: BigBlueButtonGetJoinUrlRequest, ): Promise { // Check if the meeting exists and is running. If not, return an error for each case let getMeetingInfoParameters = { - meetingID: meetingId, + meetingID: getJoinUrlRequest.meetingId, } const getMeetingInfoResponse = await this.makeBBBApiCall("GET", "getMeetingInfo", getMeetingInfoParameters, null); @@ -292,16 +282,25 @@ export class DimensionBigBlueButtonService { ); } + // Construct a fullName parameter from the provided user ID and display name (if provided). + // It's important to display the user ID so that users cannot impersonate each other. + let fullName: string; + if (getJoinUrlRequest.displayName) { + fullName = `${getJoinUrlRequest.displayName} (${getJoinUrlRequest.userId})`; + } else { + fullName = getJoinUrlRequest.userId; + } + let joinQueryParameters = { - meetingID: meetingId, - password: password, - fullName: `${displayName} (${userId})`, - userID: userId, + meetingID: getJoinUrlRequest.meetingId, + password: getJoinUrlRequest.meetingPassword, + fullName: fullName, + userID: getJoinUrlRequest.userId, } // Add an avatar to the join request if the user provided one - if (avatarUrl.startsWith("mxc")) { - joinQueryParameters["avatarURL"] = this.getHTTPAvatarUrlFromMXCUrl(avatarUrl); + if (getJoinUrlRequest.avatarUrl.startsWith("mxc")) { + joinQueryParameters["avatarURL"] = this.getHTTPAvatarUrlFromMXCUrl(getJoinUrlRequest.avatarUrl); } // Calculate the checksum for the join URL. We need to do so as a browser would as we're passing this back to a browser diff --git a/src/models/Widget.ts b/src/models/Widget.ts index 1b34c75..4a518c1 100644 --- a/src/models/Widget.ts +++ b/src/models/Widget.ts @@ -1,14 +1,15 @@ -export interface BigBlueButtonJoinRequest { - // A URL supplied by greenlight, BigBlueButton's nice UI project that is itself - // a BigBlueButton client - greenlightUrl: string; - // The name the user wishes to join the meeting with - fullName: string; -} - -export interface BigBlueButtonCreateAndJoinMeetingRequest { - // The ID of the room that the BBB meeting is a part of - roomId: string; - // The name the user wishes to join the meeting with - fullName: string; +export interface BigBlueButtonGetJoinUrlRequest { + // The display name of the user attempting to join the meeting. + // Will be combined with userId and passed to BigBlueButton. + displayName: string; + // The user ID of the user attempting to join the meeting. + // Will be combined with displayName and passed to BigBlueButton. + userId: string; + // Optional. The avatar of the user attempting to join the meeting. + // Will be passed to BigBlueButton. + avatarUrl: string; + // The ID of the meeting to join. + meetingId: string; + // The password to join the meeting with. + meetingPassword: string; } diff --git a/web/app/shared/services/integrations/bigbluebutton-api.service.ts b/web/app/shared/services/integrations/bigbluebutton-api.service.ts index fcc861d..028fe13 100644 --- a/web/app/shared/services/integrations/bigbluebutton-api.service.ts +++ b/web/app/shared/services/integrations/bigbluebutton-api.service.ts @@ -15,7 +15,7 @@ export class BigBlueButtonApiService extends AuthedApi { } public getJoinUrl(displayName: string, userId: string, avatarUrl: string, meetingId: string, meetingPassword: string): Promise { - return this.authedGet( + return this.authedPost( "/api/v1/dimension/bigbluebutton/getJoinUrl", {displayName: displayName, userId: userId, avatarUrl: avatarUrl, meetingId: meetingId, meetingPassword: meetingPassword}, ).toPromise(); From c2c5ce05a0672d36b44da770b47588c7cea180ef Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 7 May 2021 16:00:02 +0100 Subject: [PATCH 11/13] Add implementation documentation --- docs/bigbluebutton.md | 136 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 10 deletions(-) diff --git a/docs/bigbluebutton.md b/docs/bigbluebutton.md index f2a9ba2..3d149f2 100644 --- a/docs/bigbluebutton.md +++ b/docs/bigbluebutton.md @@ -37,15 +37,7 @@ Members in the room will see a BigBlueButton widget appear. They can click "Join Fill out the `bigbluebutton` section of Dimension's config file. Both the `apiBaseUrl` and `sharedSecret` fields must be set to the values corresponding to your own BigBlueButton server. -Note: Matrix clients can create widgets by sending a widget state event in the room with the appropriate fields. To retrieve -the necessary content of the state event, clients can -call `/api/v1/dimension/bigbluebutton/widget_state?roomId=!room:domain` -on Dimension to retrieve the necessary state. The contents of the `widget` field from the response is what should be -placed in the `content` of the widget state event. Note that while servicing this call, Dimension will create the -BigBlueButton meeting. The contents of the state event will then contain all necessary information for clients to join -the meeting. - -Element can be configured to do the above automatically without any client-side changes. Simply add the following +Element can be configured to ask Dimension to start a meeting without any client-side changes. Simply add the following information to the [Client well-known](https://matrix.org/docs/spec/client_server/r0.6.1#get-well-known-matrix-client) file of your homeserver: @@ -99,4 +91,128 @@ upstream. As mentioned above, BigBlueButton server will automatically remove meetings that everyone has left. Unfortunately we don't currently have a way to remove the widget when this happens. Someone will permissions in the room will need to recreate -the widget and thus create a new meeting. \ No newline at end of file +the widget and thus create a new meeting. + +## Implementation Details + +### When using a Greenlight URL + +See the explanation [here](https://github.com/turt2live/matrix-dimension/blob/70608c2a96a11953e0bf3bf197bb81d852df801d/src/api/dimension/DimensionBigBlueButtonService.ts#L28-L111). + +### When having Dimension create meetings + +Matrix clients can create widgets by sending a widget state event in the room with the appropriate fields. To retrieve +the necessary content of a state event that embeds a BigBlueButton meeting, clients can +call `GET /api/v1/dimension/bigbluebutton/widget_state?roomId=!room:domain` +on Dimension to retrieve the necessary json contents for the state event. An example response may look like: + +```json +{ + "widget_id": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337", + "widget": { + "creatorUserId": "@dimension:localhost", + "id": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337", + "type": "m.custom", + "waitForIframeLoad": true, + "name": "BigBlueButton Conference", + "avatar_url": "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c", + "url": "https://dimension.example.com/widgets/bigbluebutton?widgetId=$matrix_widget_id&roomId=$matrix_room_id&createMeeting=true&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&meetingId=GsmiDReG&meetingPassword=dvKIv7EX&auth=$openidtoken-jwt", + "data": { + "title": "Join the conference" + } + }, + "layout": { + "container": "top", + "index": 0, + "width": 65, + "height": 50 + } +} +``` + +The contents of the `widget` dict from the response is what should be placed in the `content` of the widget state +event. An example widget state event generated from the above looks like: + +```json +{ + "type": "im.vector.modular.widgets", + "sender": "@admin:localhost", + "content": { + "creatorUserId": "@admin:localhost", + "id": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337", + "type": "m.custom", + "waitForIframeLoad": true, + "name": "BigBlueButton Conference", + "avatar_url": "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c", + "url": "https://dimension.example.com/widgets/bigbluebutton?widgetId=$matrix_widget_id&roomId=$matrix_room_id&createMeeting=true&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&meetingId=GsmiDReG&meetingPassword=dvKIv7EX&auth=$openidtoken-jwt", + "data": { + "title": "Join the conference" + }, + "roomId": "!ZsCMQAoIIHgOlXMzwX:localhost", + "eventId": "$2RbnJDUPFIMTDVda_-Z01lyZt30W-bZnw3z7CFIRWKQ" + }, + "state_key": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337", + "origin_server_ts": 1620386456620, + "unsigned": { + "age": 96 + }, + "event_id": "$2RbnJDUPFIMTDVda_-Z01lyZt30W-bZnw3z7CFIRWKQ", + "room_id": "!ZsCMQAoIIHgOlXMzwX:localhost" +} +``` + +While servicing the `/widget_state` call, Dimension will create the BigBlueButton meeting by calling the +[BigBlueButton `/create` API](https://docs.bigbluebutton.org/dev/api.html#create). We get back a meeting ID and two +passwords from BigBlueButton: one "attendee" and one "moderator" password. We can pass either of these back to the user, +and it'll be placed in the widget state event in the room. (For now, we just pass back the moderator password). You'll +notice that it's included in the `url` field as a query parameter. Those query parameters are passed to the widget when +it loads, which the widget then uses to populate fields when making subsequent calls to Dimension. + +The widget will then use the meetingID, password and some additional user metadata (displayname, userID, avatarURL) to call +`POST /api/v1/dimension/bigbluebutton/getJoinUrl`. Dimension will craft a URL that the widget can use to call the +[BigBlueButton `/join` API](https://docs.bigbluebutton.org/dev/api.html#join), and respond with the following: + +```json +{ + "url": "https://bbb.example.com/bigbluebutton/api/join?meetingID=2QdrCVAl&password=yX2w587M&fullName=bob%20(%40bob%3Aexample.com)&userID=%40bob%3Aexample.com&checksum=fa35ecdf711478423bf5173cb81c5b2e0e9e59bb8779811b614a44645ee94d89" +} +``` + +That URL is embedded and loaded by the widget - joining the meeting. + +Note that if everyone in the meeting leaves, the meeting will be garbage-collected automatically server-side by BigBlueButton. +However the widget will still remain in the room. In this case, if users attempt to join the meeting again, they will be +informed that a new meeting needs to be created. This works by having Dimension check if a meeting is still running in +`/getJoinUrl`. It does so by using the +[BigBlueButton `getMeetingInfo` API](https://docs.bigbluebutton.org/dev/api.html#getmeetinginfo) to check that: + +* A current or past meeting actually exists with the provided meeting ID - if not, the following will be returned to the + widget: + + ```json + { + "jsonResponse": { + "error": "This meeting does not exist.", + "dim_errcode": "UNKNOWN_MEETING_ID", + "errcode": "UNKNOWN_MEETING_ID" + }, + "statusCode": 400, + "errorCode": "UNKNOWN_MEETING_ID" + } + ``` +* A meeting exists, but has both `running` as `false` and has an `endTime` other than 0. If both of those are true, + then the meeting existed but is no longer running. In that case, the following will be returned to the widget: + + ```json + { + "jsonResponse": { + "error": "This meeting does not exist.", + "dim_errcode": "MEETING_HAS_ENDED", + "errcode": "MEETING_HAS_ENDED" + }, + "statusCode": 400, + "errorCode": "MEETING_HAS_ENDED" + } + ``` + +Upon receiving either of these, the widget will inform the user with an error message that a new meeting must be created. \ No newline at end of file From a085be025f3dd2b7acb82354c31b4628226c0de1 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 7 May 2021 17:26:42 +0100 Subject: [PATCH 12/13] Return the moderator password instead of the attendee one --- src/api/dimension/DimensionBigBlueButtonService.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/api/dimension/DimensionBigBlueButtonService.ts b/src/api/dimension/DimensionBigBlueButtonService.ts index 0316774..849817f 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -200,11 +200,12 @@ export class DimensionBigBlueButtonService { LogService.info("BigBlueButton", createResponse); // The password users will join with. - // TODO: We could give users access to moderate the meeting if we returned createResponse.moderatorPW, but it's - // unclear how we pass this to the user without also leaking it to others in the room. - // We could have the client request the password and depending on their power level in the room, return either - // the attendee or moderator one. - const attendeePassword = createResponse.attendeePW[0]; + // TODO: We currently give users access to moderate the meeting by returning createResponse.moderatorPW instead + // of createResponse.attendeePW. The latter lets people join as viewers without any moderator permissions. + // Allowing this would likely require saving the moderator password for a meeting in Dimension and authenticating + // users by room power level when they call getJoinUrl. Unfortunately, doing would either require us to have a + // user in the room or have the user be a Synapse server admin (so that they can see room state). + const meetingPassword = createResponse.moderatorPW[0]; // Retrieve the user ID that dimension is running as const widgetCreatorUserId = await MatrixStickerBot.getUserId(); @@ -222,7 +223,7 @@ export class DimensionBigBlueButtonService { "&userId=$matrix_user_id" + // Provide the meeting details in the state event `&meetingId=${createResponse.meetingID[0]}` + - `&meetingPassword=${attendeePassword}` + + `&meetingPassword=${meetingPassword}` + "&auth=$openidtoken-jwt"; return { From eae1e3e295ff090ccd7b4f6a19a9217faef2caa5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 10 May 2021 19:38:01 -0600 Subject: [PATCH 13/13] Update docs/bigbluebutton.md --- docs/bigbluebutton.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/bigbluebutton.md b/docs/bigbluebutton.md index 3d149f2..1540ba8 100644 --- a/docs/bigbluebutton.md +++ b/docs/bigbluebutton.md @@ -11,7 +11,7 @@ Dimension supports embedding BigBlueButton meetings into a Matrix room via widge In this instance, Greenlight is acting as a client of BigBlueButton's server-side API, and Dimension is relying on a Greenlight URL to allow Matrix users to join a meeting. * Alternatively, Dimension can be configured to connect directly to BigBlueButton and create meetings itself. This does - require running your own BigBlueButton server which Dimension will have authorization to control. This configuration is + require running your own BigBlueButton server which Dimension will need authorization to control. This configuration is useful as meetings can be created without needing to leave your Matrix client, as well as sidesteps the need to set up Greenlight on your deployment. This method can also be used to allow the 'Video Call' button in Element to start a meeting. @@ -215,4 +215,4 @@ informed that a new meeting needs to be created. This works by having Dimension } ``` -Upon receiving either of these, the widget will inform the user with an error message that a new meeting must be created. \ No newline at end of file +Upon receiving either of these, the widget will inform the user with an error message that a new meeting must be created.