From f253670f437009ca9e8d74e5425b9ad61f38ac71 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Thu, 12 Aug 2021 14:46:12 -0500 Subject: [PATCH] Support: update contact information via Front webhook --- readthedocs/settings/base.py | 3 + readthedocs/support/__init__.py | 0 readthedocs/support/front.py | 31 +++++ readthedocs/support/tests/__init__.py | 0 readthedocs/support/tests/test_views.py | 101 ++++++++++++++++ readthedocs/support/urls.py | 7 ++ readthedocs/support/views.py | 144 +++++++++++++++++++++++ readthedocs/templates/support/index.html | 8 -- readthedocs/urls.py | 1 + 9 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 readthedocs/support/__init__.py create mode 100644 readthedocs/support/front.py create mode 100644 readthedocs/support/tests/__init__.py create mode 100644 readthedocs/support/tests/test_views.py create mode 100644 readthedocs/support/urls.py create mode 100644 readthedocs/support/views.py diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index c07af4e1c70..37234bd4bd7 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -67,6 +67,8 @@ class CommunityBaseSettings(Settings): SERVER_EMAIL = DEFAULT_FROM_EMAIL SUPPORT_EMAIL = None SUPPORT_FORM_ENDPOINT = None + FRONT_TOKEN = None + FRONT_API_SECRET = None # Sessions SESSION_COOKIE_DOMAIN = 'readthedocs.org' @@ -623,6 +625,7 @@ def DOCKER_LIMITS(self): DEFAULT_VERSION_PRIVACY_LEVEL = 'public' GROK_API_HOST = 'https://api.grokthedocs.com' ALLOW_ADMIN = True + ADMIN_URL = None RTD_ALLOW_ORGANIZATIONS = False # Elasticsearch settings. diff --git a/readthedocs/support/__init__.py b/readthedocs/support/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/support/front.py b/readthedocs/support/front.py new file mode 100644 index 00000000000..fc48c75f6a7 --- /dev/null +++ b/readthedocs/support/front.py @@ -0,0 +1,31 @@ +"""Front's API client.""" + +import requests + + +class FrontClient: + + """Wrapper around Front's API.""" + + BASE_URL = 'https://api2.frontapp.com' + + def __init__(self, token): + self.token = token + + @property + def _headers(self): + headers = { + "Authorization": f"Bearer {self.token}", + } + return headers + + def _get_url(self, path): + return f'{self.BASE_URL}{path}' + + def get(self, path, **kwargs): + kwargs.setdefault('headers', {}).update(self._headers) + return requests.get(self._get_url(path), **kwargs) + + def patch(self, path, **kwargs): + kwargs.setdefault('headers', {}).update(self._headers) + return requests.patch(self._get_url(path), **kwargs) diff --git a/readthedocs/support/tests/__init__.py b/readthedocs/support/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/support/tests/test_views.py b/readthedocs/support/tests/test_views.py new file mode 100644 index 00000000000..23e2b6f3ad3 --- /dev/null +++ b/readthedocs/support/tests/test_views.py @@ -0,0 +1,101 @@ +from unittest import mock + +import requests_mock +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse +from django_dynamic_fixture import get + +from readthedocs.support.views import FrontWebhookBase + + +@override_settings(ADMIN_URL='https://readthedocs.org/admin') +class TestFrontWebhook(TestCase): + + def setUp(self): + self.user = get(User, email='test@example.com', username='test') + self.url = reverse('front_webhook') + + def test_invalid_payload(self): + resp = self.client.post( + self.url, + data={'foo': 'bar'}, + content_type='application/json', + ) + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.data['detail'], 'Invalid payload') + + @mock.patch.object(FrontWebhookBase, '_is_payload_valid') + def test_invalid_event(self, is_payload_valid): + is_payload_valid.return_value = True + resp = self.client.post( + self.url, + data={'type': 'outbound'}, + content_type='application/json', + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['detail'], 'Skipping outbound event') + + @requests_mock.Mocker(kw='mock_request') + @mock.patch.object(FrontWebhookBase, '_is_payload_valid') + def test_inbound_event(self, is_payload_valid, mock_request): + is_payload_valid.return_value = True + self._mock_request(mock_request) + resp = self.client.post( + self.url, + data={ + 'type': 'inbound', + 'conversation': {'id': 'cnv_123'} + }, + content_type='application/json', + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['detail'], 'User updated test@example.com') + last_request = mock_request.last_request + self.assertEqual(last_request.method, 'PATCH') + # Existing custom fields are left unchanged. + custom_fields = last_request.json()['custom_fields'] + for field in ['com:dont-change', 'org:dont-change', 'ads:dont-change']: + self.assertEqual(custom_fields[field], 'Do not change this') + + @requests_mock.Mocker(kw='mock_request') + @mock.patch.object(FrontWebhookBase, '_is_payload_valid') + def test_inbound_event_unknow_email(self, is_payload_valid, mock_request): + self.user.email = 'unknown@example.com' + self.user.save() + is_payload_valid.return_value = True + self._mock_request(mock_request) + resp = self.client.post( + self.url, + data={ + 'type': 'inbound', + 'conversation': {'id': 'cnv_123'} + }, + content_type='application/json', + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.data['detail'], + 'User with email test@example.com not found in our database', + ) + + def _mock_request(self, mock_request): + mock_request.get( + 'https://api2.frontapp.com/conversations/cnv_123', + json={ + 'recipient': { + 'handle': 'test@example.com', + }, + }, + ) + mock_request.get( + 'https://api2.frontapp.com/contacts/alt:email:test@example.com', + json={ + 'custom_fields': { + 'org:dont-change': 'Do not change this', + 'com:dont-change': 'Do not change this', + 'ads:dont-change': 'Do not change this', + }, + }, + ) + mock_request.patch('https://api2.frontapp.com/contacts/alt:email:test@example.com') diff --git a/readthedocs/support/urls.py b/readthedocs/support/urls.py new file mode 100644 index 00000000000..c23b9e1e7c5 --- /dev/null +++ b/readthedocs/support/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import url + +from readthedocs.support.views import FrontWebhook + +urlpatterns = [ + url(r'^front-webhook/$', FrontWebhook.as_view(), name='front_webhook'), +] diff --git a/readthedocs/support/views.py b/readthedocs/support/views.py new file mode 100644 index 00000000000..358fba39fc3 --- /dev/null +++ b/readthedocs/support/views.py @@ -0,0 +1,144 @@ +"""Support views.""" + +import base64 +import hashlib +import hmac +import logging + +from django.conf import settings +from django.contrib.auth.models import User +from django.db.models import Q +from rest_framework.response import Response +from rest_framework.status import ( + HTTP_400_BAD_REQUEST, + HTTP_500_INTERNAL_SERVER_ERROR, +) +from rest_framework.views import APIView + +from readthedocs.core.utils.extend import SettingsOverrideObject +from readthedocs.support.front import FrontClient + +log = logging.getLogger(__name__) + + +class FrontWebhookBase(APIView): + + """ + Front's webhook handler. + + Currently we only listen to inbound messages events. + Contact information is updated when a new message is received. + + See https://dev.frontapp.com/docs/webhooks-1. + """ + + http_method_names = ['post'] + + def post(self, request): + if not self._is_payload_valid(): + return Response( + {'detail': 'Invalid payload'}, + status=HTTP_400_BAD_REQUEST, + ) + + event = request.data.get('type') + if event == 'inbound': + return self._update_contact_information(request.data) + return Response({'detail': f'Skipping {event} event'}) + + def _update_contact_information(self, data): + """ + Update contact information using Front's API. + + The webhook event give us the conversation_id, + we use that to + """ + client = FrontClient(token=settings.FRONT_TOKEN) + + # Retrieve the user from the email from the conversation. + conversation_id = data.get('conversation', {}).get('id') + try: + resp = client.get(f'/conversations/{conversation_id}').json() + email = resp.get('recipient', {}).get('handle') + except Exception: # noqa + msg = f'Error while getting conversation {conversation_id}' + log.exception(msg) + return Response({'detail': msg}, status=HTTP_500_INTERNAL_SERVER_ERROR) + + user = ( + User.objects + .filter(Q(email=email) | Q(emailaddress__email=email)) + .first() + ) + if not user: + return Response({'detail': f'User with email {email} not found in our database'}) + + # Get current custom fields, and update them. + try: + resp = client.get(f'/contacts/alt:email:{email}').json() + except Exception: # noqa + msg = f'Error while getting contact {email}' + log.exception(msg) + return Response({'detail': msg}, HTTP_500_INTERNAL_SERVER_ERROR) + + new_custom_fields = self._get_custom_fields(user) + custom_fields = resp.get('custom_fields', {}) + custom_fields.update(new_custom_fields) + + try: + client.patch( + f'/contacts/alt:email:{email}', + json={'custom_fields': custom_fields} + ) + except Exception: # noqa + msg = f'Error while updating contact information for {email}' + log.exception(msg) + return Response( + { + 'detail': msg, + 'custom_fields': new_custom_fields, + }, + status=HTTP_500_INTERNAL_SERVER_ERROR, + ) + else: + return Response({ + 'detail': f'User updated {email}', + 'custom_fields': new_custom_fields, + }) + + # pylint: disable=no-self-use + def _get_custom_fields(self, user): + """ + Attach custom fields for this user. + + These fields need to be created on Front (settings -> Contacts -> Custom Fields). + """ + custom_fields = {} + custom_fields['org:username'] = user.username + custom_fields['org:admin'] = f'{settings.ADMIN_URL}/auth/user/{user.pk}/change' + return custom_fields + + def _is_payload_valid(self): + """ + Check if the signature and the payload from the webhook matches. + + https://dev.frontapp.com/docs/webhooks-1#validating-data-integrity + """ + digest = self._get_digest() + signature = self.request.headers.get('X-Front-Signature', '') + result = hmac.compare_digest(digest, signature.encode()) + return result + + def _get_digest(self): + """Get a HMAC digest of the request using Front's API secret.""" + secret = settings.FRONT_API_SECRET + digest = hmac.new( + secret.encode(), + msg=self.request.body, + digestmod=hashlib.sha1, + ) + return base64.b64encode(digest.digest()) + + +class FrontWebhook(SettingsOverrideObject): + _default_class = FrontWebhookBase diff --git a/readthedocs/templates/support/index.html b/readthedocs/templates/support/index.html index 38772600881..a1e3b676ffd 100644 --- a/readthedocs/templates/support/index.html +++ b/readthedocs/templates/support/index.html @@ -120,14 +120,6 @@

User Support

{% endif %} - - {% if request.user.is_authenticated %} - - - {% else %} - - {% endif %} - diff --git a/readthedocs/urls.py b/readthedocs/urls.py index 37f791e79cb..184bc3d519b 100644 --- a/readthedocs/urls.py +++ b/readthedocs/urls.py @@ -34,6 +34,7 @@ url(r'^support/error/$', TemplateView.as_view(template_name='support/error.html'), name='support_error'), + url(r'^support/', include('readthedocs.support.urls')), ] rtd_urls = [