diff --git a/combo/apps/notifications/models.py b/combo/apps/notifications/models.py index dd3941ed..e3bf7a27 100644 --- a/combo/apps/notifications/models.py +++ b/combo/apps/notifications/models.py @@ -27,6 +27,8 @@ from django.db.models.query import QuerySet from combo.data.models import CellBase from combo.data.library import register_cell_class +from combo.apps.pwa.models import PwaSettings + class NotificationQuerySet(QuerySet): def namespace(self, namespace): @@ -181,6 +183,8 @@ class NotificationsCell(CellBase): qs = Notification.objects.visible(user) extra_context['notifications'] = qs extra_context['new_notifications'] = qs.new() + pwa_settings = PwaSettings.singleton() + extra_context['push_notifications_enabled'] = pwa_settings.push_notifications return extra_context def get_badge(self, context): diff --git a/combo/apps/notifications/templates/combo/notificationscell.html b/combo/apps/notifications/templates/combo/notificationscell.html index 3df3ad84..549046e9 100644 --- a/combo/apps/notifications/templates/combo/notificationscell.html +++ b/combo/apps/notifications/templates/combo/notificationscell.html @@ -20,4 +20,37 @@

{% trans 'No notifications.' %}

{% endif %} + +{% if push_notifications_enabled %} +
+ + +
+ + +{% endif %} + {% endblock %} diff --git a/combo/apps/pwa/manager_views.py b/combo/apps/pwa/manager_views.py index 8ea68829..f6b71e23 100644 --- a/combo/apps/pwa/manager_views.py +++ b/combo/apps/pwa/manager_views.py @@ -24,12 +24,13 @@ from django.views.generic import CreateView, UpdateView, DeleteView from combo.data.forms import get_page_choices from .models import PwaSettings, PwaNavigationEntry +from .forms import PwaSettingsForm class ManagerHomeView(UpdateView): template_name = 'combo/pwa/manager_home.html' model = PwaSettings - fields = '__all__' + form_class = PwaSettingsForm success_url = reverse_lazy('pwa-manager-homepage') def get_initial(self): diff --git a/combo/apps/pwa/models.py b/combo/apps/pwa/models.py index 355291a1..05e8703c 100644 --- a/combo/apps/pwa/models.py +++ b/combo/apps/pwa/models.py @@ -29,6 +29,8 @@ from django.utils.encoding import force_text, force_bytes from django.utils.six import BytesIO from django.utils.translation import ugettext_lazy as _ +from py_vapid import Vapid + from jsonfield import JSONField from combo.data.fields import RichTextField from combo import utils @@ -51,8 +53,24 @@ class PwaSettings(models.Model): default=_('You are currently offline.'), config_name='small') offline_retry_button = models.BooleanField(_('Include Retry Button'), default=True) + push_notifications = models.BooleanField( + verbose_name=_('Enable subscription to push notifications'), + default=False) + push_notifications_infos = JSONField(blank=True) last_update_timestamp = models.DateTimeField(auto_now=True) + def save(self, **kwargs): + if self.push_notifications and not self.push_notifications_infos: + # generate VAPID keys + vapid = Vapid() + vapid.generate_keys() + self.push_notifications_infos = { + 'private_key': vapid.private_pem(), + } + elif not self.push_notifications: + self.push_notifications_infos = {} + return super(PwaSettings, self).save(**kwargs) + @classmethod def singleton(cls): return cls.objects.first() or cls() diff --git a/combo/apps/pwa/signals.py b/combo/apps/pwa/signals.py index 2b7f2b3a..91f6391d 100644 --- a/combo/apps/pwa/signals.py +++ b/combo/apps/pwa/signals.py @@ -21,22 +21,29 @@ from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver -try: - import pywebpush -except ImportError: - pywebpush = None +from py_vapid import Vapid +import pywebpush from combo.apps.notifications.models import Notification -from .models import PushSubscription +from .models import PushSubscription, PwaSettings @receiver(post_save, sender=Notification) def notification(sender, instance=None, created=False, **kwargs): - if not pywebpush: - return 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} message = json.dumps({ 'summary': instance.summary, 'body': instance.body, @@ -48,8 +55,8 @@ def notification(sender, instance=None, created=False, **kwargs): pywebpush.webpush( subscription_info=subscription.subscription_info, data=message, - vapid_private_key=settings.PWA_VAPID_PRIVATE_KEY, - vapid_claims=settings.PWA_VAPID_CLAIMS + vapid_private_key=pwa_vapid_private_key, + vapid_claims=claims, ) except pywebpush.WebPushException as e: logger = logging.getLogger(__name__) diff --git a/combo/apps/pwa/templates/combo/service-worker-registration.js b/combo/apps/pwa/templates/combo/service-worker-registration.js index a9dbba1f..0dc27e68 100644 --- a/combo/apps/pwa/templates/combo/service-worker-registration.js +++ b/combo/apps/pwa/templates/combo/service-worker-registration.js @@ -1,4 +1,6 @@ -var applicationServerPublicKey = {% if pwa_vapid_publik_key %}'{{ pwa_vapid_publik_key }}'{% else %}null{% endif %}; +{% load combo %} + +var applicationServerPublicKey = {{ pwa_vapid_public_key|as_json|safe }}; var COMBO_PWA_USER_SUBSCRIPTION = false; function urlB64ToUint8Array(base64String) { diff --git a/combo/apps/pwa/templates/combo/service-worker.js b/combo/apps/pwa/templates/combo/service-worker.js index 92c6e317..47673bff 100644 --- a/combo/apps/pwa/templates/combo/service-worker.js +++ b/combo/apps/pwa/templates/combo/service-worker.js @@ -3,7 +3,7 @@ /* global self, caches, fetch, URL, Response */ 'use strict'; -const applicationServerPublicKey = {{ pwa_vapid_publik_key|as_json|safe }}; +const applicationServerPublicKey = {{ pwa_vapid_public_key|as_json|safe }}; function urlB64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); diff --git a/combo/apps/pwa/views.py b/combo/apps/pwa/views.py index b4c82e7b..09bac4fd 100644 --- a/combo/apps/pwa/views.py +++ b/combo/apps/pwa/views.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import base64 import json from django.conf import settings @@ -23,6 +24,9 @@ from django.template.loader import get_template, TemplateDoesNotExist from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView +from cryptography.hazmat.primitives import serialization +from py_vapid import Vapid + from .models import PushSubscription, PwaSettings from combo import VERSION @@ -40,8 +44,19 @@ def manifest_json(request, *args, **kwargs): def js_response(request, template_name, **kwargs): template = get_template(template_name) + pwa_vapid_public_key = None + pwa_settings = PwaSettings.singleton() + if pwa_settings.push_notifications: + if settings.PWA_VAPID_PUBLIK_KEY: # legacy + pwa_vapid_public_key = settings.PWA_VAPID_PUBLIK_KEY + else: + pwa_vapid_public_key = base64.urlsafe_b64encode( + Vapid.from_pem(pwa_settings.push_notifications_infos['private_key'].encode('ascii') + ).private_key.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint)).strip('=') context = { - 'pwa_vapid_publik_key': settings.PWA_VAPID_PUBLIK_KEY, + 'pwa_vapid_public_key': pwa_vapid_public_key, 'pwa_notification_badge_url': settings.PWA_NOTIFICATION_BADGE_URL, 'pwa_notification_icon_url': settings.PWA_NOTIFICATION_ICON_URL, } diff --git a/debian/control b/debian/control index 4c12d6d8..fb165de3 100644 --- a/debian/control +++ b/debian/control @@ -23,8 +23,9 @@ Depends: ${misc:Depends}, ${python:Depends}, python-eopayment (>= 1.35), python-django-haystack (>= 2.4.0), python-sorl-thumbnail, - python-pil -Recommends: python-django-mellon, python-whoosh, python-pywebpush + python-pil, + python-pywebpush +Recommends: python-django-mellon, python-whoosh Conflicts: python-lingo Description: Portal Management System (Python module) diff --git a/setup.py b/setup.py index 70086d6d..5e773fab 100644 --- a/setup.py +++ b/setup.py @@ -165,6 +165,7 @@ setup( 'sorl-thumbnail', 'Pillow', 'pyproj', + 'pywebpush', ], zip_safe=False, cmdclass={ diff --git a/tests/test_pwa.py b/tests/test_pwa.py index 88ebf6f0..67b324c9 100644 --- a/tests/test_pwa.py +++ b/tests/test_pwa.py @@ -5,10 +5,7 @@ import mock import pytest from webtest import Upload -try: - import pywebpush -except ImportError: - pywebpush = None +import pywebpush from django.conf import settings from django.core.files import File @@ -34,7 +31,23 @@ def test_manifest_json(app): def test_service_worker(app): app.get('/service-worker.js', status=200) - app.get('/service-worker-registration.js', status=200) + resp = app.get('/service-worker-registration.js', status=200) + assert 'applicationServerPublicKey = null' in resp.text + + pwa_settings = PwaSettings.singleton() + pwa_settings.push_notifications = True + pwa_settings.save() + + resp = app.get('/service-worker-registration.js', status=200) + assert 'applicationServerPublicKey = "' in resp.text + + # 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'}): + resp = app.get('/service-worker-registration.js', status=200) + assert 'applicationServerPublicKey = "BFzvUdXB..."' in resp.text def test_webpush_subscription(app, john_doe, jane_doe): app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=403) @@ -53,17 +66,37 @@ def test_webpush_subscription(app, john_doe, jane_doe): app.post_json(reverse('pwa-subscribe-push'), params=None, status=200) assert PushSubscription.objects.count() == 1 -@pytest.mark.skipif('pywebpush is None') def test_webpush_notification(app, john_doe): 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) + pwa_settings = PwaSettings.singleton() + pwa_settings.push_notifications = False + pwa_settings.save() + with mock.patch('pywebpush.webpush') as webpush: + notification = Notification.notify(john_doe, 'test', body='hello world') + assert webpush.call_count == 0 + + pwa_settings.push_notifications = True + pwa_settings.save() with mock.patch('pywebpush.webpush') as webpush: notification = Notification.notify(john_doe, 'test', body='hello world') assert webpush.call_count == 1 assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} + # 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 = 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 + def test_no_pwa_manager(app, admin_user): app = login(app) resp = app.get('/manage/', status=200)