diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 47032dcec2..306834270c 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 337** + +* `POST /calls/bigbluebutton/create`: Added a `voice_only` parameter + controlling whether the call should be voice-only, in which case we + keep cameras disabled for this call. Now the call creator is a + moderator and all other joinees are viewers. + **Feature level 336** * [Markdown message formatting](/api/message-formatting#image-previews): Added diff --git a/docs/production/video-calls.md b/docs/production/video-calls.md index cb5e5cbbf9..22081924cc 100644 --- a/docs/production/video-calls.md +++ b/docs/production/video-calls.md @@ -74,7 +74,7 @@ in the Zulip organizations where you want to use it. To use the [BigBlueButton](https://bigbluebutton.org/) video call integration on a self-hosted Zulip installation, you'll need to have a -BigBlueButton server and configure it: +BigBlueButton server (version 2.4+) and configure it: 1. Get the Shared Secret using the `bbb-conf --secret` command on your BigBlueButton Server. See also [the BigBlueButton diff --git a/help/start-a-call.md b/help/start-a-call.md index 0e5894532c..98754c23b0 100644 --- a/help/start-a-call.md +++ b/help/start-a-call.md @@ -90,14 +90,10 @@ supported by Zulip are: * [Zoom integration](/integrations/doc/zoom) * [BigBlueButton integration](/integrations/doc/big-blue-button) -If you choose BigBlueButton as the call provider, there will be a single button -() for starting a call in the -compose box. The call is initiated with cameras turned off. - !!! tip "" - It is also possible to disable the video and voice call buttons for your organization - by setting the provider to "None". + You can disable the video and voice call buttons for your organization + by setting the **call provider** to "None". ### Change your organization's call provider diff --git a/version.py b/version.py index be7a19b60d..dcdd3bb158 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 336 # Last bumped for data-original-content-type and data-transcoded-image +API_FEATURE_LEVEL = 337 # Last bumped for voice_only param addition for BigBlueButton # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/web/src/compose_call.ts b/web/src/compose_call.ts index b8428a1e67..1a968a6a2b 100644 --- a/web/src/compose_call.ts +++ b/web/src/compose_call.ts @@ -39,7 +39,9 @@ export function compute_show_audio_chat_button(): boolean { get_jitsi_server_url() !== null && realm.realm_video_chat_provider === available_providers.jitsi_meet.id) || (available_providers.zoom && - realm.realm_video_chat_provider === available_providers.zoom.id) + realm.realm_video_chat_provider === available_providers.zoom.id) || + (available_providers.big_blue_button && + realm.realm_video_chat_provider === available_providers.big_blue_button.id) ) { return true; } diff --git a/web/src/compose_call_ui.ts b/web/src/compose_call_ui.ts index 962eae3584..b3f18351a0 100644 --- a/web/src/compose_call_ui.ts +++ b/web/src/compose_call_ui.ts @@ -120,19 +120,21 @@ export function generate_and_insert_audio_or_video_call_link( available_providers.big_blue_button && realm.realm_video_chat_provider === available_providers.big_blue_button.id ) { - if (is_audio_call) { - // TODO: Add support for audio-only BigBlueButton calls here. - return; - } const meeting_name = get_recipient_label() + " meeting"; + const request = { + meeting_name, + voice_only: is_audio_call, + }; void channel.get({ url: "/json/calls/bigbluebutton/create", - data: { - meeting_name, - }, + data: request, success(response) { const data = call_response_schema.parse(response); - insert_video_call_url(data.url, $target_textarea); + if (is_audio_call) { + insert_audio_call_url(data.url, $target_textarea); + } else { + insert_video_call_url(data.url, $target_textarea); + } }, }); } else { diff --git a/web/tests/compose_video.test.cjs b/web/tests/compose_video.test.cjs index bb951ce4ee..4a0458c0de 100644 --- a/web/tests/compose_video.test.cjs +++ b/web/tests/compose_video.test.cjs @@ -217,7 +217,7 @@ test("videos", ({override}) => { assert.match(syntax_to_insert, audio_link_regex); })(); - (function test_bbb_video_link_compose_clicked() { + (function test_bbb_audio_and_video_links_compose_clicked() { let syntax_to_insert; let called = false; @@ -237,7 +237,6 @@ test("videos", ({override}) => { called = true; }); - const handler = $("body").get_on_handler("click", ".video_link"); $("textarea#compose-textarea").val(""); override( @@ -254,15 +253,28 @@ test("videos", ({override}) => { options.success({ result: "success", msg: "", - url: "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22", + url: + "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=" + + options.data.voice_only + + "&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22", }); }; - handler(ev); + $("textarea#compose-textarea").val(""); + + const video_handler = $("body").get_on_handler("click", ".video_link"); + video_handler(ev); const video_link_regex = - /\[translated: Join video call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/; + /\[translated: Join video call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=false&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/; assert.ok(called); assert.match(syntax_to_insert, video_link_regex); + + const audio_handler = $("body").get_on_handler("click", ".audio_link"); + audio_handler(ev); + const audio_link_regex = + /\[translated: Join voice call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=true&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/; + assert.ok(called); + assert.match(syntax_to_insert, audio_link_regex); })(); }); diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 855b3dd2cc..51b483826a 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -21864,8 +21864,14 @@ paths: summary: Create BigBlueButton video call description: | Create a video call URL for a BigBlueButton video call. - Requires [BigBlueButton](/integrations/doc/big-blue-button) + Requires [BigBlueButton 2.4+](/integrations/doc/big-blue-button) to be configured on the Zulip server. + + The acting user will be given the moderator role on the call. + + **Changes**: Prior to Zulip 10.0 (feature level 337), every + user was given the moderator role on BigBlueButton calls, via + encoding a moderator password in the generated URLs. parameters: - in: query name: meeting_name @@ -21875,6 +21881,18 @@ paths: description: | Meeting name for the BigBlueButton video call. example: "test_channel meeting" + - in: query + name: voice_only + schema: + type: boolean + required: false + description: | + Configures whether the call is voice-only; if true, + disables cameras for all users. Only the call + creator/moderator can edit this configuration. + + **Changes**: New in Zulip 10.0 (feature level 337). + example: true responses: "200": description: Success. diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py index a592e9819b..4c9059d020 100644 --- a/zerver/tests/test_create_video_call.py +++ b/zerver/tests/test_create_video_call.py @@ -22,7 +22,17 @@ class TestVideoCall(ZulipTestCase): { "meeting_id": "a", "name": "a", - "password": "a", + "lock_settings_disable_cam": True, + "moderator": self.user.id, + } + ) + # For testing viewer role (different creator / moderator from self) + self.signed_bbb_a_object_different_creator = self.signer.sign_object( + { + "meeting_id": "a", + "name": "a", + "lock_settings_disable_cam": True, + "moderator": self.example_user("cordelia").id, } ) @@ -234,8 +244,30 @@ class TestVideoCall(ZulipTestCase): mock.patch("zerver.views.video_calls.random.randint", return_value="1"), mock.patch("secrets.token_bytes", return_value=b"\x00" * 20), ): + with mock.patch("zerver.views.video_calls.random.randint", return_value="1"): + response = self.client_get( + "/json/calls/bigbluebutton/create?meeting_name=general > meeting&voice_only=false" + ) + response_dict = self.assert_json_success(response) + self.assertEqual( + response_dict["url"], + append_url_query_string( + "/calls/bigbluebutton/join", + "bigbluebutton=" + + self.signer.sign_object( + { + "meeting_id": "zulip-1", + "name": "general > meeting", + "lock_settings_disable_cam": False, + "moderator": self.user.id, + } + ), + ), + ) + + # Testing for audio call response = self.client_get( - "/json/calls/bigbluebutton/create?meeting_name=general > meeting" + "/json/calls/bigbluebutton/create?meeting_name=general > meeting&voice_only=true" ) response_dict = self.assert_json_success(response) self.assertEqual( @@ -247,7 +279,8 @@ class TestVideoCall(ZulipTestCase): { "meeting_id": "zulip-1", "name": "general > meeting", - "password": "A" * 32, + "lock_settings_disable_cam": True, + "moderator": self.user.id, } ), ), @@ -257,8 +290,8 @@ class TestVideoCall(ZulipTestCase): def test_join_bigbluebutton_redirect(self) -> None: responses.add( responses.GET, - "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a" - "&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", + "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True" + "&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20", "SUCCESS0", ) response = self.client_get( @@ -269,15 +302,27 @@ class TestVideoCall(ZulipTestCase): self.assertEqual( response["Location"], "https://bbb.example.com/bigbluebutton/api/join?meetingID=a&" - "password=a&fullName=King%20Hamlet&createTime=0&checksum=47ca959b4ff5c8047a5a56d6e99c07e17eac43dbf792afc0a2a9f6491ec0048b", + "role=MODERATOR&fullName=King%20Hamlet&createTime=0&checksum=54259b884a7c20ddcd7b280a1b62e59d7990568fe4f22001812bc4bcfd161a46", + ) + # Testing for viewer role + response = self.client_get( + "/calls/bigbluebutton/join", + {"bigbluebutton": self.signed_bbb_a_object_different_creator}, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(isinstance(response, HttpResponseRedirect), True) + self.assertEqual( + response["Location"], + "https://bbb.example.com/bigbluebutton/api/join?meetingID=a&" + "role=VIEWER&fullName=King%20Hamlet&createTime=0&checksum=52efaf64109ca4ec5a20a1d295f315af53f9e6ec30b50ed3707fd2909ac6bd94", ) @responses.activate def test_join_bigbluebutton_invalid_signature(self) -> None: responses.add( responses.GET, - "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a" - "&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", + "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True" + "&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20", "SUCCESS0", ) response = self.client_get( @@ -289,7 +334,7 @@ class TestVideoCall(ZulipTestCase): def test_join_bigbluebutton_redirect_wrong_big_blue_button_checksum(self) -> None: responses.add( responses.GET, - "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", + "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20", "FAILEDchecksumError" "You did not pass the checksum security check", ) @@ -304,7 +349,7 @@ class TestVideoCall(ZulipTestCase): # Simulate bbb server error responses.add( responses.GET, - "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", + "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20", "", status=500, ) @@ -319,7 +364,7 @@ class TestVideoCall(ZulipTestCase): # Simulate bbb server error responses.add( responses.GET, - "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", + "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20", "FAILUREotherFailure", ) response = self.client_get( diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py index 07799819a8..3697e00f82 100644 --- a/zerver/views/video_calls.py +++ b/zerver/views/video_calls.py @@ -1,8 +1,6 @@ import hashlib import json import random -import secrets -from base64 import b32encode from urllib.parse import quote, urlencode, urljoin import requests @@ -206,12 +204,15 @@ def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse: @typed_endpoint def get_bigbluebutton_url( - request: HttpRequest, user_profile: UserProfile, *, meeting_name: str + request: HttpRequest, + user_profile: UserProfile, + *, + meeting_name: str, + voice_only: Json[bool] = False, ) -> HttpResponse: # https://docs.bigbluebutton.org/dev/api.html#create for reference on the API calls # https://docs.bigbluebutton.org/dev/api.html#usage for reference for checksum id = "zulip-" + str(random.randint(100000000000, 999999999999)) - password = b32encode(secrets.token_bytes(20)).decode() # 20 bytes means 32 characters # We sign our data here to ensure a Zulip user cannot tamper with # the join link to gain access to other meetings that are on the @@ -220,7 +221,8 @@ def get_bigbluebutton_url( { "meeting_id": id, "name": meeting_name, - "password": password, + "lock_settings_disable_cam": voice_only, + "moderator": request.user.id, } ) url = append_url_query_string("/calls/bigbluebutton/join", "bigbluebutton=" + signed) @@ -250,14 +252,7 @@ def join_bigbluebutton(request: HttpRequest, *, bigbluebutton: str) -> HttpRespo { "meetingID": bigbluebutton_data["meeting_id"], "name": bigbluebutton_data["name"], - "moderatorPW": bigbluebutton_data["password"], - # We generate the attendee password from moderatorPW, - # because the BigBlueButton API requires a separate - # password. This integration is designed to have all users - # join as moderators, so we generate attendeePW by - # truncating the moderatorPW while keeping it long enough - # to not be vulnerable to brute force attacks. - "attendeePW": bigbluebutton_data["password"][:16], + "lockSettingsDisableCam": bigbluebutton_data["lock_settings_disable_cam"], }, quote_via=quote, ) @@ -286,9 +281,11 @@ def join_bigbluebutton(request: HttpRequest, *, bigbluebutton: str) -> HttpRespo join_params = urlencode( { "meetingID": bigbluebutton_data["meeting_id"], - # We use the moderator password here to grant ever user - # full moderator permissions to the bigbluebutton session. - "password": bigbluebutton_data["password"], + # We use the moderator role only for the user who created the + # meeting, the attendee role for everyone else, so that only + # the user who created the meeting can convert a voice-only + # call to a video call. + "role": "MODERATOR" if bigbluebutton_data["moderator"] == request.user.id else "VIEWER", "fullName": request.user.full_name, # https://docs.bigbluebutton.org/dev/api.html#create # The createTime option is used to have the user redirected to a link