Projet

Général

Profil

0003-pwa-add-url-views-and-javascript-for-webpush-25462.patch

Anonyme, 24 août 2018 17:21

Télécharger (22,3 ko)

Voir les différences:

Subject: [PATCH 3/6] pwa: add url, views and javascript for webpush (#25462)

 combo/apps/pwa/static/js/webpush.js           | 255 ++++++++++++++++
 .../pwa/templates/combo/service-worker.js     | 273 ++++++++++++------
 combo/apps/pwa/urls.py                        |  12 +-
 combo/apps/pwa/views.py                       |  67 ++++-
 4 files changed, 510 insertions(+), 97 deletions(-)
 create mode 100644 combo/apps/pwa/static/js/webpush.js
combo/apps/pwa/static/js/webpush.js
1
/* globals window, navigator, Uint8Array, $, console, Notification */
2
"use strict";
3
// Webpush Application controller for Combo
4
// Copyright (C) 2018  Entr'ouvert
5
//
6
// This program is free software: you can redistribute it and/or modify it
7
// under the terms of the GNU General Public License as published
8
// by the Free Software Foundation, either version 3 of the License, or
9
// (at your option) any later version.
10
//
11
// This program is distributed in the hope that it will be useful,
12
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
// GNU General Public License for more details.
15
//
16
// You should have received a copy of the GNU General Public License
17
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
18

  
19
// Utils functions:
20
function loadVersionBrowser(userAgent) {
21
	var ua = userAgent,
22
		tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
23
	if (/trident/i.test(M[1])) {
24
		tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
25
		return {
26
			name: 'IE',
27
			version: (tem[1] || '')
28
		};
29
	}
30
	if (M[1] === 'Chrome') {
31
		tem = ua.match(/\bOPR\/(\d+)/);
32
		if (tem != null) {
33
			return {
34
				name: 'Opera',
35
				version: tem[1]
36
			};
37
		}
38
	}
39
	M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
40
	if ((tem = ua.match(/version\/(\d+)/i)) != null) {
41
		M.splice(1, 1, tem[1]);
42
	}
43
	return {
44
		name: M[0],
45
		version: M[1]
46
	};
47
}
48

  
49
function urlBase64ToUint8Array(base64String) {
50
	var padding = '='.repeat((4 - base64String.length % 4) % 4)
51
	var base64 = (base64String + padding)
52
		.replace(/\-/g, '+')
53
		.replace(/_/g, '/')
54

  
55
	var rawData = window.atob(base64)
56
	var outputArray = new Uint8Array(rawData.length)
57

  
58
	for (var i = 0; i < rawData.length; ++i) {
59
		outputArray[i] = rawData.charCodeAt(i)
60
	}
61
	return outputArray;
62
}
63

  
64
$(window)
65
	.load(function () {
66
		var gruPushSubscribeButton,
67
			gruPushMessageElt,
68
			gruSW;
69

  
70
		var activateTextMessage = 'Activez les notifications';
71
		var stopTextMessage = 'Stoppez les notifications';
72
		var incompatibleMessage = 'Ce navigateur n&#39;est pas compatible avec les notifications push.';
73

  
74
		gruPushSubscribeButton = $('#webpush-subscribe-checkbox');
75
		gruPushMessageElt = $('#webpush-subscribe-message');
76

  
77
		console.log("user has got webpush subscriptions :", gruPushSubscribeButton.data('userDevices'));
78

  
79
		function uncheckSubscribeButton() {
80
			gruPushSubscribeButton.attr('disabled', false);
81
			gruPushSubscribeButton.attr('checked', false);
82
			gruPushSubscribeButton.removeClass("checked");
83
			gruPushMessageElt.text(activateTextMessage);
84
		}
85

  
86
		function checkSubscribeButton() {
87
			gruPushSubscribeButton.attr('disabled', false);
88
			gruPushSubscribeButton.attr('checked', true);
89
			gruPushSubscribeButton.addClass("checked");
90
			gruPushMessageElt.text(stopTextMessage);
91
		}
92
		// disable if not supported
93
		if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
94
			gruPushMessageElt.text(incompatibleMessage);
95
			gruPushSubscribeButton.attr('checked', false);
96
			gruPushSubscribeButton.attr('disabled', true);
97
			return;
98
		}
99
		// Get the initial subscription and refresh state from server
100
		if ('serviceWorker' in navigator) {
101
			var serviceWorkerSrc = '/service-worker.js';
102
			navigator.serviceWorker.register(serviceWorkerSrc)
103
				.then(function (reg) {
104
					gruSW = reg;
105
					// Get the initial Subscription
106
					gruSW.pushManager.getSubscription()
107
						.then(function (subscription) {
108
							// Check we have a subscription to unsubscribe
109
							if (!subscription) {
110
								// No subscription object, so we uncheck
111
								uncheckSubscribeButton();
112
							} else {
113
								// existing subscription object, so we check
114
								checkSubscribeButton();
115
							}
116
							refreshSubscription(reg);
117
						});
118
				})
119
				.catch(function (err) {
120
					console.log('error registering service worker : ', err);
121
				});
122
		}
123

  
124

  
125
		gruPushSubscribeButton.click(
126
			function () {
127
				gruPushMessageElt.text('Connexion au serveur en cours...');
128
				gruPushSubscribeButton.attr('disabled', true);
129
				refreshSubscription(gruSW);
130
			}
131
		);
132

  
133

  
134
		// Once the service worker is registered set the initial state
135
		function refreshSubscription(reg) {
136
			// If its denied, it's a permanent block until the
137
			if (Notification.permission === 'denied') {
138
				// Show a message and uncheck the button
139
				uncheckSubscribeButton();
140
				return;
141
			}
142
			// based on ":checked" being set before by pushManager.getSubscription()
143
			if (gruPushSubscribeButton.filter(':checked').length > 0) {
144
				return subscribe(reg);
145
			} else {
146
				return unsubscribe(reg);
147
			}
148
		}
149

  
150

  
151
		// get the Subscription or register one new to POST to our server
152
		function subscribe(reg) {
153
			getOrCreateSubscription(reg)
154
				.then(function (subscription) {
155
					postSubscribeObj(true, subscription);
156
				})
157
				.catch(function (error) {
158
					gruPushMessageElt.text('Impossible de communiquer avec le serveur, veuillez retenter dans quelques minutes (Debug = ' + error + ')');
159
				});
160
		}
161

  
162
		function getOrCreateSubscription(reg) {
163
			return reg.pushManager.getSubscription()
164
				.then(function (subscription) {
165
					var applicationServerKey, options;
166
					// Check if a subscription is available
167
					if (subscription) {
168
						return subscription;
169
					}
170
					applicationServerKey = gruPushSubscribeButton.data('applicationServerKey');
171
					options = {
172
						userVisibleOnly: true, // required by chrome
173
						applicationServerKey: urlBase64ToUint8Array(applicationServerKey)
174
					};
175
					// If not, register one
176
					return reg.pushManager.subscribe(options)
177
				})
178
		}
179

  
180
		function unsubscribe() {
181
			// Get the Subscription to unregister
182
			gruSW.pushManager.getSubscription()
183
				.then(function (subscription) {
184
						// Check we have a subscription to unsubscribe
185
						if (!subscription) {
186
							// No subscription object, so set the state
187
							// to allow the user to subscribe to push
188
							uncheckSubscribeButton();
189
							return;
190
						}
191
						postSubscribeObj(false, subscription);
192
					})
193
		}
194

  
195
		/*
196
		* Send the parameter to the server
197
		* the type of the request, the name of the user subscribing,
198
		* and the push subscription endpoint + key the server needs
199
		* Each subscription is different on different browsers
200
		*/
201
		function postSubscribeObj(active, subscription) {
202
			subscription = subscription.toJSON()
203
			var browser = loadVersionBrowser(navigator.userAgent);
204
			var endpointParts = subscription.endpoint.split('/');
205
			var registrationId = endpointParts[endpointParts.length - 1];
206
			var data = {
207
				'browser': browser.name.toUpperCase(),
208
				'p256dh': subscription.keys.p256dh,
209
				'auth': subscription.keys.auth,
210
				'name': 'gru-notificationcell-subscription',
211
				'registration_id': registrationId,
212
				'active': active
213
			};
214
			$.ajax({
215
				url: gruPushSubscribeButton.data('comboApiUrl'),
216
				method: 'POST',
217
				data: JSON.stringify(data),
218
				dataType: 'json',
219
				crossDomain: true,
220
				cache: false,
221
				contentType: 'application/json; charset=UTF-8',
222
				xhrFields: { withCredentials: true }
223
			})
224
			.done(function (response) {
225
					// Check if the parameter is saved on the server
226
					if (response.active) {
227
						// Show the unsubscribe button
228
						checkSubscribeButton();
229
					}
230
					// Check if the information is deleted from server
231
					else if (!response.active) {
232
						// Get the Subscription
233
						getOrCreateSubscription(gruSW)
234
							.then(function (subscription) {
235
									// Remove the subscription
236
									subscription
237
										.unsubscribe()
238
										.then(function () {
239
											// Show the subscribe button
240
											uncheckSubscribeButton();
241
										});
242
							})
243
							.catch(function (error) {
244
								gruPushSubscribeButton.attr('disabled', false);
245
								gruPushSubscribeButton.attr('checked', false);
246
								gruPushSubscribeButton.removeClass("checked");
247
								gruPushMessageElt.text('Erreur lors de la requête, veuillez réessayer dans quelques minutes : ', error);
248
							});
249
					}
250
				})
251
				.fail(function (error) {
252
					gruPushMessageElt.text('Erreur lors de la requête, veuillez réessayer dans quelques minutes : ', error);
253
				});
254
		}
255
	});
combo/apps/pwa/templates/combo/service-worker.js
1
{% load gadjo %}
1
{% load gadjo static %}
2 2
/* global self, caches, fetch, URL, Response */
3 3
'use strict';
4 4

  
5 5
var config = {
6
  version: 'v{% start_timestamp %}',
7
  staticCacheItems: [
8
    '/offline/'
9
  ],
10
  cachePathPattern: /^\/static\/.*/,
11
  handleFetchPathPattern: /.*/,
12
  offlinePage: '/offline/'
6
	version: 'v{% start_timestamp %}',
7
	staticCacheItems: [], // putting 404 items fail its registration (will never be "installed")
8
	cachePathPattern: /^\/static\/.*/,
9
	handleFetchPathPattern: /.*/,
10
	offlinePage: '/offline/'
13 11
};
14 12

  
15 13
function cacheName (key, opts) {
16
  return `${opts.version}-${key}`;
14
	return `${opts.version}-${key}`;
17 15
}
18 16

  
19 17
function addToCache (cacheKey, request, response) {
20
  if (response.ok && cacheKey !== null) {
21
    var copy = response.clone();
22
    caches.open(cacheKey).then( cache => {
23
      cache.put(request, copy);
24
    });
25
  }
26
  return response;
18
	if (response.ok && cacheKey !== null) {
19
		var copy = response.clone();
20
		caches.open(cacheKey).then( cache => {
21
			cache.put(request, copy);
22
		});
23
	}
24
	return response;
27 25
}
28 26

  
29 27
function fetchFromCache (event) {
30
  return caches.match(event.request).then(response => {
31
    if (!response) {
32
      throw Error(`${event.request.url} not found in cache`);
33
    }
34
    return response;
35
  });
28
	return caches.match(event.request).then(response => {
29
		if (!response) {
30
			throw Error(`${event.request.url} not found in cache`);
31
		}
32
		return response;
33
	});
36 34
}
37 35

  
38 36
function offlineResponse (resourceType, opts) {
39
  if (resourceType === 'content') {
40
    return caches.match(opts.offlinePage);
41
  }
42
  return undefined;
37
	if (resourceType === 'content') {
38
		return caches.match(opts.offlinePage);
39
	}
40
	return undefined;
43 41
}
44 42

  
45 43
self.addEventListener('install', event => {
46
  function onInstall (event, opts) {
47
    var cacheKey = cacheName('static', opts);
48
    return caches.open(cacheKey)
49
      .then(cache => cache.addAll(opts.staticCacheItems));
50
  }
51

  
52
  event.waitUntil(
53
    onInstall(event, config).then( () => self.skipWaiting() )
54
  );
44
	function onInstall (event, opts) {
45
		var cacheKey = cacheName('static', opts);
46
		return caches.open(cacheKey)
47
			.then(cache => cache.addAll(opts.staticCacheItems));
48
	}
49

  
50
	event.waitUntil(
51
		onInstall(event, config).then( () => self.skipWaiting() )
52
	);
55 53
});
56 54

  
57 55
self.addEventListener('activate', event => {
58
  function onActivate (event, opts) {
59
    return caches.keys()
60
      .then(cacheKeys => {
61
        var oldCacheKeys = cacheKeys.filter(key => key.indexOf(opts.version) !== 0);
62
        var deletePromises = oldCacheKeys.map(oldKey => caches.delete(oldKey));
63
        return Promise.all(deletePromises);
64
      });
65
  }
66

  
67
  event.waitUntil(
68
    onActivate(event, config)
69
      .then( () => self.clients.claim() )
70
  );
56
	function onActivate (event, opts) {
57
		return caches.keys()
58
			.then(cacheKeys => {
59
				var oldCacheKeys = cacheKeys.filter(key => key.indexOf(opts.version) !== 0);
60
				var deletePromises = oldCacheKeys.map(oldKey => caches.delete(oldKey));
61
				return Promise.all(deletePromises);
62
			});
63
	}
64

  
65
	event.waitUntil(
66
		onActivate(event, config)
67
			.then( () => self.clients.claim() )
68
	);
71 69
});
72 70

  
73 71
self.addEventListener('fetch', event => {
74 72

  
75
  function shouldHandleFetch (event, opts) {
76
    var request            = event.request;
77
    var url                = new URL(request.url);
78
    var criteria           = {
79
      matchesPathPattern: opts.handleFetchPathPattern.test(url.pathname),
80
      isGETRequest      : request.method === 'GET',
81
      isFromMyOrigin    : url.origin === self.location.origin
82
    };
83
    var failingCriteria    = Object.keys(criteria)
84
      .filter(criteriaKey => !criteria[criteriaKey]);
85
    return !failingCriteria.length;
86
  }
87

  
88
  function onFetch (event, opts) {
89
    var request = event.request;
90
    var url           = new URL(request.url);
91
    var acceptHeader = request.headers.get('Accept');
92
    var resourceType = 'static';
93
    var cacheKey;
94

  
95
    if (acceptHeader.indexOf('text/html') !== -1) {
96
      resourceType = 'content';
97
    } else if (acceptHeader.indexOf('image') !== -1) {
98
      resourceType = 'image';
99
    }
100

  
101
    cacheKey = null;
102
    if (opts.cachePathPattern.test(url.pathname)) {
103
      cacheKey = cacheName(resourceType, opts);
104
    }
105

  
106
    /* always network first */
107
    event.respondWith(
108
        fetch(request)
109
          .then(response => addToCache(cacheKey, request, response))
110
          .catch(() => fetchFromCache(event))
111
          .catch(() => offlineResponse(resourceType, opts))
112
      );
113
  }
114
  if (shouldHandleFetch(event, config)) {
115
    onFetch(event, config);
116
  }
73
	function shouldHandleFetch (event, opts) {
74
		var request            = event.request;
75
		var url                = new URL(request.url);
76
		var criteria           = {
77
			matchesPathPattern: opts.handleFetchPathPattern.test(url.pathname),
78
			isGETRequest      : request.method === 'GET',
79
			isFromMyOrigin    : url.origin === self.location.origin
80
		};
81
		var failingCriteria    = Object.keys(criteria)
82
			.filter(criteriaKey => !criteria[criteriaKey]);
83
		return !failingCriteria.length;
84
	}
85

  
86
	function onFetch (event, opts) {
87
		var request = event.request;
88
		var url           = new URL(request.url);
89
		var acceptHeader = request.headers.get('Accept');
90
		var resourceType = 'static';
91
		var cacheKey;
92

  
93
		if (acceptHeader.indexOf('text/html') !== -1) {
94
			resourceType = 'content';
95
		} else if (acceptHeader.indexOf('image') !== -1) {
96
			resourceType = 'image';
97
		}
98

  
99
		cacheKey = null;
100
		if (opts.cachePathPattern.test(url.pathname)) {
101
			cacheKey = cacheName(resourceType, opts);
102
		}
103

  
104
		/* always network first */
105
		event.respondWith(
106
				fetch(request)
107
					.then(response => addToCache(cacheKey, request, response))
108
					.catch(() => fetchFromCache(event))
109
					.catch(() => offlineResponse(resourceType, opts))
110
			);
111
	}
112
	if (shouldHandleFetch(event, config)) {
113
		onFetch(event, config);
114
	}
115
});
116

  
117
/*
118
* Return the options paramter for showNotification
119
* Only Chrome has extended support for extra features like actions, badge, icon, etc
120
* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification
121
*/
122
var getNotificationOptions = function (responseJson) {
123
	var body = responseJson.body,
124
		icon = responseJson.icon,
125
		vibrate = responseJson.vibrate,
126
		data = responseJson.data,
127
		actions = responseJson.actions;
128
	/* default icon is configured in the theme 'css_variant' */
129
	if (!icon ) icon = '{{ site_base }}{% static "" %}{{ css_variant }}/{{ icon_prefix }}512px.png';
130
	var options = {
131
		body: body,
132
		icon: icon,
133
		requireInteraction: 'true',
134
		data: data,
135
		actions: actions
136
	};
137
	/* optional vibration */
138
	if (vibrate) options.vibrate = vibrate;
139
	return options;
140
};
141

  
142
/*
143
* Push event handler
144
* documentation at https://developers.google.com/web/fundamentals/push-notifications/display-a-notification
145
*/
146
self.addEventListener('push', function (event) {
147
	try {
148
		// Push is a JSON
149
		var responseJson = event.data.json();
150
		var title = responseJson.title;
151
		var options = getNotificationOptions(responseJson)
152
	} catch (err) {
153
		// Push is a simple text (usually debugging)
154
		console.log('Push is a simple text');
155
		var options = {
156
			'body': event.data.text()
157
		};
158
		var title = '';
159
	}
160
	event.waitUntil(self.registration.showNotification(title, options));
161
});
162

  
163
self.addEventListener('notificationclick', function (event) {
164
	if (!event.action) {
165
		// Was a normal notification click
166
		console.log('No actions');
167
	}
168
	var urlToOpen = event.notification.data.open_url;
169
	switch (event.action) {
170
		case 'ack':
171
			break;
172
		case 'forget':
173
			break;
174
	}
175
	if (event.action) {
176
		// ack or forget
177
		fetch(event.notification.data.callback_url + '?action=' + event.action, {
178
			method: 'GET',
179
			mode: 'cors', // no-cors, cors, *same-origin
180
			cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
181
		})
182
		.catch(function() {
183
			console.log("error on GET ", event.notification.data.callback_url + '?action=' + event.action);
184
		});
185
	}
186
	// Check if there's already a tab open with this urlToOpen.
187
	event.waitUntil(self.clients.matchAll({
188
			type: 'window',
189
			includeUncontrolled: true
190
		})
191
		.then(function (windowClients) {
192
			let matchingClient = null;
193
			for (let i = 0; i < windowClients.length; i++) {
194
				const windowClient = windowClients[i];
195
				if (windowClient.url === urlToOpen) {
196
					matchingClient = windowClient;
197
					break;
198
				}
199
			}
200
			if (matchingClient) {
201
				return matchingClient.focus();
202
			} else {
203
				return clients.openWindow(urlToOpen);
204
			}
205
		})
206
	);
207
	// Android doesn't close the notification when you click it
208
	// See http://crbug.com/463146
209
	event.notification.close();
117 210
});
combo/apps/pwa/urls.py
1
# combo - content management system
2
# Copyright (C) 2015-2018  Entr'ouvert
1
# -*- coding: utf-8 -*-
2
# combo - Combo PWA App
3
# Copyright (C) 2018  Entr'ouvert
3 4
#
4 5
# This program is free software: you can redistribute it and/or modify it
5 6
# under the terms of the GNU Affero General Public License as published
......
16 17

  
17 18
from django.conf.urls import url
18 19

  
19
from .views import manifest_json, service_worker_js
20
from .views import manifest_json, service_worker_js, WebPushDeviceAuthorizedViewSetNoCsrf, NotificationCallback
21

  
20 22

  
21 23
urlpatterns = [
22 24
    url('^manifest.json$', manifest_json),
23 25
    url('^service-worker.js$', service_worker_js),
24
]
26
    url('^webpush/subscribe/$', WebPushDeviceAuthorizedViewSetNoCsrf.as_view({'post': 'create'}), name='create_webpush_subscription'),
27
    url(r'^webpush/notification/(\w+)/(\w+)/(\S+)/$', NotificationCallback.as_view(), name='webpush-notification-callback'),
28
    ]
combo/apps/pwa/views.py
1
# combo - content management system
2
# Copyright (C) 2015-2018  Entr'ouvert
1
# -*- coding: utf-8 -*-
2
# combo - Combo PWA App
3
# Copyright (C) 2018  Entr'ouvert
4
#
5
# This program is free software: you can redistribute it and/or modify it
6
# under the terms of the GNU Affero General Public License as published
7
# by the Free Software Foundation, either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17

  
18
# combo - Combo PWA App
19
# Copyright (C) 2018  Entr'ouvert
3 20
#
4 21
# This program is free software: you can redistribute it and/or modify it
5 22
# under the terms of the GNU Affero General Public License as published
......
17 34
from django.http import HttpResponse, Http404
18 35
from django.template.loader import get_template, TemplateDoesNotExist
19 36

  
37
from rest_framework import permissions, status
38
from rest_framework.generics import GenericAPIView
39
from rest_framework.response import Response
40
from rest_framework.authentication import SessionAuthentication
41

  
42
from push_notifications.models import WebPushDevice
43
from push_notifications.api.rest_framework import WebPushDeviceAuthorizedViewSet
44

  
45
from combo.apps.notifications.models import Notification
46

  
20 47

  
21 48
def manifest_json(request, *args, **kwargs):
22 49
    try:
......
29 56
def service_worker_js(request, *args, **kwargs):
30 57
    template = get_template('combo/service-worker.js')
31 58
    return HttpResponse(template.render({}, request),
32
            content_type='application/javascript; charset=utf-8')
59
        content_type='application/javascript; charset=utf-8')
60

  
61

  
62
class CsrfExemptSessionAuthentication(SessionAuthentication):
63
    def enforce_csrf(self, request):
64
        return  # To not perform the csrf check previously happening
65

  
66

  
67
class WebPushDeviceAuthorizedViewSetNoCsrf(WebPushDeviceAuthorizedViewSet):
68
    authentication_classes = (CsrfExemptSessionAuthentication,)
69

  
70

  
71
class NotificationCallback(GenericAPIView):
72
    '''
73
    Ack or forget a Notification object
74
    Anonymously but with a check on the user's public webpush registration key
75
    '''
76
    permission_classes = (permissions.AllowAny,)
77
    authentication_classes = (CsrfExemptSessionAuthentication,)
78

  
79
    def get(self, request, notification_id, user, key, *args, **kwargs):
80
        action = request.GET['action']
81
        try:
82
            WebPushDevice.objects.get(p256dh=key)
83
            qs = Notification.objects.find(user, notification_id)
84
            if action == 'ack':
85
                qs.ack()
86
            if action == 'forget':
87
                qs.forget()
88
            return Response({'err': 0})
89
        except WebPushDevice.DoesNotExist:
90
            return Response({'err': 1}, status.HTTP_400_BAD_REQUEST)
91

  
92

  
93
notification_callback = NotificationCallback.as_view()
33
-