From a3ee5985da95e0e21e61a1bb96a39971fa1f700a Mon Sep 17 00:00:00 2001 From: Elias Showk Date: Thu, 23 Aug 2018 16:29:38 +0200 Subject: [PATCH 3/6] pwa: add views and statics for webpush (#25462) --- combo/apps/pwa/static/js/webpush.js | 255 ++++++++++++++++ .../pwa/templates/combo/service-worker.js | 277 ++++++++++++------ combo/apps/pwa/urls.py | 12 +- combo/apps/pwa/views.py | 67 ++++- 4 files changed, 514 insertions(+), 97 deletions(-) create mode 100644 combo/apps/pwa/static/js/webpush.js diff --git a/combo/apps/pwa/static/js/webpush.js b/combo/apps/pwa/static/js/webpush.js new file mode 100644 index 0000000..4b242a7 --- /dev/null +++ b/combo/apps/pwa/static/js/webpush.js @@ -0,0 +1,255 @@ +/* globals window, navigator, Uint8Array, $, console, Notification */ +"use strict"; +// Webpush Application controller for Combo +// Copyright (C) 2018 Entr'ouvert +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Utils functions: +function loadVersionBrowser(userAgent) { + var ua = userAgent, + tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; + if (/trident/i.test(M[1])) { + tem = /\brv[ :]+(\d+)/g.exec(ua) || []; + return { + name: 'IE', + version: (tem[1] || '') + }; + } + if (M[1] === 'Chrome') { + tem = ua.match(/\bOPR\/(\d+)/); + if (tem != null) { + return { + name: 'Opera', + version: tem[1] + }; + } + } + M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?']; + if ((tem = ua.match(/version\/(\d+)/i)) != null) { + M.splice(1, 1, tem[1]); + } + return { + name: M[0], + version: M[1] + }; +} + +function urlBase64ToUint8Array(base64String) { + var padding = '='.repeat((4 - base64String.length % 4) % 4) + var base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/') + + var rawData = window.atob(base64) + var outputArray = new Uint8Array(rawData.length) + + for (var i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + return outputArray; +} + +$(window) + .load(function () { + var gruPushSubscribeButton, + gruPushMessageElt, + gruSW; + + var activateTextMessage = 'Activez les notifications'; + var stopTextMessage = 'Stoppez les notifications'; + var incompatibleMessage = 'Ce navigateur n'est pas compatible avec les notifications push.'; + + gruPushSubscribeButton = $('#webpush-subscribe-checkbox'); + gruPushMessageElt = $('#webpush-subscribe-message'); + + console.log("user has got webpush subscriptions :", gruPushSubscribeButton.data('userDevices')); + + function uncheckSubscribeButton() { + gruPushSubscribeButton.attr('disabled', false); + gruPushSubscribeButton.attr('checked', false); + gruPushSubscribeButton.removeClass("checked"); + gruPushMessageElt.text(activateTextMessage); + } + + function checkSubscribeButton() { + gruPushSubscribeButton.attr('disabled', false); + gruPushSubscribeButton.attr('checked', true); + gruPushSubscribeButton.addClass("checked"); + gruPushMessageElt.text(stopTextMessage); + } + // disable if not supported + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + gruPushMessageElt.text(incompatibleMessage); + gruPushSubscribeButton.attr('checked', false); + gruPushSubscribeButton.attr('disabled', true); + return; + } + // Get the initial subscription and refresh state from server + if ('serviceWorker' in navigator) { + var serviceWorkerSrc = '/service-worker.js'; + navigator.serviceWorker.register(serviceWorkerSrc) + .then(function (reg) { + gruSW = reg; + // Get the initial Subscription + gruSW.pushManager.getSubscription() + .then(function (subscription) { + // Check we have a subscription to unsubscribe + if (!subscription) { + // No subscription object, so we uncheck + uncheckSubscribeButton(); + } else { + // existing subscription object, so we check + checkSubscribeButton(); + } + refreshSubscription(reg); + }); + }) + .catch(function (err) { + console.log('error registering service worker : ', err); + }); + } + + + gruPushSubscribeButton.click( + function () { + gruPushMessageElt.text('Connexion au serveur en cours...'); + gruPushSubscribeButton.attr('disabled', true); + refreshSubscription(gruSW); + } + ); + + + // Once the service worker is registered set the initial state + function refreshSubscription(reg) { + // If its denied, it's a permanent block until the + if (Notification.permission === 'denied') { + // Show a message and uncheck the button + uncheckSubscribeButton(); + return; + } + // based on ":checked" being set before by pushManager.getSubscription() + if (gruPushSubscribeButton.filter(':checked').length > 0) { + return subscribe(reg); + } else { + return unsubscribe(reg); + } + } + + + // get the Subscription or register one new to POST to our server + function subscribe(reg) { + getOrCreateSubscription(reg) + .then(function (subscription) { + postSubscribeObj(true, subscription); + }) + .catch(function (error) { + gruPushMessageElt.text('Impossible de communiquer avec le serveur, veuillez retenter dans quelques minutes (Debug = ' + error + ')'); + }); + } + + function getOrCreateSubscription(reg) { + return reg.pushManager.getSubscription() + .then(function (subscription) { + var applicationServerKey, options; + // Check if a subscription is available + if (subscription) { + return subscription; + } + applicationServerKey = gruPushSubscribeButton.data('applicationServerKey'); + options = { + userVisibleOnly: true, // required by chrome + applicationServerKey: urlBase64ToUint8Array(applicationServerKey) + }; + // If not, register one + return reg.pushManager.subscribe(options) + }) + } + + function unsubscribe() { + // Get the Subscription to unregister + gruSW.pushManager.getSubscription() + .then(function (subscription) { + // Check we have a subscription to unsubscribe + if (!subscription) { + // No subscription object, so set the state + // to allow the user to subscribe to push + uncheckSubscribeButton(); + return; + } + postSubscribeObj(false, subscription); + }) + } + + /* + * Send the parameter to the server + * the type of the request, the name of the user subscribing, + * and the push subscription endpoint + key the server needs + * Each subscription is different on different browsers + */ + function postSubscribeObj(active, subscription) { + subscription = subscription.toJSON() + var browser = loadVersionBrowser(navigator.userAgent); + var endpointParts = subscription.endpoint.split('/'); + var registrationId = endpointParts[endpointParts.length - 1]; + var data = { + 'browser': browser.name.toUpperCase(), + 'p256dh': subscription.keys.p256dh, + 'auth': subscription.keys.auth, + 'name': 'gru-notificationcell-subscription', + 'registration_id': registrationId, + 'active': active + }; + $.ajax({ + url: gruPushSubscribeButton.data('comboApiUrl'), + method: 'POST', + data: JSON.stringify(data), + dataType: 'json', + crossDomain: true, + cache: false, + contentType: 'application/json; charset=UTF-8', + xhrFields: { withCredentials: true } + }) + .done(function (response) { + // Check if the parameter is saved on the server + if (response.active) { + // Show the unsubscribe button + checkSubscribeButton(); + } + // Check if the information is deleted from server + else if (!response.active) { + // Get the Subscription + getOrCreateSubscription(gruSW) + .then(function (subscription) { + // Remove the subscription + subscription + .unsubscribe() + .then(function () { + // Show the subscribe button + uncheckSubscribeButton(); + }); + }) + .catch(function (error) { + gruPushSubscribeButton.attr('disabled', false); + gruPushSubscribeButton.attr('checked', false); + gruPushSubscribeButton.removeClass("checked"); + gruPushMessageElt.text('Erreur lors de la requête, veuillez réessayer dans quelques minutes : ', error); + }); + } + }) + .fail(function (error) { + gruPushMessageElt.text('Erreur lors de la requête, veuillez réessayer dans quelques minutes : ', error); + }); + } + }); diff --git a/combo/apps/pwa/templates/combo/service-worker.js b/combo/apps/pwa/templates/combo/service-worker.js index 56c5824..00b9965 100644 --- a/combo/apps/pwa/templates/combo/service-worker.js +++ b/combo/apps/pwa/templates/combo/service-worker.js @@ -1,117 +1,214 @@ -{% load gadjo %} +{% load gadjo static %} /* global self, caches, fetch, URL, Response */ 'use strict'; var config = { - version: 'v{% start_timestamp %}', - staticCacheItems: [ - '/offline/' - ], - cachePathPattern: /^\/static\/.*/, - handleFetchPathPattern: /.*/, - offlinePage: '/offline/' + version: 'v{% start_timestamp %}', + staticCacheItems: [], // putting 404 items fail its registration (will never be "installed") + cachePathPattern: /^\/static\/.*/, + handleFetchPathPattern: /.*/, + offlinePage: '/offline/' }; function cacheName (key, opts) { - return `${opts.version}-${key}`; + return `${opts.version}-${key}`; } function addToCache (cacheKey, request, response) { - if (response.ok && cacheKey !== null) { - var copy = response.clone(); - caches.open(cacheKey).then( cache => { - cache.put(request, copy); - }); - } - return response; + if (response.ok && cacheKey !== null) { + var copy = response.clone(); + caches.open(cacheKey).then( cache => { + cache.put(request, copy); + }); + } + return response; } function fetchFromCache (event) { - return caches.match(event.request).then(response => { - if (!response) { - throw Error(`${event.request.url} not found in cache`); - } - return response; - }); + return caches.match(event.request).then(response => { + if (!response) { + throw Error(`${event.request.url} not found in cache`); + } + return response; + }); } function offlineResponse (resourceType, opts) { - if (resourceType === 'content') { - return caches.match(opts.offlinePage); - } - return undefined; + if (resourceType === 'content') { + return caches.match(opts.offlinePage); + } + return undefined; } self.addEventListener('install', event => { - function onInstall (event, opts) { - var cacheKey = cacheName('static', opts); - return caches.open(cacheKey) - .then(cache => cache.addAll(opts.staticCacheItems)); - } - - event.waitUntil( - onInstall(event, config).then( () => self.skipWaiting() ) - ); + function onInstall (event, opts) { + var cacheKey = cacheName('static', opts); + return caches.open(cacheKey) + .then(cache => cache.addAll(opts.staticCacheItems)); + } + + event.waitUntil( + onInstall(event, config).then( () => self.skipWaiting() ) + ); }); self.addEventListener('activate', event => { - function onActivate (event, opts) { - return caches.keys() - .then(cacheKeys => { - var oldCacheKeys = cacheKeys.filter(key => key.indexOf(opts.version) !== 0); - var deletePromises = oldCacheKeys.map(oldKey => caches.delete(oldKey)); - return Promise.all(deletePromises); - }); - } - - event.waitUntil( - onActivate(event, config) - .then( () => self.clients.claim() ) - ); + function onActivate (event, opts) { + return caches.keys() + .then(cacheKeys => { + var oldCacheKeys = cacheKeys.filter(key => key.indexOf(opts.version) !== 0); + var deletePromises = oldCacheKeys.map(oldKey => caches.delete(oldKey)); + return Promise.all(deletePromises); + }); + } + + event.waitUntil( + onActivate(event, config) + .then( () => self.clients.claim() ) + ); }); self.addEventListener('fetch', event => { - function shouldHandleFetch (event, opts) { - var request = event.request; - var url = new URL(request.url); - var criteria = { - matchesPathPattern: opts.handleFetchPathPattern.test(url.pathname), - isGETRequest : request.method === 'GET', - isFromMyOrigin : url.origin === self.location.origin - }; - var failingCriteria = Object.keys(criteria) - .filter(criteriaKey => !criteria[criteriaKey]); - return !failingCriteria.length; - } - - function onFetch (event, opts) { - var request = event.request; - var url = new URL(request.url); - var acceptHeader = request.headers.get('Accept'); - var resourceType = 'static'; - var cacheKey; - - if (acceptHeader.indexOf('text/html') !== -1) { - resourceType = 'content'; - } else if (acceptHeader.indexOf('image') !== -1) { - resourceType = 'image'; - } - - cacheKey = null; - if (opts.cachePathPattern.test(url.pathname)) { - cacheKey = cacheName(resourceType, opts); - } - - /* always network first */ - event.respondWith( - fetch(request) - .then(response => addToCache(cacheKey, request, response)) - .catch(() => fetchFromCache(event)) - .catch(() => offlineResponse(resourceType, opts)) - ); - } - if (shouldHandleFetch(event, config)) { - onFetch(event, config); - } + function shouldHandleFetch (event, opts) { + var request = event.request; + var url = new URL(request.url); + var criteria = { + matchesPathPattern: opts.handleFetchPathPattern.test(url.pathname), + isGETRequest : request.method === 'GET', + isFromMyOrigin : url.origin === self.location.origin + }; + var failingCriteria = Object.keys(criteria) + .filter(criteriaKey => !criteria[criteriaKey]); + return !failingCriteria.length; + } + + function onFetch (event, opts) { + var request = event.request; + var url = new URL(request.url); + var acceptHeader = request.headers.get('Accept'); + var resourceType = 'static'; + var cacheKey; + + if (acceptHeader.indexOf('text/html') !== -1) { + resourceType = 'content'; + } else if (acceptHeader.indexOf('image') !== -1) { + resourceType = 'image'; + } + + cacheKey = null; + if (opts.cachePathPattern.test(url.pathname)) { + cacheKey = cacheName(resourceType, opts); + } + + /* always network first */ + event.respondWith( + fetch(request) + .then(response => addToCache(cacheKey, request, response)) + .catch(() => fetchFromCache(event)) + .catch(() => offlineResponse(resourceType, opts)) + ); + } + if (shouldHandleFetch(event, config)) { + onFetch(event, config); + } }); + +/* +* Return the options paramter for showNotification +* Only Chrome has extended support for extra features like actions, badge, icon, etc +* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification +*/ +var getNotificationOptions = function (responseJson) { + var body = responseJson.body, + icon = responseJson.icon, + vibrate = responseJson.vibrate, + data = responseJson.data, + actions = responseJson.actions; + /* default icon is configured in the theme 'css_variant' */ + if (!icon ) icon = '{{ site_base }}{% static "" %}{{ css_variant }}/{{ icon_prefix }}192px.png'; + var options = { + body: body, + icon: icon, + requireInteraction: 'true', + data: data, + actions: actions + }; + /* optional vibration */ + if (vibrate) options.vibrate = vibrate; + console.log(options); + return options; +}; + +/* +* Push event handler +* documentation at https://developers.google.com/web/fundamentals/push-notifications/display-a-notification +*/ +self.addEventListener('push', function (event) { + try { + // Push is a JSON + var responseJson = event.data.json(); + var title = responseJson.title; + var options = getNotificationOptions(responseJson) + } catch (err) { + // Push is a simple text (usually debugging) + console.log('Push is a simple text'); + var options = { + 'body': event.data.text() + }; + var title = ''; + } + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', function (event) { + if (!event.action) { + // Was a normal notification click + console.log('No actions'); + return; + } + var urlToOpen = '..'; + switch (event.action) { + case 'ack': + urlToOpen = event.notification.data.open_url; + break; + case 'forget': + break; + } + // ack or forget + fetch(event.notification.data.callback_url + '?action=' + event.action, { + method: 'GET', + mode: 'cors', // no-cors, cors, *same-origin + cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached + }) + .then(function(response) { + console.log(response); + }); + // Check if there's already a tab open with this urlToOpen. + event.waitUntil(self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }) + .then(function (windowClients) { + let matchingClient = null; + for (let i = 0; i < windowClients.length; i++) { + const windowClient = windowClients[i]; + if (windowClient.url === urlToOpen) { + matchingClient = windowClient; + break; + } + } + if (matchingClient) { + return matchingClient.focus(); + } else { + return clients.openWindow(urlToOpen); + } + }).then(function(client) { + console.log(client); + }) + ); + // Android doesn't close the notification when you click it + // See http://crbug.com/463146 + event.notification.close(); +}); + diff --git a/combo/apps/pwa/urls.py b/combo/apps/pwa/urls.py index 6a17307..2aed263 100644 --- a/combo/apps/pwa/urls.py +++ b/combo/apps/pwa/urls.py @@ -1,5 +1,6 @@ -# combo - content management system -# Copyright (C) 2015-2018 Entr'ouvert +# -*- coding: utf-8 -*- +# combo - Combo PWA App +# Copyright (C) 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 @@ -16,9 +17,12 @@ from django.conf.urls import url -from .views import manifest_json, service_worker_js +from .views import manifest_json, service_worker_js, WebPushDeviceAuthorizedViewSetNoCsrf, NotificationCallback + urlpatterns = [ url('^manifest.json$', manifest_json), url('^service-worker.js$', service_worker_js), -] + url('^webpush/subscribe/$', WebPushDeviceAuthorizedViewSetNoCsrf.as_view({'post': 'create'}), name='create_webpush_subscription'), + url(r'^webpush/notification/(\w+)/(\w+)/(\S+)/$', NotificationCallback.as_view(), name='webpush-notification-callback'), + ] diff --git a/combo/apps/pwa/views.py b/combo/apps/pwa/views.py index 3971bf6..ea46ae0 100644 --- a/combo/apps/pwa/views.py +++ b/combo/apps/pwa/views.py @@ -1,5 +1,22 @@ -# combo - content management system -# Copyright (C) 2015-2018 Entr'ouvert +# -*- coding: utf-8 -*- +# combo - Combo PWA App +# Copyright (C) 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 . + +# combo - Combo PWA App +# Copyright (C) 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 @@ -17,6 +34,16 @@ from django.http import HttpResponse, Http404 from django.template.loader import get_template, TemplateDoesNotExist +from rest_framework import permissions, status +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework.authentication import SessionAuthentication + +from push_notifications.models import WebPushDevice +from push_notifications.api.rest_framework import WebPushDeviceAuthorizedViewSet + +from combo.apps.notifications.models import Notification + def manifest_json(request, *args, **kwargs): try: @@ -29,4 +56,38 @@ def manifest_json(request, *args, **kwargs): def service_worker_js(request, *args, **kwargs): template = get_template('combo/service-worker.js') return HttpResponse(template.render({}, request), - content_type='application/javascript; charset=utf-8') + content_type='application/javascript; charset=utf-8') + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return # To not perform the csrf check previously happening + + +class WebPushDeviceAuthorizedViewSetNoCsrf(WebPushDeviceAuthorizedViewSet): + authentication_classes = (CsrfExemptSessionAuthentication,) + + +class NotificationCallback(GenericAPIView): + ''' + Ack or forget a Notification object + Anonymously but with a check on the user's public webpush registration key + ''' + permission_classes = (permissions.AllowAny,) + authentication_classes = (CsrfExemptSessionAuthentication,) + + def get(self, request, notification_id, user, key, *args, **kwargs): + action = request.GET['action'] + try: + WebPushDevice.objects.get(p256dh=key) + qs = Notification.objects.find(user, notification_id) + if action == 'ack': + qs.ack() + if action == 'forget': + qs.forget() + return Response({'err': 0}) + except WebPushDevice.DoesNotExist: + return Response({'err': 1}, status.HTTP_400_BAD_REQUEST) + + +notification_callback = NotificationCallback.as_view() -- 2.18.0