mirror of https://github.com/zulip/zulip.git
compose: Rewrite Zoom video call integration to use OAuth.
This reimplements our Zoom video call integration to use an OAuth application. In addition to providing a cleaner setup experience, especially on zulipchat.com where the server administrators can have done the app registration already, it also fixes the limitation of the previous integration that it could only have one call active at a time when set up with typical Zoom API keys. Fixes #11672. Co-authored-by: Marco Burstein <marco@marco.how> Co-authored-by: Tim Abbott <tabbott@zulipchat.com> Signed-off-by: Anders Kaseorg <andersk@mit.edu>
This commit is contained in:
parent
7a53da7526
commit
4d04fa3118
|
@ -1,15 +1,15 @@
|
|||
{% extends "!layout.html" %}
|
||||
{% block document %}
|
||||
<!---
|
||||
{#
|
||||
# This allows us to insert a warning that appears only on the development
|
||||
# version e.g. to say that something is likely to have changed.
|
||||
# For more info see: https://www.sphinx-doc.org/en/master/templating.html
|
||||
-->
|
||||
{% if (pagename == "production/management-commands" or pagename == "production/email-gateway" or pagename == "production/upgrade-or-modify") and release.endswith('+git') %}
|
||||
<!--
|
||||
# email-gateway.html page doesn't exist in the stable documentation yet.
|
||||
#}
|
||||
{% if pagename in ["production/management-commands", "production/email-gateway", "production/upgrade-or-modify", "production/zoom-configuration"] and release.endswith('+git') %}
|
||||
{#
|
||||
# This page doesn't exist in the stable documentation yet.
|
||||
# This temporary workaround prevents CircleCI failure and should be removed after the next release.
|
||||
-->
|
||||
#}
|
||||
<div class="admonition warning">
|
||||
<p class="first admonition-title">Warning</p>
|
||||
<p class="last">You are reading a <strong>development version</strong> of the Zulip documentation. These instructions may not correspond to the latest Zulip Server release.
|
||||
|
|
|
@ -21,3 +21,4 @@ Zulip in Production
|
|||
email
|
||||
deployment
|
||||
email-gateway
|
||||
zoom-configuration
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
|
@ -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);
|
||||
|
|
|
@ -181,3 +181,7 @@ django-cookies-samesite
|
|||
|
||||
# For server-side enforcement of password strength
|
||||
zxcvbn
|
||||
|
||||
# Needed for sending HTTP requests
|
||||
requests[security]
|
||||
requests-oauthlib
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -624,7 +624,6 @@ input[type=checkbox] {
|
|||
}
|
||||
|
||||
#google_hangouts_domain,
|
||||
#zoom_help_text,
|
||||
.organization-settings-parent div:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
|
|
@ -196,36 +196,6 @@
|
|||
class="admin-realm-google-hangouts-domain setting-widget prop-element"
|
||||
data-setting-widget-type="string"/>
|
||||
</div>
|
||||
<div id="zoom_help_text" class="zoom_credentials">
|
||||
<p>
|
||||
Note: Zoom support is experimental. In particular, Zulip currently supports having
|
||||
only one active Zoom meeting at a time.
|
||||
</p>
|
||||
</div>
|
||||
<div id="zoom_user_id" class="zoom_credentials">
|
||||
<label>{{t 'Zoom user ID or email address (required)' }}:</label>
|
||||
<input type="text" id="id_realm_zoom_user_id"
|
||||
name="realm_zoom_user_id"
|
||||
autocomplete="off"
|
||||
class="admin-realm-zoom-field setting-widget prop-element"
|
||||
data-setting-widget-type="string"/>
|
||||
</div>
|
||||
<div id="zoom_api_key" class="zoom_credentials">
|
||||
<label>{{t 'Zoom API key (required)' }}:</label>
|
||||
<input type="text" id="id_realm_zoom_api_key"
|
||||
name="realm_zoom_api_key"
|
||||
autocomplete="off"
|
||||
class="admin-realm-zoom-field setting-widget prop-element"
|
||||
data-setting-widget-type="string"/>
|
||||
</div>
|
||||
<div id="zoom_api_secret" class="zoom_credentials">
|
||||
<label>{{t 'Zoom API secret (required if changed)' }}:</label>
|
||||
<input type="text" id="id_realm_zoom_api_secret"
|
||||
name="realm_zoom_api_secret"
|
||||
autocomplete="off"
|
||||
class="admin-realm-zoom-field setting-widget prop-element"
|
||||
data-setting-widget-type="string"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{> dropdown_list_widget
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Callback complete</title>
|
||||
<script>
|
||||
window.close();
|
||||
// Why doesn’t this work in Firefox? See
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1353466
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>You may now close this window.</p>
|
||||
</body>
|
||||
</html>
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'],))
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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/<user_group_id>' in urls.py but fails the
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = <your Zoom Client ID>
|
||||
|
||||
|
||||
################
|
||||
# LDAP integration.
|
||||
#
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<integration_name>[^/]*)$',
|
||||
zerver.views.documentation.integration_doc,
|
||||
|
|
Loading…
Reference in New Issue