diff --git a/docs/overview/changelog.md b/docs/overview/changelog.md index 68113475da..c150c10269 100644 --- a/docs/overview/changelog.md +++ b/docs/overview/changelog.md @@ -64,6 +64,7 @@ in bursts. - Added server support for creating an account from mobile/terminal apps. - The Zulip desktop apps now do social authentication (Google, GitHub, etc.) via an external browser. +- Added support for BigBlueButton as video chat provider. - Added support for setting an organization-wide default language for code blocks. - Added an API endpoint for fetching a single user. diff --git a/frontend_tests/node_tests/compose.js b/frontend_tests/node_tests/compose.js index ec68a9138c..5470c87dc0 100644 --- a/frontend_tests/node_tests/compose.js +++ b/frontend_tests/node_tests/compose.js @@ -947,6 +947,10 @@ run_test('initialize', () => { id: 3, name: "Zoom", }, + big_blue_button: { + id: 4, + name: "Big Blue Button", + }, }; page_params.realm_video_chat_provider = @@ -1463,6 +1467,18 @@ run_test('on_events', () => { video_link_regex = /\[Click to join video call\]\(example\.zoom\.com\)/; assert(video_link_regex.test(syntax_to_insert)); + page_params.realm_video_chat_provider = + page_params.realm_available_video_chat_providers.big_blue_button.id; + + channel.get = function (options) { + assert(options.url === '/json/calls/bigbluebutton/create'); + options.success({ url: '/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22' }); + }; + + handler(ev); + video_link_regex = /\[Click to join video call\]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/; + assert(video_link_regex.test(syntax_to_insert)); + }()); (function test_markdown_preview_compose_clicked() { diff --git a/frontend_tests/node_tests/settings_org.js b/frontend_tests/node_tests/settings_org.js index d643585616..84d5c2f9f7 100644 --- a/frontend_tests/node_tests/settings_org.js +++ b/frontend_tests/node_tests/settings_org.js @@ -759,6 +759,10 @@ run_test('set_up', () => { id: 3, name: "Zoom", }, + big_blue_button: { + id: 4, + name: "Big Blue Button", + }, }; simulate_tip_box(); diff --git a/static/js/compose.js b/static/js/compose.js index 68987295ac..6352cb3c2c 100644 --- a/static/js/compose.js +++ b/static/js/compose.js @@ -1122,6 +1122,15 @@ exports.initialize = function () { "width=800,height=500,noopener,noreferrer" ); } + } else if ( + page_params.realm_video_chat_provider === available_providers.big_blue_button.id) { + + channel.get({ + url: '/json/calls/bigbluebutton/create', + success: function (response) { + insert_video_call_url(response.url, target_textarea); + }, + }); } else { video_call_link = page_params.jitsi_server_url + "/" + video_call_id; insert_video_call_url(video_call_link, target_textarea); diff --git a/templates/zerver/features.html b/templates/zerver/features.html index 6d77716b74..94157db3d8 100644 --- a/templates/zerver/features.html +++ b/templates/zerver/features.html @@ -238,7 +238,7 @@

VIDEO CALLS

Create and join video calls with a single click. Powered - by your choice of Jitsi Meet or Zoom. + by your choice of Zoom, Jitsi Meet or Big Blue Button.

diff --git a/templates/zerver/help/start-a-call.md b/templates/zerver/help/start-a-call.md index 3441c1a34a..564bc81e7b 100644 --- a/templates/zerver/help/start-a-call.md +++ b/templates/zerver/help/start-a-call.md @@ -37,6 +37,12 @@ Meet](https://meet.jit.si/). You can also use Zulip with Jitsi Meet on-premise; to configure this, just set `JITSI_SERVER_URL` in `/etc/zulip/settings.py`. +{tab|bigbluebutton} + +In order to use Big Blue Button as the video call provider, you need +to first configure the `BIG_BLUE_BUTTON_URL` setting in +`/etc/zulip/settings.py`. + {tab|zoom} Zulip supports Zoom as the video chat provider using an OAuth diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py index 2e1fbc0452..881eb2d482 100644 --- a/tools/lib/capitalization.py +++ b/tools/lib/capitalization.py @@ -66,6 +66,7 @@ IGNORED_PHRASES = [ r"Emoji One", r"mailinator.com", r"HQ", + r"Big Blue Button", # Code things r".zuliprc", r"__\w+\.\w+__", diff --git a/zerver/lib/bugdown/tabbed_sections.py b/zerver/lib/bugdown/tabbed_sections.py index 86a488b2ec..d7bfb5dd20 100644 --- a/zerver/lib/bugdown/tabbed_sections.py +++ b/zerver/lib/bugdown/tabbed_sections.py @@ -67,6 +67,7 @@ TAB_DISPLAY_NAMES = { 'zoom': 'Zoom (experimental)', 'jitsi-meet': 'Jitsi Meet', + 'bigbluebutton': 'Big Blue Button', 'disable': 'Disabled', 'chrome': 'Chrome', diff --git a/zerver/models.py b/zerver/models.py index 2898184b47..ef08bc24f3 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -362,11 +362,19 @@ class Realm(models.Model): # ID 2 was used for the now-deleted Google Hangouts. # ID 3 reserved for optional Zoom, see below. } + if settings.VIDEO_ZOOM_CLIENT_ID is not None and settings.VIDEO_ZOOM_CLIENT_SECRET is not None: VIDEO_CHAT_PROVIDERS['zoom'] = { 'name': "Zoom", 'id': 3, } + + if settings.BIG_BLUE_BUTTON_SECRET is not None and settings.BIG_BLUE_BUTTON_URL is not None: + VIDEO_CHAT_PROVIDERS['big_blue_button'] = { + 'name': "Big Blue Button", + 'id': 4 + } + video_chat_provider = models.PositiveSmallIntegerField(default=VIDEO_CHAT_PROVIDERS['jitsi_meet']['id']) default_code_block_language: Optional[str] = models.TextField(null=True, default=None) diff --git a/zerver/openapi/openapi.py b/zerver/openapi/openapi.py index 7d6ded4ab9..56e196f61c 100644 --- a/zerver/openapi/openapi.py +++ b/zerver/openapi/openapi.py @@ -88,6 +88,11 @@ EXCLUDE_PROPERTIES = { '200': ['deliver_at'], } }, + '/calls/bigbluebutton/create': { + 'get': { + '200': ['url'] + } + }, } # A list of endpoint-methods such that the endpoint diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index ab3323dbe7..6f60039199 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -4495,6 +4495,27 @@ paths: "bot_email": "outgoing-bot@localhost" } + /calls/bigbluebutton/create: + get: + tags: ["streams"] + operationId: create_big_blue_button_video_call + description: | + Create a video call url for a Big Blue Button video call. + Requires Big Blue Button to be configured on the Zulip server. + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/JsonSuccess' + example: + { + "msg": "", + "result": "success", + "url": "/calls/bbb/join?meeting_id=%22zulip-something%22&password=%22something%22&checksum=%22somechecksum%22" + } + components: ####################### # Security definitions diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py index e393b1da2d..28c0ef8f07 100644 --- a/zerver/tests/test_create_video_call.py +++ b/zerver/tests/test_create_video_call.py @@ -1,4 +1,7 @@ +from unittest import mock + import responses +from django.http import HttpResponseRedirect from zerver.lib.test_classes import ZulipTestCase @@ -157,3 +160,57 @@ class TestVideoCall(ZulipTestCase): content_type="application/json", ) self.assert_json_success(response) + + def test_create_bigbluebutton_link(self) -> None: + with mock.patch('zerver.views.video_calls.random.randint', return_value="1"), mock.patch( + 'zerver.views.video_calls.random.SystemRandom.choice', return_value="A"): + response = self.client_get("/json/calls/bigbluebutton/create") + self.assert_json_success(response) + self.assertEqual(response.json()['url'], + "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22" + "&checksum=%22697939301a52d3a2f0b3c3338895c1a5ab528933%22") + + @responses.activate + def test_join_bigbluebutton_redirect(self) -> None: + responses.add(responses.GET, "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", + "SUCCESS") + response = self.client_get("/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22") + self.assertEqual(response.status_code, 302) + self.assertEqual(isinstance(response, HttpResponseRedirect), True) + self.assertEqual(response.url, "https://bbb.example.com/bigbluebutton/api/join?meetingID=zulip-1&password=a" + "&fullName=King%20Hamlet&checksum=7ddbb4e7e5aa57cb8c58db12003f3b5b040ff530") + + @responses.activate + def test_join_bigbluebutton_redirect_wrong_check(self) -> None: + responses.add(responses.GET, + "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", + "FAILEDchecksumError" + "You did not pass the checksum security check") + response = self.client_get("/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22") + self.assert_json_error(response, "Error authenticating to the Big Blue Button server.") + + @responses.activate + def test_join_bigbluebutton_redirect_server_error(self) -> None: + # Simulate bbb server error + responses.add(responses.GET, + "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", "", status=500) + response = self.client_get( + "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22") + self.assert_json_error(response, "Error connecting to the Big Blue Button server.") + + @responses.activate + def test_join_bigbluebutton_redirect_error_by_server(self) -> None: + # Simulate bbb server error + responses.add(responses.GET, + "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", + "FAILUREotherFailure") + response = self.client_get( + "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22") + self.assert_json_error(response, "Big Blue Button server returned an unexpected error.") + + def test_join_bigbluebutton_redirect_not_configured(self) -> None: + with self.settings(BIG_BLUE_BUTTON_SECRET=None, + BIG_BLUE_BUTTON_URL=None): + response = self.client_get( + "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22") + self.assert_json_error(response, "Big Blue Button is not configured.") diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index ce4acf84e7..296a724403 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -504,7 +504,7 @@ class RealmTest(ZulipTestCase): invite_to_stream_policy=10, email_address_visibility=10, message_retention_days=10, - video_chat_provider=4, + video_chat_provider=10, waiting_period_threshold=-10, digest_weekday=10, user_group_edit_policy=10, @@ -539,7 +539,7 @@ class RealmTest(ZulipTestCase): self.assertEqual(get_realm('zulip').video_chat_provider, Realm.VIDEO_CHAT_PROVIDERS['jitsi_meet']['id']) self.login('iago') - invalid_video_chat_provider_value = 4 + invalid_video_chat_provider_value = 10 req = {"video_chat_provider": ujson.dumps(invalid_video_chat_provider_value)} result = self.client_patch('/json/realm', req) self.assert_json_error(result, @@ -556,6 +556,12 @@ class RealmTest(ZulipTestCase): self.assert_json_success(result) self.assertEqual(get_realm('zulip').video_chat_provider, Realm.VIDEO_CHAT_PROVIDERS['jitsi_meet']['id']) + req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['big_blue_button']['id'])} + result = self.client_patch('/json/realm', req) + self.assert_json_success(result) + self.assertEqual(get_realm('zulip').video_chat_provider, + Realm.VIDEO_CHAT_PROVIDERS['big_blue_button']['id']) + req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id'])} result = self.client_patch('/json/realm', req) self.assert_json_success(result) diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py index 6002cb86cf..3e9d4e3101 100644 --- a/zerver/views/video_calls.py +++ b/zerver/views/video_calls.py @@ -1,9 +1,13 @@ +import hashlib import json +import random +import string from functools import partial from typing import Dict -from urllib.parse import urljoin +from urllib.parse import quote, urlencode, urljoin import requests +from defusedxml import ElementTree from django.conf import settings from django.http import HttpRequest, HttpResponse from django.middleware import csrf @@ -20,8 +24,9 @@ from zerver.decorator import REQ, has_request_variables, zulip_login_required from zerver.lib.actions import do_set_zoom_token from zerver.lib.exceptions import ErrorCode, JsonableError from zerver.lib.pysa import mark_sanitized -from zerver.lib.response import json_success +from zerver.lib.response import json_error, json_success from zerver.lib.subdomains import get_subdomain +from zerver.lib.url_encoding import add_query_arg_to_redirect_url, add_query_to_redirect_url from zerver.lib.validator import check_dict, check_string from zerver.models import UserProfile, get_realm @@ -135,7 +140,6 @@ def make_zoom_video_call(request: HttpRequest, user: UserProfile) -> HttpRespons return json_success({"url": res.json()["join_url"]}) - @csrf_exempt @require_POST @has_request_variables @@ -155,3 +159,62 @@ def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse: auth=(settings.VIDEO_ZOOM_CLIENT_ID, settings.VIDEO_ZOOM_CLIENT_SECRET), ).raise_for_status() return json_success() + + +def get_bigbluebutton_url(request: HttpRequest, user_profile: UserProfile) -> 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 = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(10)) + checksum = hashlib.sha1(("create" + "meetingID=" + id + "&moderatorPW=" + + password + "&attendeePW=" + password + "a" + settings.BIG_BLUE_BUTTON_SECRET).encode()).hexdigest() + url = add_query_to_redirect_url("/calls/bigbluebutton/join", urlencode({ + "meeting_id": "\"" + id + "\"", + "password": "\"" + password + "\"", + "checksum": "\"" + checksum + "\"" + })) + return json_success({"url": url}) + + +# We use zulip_login_required here mainly to get access to the user's +# full name from Zulip to prepopulate the user's name in the +# BigBlueButton meeting. Since the meeting's details are encoded in +# the link the user is clicking, there is no validation tying this +# meeting to the Zulip organization it was created in. +@zulip_login_required +@never_cache +@has_request_variables +def join_bigbluebutton(request: HttpRequest, meeting_id: str = REQ(validator=check_string), + password: str = REQ(validator=check_string), + checksum: str = REQ(validator=check_string)) -> HttpResponse: + if settings.BIG_BLUE_BUTTON_URL is None or settings.BIG_BLUE_BUTTON_SECRET is None: + return json_error(_("Big Blue Button is not configured.")) + else: + response = requests.get( + add_query_to_redirect_url(settings.BIG_BLUE_BUTTON_URL + "api/create", urlencode({ + "meetingID": meeting_id, + "moderatorPW": password, + "attendeePW": password + "a", + "checksum": checksum + }))) + try: + response.raise_for_status() + except Exception: + return json_error(_("Error connecting to the Big Blue Button server.")) + + payload = ElementTree.fromstring(response.text) + if payload.find("messageKey").text == "checksumError": + return json_error(_("Error authenticating to the Big Blue Button server.")) + + if payload.find("returncode").text != "SUCCESS": + return json_error(_("Big Blue Button server returned an unexpected error.")) + + join_params = urlencode({ # type: ignore[type-var] # MyPy has an AnyStr / Union[bytes, str] mismatch here. + "meetingID": meeting_id, + "password": password, + "fullName": request.user.full_name + }, quote_via=quote) + + checksum = hashlib.sha1(("join" + join_params + settings.BIG_BLUE_BUTTON_SECRET).encode()).hexdigest() + redirect_url_base = add_query_to_redirect_url(settings.BIG_BLUE_BUTTON_URL + "api/join", join_params) + return redirect(add_query_arg_to_redirect_url(redirect_url_base, "checksum=" + checksum)) diff --git a/zproject/computed_settings.py b/zproject/computed_settings.py index d53e593706..5f7eca928d 100644 --- a/zproject/computed_settings.py +++ b/zproject/computed_settings.py @@ -458,6 +458,8 @@ ANDROID_GCM_API_KEY = get_secret("android_gcm_api_key") DROPBOX_APP_KEY = get_secret("dropbox_app_key") +BIG_BLUE_BUTTON_SECRET = get_secret('big_blue_button_secret') + MAILCHIMP_API_KEY = get_secret("mailchimp_api_key") # Twitter API credentials diff --git a/zproject/default_settings.py b/zproject/default_settings.py index 979f3323ef..b5f956cadf 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -116,6 +116,10 @@ MAX_FILE_UPLOAD_SIZE = 25 # Jitsi Meet video call integration; set to None to disable integration. JITSI_SERVER_URL = 'https://meet.jit.si/' +# Allow setting BigBlueButton settings in zulip-secrets.conf in +# development; this is useful since there are no public BigBlueButton servers. +BIG_BLUE_BUTTON_URL = get_secret('big_blue_button_url', development_only=True) + # Max state storage per user # TODO: Add this to zproject/prod_settings_template.py once stateful bots are fully functional. USER_STATE_SIZE_LIMIT = 10000000 diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index ec97e08cda..273b2b587f 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -649,3 +649,7 @@ CAMO_URI = '/external_content/' # your own Jitsi Meet server, or if you'd like to disable the # integration, set JITSI_SERVER_URL = None. #JITSI_SERVER_URL = 'jitsi.example.com' + +# Controls the Big Blue Button video call integration. You must also +# set big_blue_button_secret in zulip-secrets.conf. +# BIG_BLUE_BUTTON_URL = "https://bbb.example.com/bigbluebutton/" diff --git a/zproject/test_extra_settings.py b/zproject/test_extra_settings.py index f100b94042..cda3b7958d 100644 --- a/zproject/test_extra_settings.py +++ b/zproject/test_extra_settings.py @@ -185,6 +185,9 @@ APPLE_ID_TOKEN_GENERATION_KEY = get_from_file_if_exists("zerver/tests/fixtures/a VIDEO_ZOOM_CLIENT_ID = "client_id" VIDEO_ZOOM_CLIENT_SECRET = "client_secret" +BIG_BLUE_BUTTON_SECRET = "123" +BIG_BLUE_BUTTON_URL = "https://bbb.example.com/bigbluebutton/" + # By default two factor authentication is disabled in tests. # Explicitly set this to True within tests that must have this on. TWO_FACTOR_AUTHENTICATION_ENABLED = False diff --git a/zproject/urls.py b/zproject/urls.py index 5c96052f58..92807dd4e4 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -405,6 +405,9 @@ v1_api_and_json_patterns = [ # Used to generate a Zoom video call URL url(r'^calls/zoom/create$', rest_dispatch, {'POST': 'zerver.views.video_calls.make_zoom_video_call'}), + # Used to generate a Big Blue Button video call URL + url(r'^calls/bigbluebutton/create$', rest_dispatch, + {'GET': 'zerver.views.video_calls.get_bigbluebutton_url'}), # export/realm -> zerver.views.realm_export url(r'^export/realm$', rest_dispatch, @@ -554,6 +557,9 @@ i18n_urls = [ url(r'^calls/zoom/complete$', zerver.views.video_calls.complete_zoom_user), url(r'^calls/zoom/deauthorize$', zerver.views.video_calls.deauthorize_zoom_user), + # Used to join a Big Blue Button video call + url(r'^calls/bigbluebutton/join$', zerver.views.video_calls.join_bigbluebutton), + # API and integrations documentation url(r'^integrations/doc-html/(?P[^/]*)$', zerver.views.documentation.integration_doc,