diff --git a/combo/apps/pwa/signals.py b/combo/apps/pwa/signals.py index d2ad83af..ab5330d2 100644 --- a/combo/apps/pwa/signals.py +++ b/combo/apps/pwa/signals.py @@ -15,11 +15,14 @@ # along with this program. If not, see . import datetime +import hashlib import json import logging +import urllib.parse import pywebpush from django.conf import settings +from django.core.cache import cache from django.db.models.signals import post_save from django.dispatch import receiver from py_vapid import Vapid @@ -28,47 +31,76 @@ from combo.apps.notifications.models import Notification from .models import PushSubscription, PwaSettings +logger = logging.getLogger(__name__) + + +def get_vapid_headers(private_key, subscription_info): + url = urllib.parse.urlparse(subscription_info['endpoint']) + aud = f'{url.scheme}://{url.netloc}' + + key_bytes = private_key.encode('ascii') + + cache_key = 'vapid-headers-' + hashlib.sha256(aud.encode() + key_bytes).hexdigest() + headers = cache.get(cache_key) + if headers: + return headers + + pwa_vapid_private_key = Vapid.from_pem(key_bytes) + + headers = pwa_vapid_private_key.sign( + { + 'aud': aud, + 'sub': 'mailto:%s' % settings.DEFAULT_FROM_EMAIL, + 'exp': int(datetime.datetime.now().timestamp() + 3600 * 24), # expire after 24 hours + } + ) + + cache.set(cache_key, headers, 23 * 3600) # but keep it 23 hours + return headers + + +class DeadSubscription(Exception): + pass + + +def send_webpush(private_key, subscription_info, **kwargs): + message = json.dumps(kwargs) + + headers = get_vapid_headers(private_key, subscription_info) + webpusher = pywebpush.WebPusher(subscription_info) + response = webpusher.send( + data=message, + headers=headers, + ttl=86400 * 30, + ) + if response.status_code in (404, 410): + raise DeadSubscription + response.raise_for_status() + @receiver(post_save, sender=Notification) def notification(sender, instance=None, created=False, **kwargs): if not created: return + pwa_settings = PwaSettings.singleton() if not pwa_settings.push_notifications: return - if settings.PWA_VAPID_PRIVATE_KEY: # legacy - pwa_vapid_private_key = settings.PWA_VAPID_PRIVATE_KEY - else: - pwa_vapid_private_key = Vapid.from_pem( - pwa_settings.push_notifications_infos['private_key'].encode('ascii') - ) - if settings.PWA_VAPID_CLAIMS: # legacy - claims = settings.PWA_VAPID_CLAIMS - else: - claims = { - 'sub': 'mailto:%s' % settings.DEFAULT_FROM_EMAIL, - 'exp': int(datetime.datetime.now().timestamp() + 3600 * 3), - } - message = json.dumps( - { - 'summary': instance.summary, - 'body': instance.body, - 'url': instance.url, - } - ) - for subscription in PushSubscription.objects.filter(user_id=instance.user_id): + private_key = pwa_settings.push_notifications_infos['private_key'] + + for subscription in PushSubscription.objects.filter(user=instance.user): try: - pywebpush.webpush( + send_webpush( + private_key=private_key, subscription_info=subscription.subscription_info, - data=message, - vapid_private_key=pwa_vapid_private_key, - vapid_claims=claims, - ttl=86400 * 30, + summary=instance.summary, + body=instance.body, + url=instance.url, ) - except pywebpush.WebPushException as e: - if 'Push failed: 410 Gone' in str(e): - subscription.delete() - continue - logger = logging.getLogger(__name__) - logger.exception('webpush error (%r)', e) + logger.info('webpush: notification sent') + except DeadSubscription: + subscription.delete() + logger.info('webpush: deleting dead subscription') + except Exception: + logger.exception('webpush: request failed') diff --git a/debian/control b/debian/control index 77cda838..afc5c70f 100644 --- a/debian/control +++ b/debian/control @@ -23,6 +23,7 @@ Depends: python3-distutils, python3-lxml, python3-pil, python3-publik-django-templatetags, + python3-py-vapid (>= 1.8.2), python3-pycryptodome, python3-pyexcel-ods, python3-pygal, diff --git a/setup.py b/setup.py index 3345d01b..1171630e 100644 --- a/setup.py +++ b/setup.py @@ -178,6 +178,7 @@ setup( 'pyproj', 'pyquery', 'pywebpush', + 'py-vapid>=1.8.2', 'pygal', 'pyexcel-ods', 'lxml', diff --git a/tests/test_pwa.py b/tests/test_pwa.py index b4faca80..a1a89cbf 100644 --- a/tests/test_pwa.py +++ b/tests/test_pwa.py @@ -4,7 +4,6 @@ from io import BytesIO from unittest import mock import pytest -from django.conf import settings from django.core.files import File from django.template import Context, Template from django.test import override_settings @@ -71,37 +70,74 @@ def test_webpush_subscription(app, john_doe, jane_doe): assert 'bad json' in resp.text -def test_webpush_notification(app, john_doe): +def test_webpush_notification(app, john_doe, caplog): + caplog.set_level('INFO') PushSubscription.objects.all().delete() app = login(app, john_doe.username, john_doe.username) - app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200) + app.post_json( + reverse('pwa-subscribe-push'), + params={'endpoint': 'https://push.example.com:1000/', 'sample': 'content'}, + status=200, + ) pwa_settings = PwaSettings.singleton() pwa_settings.push_notifications = False pwa_settings.save() - with mock.patch('pywebpush.webpush') as webpush: + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.return_value.status_code = 200 Notification.notify(john_doe, 'test', body='hello world') - assert webpush.call_count == 0 + assert webpusher.call_count == 0 pwa_settings.push_notifications = True pwa_settings.save() - with mock.patch('pywebpush.webpush') as webpush: + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.return_value.status_code = 200 Notification.notify(john_doe, 'test', body='hello world') - assert webpush.call_count == 1 - assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} + assert webpusher.mock_calls == [ + mock.call({'sample': 'content', 'endpoint': 'https://push.example.com:1000/'}), + mock.call().send( + data='{"summary": "test", "body": "hello world", "url": ""}', + headers={'Authorization': mock.ANY}, + ttl=2592000, + ), + mock.call().send().raise_for_status(), + ] + assert caplog.messages == ['webpush: notification sent'] - # check legacy settings are still supported - with override_settings( - PWA_VAPID_PUBLIK_KEY='BFzvUdXB...', - PWA_VAPID_PRIVATE_KEY='4WbCnBF...', - PWA_VAPID_CLAIMS={'sub': 'mailto:admin@entrouvert.com'}, - ): - with mock.patch('pywebpush.webpush') as webpush: - Notification.notify(john_doe, 'test', body='hello world') - assert webpush.call_count == 1 - assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} - assert webpush.call_args[1]['vapid_private_key'] == settings.PWA_VAPID_PRIVATE_KEY - assert webpush.call_args[1]['vapid_claims'] == settings.PWA_VAPID_CLAIMS + # check key is reused + with mock.patch('pywebpush.WebPusher') as webpusher, mock.patch('combo.apps.pwa.signals.Vapid') as vapid: + webpusher.return_value.send.return_value.status_code = 200 + Notification.notify(john_doe, 'test', body='hello world') + assert vapid.mock_calls == [] + assert webpusher.mock_calls == [ + mock.call({'sample': 'content', 'endpoint': 'https://push.example.com:1000/'}), + mock.call().send( + data='{"summary": "test", "body": "hello world", "url": ""}', + headers={'Authorization': mock.ANY}, + ttl=2592000, + ), + mock.call().send().raise_for_status(), + ] + + # check subscription is deleted on status 410... + caplog.clear() + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.return_value.status_code = 410 + Notification.notify(john_doe, 'test', body='hello world') + assert PushSubscription.objects.count() == 0 + assert caplog.messages == ['webpush: deleting dead subscription'] + + # on any other error + caplog.clear() + app.post_json( + reverse('pwa-subscribe-push'), + params={'endpoint': 'https://push.example.com:1000/', 'sample': 'content'}, + status=200, + ) + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.side_effect = Exception('Boom!') + Notification.notify(john_doe, 'test', body='hello world') + assert caplog.messages == ['webpush: request failed'] def test_no_pwa_manager(app, admin_user):