From 32d0bd3aec26380285e2622d2ed46005f078a650 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 5 May 2021 20:37:19 +0100 Subject: [PATCH] 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;