From 5c28ec1d9406aca5abe7cf45d1b287bb5179cbb0 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 4 May 2021 09:38:44 +0100 Subject: [PATCH] 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"; }); }