diff --git a/config/default.yaml b/config/default.yaml index e77735d..9ba7cb3 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -91,6 +91,26 @@ 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. + widgetAvatarUrl: "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c" + # Settings for controlling how logging works logging: file: logs/dimension.log diff --git a/docs/bigbluebutton.md b/docs/bigbluebutton.md new file mode 100644 index 0000000..1540ba8 --- /dev/null +++ b/docs/bigbluebutton.md @@ -0,0 +1,218 @@ +# 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 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. + +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. + +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: + +```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. + +## 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. diff --git a/package-lock.json b/package-lock.json index e78ea89..6b89a30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13962,6 +13962,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..d5fdb8b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,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 fb2cadf..849817f 100644 --- a/src/api/dimension/DimensionBigBlueButtonService.ts +++ b/src/api/dimension/DimensionBigBlueButtonService.ts @@ -1,11 +1,16 @@ -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 { BigBlueButtonJoinResponse } from "../../models/WidgetResponses"; +import { BigBlueButtonGetJoinUrlRequest } from "../../models/Widget"; +import { BigBlueButtonJoinResponse, BigBlueButtonCreateAndJoinMeetingResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses"; import { AutoWired } from "typescript-ioc/es6"; import { ApiError } from "../ApiError"; +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. @@ -21,6 +26,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 @@ -104,7 +112,6 @@ export class DimensionBigBlueButtonService { @GET @Path("join") public async join( - joinRequest: BigBlueButtonJoinRequest, @QueryParam("greenlightUrl") greenlightURL: string, @QueryParam("fullName") fullName: string, ): Promise { @@ -114,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); @@ -163,6 +169,238 @@ 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 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(); + + // 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=${meetingPassword}` + + "&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 {BigBlueButtonGetJoinUrlRequest} getJoinUrlRequest The body of the request. + */ + @POST + @Path("getJoinUrl") + 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: getJoinUrlRequest.meetingId, + } + + 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."}, + "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", + ); + } + + // 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: getJoinUrlRequest.meetingId, + password: getJoinUrlRequest.meetingPassword, + fullName: fullName, + userID: getJoinUrlRequest.userId, + } + + // Add an avatar to the join request if the user provided one + 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 + 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. + * @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 async doRequest( method: string, url: string, @@ -180,6 +418,7 @@ export class DimensionBigBlueButtonService { followRedirect: followRedirect, jar: true, // remember cookies between requests json: false, // expect html + }, (err, res, _body) => { try { if (err) { @@ -204,4 +443,13 @@ export class DimensionBigBlueButtonService { }); } + private getHTTPAvatarUrlFromMXCUrl(mxc: string): string { + const width = 64; + const height = 64; + const method = "scale"; + + 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/config.ts b/src/config.ts index 510342b..d7577ed 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,13 @@ export interface DimensionConfig { telegram: { botToken: string; }; + bigbluebutton: { + apiBaseUrl: string; + sharedSecret: string; + widgetName: string; + widgetTitle: string; + widgetAvatarUrl: string; + }; stickers: { enabled: boolean; stickerBot: string; diff --git a/src/models/Widget.ts b/src/models/Widget.ts index 3a26ef2..4a518c1 100644 --- a/src/models/Widget.ts +++ b/src/models/Widget.ts @@ -1,7 +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 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/src/models/WidgetResponses.ts b/src/models/WidgetResponses.ts index 854ea31..364ae96 100644 --- a/src/models/WidgetResponses.ts +++ b/src/models/WidgetResponses.ts @@ -2,3 +2,30 @@ export interface BigBlueButtonJoinResponse { // The meeting URL the client should load to join the meeting 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: { + 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 diff --git a/src/utils/hashing.ts b/src/utils/hashing.ts index 7587743..94c9c69 100644 --- a/src/utils/hashing.ts +++ b/src/utils/hashing.ts @@ -2,4 +2,8 @@ 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 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..4fb3823 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["createMeeting"] = this.bigBlueButtonWidget.options.createMeeting; } protected OnWidgetBeforeAdd(widget: EditableWidget) { @@ -43,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", @@ -50,5 +52,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..202fbd1 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; + 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 4a23cfe..028fe13 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"; @@ -10,7 +10,15 @@ 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 getJoinUrl(displayName: string, userId: string, avatarUrl: string, meetingId: string, meetingPassword: string): Promise { + return this.authedPost( + "/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 fdd679d..b50aecf 100644 --- a/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts +++ b/web/app/widget-wrappers/bigbluebutton/bigbluebutton.component.ts @@ -1,12 +1,11 @@ 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"; 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({ @@ -24,6 +23,28 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement private conferenceUrl: string; private displayName: string; private userId: string; + private avatarUrl: string; + private meetingId: string; + private meetingPassword: 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. + * + * 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 @@ -52,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(); @@ -60,12 +80,22 @@ 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.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})`; + + 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 ScalarWidgetApi.widgetId = params.widgetId; } @@ -101,12 +131,53 @@ 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) { + // 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 + this.joinThroughGreenlightUrl(); + } + } + + // Ask Dimension to create a meeting (or use an existing one) for this room and return the embeddable meeting URL + private async joinThroughDimension() { + console.log("BigBlueButton: Joining meeting created by Dimension with meeting ID: " + this.meetingId); + + 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 === "UNKNOWN_MEETING_ID") { + // 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"; + } + + // 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() { 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") { @@ -122,32 +193,32 @@ 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; - - // Check if the given URL is embeddable - this.widgetApi.isEmbeddable(joinUrl).then(result => { - this.canEmbed = result.canEmbed; - this.statusMessage = null; - - // Embed the return meeting URL, joining the meeting - this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(joinUrl); - - // 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"; - }); + this.embedMeetingWithUrl(joinUrl); }); } + private embedMeetingWithUrl(url: string) { + // Hide widget-related UI + this.statusMessage = null; + + // Embed the return meeting URL, joining the meeting + this.canEmbed = true; + this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); + + // Inform the client that we would like the meeting to remain visible for its duration + ScalarWidgetApi.sendSetAlwaysOnScreen(true); + } + public ngOnDestroy() { if (this.bigBlueButtonApiSubscription) this.bigBlueButtonApiSubscription.unsubscribe(); } protected onCapabilitiesSent(): void { super.onCapabilitiesSent(); + + // Don't set alwaysOnScreen until we start a meeting ScalarWidgetApi.sendSetAlwaysOnScreen(false); } 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:": "",