pwa: add url, views and javascript for webpush (#25462)

This commit is contained in:
Elias Showk 2018-08-23 16:29:38 +02:00
parent 947f479949
commit 1b4edd9a52
4 changed files with 504 additions and 91 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
// 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&#39;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);
});
}
});

View File

@ -1,117 +1,210 @@
{% 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));
}
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() )
);
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);
});
}
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() )
);
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 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;
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';
}
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);
}
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);
}
/* 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 }}512px.png';
var options = {
body: body,
icon: icon,
requireInteraction: 'true',
data: data,
actions: actions
};
/* optional vibration */
if (vibrate) options.vibrate = vibrate;
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');
}
var urlToOpen = event.notification.data.open_url;
switch (event.action) {
case 'ack':
break;
case 'forget':
break;
}
if (event.action) {
// 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
})
.catch(function() {
console.log("error on GET ", event.notification.data.callback_url + '?action=' + event.action);
});
}
// 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);
}
})
);
// Android doesn't close the notification when you click it
// See http://crbug.com/463146
event.notification.close();
});

View File

@ -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'),
]

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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()