diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index 13e996930e..d19f8b3a31 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -1,15 +1,15 @@ {% extends "!layout.html" %} {% block document %} - - {% if (pagename == "production/management-commands" or pagename == "production/email-gateway" or pagename == "production/upgrade-or-modify") and release.endswith('+git') %} - + #}

Warning

You are reading a development version of the Zulip documentation. These instructions may not correspond to the latest Zulip Server release. diff --git a/docs/production/index.rst b/docs/production/index.rst index f520abd0ee..fe554358a6 100644 --- a/docs/production/index.rst +++ b/docs/production/index.rst @@ -21,3 +21,4 @@ Zulip in Production email deployment email-gateway + zoom-configuration diff --git a/docs/production/settings.md b/docs/production/settings.md index 8bb5f69030..b671b88f22 100644 --- a/docs/production/settings.md +++ b/docs/production/settings.md @@ -100,6 +100,7 @@ Some popular settings in `/etc/zulip/settings.py` include: tweets. * The [email gateway](../production/email-gateway.md), which lets users send emails into Zulip. +* The [Zoom video call integration](zoom-configuration.md). ## Zulip announcement list diff --git a/docs/production/zoom-configuration.md b/docs/production/zoom-configuration.md new file mode 100644 index 0000000000..1dd7d483f3 --- /dev/null +++ b/docs/production/zoom-configuration.md @@ -0,0 +1,36 @@ +# Zoom Video Calling OAuth Configuration + +To use the [Zoom](https://zoom.us) integration on a self-hosted +installation, you'll need to register a custom Zoom Application as +follows: + +1. Visit the [Zoom Marketplace](https://marketplace.zoom.us/develop/create). + +1. Create a new application, choosing **OAuth** as the app type. +We recommend using a name like "ExampleCorp Zulip". + +1. Select *account-level app* for the authentication type, disable +the option to publish the app in the Marketplace, and click **Create**. + +1. Inside of the Zoom app management page, set the Redirect URL to +`https://zulip.example.com/calls/zoom/complete` (replacing +`zulip.example.com` by your main Zulip hostname). + +1. Set the "Scopes" to `meeting:write:admin`. + +You can then configure your Zulip server to use that Zoom application +as follows: + +1. In `/etc/zulip/zulip-secrets.conf`, set `video_zoom_client_secret` +to be your app's "Client Secret". + +1. In `/etc/zulip/settings.py`, set `VIDEO_ZOOM_CLIENT_ID` to your + app's "Client ID". + +1. Restart the Zulip server with + `/home/zulip/deployments/current/scripts/restart-server`. + +This enables Zoom support in your Zulip server. Finally, [configure +Zoom as the video call +provider](https://zulipchat.com/help/start-a-call) in the Zulip +organization(s) where you want to use it. diff --git a/frontend_tests/node_tests/compose.js b/frontend_tests/node_tests/compose.js index a31574e0dd..d06a06dcd9 100644 --- a/frontend_tests/node_tests/compose.js +++ b/frontend_tests/node_tests/compose.js @@ -72,6 +72,7 @@ zrequire('compose_pm_pill'); zrequire('echo'); zrequire('compose'); zrequire('upload'); +zrequire('server_events_dispatch'); people.small_avatar_url_for_person = function () { return 'http://example.com/example.png'; @@ -1458,13 +1459,18 @@ run_test('on_events', () => { page_params.realm_video_chat_provider = page_params.realm_available_video_chat_providers.zoom.id; - page_params.realm_zoom_user_id = 'example@example.com'; - page_params.realm_zoom_api_key = 'abc'; - page_params.realm_zoom_api_secret = 'abc'; - channel.get = function (options) { - assert(options.url === '/json/calls/create'); - options.success({ zoom_url: 'example.zoom.com' }); + window.open = function (url) { + assert(url.endsWith('/calls/zoom/register')); + server_events_dispatch.dispatch_normal_event({ + type: "has_zoom_token", + value: true, + }); + }; + + channel.post = function (payload) { + assert.equal(payload.url, '/json/calls/zoom/create'); + payload.success({ url: 'example.zoom.com' }); }; handler(ev); diff --git a/requirements/common.in b/requirements/common.in index a9bb9b8c4b..a3a1316ab2 100644 --- a/requirements/common.in +++ b/requirements/common.in @@ -181,3 +181,7 @@ django-cookies-samesite # For server-side enforcement of password strength zxcvbn + +# Needed for sending HTTP requests +requests[security] +requests-oauthlib diff --git a/requirements/dev.txt b/requirements/dev.txt index dfdeb0970f..6e61165b86 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -818,11 +818,11 @@ regex==2020.4.4 \ requests-oauthlib==1.3.0 \ --hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \ --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \ - # via python-twitter, social-auth-core + # via -r requirements/common.in, python-twitter, social-auth-core requests[security]==2.23.0 \ --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ --hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 \ - # via docker, hypchat, matrix-client, moto, premailer, pyoembed, python-digitalocean, python-gcm, python-twitter, requests-oauthlib, responses, social-auth-core, sphinx, stripe, twilio, zulip + # via -r requirements/common.in, docker, hypchat, matrix-client, moto, premailer, pyoembed, python-digitalocean, python-gcm, python-twitter, requests-oauthlib, responses, social-auth-core, sphinx, stripe, twilio, zulip responses==0.10.12 \ --hash=sha256:0474ce3c897fbcc1aef286117c93499882d5c440f06a805947e4b1cb5ab3d474 \ --hash=sha256:f83613479a021e233e82d52ffb3e2e0e2836d24b0cc88a0fa31978789f78d0e5 \ diff --git a/requirements/prod.txt b/requirements/prod.txt index f1e49abf89..2d41ef1307 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -582,11 +582,11 @@ regex==2020.4.4 \ requests-oauthlib==1.3.0 \ --hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \ --hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a \ - # via python-twitter, social-auth-core + # via -r requirements/common.in, python-twitter, social-auth-core requests[security]==2.23.0 \ --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ --hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 \ - # via hypchat, matrix-client, premailer, pyoembed, python-gcm, python-twitter, requests-oauthlib, social-auth-core, stripe, twilio, zulip + # via -r requirements/common.in, hypchat, matrix-client, premailer, pyoembed, python-gcm, python-twitter, requests-oauthlib, social-auth-core, stripe, twilio, zulip s3transfer==0.3.3 \ --hash=sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13 \ --hash=sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db \ diff --git a/static/js/compose.js b/static/js/compose.js index 14c18ccd8e..3c96944ffb 100644 --- a/static/js/compose.js +++ b/static/js/compose.js @@ -168,6 +168,17 @@ exports.abort_xhr = function () { uppy.cancelAll(); }; +exports.zoom_token_callbacks = new Map(); +exports.zoom_xhrs = new Map(); + +exports.abort_zoom = function (edit_message_id) { + const key = edit_message_id || ""; + exports.zoom_token_callbacks.delete(key); + if (exports.zoom_xhrs.has(key)) { + exports.zoom_xhrs.get(key).abort(); + } +}; + exports.empty_topic_placeholder = function () { return i18n.t("(no topic)"); }; @@ -1078,13 +1089,45 @@ exports.initialize = function () { if (page_params.realm_video_chat_provider === available_providers.google_hangouts.id) { video_call_link = "https://hangouts.google.com/hangouts/_/" + page_params.realm_google_hangouts_domain + "/" + video_call_id; insert_video_call_url(video_call_link, target_textarea); - } else if (page_params.realm_video_chat_provider === available_providers.zoom.id) { - channel.get({ - url: '/json/calls/create', - success: function (response) { - insert_video_call_url(response.zoom_url, target_textarea); - }, - }); + } else if (available_providers.zoom + && page_params.realm_video_chat_provider === available_providers.zoom.id) { + exports.abort_zoom(edit_message_id); + const key = edit_message_id || ""; + + const make_zoom_call = () => { + exports.zoom_xhrs.set(key, channel.post({ + url: "/json/calls/zoom/create", + success(res) { + exports.zoom_xhrs.delete(key); + insert_video_call_url(res.url, target_textarea); + }, + error(xhr, status) { + exports.zoom_xhrs.delete(key); + if (status === "error" && + xhr.responseJSON && + xhr.responseJSON.code === "INVALID_ZOOM_TOKEN") { + page_params.has_zoom_token = false; + } + if (status !== "abort") { + ui_report.generic_embed_error(i18n.t("Failed to create video call.")); + } + }, + })); + }; + + if (page_params.has_zoom_token) { + make_zoom_call(); + } else { + exports.zoom_token_callbacks.set(key, make_zoom_call); + window.open( + window.location.protocol + + "//" + + window.location.host + + "/calls/zoom/register", + "_blank", + "width=800,height=500,noopener,noreferrer" + ); + } } else { video_call_link = page_params.jitsi_server_url + "/" + video_call_id; insert_video_call_url(video_call_link, target_textarea); diff --git a/static/js/compose_actions.js b/static/js/compose_actions.js index b4b223d200..cdb636e62c 100644 --- a/static/js/compose_actions.js +++ b/static/js/compose_actions.js @@ -246,6 +246,7 @@ exports.cancel = function () { clear_box(); notifications.clear_compose_notifications(); compose.abort_xhr(); + compose.abort_zoom(undefined); compose_state.set_message_type(false); compose_pm_pill.clear(); $(document).trigger($.Event('compose_canceled.zulip')); diff --git a/static/js/message_edit.js b/static/js/message_edit.js index 8c58e6a17c..5df0ad5f75 100644 --- a/static/js/message_edit.js +++ b/static/js/message_edit.js @@ -522,6 +522,8 @@ exports.end_message_row_edit = function (row) { currently_editing_messages.delete(message.id); current_msg_list.hide_edit_message(row); + + compose.abort_zoom(message.id); } condense.show_message_expander(row); row.find(".message_reactions").show(); diff --git a/static/js/server_events_dispatch.js b/static/js/server_events_dispatch.js index de7b7ca17b..0499b1c12e 100644 --- a/static/js/server_events_dispatch.js +++ b/static/js/server_events_dispatch.js @@ -38,6 +38,16 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) { break; } + case 'has_zoom_token': + page_params.has_zoom_token = event.value; + if (event.value) { + for (const callback of compose.zoom_token_callbacks.values()) { + callback(); + } + compose.zoom_token_callbacks.clear(); + } + break; + case 'hotspots': hotspots.load_new(event.hotspots); page_params.hotspots = page_params.hotspots ? @@ -121,9 +131,6 @@ exports.dispatch_normal_event = function dispatch_normal_event(event) { emails_restricted_to_domains: noop, video_chat_provider: compose.update_video_chat_button_display, waiting_period_threshold: noop, - zoom_user_id: noop, - zoom_api_key: noop, - zoom_api_secret: noop, }; if (event.op === 'update' && Object.prototype.hasOwnProperty.call(realm_settings, event.property)) { page_params['realm_' + event.property] = event.value; diff --git a/static/js/settings_org.js b/static/js/settings_org.js index 5a693bb707..4d0f4109c0 100644 --- a/static/js/settings_org.js +++ b/static/js/settings_org.js @@ -212,17 +212,9 @@ function set_video_chat_provider_dropdown() { $("#id_realm_video_chat_provider").val(chat_provider_id); if (chat_provider_id === available_providers.google_hangouts.id) { $("#google_hangouts_domain").show(); - $(".zoom_credentials").hide(); $("#id_realm_google_hangouts_domain").val(page_params.realm_google_hangouts_domain); - } else if (chat_provider_id === available_providers.zoom.id) { - $("#google_hangouts_domain").hide(); - $(".zoom_credentials").show(); - $("#id_realm_zoom_user_id").val(page_params.realm_zoom_user_id); - $("#id_realm_zoom_api_key").val(page_params.realm_zoom_api_key); - $("#id_realm_zoom_api_secret").val(page_params.realm_zoom_api_secret); } else { $("#google_hangouts_domain").hide(); - $(".zoom_credentials").hide(); } } @@ -845,13 +837,8 @@ exports.build_page = function () { if (video_chat_provider_id === available_providers.google_hangouts.id) { $("#google_hangouts_domain").show(); - $(".zoom_credentials").hide(); - } else if (video_chat_provider_id === available_providers.zoom.id) { - $("#google_hangouts_domain").hide(); - $(".zoom_credentials").show(); } else { $("#google_hangouts_domain").hide(); - $(".zoom_credentials").hide(); } }); diff --git a/static/styles/settings.scss b/static/styles/settings.scss index 9c45296fff..653550c34c 100644 --- a/static/styles/settings.scss +++ b/static/styles/settings.scss @@ -624,7 +624,6 @@ input[type=checkbox] { } #google_hangouts_domain, -#zoom_help_text, .organization-settings-parent div:first-of-type { margin-top: 10px; } diff --git a/static/templates/settings/organization_settings_admin.hbs b/static/templates/settings/organization_settings_admin.hbs index 8d42f05469..dd3f1ca9f7 100644 --- a/static/templates/settings/organization_settings_admin.hbs +++ b/static/templates/settings/organization_settings_admin.hbs @@ -196,36 +196,6 @@ class="admin-realm-google-hangouts-domain setting-widget prop-element" data-setting-widget-type="string"/>

-
-

- Note: Zoom support is experimental. In particular, Zulip currently supports having - only one active Zoom meeting at a time. -

-
-
- - -
-
- - -
-
- - -
{{> dropdown_list_widget diff --git a/templates/zerver/close_window.html b/templates/zerver/close_window.html new file mode 100644 index 0000000000..e2aec753ec --- /dev/null +++ b/templates/zerver/close_window.html @@ -0,0 +1,15 @@ + + + + + Callback complete + + + +

You may now close this window.

+ + diff --git a/templates/zerver/help/start-a-call.md b/templates/zerver/help/start-a-call.md index a998cfe90a..9c76023982 100644 --- a/templates/zerver/help/start-a-call.md +++ b/templates/zerver/help/start-a-call.md @@ -25,8 +25,8 @@ By default, Zulip integrates with source video conferencing solution. Organization administrators can also change the organization's video chat provider. -Note that both the Google Hangouts and Zoom integrations require paid -accounts with their respective providers. +Note that the Google Hangouts integration requires a paid Google G +Suite account. ### Change your organization's video chat provider @@ -48,13 +48,18 @@ accounts with their respective providers. 1. Under **Other settings** set **Video chat provider** to **Zoom**. -1. Enter your Zoom email address, API key, and API secret. - 1. Click **Save changes**. -!!! warn "" - **Note**: Zoom support is experimental. In particular, Zulip currently - supports having only one active Zoom meeting at a time. +Any user who creates a video call link using the instructions above +will be prompted to link a Zoom account with their Zulip account. + +If you would like to unlink Zoom from your Zulip account: + +1. Log in to the [Zoom App Marketplace](https://marketplace.zoom.us/). + +1. Click **Manage** → **Installed Apps**. + +1. Click the **Uninstall** button next to the Zulip app. {tab|jitsi-on-premise} diff --git a/version.py b/version.py index c869d621cf..3e61a77f6b 100644 --- a/version.py +++ b/version.py @@ -44,4 +44,4 @@ API_FEATURE_LEVEL = 8 # historical commits sharing the same major version, in which case a # minor version bump suffices. -PROVISION_VERSION = '83.1' +PROVISION_VERSION = '83.2' diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index e95f383349..7f12a1724c 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -153,7 +153,6 @@ from zerver.lib.sessions import delete_user_sessions from zerver.lib.upload import claim_attachment, delete_message_image, \ upload_emoji_image, delete_avatar_image, \ delete_export_tarball -from zerver.lib.video_calls import request_zoom_video_call_url from zerver.tornado.event_queue import send_event from zerver.lib.types import ProfileFieldData from zerver.lib.streams import access_stream_for_send_message, subscribed_to_stream, check_stream_name, \ @@ -604,9 +603,6 @@ def do_set_realm_property(realm: Realm, name: str, value: Any) -> None: setattr(realm, name, value) realm.save(update_fields=[name]) - if name == 'zoom_api_secret': - # Send '' as the value through the API for the API secret - value = '' event = dict( type='realm', op='update', @@ -5747,18 +5743,13 @@ def do_send_realm_reactivation_email(realm: Realm) -> None: from_name=FromAddress.security_email_from_name(language=language), language=language, context=context) -def get_zoom_video_call_url(realm: Realm) -> str: - response = request_zoom_video_call_url( - realm.zoom_user_id, - realm.zoom_api_key, - realm.zoom_api_secret +def do_set_zoom_token(user: UserProfile, token: Optional[Dict[str, object]]) -> None: + user.zoom_token = token + user.save(update_fields=["zoom_token"]) + send_event( + user.realm, dict(type="has_zoom_token", value=token is not None), [user.id] ) - if response is None: - return '' - - return response['join_url'] - def notify_realm_export(user_profile: UserProfile) -> None: # In the future, we may want to send this event to all realm admins. event = dict(type='realm_export', diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 1a1f63193b..46bc0ab40d 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -138,10 +138,6 @@ def fetch_initial_state_data(user_profile: UserProfile, for property_name in Realm.property_types: state['realm_' + property_name] = getattr(realm, property_name) - # Don't send the zoom API secret to clients. - if state.get('realm_zoom_api_secret'): - state['realm_zoom_api_secret'] = '' - # Most state is handled via the property_types framework; # these manual entries are for those realm settings that don't # fit into that framework. @@ -325,6 +321,9 @@ def fetch_initial_state_data(user_profile: UserProfile, if want('user_status'): state['user_status'] = get_user_info_dict(realm_id=realm.id) + if want('video_calls'): + state['has_zoom_token'] = user_profile.zoom_token is not None + return state def apply_events(state: Dict[str, Any], events: Iterable[Dict[str, Any]], @@ -811,6 +810,8 @@ def apply_event(state: Dict[str, Any], user_status.pop(user_id, None) state['user_status'] = user_status + elif event['type'] == 'has_zoom_token': + state['has_zoom_token'] = event['value'] else: raise AssertionError("Unexpected event type %s" % (event['type'],)) diff --git a/zerver/lib/exceptions.py b/zerver/lib/exceptions.py index 514ad13afa..9447394764 100644 --- a/zerver/lib/exceptions.py +++ b/zerver/lib/exceptions.py @@ -46,6 +46,7 @@ class ErrorCode(AbstractEnum): INVALID_MARKDOWN_INCLUDE_STATEMENT = () REQUEST_CONFUSING_VAR = () INVALID_API_KEY = () + INVALID_ZOOM_TOKEN = () class JsonableError(Exception): '''A standardized error format we can turn into a nice JSON HTTP response. diff --git a/zerver/lib/video_calls.py b/zerver/lib/video_calls.py deleted file mode 100644 index 8e5d08744b..0000000000 --- a/zerver/lib/video_calls.py +++ /dev/null @@ -1,26 +0,0 @@ -import requests -import jwt -from typing import Any, Dict, Optional -import time - -def request_zoom_video_call_url(user_id: str, api_key: str, api_secret: str) -> Optional[Dict[str, Any]]: - encodedToken = jwt.encode({ - 'iss': api_key, - 'exp': int(round(time.time() * 1000)) + 5000 - }, api_secret, algorithm='HS256').decode('utf-8') - - response = requests.post( - 'https://api.zoom.us/v2/users/' + user_id + '/meetings', - headers = { - 'Authorization': 'Bearer ' + encodedToken, - 'content-type': 'application/json' - }, - json = {} - ) - - try: - response.raise_for_status() - except Exception: - return None - - return response.json() diff --git a/zerver/migrations/0281_zoom_oauth.py b/zerver/migrations/0281_zoom_oauth.py new file mode 100644 index 0000000000..1ba23beaac --- /dev/null +++ b/zerver/migrations/0281_zoom_oauth.py @@ -0,0 +1,19 @@ +# Generated by Django 1.11.25 on 2019-11-06 22:40 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('zerver', '0280_userprofile_presence_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='zoom_token', + field=django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 9d0de87abd..ecd6728def 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -36,6 +36,7 @@ from zerver.lib.types import Validator, ExtendedValidator, \ ExtendedFieldElement, UserFieldElement, FieldElement, \ DisplayRecipientT from zerver.lib.exceptions import JsonableError +from django.contrib.postgres.fields import JSONField from bitfield import BitField from bitfield.types import BitHandler @@ -321,11 +322,12 @@ class Realm(models.Model): 'name': "Google Hangouts", 'id': 2 }, - 'zoom': { + } + 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 } - } video_chat_provider = models.PositiveSmallIntegerField(default=VIDEO_CHAT_PROVIDERS['jitsi_meet']['id']) google_hangouts_domain = models.TextField(default="") zoom_user_id = models.TextField(default="") @@ -350,9 +352,6 @@ class Realm(models.Model): email_address_visibility=int, email_changes_disabled=bool, google_hangouts_domain=str, - zoom_user_id=str, - zoom_api_key=str, - zoom_api_secret=str, invite_required=bool, invite_by_admins_only=bool, inline_image_preview=bool, @@ -1026,6 +1025,8 @@ class UserProfile(AbstractBaseUser, PermissionsMixin): # completed. onboarding_steps: str = models.TextField(default='[]') + zoom_token: Optional[object] = JSONField(default=None, null=True) + objects: UserManager = UserManager() # Define the types of the various automatically managed properties diff --git a/zerver/signals.py b/zerver/signals.py index 70f08f338b..f54c2d70e0 100644 --- a/zerver/signals.py +++ b/zerver/signals.py @@ -1,7 +1,7 @@ from typing import Any, Optional from django.conf import settings -from django.contrib.auth.signals import user_logged_in +from django.contrib.auth.signals import user_logged_in, user_logged_out from django.dispatch import receiver from django.utils.timezone import \ get_current_timezone_name as timezone_get_current_timezone_name @@ -9,6 +9,7 @@ from django.utils.timezone import now as timezone_now from django.utils.translation import ugettext as _ from confirmation.models import one_click_unsubscribe_link +from zerver.lib.actions import do_set_zoom_token from zerver.lib.queue import queue_json_publish from zerver.lib.send_email import FromAddress from zerver.models import UserProfile @@ -99,3 +100,11 @@ def email_on_new_login(sender: Any, user: UserProfile, request: Any, **kwargs: A 'from_address': FromAddress.NOREPLY, 'context': context} queue_json_publish("email_senders", email_dict) + + +@receiver(user_logged_out) +def clear_zoom_token_on_logout( + sender: object, *, user: Optional[UserProfile], **kwargs: object +) -> None: + if user is not None and user.zoom_token is not None: + do_set_zoom_token(user, None) diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py index d78c214a18..a3a0a9e162 100644 --- a/zerver/tests/test_create_video_call.py +++ b/zerver/tests/test_create_video_call.py @@ -1,63 +1,158 @@ -import json -from unittest import mock +import responses from zerver.lib.test_classes import ZulipTestCase -from typing import Dict + class TestVideoCall(ZulipTestCase): def setUp(self) -> None: super().setUp() - user_profile = self.example_user('hamlet') - self.login_user(user_profile) + self.user = self.example_user("hamlet") + self.login_user(self.user) - def test_create_video_call_success(self) -> None: - with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value={'join_url': 'example.com'}): - result = self.client_get("/json/calls/create") - self.assert_json_success(result) - self.assertEqual(200, result.status_code) - content = result.json() - self.assertEqual(content['zoom_url'], 'example.com') + def test_register_video_request_no_settings(self) -> None: + with self.settings(VIDEO_ZOOM_CLIENT_ID=None): + response = self.client_get("/calls/zoom/register") + self.assert_json_error( + response, "Zoom credentials have not been configured" + ) - def test_create_video_call_failure(self) -> None: - with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value=None): - result = self.client_get("/json/calls/create") - self.assert_json_success(result) - self.assertEqual(200, result.status_code) - content = result.json() - self.assertEqual(content['zoom_url'], '') + def test_register_video_request(self) -> None: + response = self.client_get("/calls/zoom/register") + self.assertEqual(response.status_code, 302) + @responses.activate def test_create_video_request_success(self) -> None: - class MockResponse: - def __init__(self) -> None: - self.status_code = 200 + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "oldtoken", "expires_in": -60}, + ) - def json(self) -> Dict[str, str]: - return {"join_url": "example.com"} + response = self.client_get( + "/calls/zoom/complete", + {"code": "code", "state": '{"realm":"zulip","sid":""}'}, + ) + self.assertEqual(response.status_code, 200) - def raise_for_status(self) -> None: - return None + responses.replace( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "newtoken", "expires_in": 60}, + ) - with mock.patch('requests.post', return_value=MockResponse()): - result = self.client_get("/json/calls/create") - self.assert_json_success(result) + responses.add( + responses.POST, + "https://api.zoom.us/v2/users/me/meetings", + json={"join_url": "example.com"}, + ) - def test_create_video_request_http_error(self) -> None: - class MockResponse: - def __init__(self) -> None: - self.status_code = 401 + response = self.client_post("/json/calls/zoom/create") + self.assertEqual( + responses.calls[-1].request.url, "https://api.zoom.us/v2/users/me/meetings" + ) + self.assertEqual( + responses.calls[-1].request.headers["Authorization"], "Bearer newtoken" + ) + json = self.assert_json_success(response) + self.assertEqual(json["url"], "example.com") - def raise_for_status(self) -> None: - raise Exception("Invalid request!") + self.logout() + self.login_user(self.user) - with mock.patch('requests.post', return_value=MockResponse()): - result = self.client_get("/json/calls/create") - self.assert_json_success(result) - result_dict = json.loads(result.content.decode('utf-8')) + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Invalid Zoom access token") - # TODO: Arguably this is the wrong result for errors, but - # in any case we should test it. - self.assertEqual(result_dict['zoom_url'], '') + def test_create_video_realm_redirect(self) -> None: + response = self.client_get( + "/calls/zoom/complete", + {"code": "code", "state": '{"realm":"zephyr","sid":"somesid"}'}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("http://zephyr.testserver/", response.url) + self.assertIn("somesid", response.url) - def test_create_video_request(self) -> None: - with mock.patch('requests.post'): - result = self.client_get("/json/calls/create") - self.assert_json_success(result) + def test_create_video_sid_error(self) -> None: + response = self.client_get( + "/calls/zoom/complete", + {"code": "code", "state": '{"realm":"zulip","sid":"bad"}'}, + ) + self.assert_json_error(response, "Invalid Zoom session identifier") + + @responses.activate + def test_create_video_credential_error(self) -> None: + responses.add(responses.POST, "https://zoom.us/oauth/token", status=400) + + response = self.client_get( + "/calls/zoom/complete", + {"code": "code", "state": '{"realm":"zulip","sid":""}'}, + ) + self.assert_json_error(response, "Invalid Zoom credentials") + + @responses.activate + def test_create_video_refresh_error(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token", "expires_in": -60}, + ) + + response = self.client_get( + "/calls/zoom/complete", + {"code": "code", "state": '{"realm":"zulip","sid":""}'}, + ) + self.assertEqual(response.status_code, 200) + + responses.replace(responses.POST, "https://zoom.us/oauth/token", status=400) + + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Invalid Zoom access token") + + @responses.activate + def test_create_video_request_error(self) -> None: + responses.add( + responses.POST, + "https://zoom.us/oauth/token", + json={"access_token": "token"}, + ) + + responses.add( + responses.POST, "https://api.zoom.us/v2/users/me/meetings", status=400 + ) + + response = self.client_get( + "/calls/zoom/complete", + {"code": "code", "state": '{"realm":"zulip","sid":""}'}, + ) + self.assertEqual(response.status_code, 200) + + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Failed to create Zoom call") + + responses.replace( + responses.POST, "https://api.zoom.us/v2/users/me/meetings", status=401 + ) + + response = self.client_post("/json/calls/zoom/create") + self.assert_json_error(response, "Invalid Zoom access token") + + @responses.activate + def test_deauthorize_zoom_user(self) -> None: + responses.add(responses.POST, "https://api.zoom.us/oauth/data/compliance") + + response = self.client_post( + "/calls/zoom/deauthorize", + """\ +{ + "event": "app_deauthorized", + "payload": { + "user_data_retention": "false", + "account_id": "EabCDEFghiLHMA", + "user_id": "z9jkdsfsdfjhdkfjQ", + "signature": "827edc3452044f0bc86bdd5684afb7d1e6becfa1a767f24df1b287853cf73000", + "deauthorization_time": "2019-06-17T13:52:28.632Z", + "client_id": "ADZ9k9bTWmGUoUbECUKU_a" + } +} +""", + content_type="application/json", + ) + self.assert_json_success(response) diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 908033478d..003f5c3968 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -82,6 +82,7 @@ from zerver.lib.actions import ( do_set_user_display_setting, do_set_realm_notifications_stream, do_set_realm_signup_notifications_stream, + do_set_zoom_token, do_unmute_topic, do_update_embedded_data, do_update_message, @@ -1628,9 +1629,6 @@ class EventsRegisterTest(ZulipTestCase): Realm.VIDEO_CHAT_PROVIDERS['google_hangouts']['id'] ], google_hangouts_domain=["zulip.com", "zulip.org"], - zoom_api_secret=["abc", "xyz"], - zoom_api_key=["abc", "xyz"], - zoom_user_id=["example@example.com", "example@example.org"], default_code_block_language=['python', 'javascript'], ) @@ -1661,8 +1659,6 @@ class EventsRegisterTest(ZulipTestCase): do_set_realm_property(self.user_profile.realm, name, vals[0]) for val in vals[1:]: state_change_expected = True - if name == "zoom_api_secret": - state_change_expected = False events = self.do_test( lambda: do_set_realm_property(self.user_profile.realm, name, val), state_change_expected=state_change_expected) @@ -2940,6 +2936,25 @@ class EventsRegisterTest(ZulipTestCase): error = failed_schema_checker('events[1]', events[1]) self.assert_on_error(error) + def test_has_zoom_token(self) -> None: + schema_checker = self.check_events_dict([ + ('type', equals('has_zoom_token')), + ('value', equals(True)), + ]) + events = self.do_test( + lambda: do_set_zoom_token(self.user_profile, {'access_token': 'token'}) + ) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + + schema_checker = self.check_events_dict([ + ('type', equals('has_zoom_token')), + ('value', equals(False)), + ]) + events = self.do_test(lambda: do_set_zoom_token(self.user_profile, None)) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + class FetchInitialStateDataTest(ZulipTestCase): # Non-admin users don't have access to all bots def test_realm_bots_non_admin(self) -> None: @@ -3744,6 +3759,7 @@ class FetchQueriesTest(ZulipTestCase): update_global_notifications=0, update_message_flags=5, user_status=1, + video_calls=0, ) wanted_event_types = { diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 438fc4637a..6b148f84f5 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -89,6 +89,7 @@ class HomeTest(ZulipTestCase): "full_name", "furthest_read_time", "has_mobile_devices", + "has_zoom_token", "have_initial_messages", "high_contrast_mode", "hotspots", @@ -192,9 +193,6 @@ class HomeTest(ZulipTestCase): "realm_users", "realm_video_chat_provider", "realm_waiting_period_threshold", - "realm_zoom_api_key", - "realm_zoom_api_secret", - "realm_zoom_user_id", "recent_private_conversations", "root_domain_uri", "save_stacktraces", diff --git a/zerver/tests/test_openapi.py b/zerver/tests/test_openapi.py index b14a66d6e0..8d5fb0ef04 100644 --- a/zerver/tests/test_openapi.py +++ b/zerver/tests/test_openapi.py @@ -255,7 +255,7 @@ class OpenAPIArgumentsTest(ZulipTestCase): # Used for failed approach with dead Android app. '/fetch_google_client_id', # API for video calls we're planning to remove/replace. - '/calls/create', + '/calls/zoom/create', #### Documented endpoints not properly detected by tooling. # E.g. '/user_groups/' in urls.py but fails the diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 7bf6eb6bd9..6c6514c5ef 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -572,68 +572,7 @@ class RealmTest(ZulipTestCase): req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id'])} result = self.client_patch('/json/realm', req) - self.assert_json_error(result, "User ID cannot be empty") - - req = { - "video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']), - "zoom_user_id": ujson.dumps("example@example.com") - } - result = self.client_patch('/json/realm', req) - self.assert_json_error(result, "API key cannot be empty") - - req = { - "video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']), - "zoom_user_id": ujson.dumps("example@example.com"), - "zoom_api_key": ujson.dumps("abc") - } - result = self.client_patch('/json/realm', req) - self.assert_json_error(result, "API secret cannot be empty") - - with mock.patch("zerver.views.realm.request_zoom_video_call_url", return_value=None): - req = { - "video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']), - "zoom_user_id": ujson.dumps("example@example.com"), - "zoom_api_key": ujson.dumps("abc"), - "zoom_api_secret": ujson.dumps("abc"), - } - result = self.client_patch('/json/realm', req) - self.assert_json_error(result, "Invalid credentials for the Zoom API.") - - with mock.patch("zerver.views.realm.request_zoom_video_call_url", - return_value={'join_url': 'example.com'}) as mock_validation: - req = { - "video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']), - "zoom_user_id": ujson.dumps("example@example.com"), - "zoom_api_key": ujson.dumps("abc"), - "zoom_api_secret": ujson.dumps("abc"), - } - result = self.client_patch('/json/realm', req) - self.assert_json_success(result) - mock_validation.assert_called_once() - - with mock.patch("zerver.views.realm.request_zoom_video_call_url", - return_value={'join_url': 'example.com'}) as mock_validation: - req = { - "video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']), - "zoom_user_id": ujson.dumps("example@example.com"), - "zoom_api_key": ujson.dumps("abc"), - "zoom_api_secret": ujson.dumps("abc"), - } - result = self.client_patch('/json/realm', req) - self.assert_json_success(result) - mock_validation.assert_not_called() - - with mock.patch("zerver.views.realm.request_zoom_video_call_url", - return_value={'join_url': 'example.com'}) as mock_validation: - req = { - "video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']), - "zoom_user_id": ujson.dumps("example@example.com"), - "zoom_api_key": ujson.dumps("abc"), - "zoom_api_secret": ujson.dumps(""), - } - result = self.client_patch('/json/realm', req) - self.assert_json_success(result) - mock_validation.assert_not_called() + self.assert_json_success(result) def test_initial_plan_type(self) -> None: with self.settings(BILLING_ENABLED=True): @@ -779,9 +718,6 @@ class RealmAPITest(ZulipTestCase): ) ], google_hangouts_domain=['zulip.com', 'zulip.org'], - zoom_api_secret=["abc", "xyz"], - zoom_api_key=["abc", "xyz"], - zoom_user_id=["example@example.com", "example@example.org"] ) vals = test_values.get(name) diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 65a0ac99a4..d043e53ba3 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -23,7 +23,6 @@ from zerver.lib.validator import check_string, check_dict, check_bool, check_int check_int_in, to_positive_or_allowed_int, to_non_negative_int from zerver.lib.streams import access_stream_by_id from zerver.lib.domains import validate_domain -from zerver.lib.video_calls import request_zoom_video_call_url from zerver.models import Realm, UserProfile from zerver.forms import check_subdomain_available as check_subdomain from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException @@ -77,9 +76,6 @@ def update_realm( default_twenty_four_hour_time: Optional[bool]=REQ(validator=check_bool, default=None), video_chat_provider: Optional[int]=REQ(validator=check_int, default=None), google_hangouts_domain: Optional[str]=REQ(validator=check_string, default=None), - zoom_user_id: Optional[str]=REQ(validator=check_string, default=None), - zoom_api_key: Optional[str]=REQ(validator=check_string, default=None), - zoom_api_secret: Optional[str]=REQ(validator=check_string, default=None), default_code_block_language: Optional[str]=REQ(validator=check_string, default=None), digest_weekday: Optional[int]=REQ(validator=check_int_in(Realm.DIGEST_WEEKDAY_VALUES), default=None), ) -> HttpResponse: @@ -103,29 +99,6 @@ def update_realm( validate_domain(google_hangouts_domain) except ValidationError as e: return json_error(_('Invalid domain: {}').format(e.messages[0])) - if video_chat_provider == Realm.VIDEO_CHAT_PROVIDERS['zoom']['id']: - if not zoom_api_secret: - # Use the saved Zoom API secret if a new value isn't being sent - zoom_api_secret = user_profile.realm.zoom_api_secret - if not zoom_user_id: - return json_error(_('User ID cannot be empty')) - if not zoom_api_key: - return json_error(_('API key cannot be empty')) - if not zoom_api_secret: - return json_error(_('API secret cannot be empty')) - # If any of the Zoom settings have changed, validate the Zoom credentials. - # - # Technically, we could call some other API endpoint that - # doesn't create a video call link, but this is a nicer - # end-to-end test, since it verifies that the Zoom API user's - # scopes includes the ability to create video calls, which is - # the only capabiility we use. - if ((zoom_user_id != realm.zoom_user_id or - zoom_api_key != realm.zoom_api_key or - zoom_api_secret != realm.zoom_api_secret) and - not request_zoom_video_call_url(zoom_user_id, zoom_api_key, zoom_api_secret)): - return json_error(_('Invalid credentials for the %(third_party_service)s API.') % dict( - third_party_service="Zoom")) if message_retention_days is not None: realm.ensure_not_on_limited_plan() diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py index 6eff10f4f7..567dd7c63f 100644 --- a/zerver/views/video_calls.py +++ b/zerver/views/video_calls.py @@ -1,12 +1,153 @@ +from functools import partial +import json +from typing import Dict +from urllib.parse import urljoin + +from django.conf import settings from django.http import HttpResponse, HttpRequest +from django.middleware import csrf +from django.shortcuts import redirect, render +from django.utils.crypto import constant_time_compare, salted_hmac +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from oauthlib.oauth2 import OAuth2Error +import requests +from requests_oauthlib import OAuth2Session -from zerver.decorator import has_request_variables +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.response import json_success -from zerver.lib.actions import get_zoom_video_call_url -from zerver.models import UserProfile +from zerver.lib.subdomains import get_subdomain +from zerver.lib.validator import check_dict, check_string +from zerver.models import UserProfile, get_realm + +class InvalidZoomTokenError(JsonableError): + code = ErrorCode.INVALID_ZOOM_TOKEN + + def __init__(self) -> None: + super().__init__(_("Invalid Zoom access token")) + + +def get_zoom_session(user: UserProfile) -> OAuth2Session: + if settings.VIDEO_ZOOM_CLIENT_ID is None: + raise JsonableError(_("Zoom credentials have not been configured")) + + return OAuth2Session( + settings.VIDEO_ZOOM_CLIENT_ID, + redirect_uri=urljoin(settings.ROOT_DOMAIN_URI, "/calls/zoom/complete"), + scope=["meeting:write:admin"], + auto_refresh_url="https://zoom.us/oauth/token", + auto_refresh_kwargs={ + "client_id": settings.VIDEO_ZOOM_CLIENT_ID, + "client_secret": settings.VIDEO_ZOOM_CLIENT_SECRET, + }, + token=user.zoom_token, + token_updater=partial(do_set_zoom_token, user), + ) + + +def get_zoom_sid(request: HttpRequest) -> str: + # This is used to prevent CSRF attacks on the Zoom OAuth + # authentication flow. We want this value to be unpredictable and + # tied to the session, but we don’t want to expose the main CSRF + # token directly to the Zoom server. + + csrf.get_token(request) + return ( + "" + if getattr(request, "_dont_enforce_csrf_checks", False) + else salted_hmac("Zulip Zoom sid", request.META["CSRF_COOKIE"]).hexdigest() + ) + + +@zulip_login_required +@never_cache +def register_zoom_user(request: HttpRequest) -> HttpResponse: + oauth = get_zoom_session(request.user) + authorization_url, state = oauth.authorization_url( + "https://zoom.us/oauth/authorize", + state=json.dumps( + {"realm": get_subdomain(request), "sid": get_zoom_sid(request)} + ), + ) + return redirect(authorization_url) + + +@never_cache @has_request_variables -def get_zoom_url(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: - return json_success({'zoom_url': get_zoom_video_call_url( - user_profile.realm - )}) +def complete_zoom_user( + request: HttpRequest, + state: Dict[str, str] = REQ(validator=check_dict([("realm", check_string)])), +) -> HttpResponse: + if get_subdomain(request) != state["realm"]: + return redirect(urljoin(get_realm(state["realm"]).uri, request.get_full_path())) + return complete_zoom_user_in_realm(request) + + +@zulip_login_required +@has_request_variables +def complete_zoom_user_in_realm( + request: HttpRequest, + code: str = REQ(), + state: Dict[str, str] = REQ(validator=check_dict([("sid", check_string)])), +) -> HttpResponse: + if not constant_time_compare(state["sid"], get_zoom_sid(request)): + raise JsonableError(_("Invalid Zoom session identifier")) + + oauth = get_zoom_session(request.user) + try: + token = oauth.fetch_token( + "https://zoom.us/oauth/token", + code=code, + client_secret=settings.VIDEO_ZOOM_CLIENT_SECRET, + ) + except OAuth2Error: + raise JsonableError(_("Invalid Zoom credentials")) + + do_set_zoom_token(request.user, token) + return render(request, "zerver/close_window.html") + + +def make_zoom_video_call(request: HttpRequest, user: UserProfile) -> HttpResponse: + oauth = get_zoom_session(user) + if not oauth.authorized: + raise InvalidZoomTokenError + + try: + res = oauth.post("https://api.zoom.us/v2/users/me/meetings", json={}) + except OAuth2Error: + do_set_zoom_token(user, None) + raise InvalidZoomTokenError + + if res.status_code == 401: + do_set_zoom_token(user, None) + raise InvalidZoomTokenError + elif not res.ok: + raise JsonableError(_("Failed to create Zoom call")) + + return json_success({"url": res.json()["join_url"]}) + + +@csrf_exempt +@require_POST +@has_request_variables +def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse: + data = json.loads(request.body.decode("utf-8")) + payload = data["payload"] + if payload["user_data_retention"] == "false": + requests.post( + "https://api.zoom.us/oauth/data/compliance", + json={ + "client_id": settings.VIDEO_ZOOM_CLIENT_ID, + "user_id": payload["user_id"], + "account_id": payload["account_id"], + "deauthorization_event_received": payload, + "compliance_completed": True, + }, + auth=(settings.VIDEO_ZOOM_CLIENT_ID, settings.VIDEO_ZOOM_CLIENT_SECRET), + ).raise_for_status() + return json_success() diff --git a/zproject/default_settings.py b/zproject/default_settings.py index d3b869298b..71db8176f9 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -72,6 +72,9 @@ GOOGLE_OAUTH2_CLIENT_ID: Optional[str] = None # Other auth SSO_APPEND_DOMAIN: Optional[str] = None +VIDEO_ZOOM_CLIENT_ID = get_secret('video_zoom_client_id', development_only=True) +VIDEO_ZOOM_CLIENT_SECRET = get_secret('video_zoom_client_secret') + # Email gateway EMAIL_GATEWAY_PATTERN = '' EMAIL_GATEWAY_LOGIN: Optional[str] = None diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index 42d180100d..e84b04e342 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -468,6 +468,16 @@ EMAIL_GATEWAY_IMAP_PORT = 993 EMAIL_GATEWAY_IMAP_FOLDER = "INBOX" +######## +# Zoom integration. +# +# Zulip supports using Zoom as a video calling provider. To learn more about +# configuring Zoom, see: +# https://zulip.readthedocs.io/en/latest/production/zoom-configuration.html +# +#VIDEO_ZOOM_CLIENT_ID = + + ################ # LDAP integration. # diff --git a/zproject/test_settings.py b/zproject/test_settings.py index b4c51898e2..3989d7c189 100644 --- a/zproject/test_settings.py +++ b/zproject/test_settings.py @@ -174,6 +174,9 @@ SOCIAL_AUTH_GOOGLE_KEY = "key" SOCIAL_AUTH_GOOGLE_SECRET = "secret" SOCIAL_AUTH_SUBDOMAIN = 'auth' +VIDEO_ZOOM_CLIENT_ID = "client_id" +VIDEO_ZOOM_CLIENT_SECRET = "client_secret" + # 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 e7e7446564..5d245661bc 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -37,6 +37,7 @@ import zerver.views.digest import zerver.views.messages import zerver.views.realm_export import zerver.views.upload +import zerver.views.video_calls from zerver.lib.rest import rest_dispatch @@ -395,8 +396,8 @@ v1_api_and_json_patterns = [ {'POST': ('zerver.views.report.report_unnarrow_times', {'intentionally_undocumented'})}), # Used to generate a Zoom video call URL - url(r'^calls/create$', rest_dispatch, - {'GET': 'zerver.views.video_calls.get_zoom_url'}), + url(r'^calls/zoom/create$', rest_dispatch, + {'POST': 'zerver.views.video_calls.make_zoom_video_call'}), # export/realm -> zerver.views.realm_export url(r'^export/realm$', rest_dispatch, @@ -541,6 +542,11 @@ i18n_urls = [ zerver.views.registration.accounts_home_from_multiuse_invite, name='zerver.views.registration.accounts_home_from_multiuse_invite'), + # Used to generate a Zoom video call URL + url(r'^calls/zoom/register$', zerver.views.video_calls.register_zoom_user), + url(r'^calls/zoom/complete$', zerver.views.video_calls.complete_zoom_user), + url(r'^calls/zoom/deauthorize$', zerver.views.video_calls.deauthorize_zoom_user), + # API and integrations documentation url(r'^integrations/doc-html/(?P[^/]*)$', zerver.views.documentation.integration_doc,