diff --git a/combo/apps/pwa/__init__.py b/combo/apps/pwa/__init__.py index 8fec7cf3..ade658df 100644 --- a/combo/apps/pwa/__init__.py +++ b/combo/apps/pwa/__init__.py @@ -19,6 +19,9 @@ import django.apps class AppConfig(django.apps.AppConfig): name = 'combo.apps.pwa' + def ready(self): + from . import signals + def get_before_urls(self): from . import urls return urls.urlpatterns diff --git a/combo/apps/pwa/migrations/0001_initial.py b/combo/apps/pwa/migrations/0001_initial.py new file mode 100644 index 00000000..a2dd96f1 --- /dev/null +++ b/combo/apps/pwa/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-11-29 09:45 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PushSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subscription_info', jsonfield.fields.JSONField(default=dict)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/combo/apps/pwa/migrations/__init__.py b/combo/apps/pwa/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/combo/apps/pwa/models.py b/combo/apps/pwa/models.py new file mode 100644 index 00000000..2ac31cb6 --- /dev/null +++ b/combo/apps/pwa/models.py @@ -0,0 +1,26 @@ +# combo - content management system +# Copyright (C) 2015-2018 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf import settings +from django.db import models + +from jsonfield import JSONField + + +class PushSubscription(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + subscription_info = JSONField() + creation_timestamp = models.DateTimeField(auto_now_add=True) diff --git a/combo/apps/pwa/signals.py b/combo/apps/pwa/signals.py new file mode 100644 index 00000000..2b7f2b3a --- /dev/null +++ b/combo/apps/pwa/signals.py @@ -0,0 +1,56 @@ +# combo - content management system +# Copyright (C) 2015-2018 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import logging + +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 combo.apps.notifications.models import Notification + +from .models import PushSubscription + + +@receiver(post_save, sender=Notification) +def notification(sender, instance=None, created=False, **kwargs): + if not pywebpush: + return + if not created: + return + message = json.dumps({ + 'summary': instance.summary, + 'body': instance.body, + 'url': instance.url, + }) + + for subscription in PushSubscription.objects.filter(user_id=instance.user_id): + try: + pywebpush.webpush( + subscription_info=subscription.subscription_info, + data=message, + vapid_private_key=settings.PWA_VAPID_PRIVATE_KEY, + vapid_claims=settings.PWA_VAPID_CLAIMS + ) + except pywebpush.WebPushException as e: + logger = logging.getLogger(__name__) + logger.exception('webpush error (%r)', e) diff --git a/combo/apps/pwa/templates/combo/service-worker-registration.js b/combo/apps/pwa/templates/combo/service-worker-registration.js index ad7a7968..a9dbba1f 100644 --- a/combo/apps/pwa/templates/combo/service-worker-registration.js +++ b/combo/apps/pwa/templates/combo/service-worker-registration.js @@ -1,10 +1,90 @@ +var applicationServerPublicKey = {% if pwa_vapid_publik_key %}'{{ pwa_vapid_publik_key }}'{% else %}null{% endif %}; +var COMBO_PWA_USER_SUBSCRIPTION = false; + +function urlB64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function(registration) { // Registration was successful console.log('ServiceWorker registration successful with scope: ', registration.scope); swRegistration = registration; + combo_pwa_initialize(); }).catch(function(err) { // registration failed :( console.log('ServiceWorker registration failed: ', err); }); } + +function combo_pwa_initialize() { + if (applicationServerPublicKey !== null) { + swRegistration.pushManager.getSubscription() + .then(function(subscription) { + if (subscription !== null) { + COMBO_PWA_USER_SUBSCRIPTION = true; + } else { + COMBO_PWA_USER_SUBSCRIPTION = false; + } + $(document).trigger('combo:pwa-user-info'); + }); + } +} + +function combo_pwa_subscribe_user() { + const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey); + swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey + }) + .then(function(subscription) { + console.log('User is subscribed.'); + COMBO_PWA_USER_SUBSCRIPTION = true; + $(document).trigger('combo:pwa-user-info'); + combo_pwa_update_subscription_on_server(subscription); + }) + .catch(function(err) { + console.log('Failed to subscribe the user: ', err); + }); +} + +function combo_pwa_unsubscribe_user() { + swRegistration.pushManager.getSubscription() + .then(function(subscription) { + if (subscription) { + return subscription.unsubscribe(); + } + }) + .catch(function(error) { + console.log('Error unsubscribing', error); + }) + .then(function() { + combo_pwa_update_subscription_on_server(null); + console.log('User is unsubscribed.'); + COMBO_PWA_USER_SUBSCRIPTION = false; + $(document).trigger('combo:pwa-user-info'); + }); +} + +function combo_pwa_update_subscription_on_server(subscription) { + $.ajax({ + url: '{% url "pwa-subscribe-push" %}', + data: JSON.stringify(subscription), + contentType: 'application/json; charset=utf-8', + type: 'POST', + dataType: 'json', + success: function(response) { + } + }); +} diff --git a/combo/apps/pwa/templates/combo/service-worker.js b/combo/apps/pwa/templates/combo/service-worker.js index 56c58244..d813302f 100644 --- a/combo/apps/pwa/templates/combo/service-worker.js +++ b/combo/apps/pwa/templates/combo/service-worker.js @@ -1,7 +1,27 @@ -{% load gadjo %} +{% load combo gadjo %} + /* global self, caches, fetch, URL, Response */ 'use strict'; +const applicationServerPublicKey = {{ pwa_vapid_publik_key|as_json|safe }}; + +function urlB64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + + + var config = { version: 'v{% start_timestamp %}', staticCacheItems: [ @@ -115,3 +135,44 @@ self.addEventListener('fetch', event => { onFetch(event, config); } }); + +self.addEventListener('push', event => { + console.log('[Service Worker] Push Received.'); + console.log(`[Service Worker] Push had this data: "${event.data.text()}"`); + var message = JSON.parse(event.data.text()); + + const title = message.summary; + const options = { + body: message.body, + data: {"url": message.url}, + badge: {{ pwa_notification_badge_url|as_json|safe }}, + icon: {{ pwa_notification_icon_url|as_json|safe }} + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', function(event) { + var url = event.notification.data.url; + event.notification.close(); + if (url) { + event.waitUntil( + clients.openWindow(url) + ); + } +}); + +self.addEventListener('pushsubscriptionchange', event => { + console.log('[Service Worker]: \'pushsubscriptionchange\' event fired.'); + const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey); + event.waitUntil( + self.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey + }) + .then(function(newSubscription) { + console.log('[Service Worker] New subscription: ', newSubscription); + combo_pwa_update_subscription_on_server(newSubscription); + }) + ); +}); diff --git a/combo/apps/pwa/urls.py b/combo/apps/pwa/urls.py index 392c9d24..d8357820 100644 --- a/combo/apps/pwa/urls.py +++ b/combo/apps/pwa/urls.py @@ -20,10 +20,12 @@ from .views import ( manifest_json, service_worker_js, service_worker_registration_js, + subscribe_push, ) urlpatterns = [ url('^manifest.json$', manifest_json), url('^service-worker.js$', service_worker_js), url('^service-worker-registration.js$', service_worker_registration_js), + url('^api/pwa/push/subscribe$', subscribe_push, name='pwa-subscribe-push'), ] diff --git a/combo/apps/pwa/views.py b/combo/apps/pwa/views.py index 5e11d7e8..fbd775b9 100644 --- a/combo/apps/pwa/views.py +++ b/combo/apps/pwa/views.py @@ -14,8 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.http import HttpResponse, Http404 +import json + +from django.conf import settings + +from django.http import HttpResponse, HttpResponseForbidden, Http404, JsonResponse from django.template.loader import get_template, TemplateDoesNotExist +from django.views.decorators.csrf import csrf_exempt + +from .models import PushSubscription def manifest_json(request, *args, **kwargs): @@ -26,13 +33,37 @@ def manifest_json(request, *args, **kwargs): return HttpResponse(template.render({}, request), content_type='application/json') -def service_worker_js(request, *args, **kwargs): - template = get_template('combo/service-worker.js') - return HttpResponse(template.render({}, request), +def js_response(request, template_name): + template = get_template(template_name) + context = { + 'pwa_vapid_publik_key': settings.PWA_VAPID_PUBLIK_KEY, + 'pwa_notification_badge_url': settings.PWA_NOTIFICATION_BADGE_URL, + 'pwa_notification_icon_url': settings.PWA_NOTIFICATION_ICON_URL, + } + return HttpResponse(template.render(context, request), content_type='application/javascript; charset=utf-8') +def service_worker_js(request, *args, **kwargs): + return js_response(request, 'combo/service-worker.js') + + def service_worker_registration_js(request, *args, **kwargs): - template = get_template('combo/service-worker-registration.js') - return HttpResponse(template.render({}, request), - content_type='application/javascript; charset=utf-8') + return js_response(request, 'combo/service-worker-registration.js') + + +@csrf_exempt +def subscribe_push(request, *args, **kwargs): + if not (request.user and request.user.is_authenticated()): + return HttpResponseForbidden() + if request.method != 'POST': + return HttpResponseForbidden() + subscription_data = json.loads(request.body) + if subscription_data is None: + PushSubscription.objects.filter(user=request.user).delete() + else: + subscription, created = PushSubscription.objects.get_or_create( + user=request.user, + subscription_info=subscription_data) + subscription.save() + return JsonResponse({'err': 0}) diff --git a/combo/public/templatetags/combo.py b/combo/public/templatetags/combo.py index 6d2c5f3e..cf52d9e7 100644 --- a/combo/public/templatetags/combo.py +++ b/combo/public/templatetags/combo.py @@ -17,6 +17,7 @@ from __future__ import absolute_import import datetime +import json import time from django import template @@ -236,6 +237,10 @@ def is_empty_placeholder(page, placeholder_name): def as_list(obj): return list(obj) +@register.filter(name='as_json') +def as_json(obj): + return json.dumps(obj) + @register.filter def signed(obj): return signing.dumps(obj) diff --git a/combo/settings.py b/combo/settings.py index 3792b5f7..1262fee8 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -315,6 +315,14 @@ WCS_CATEGORY_ASSET_SLOTS = { WCS_FORM_ASSET_SLOTS = {} +# PWA Settings +PWA_VAPID_PUBLIK_KEY = None +PWA_VAPID_PRIVATE_KEY = None +PWA_VAPID_CLAIMS = None +PWA_NOTIFICATION_BADGE_URL = None +PWA_NOTIFICATION_ICON_URL = None + + # hide work-in-progress/experimental/broken/legacy/whatever cells for now BOOKING_CALENDAR_CELL_ENABLED = False NEWSLETTERS_CELL_ENABLED = False diff --git a/debian/control b/debian/control index 7f499925..849a2c57 100644 --- a/debian/control +++ b/debian/control @@ -24,7 +24,7 @@ Depends: ${misc:Depends}, ${python:Depends}, python-django-haystack (>= 2.4.0), python-sorl-thumbnail, python-pil -Recommends: python-django-mellon, python-whoosh +Recommends: python-django-mellon, python-whoosh, python-pywebpush Conflicts: python-lingo Description: Portal Management System (Python module) diff --git a/tests/test_pwa.py b/tests/test_pwa.py index 07ebaf78..ea24da22 100644 --- a/tests/test_pwa.py +++ b/tests/test_pwa.py @@ -1,11 +1,25 @@ import os + +import mock import pytest +try: + import pywebpush +except ImportError: + pywebpush = None + from django.conf import settings +from django.core.urlresolvers import reverse from django.test import override_settings +from combo.apps.notifications.models import Notification +from combo.apps.pwa.models import PushSubscription + +from .test_manager import login + pytestmark = pytest.mark.django_db + def test_manifest_json(app): app.get('/manifest.json', status=404) @@ -16,3 +30,32 @@ 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) + +def test_webpush_subscription(app, john_doe, jane_doe): + app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=403) + app.get(reverse('pwa-subscribe-push'), status=403) + app = login(app, john_doe.username, john_doe.username) + app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200) + assert PushSubscription.objects.count() == 1 + app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content2'}, status=200) + assert PushSubscription.objects.count() == 2 + + app = login(app, jane_doe.username, jane_doe.username) + app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200) + assert PushSubscription.objects.count() == 3 + + app = login(app, john_doe.username, john_doe.username) + 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) + + 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'} diff --git a/tox.ini b/tox.ini index b9765f1e..1ce3287e 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,7 @@ setenv = deps = django18: django>=1.8,<1.9 django111: django>=1.11,<1.12 + django111: pywebpush pytest-cov pytest-django pytest-freezegun