calls: Add Big Blue Button as a Video Call Provider.

Big Blue Button needs an API secret so communication to creating a
room has to be done server side.

Fixes #14763.
This commit is contained in:
Felix 2020-04-27 22:41:31 +02:00 committed by Tim Abbott
parent 077c741ef4
commit a389c7390d
19 changed files with 223 additions and 6 deletions

View File

@ -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.

View File

@ -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() {

View File

@ -759,6 +759,10 @@ run_test('set_up', () => {
id: 3,
name: "Zoom",
},
big_blue_button: {
id: 4,
name: "Big Blue Button",
},
};
simulate_tip_box();

View File

@ -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);

View File

@ -238,7 +238,7 @@
<h3>VIDEO CALLS</h3>
<p>
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.
</p>
</a>
<a class="feature-block" href="/help/configure-authentication-methods" target="_blank" rel="noopener noreferrer">

View File

@ -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

View File

@ -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+__",

View File

@ -67,6 +67,7 @@ TAB_DISPLAY_NAMES = {
'zoom': 'Zoom (experimental)',
'jitsi-meet': 'Jitsi Meet',
'bigbluebutton': 'Big Blue Button',
'disable': 'Disabled',
'chrome': 'Chrome',

View File

@ -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)

View File

@ -88,6 +88,11 @@ EXCLUDE_PROPERTIES = {
'200': ['deliver_at'],
}
},
'/calls/bigbluebutton/create': {
'get': {
'200': ['url']
}
},
}
# A list of endpoint-methods such that the endpoint

View File

@ -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

View File

@ -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",
"<response><returncode>SUCCESS</returncode><messageKey/></response>")
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",
"<response><returncode>FAILED</returncode><messageKey>checksumError</messageKey>"
"<message>You did not pass the checksum security check</message></response>")
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",
"<response><returncode>FAILURE</returncode><messageKey>otherFailure</messageKey></response>")
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.")

View File

@ -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)

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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/"

View File

@ -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

View File

@ -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<integration_name>[^/]*)$',
zerver.views.documentation.integration_doc,