Отправка push-уведомлений из веб-приложений Django

Интернет постоянно развивается. Сегодня он поддерживает функции, ранее доступные только на мобильных устройствах. Появление инструмента  JavaScript service workers открывает в сети такие новые возможности, как фоновая синхронизация, оффлайн-кэширование и отправка push-уведомлений.

Push-уведомления позволяют принимать обновления мобильных и веб-приложений, а также взаимодействовать с существующими приложениями, используя персонализированный контент.

Данная серия мануалов поможет вам создать приложение Django, которое будет отправлять push-уведомления в случае, если пользователь должен посетить приложение. Для создания таких уведомлений мы будем использовать пакет Django-Webpush. В этом мануале мы поможем вам настроить и зарегистрировать service worker для отображения уведомлений на клиенте.

Примечание: Этот мануал является продолжением мануала Отправка push-уведомлений из веб-приложений Django: подготовка среды, в котором описаны предварительные требования и начальные этапы работы.

1: Регистрация service worker и подписка пользователей на push-уведомления

Push-уведомления сообщают пользователям об обновлениях приложений, на которые они подписаны, а также напоминают приложении, которое они использовали когда-то. Эти уведомления основаны на двух технологиях, API push и API notifications. Обеим этим технологиям необходим service worker.

Push-уведомление отправляется, когда service worker получает от сервера информацию. Для отображения этой информации service worker использует notifications API.

Давайте подпишем пользователей на push-уведомления, а затем отправим информацию о подписке на сервер для регистрации.

В каталоге static создайте каталог js:

mkdir ~/djangopush/static/js

Там создайте файл registerSw.js:

nano ~/djangopush/static/js/registerSw.js

Добавьте в файл следующий код. Он проверяет, поддерживаются ли service workers в браузере пользователя, а после этого пытается зарегистрировать service worker:

const registerSw = async () => {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.register('sw.js');
initialiseState(reg)
} else {
showNotAllowed("You can't send push notifications ☹️")
}
};

Функция registerSw проверяет, поддерживает ли браузер пользователя service worker-ы, прежде чем их зарегистрировать. После регистрации вызывается функция initializeState с данными регистрации. Если браузер не поддерживает service worker-ы, вызывается функция showNotAllowed.

Ниже, под функцией registerSw, вставьте следующий код. Он проверяет, может ли пользователь получать push-уведомления, прежде чем подписать пользователя:

...
const initialiseState = (reg) => {
if (!reg.showNotification) {
showNotAllowed('Showing notifications isn\'t supported ☹️');
return
}
if (Notification.permission === 'denied') {
showNotAllowed('You prevented us from showing notifications ☹️');
return
}
if (!'PushManager' in window) {
showNotAllowed("Push isn't allowed in your browser ");
return
}
subscribe(reg);
}
const showNotAllowed = (message) => {
const button = document.querySelector('form>button');
button.innerHTML = `${message}`;
button.setAttribute('disabled', 'true');
};

Функция initializeState проверяет:

  • Включил ли пользователь уведомления (через значение reg.showNotification).
  • Разрешил ли пользователь отображать уведомления.
  • Поддерживает ли браузер API PushManager.

Если одна из этих проверок не будет пройдена, приложение вызовет функцию showNotAllowed, а подписка на уведомления будет отменена.

Функция showNotAllowed отключает уведомления, если пользователь не имеет права их принимать. Также она выводит соответствующее предупреждение, если пользователь заблокировал отображение push-уведомлений или браузер не поддерживает их.

Убедившись, что пользователь может получать push-уведомления, приложение оформит подписку с помощью команды pushManager. После функции showNotAllowed добавьте такой код:

...
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);
const outputData = outputArray.map((output, index) => rawData.charCodeAt(index));
return outputData;
}
const subscribe = async (reg) => {
const subscription = await reg.pushManager.getSubscription();
if (subscription) {
sendSubData(subscription);
return;
}
const vapidMeta = document.querySelector('meta[name="vapid-key"]');
const key = vapidMeta.content;
const options = {
userVisibleOnly: true,
// if key exists, create applicationServerKey property
...(key && {applicationServerKey: urlB64ToUint8Array(key)})
};
const sub = await reg.pushManager.subscribe(options);
sendSubData(sub)
};

Функция pushManager.getSubscription выводит данные для подписки. Если у пользователя есть активная подписка, вызывается функция sendSubData, а данные о подписке включаются как параметры.

Если активной подписки нет, функция urlB64TUint8Array преобразовывает зашифрованный с помощью алгоритма Base64открытый ключ VAPID в Uint8Array. После этого вызывается функция pushManager.subscribe с открытым ключом VAPID и значением userVisible в качестве параметра. О других доступных параметрах можно почитать здесь.

Когда пользователь будет успешно подписан, приложение отправит данные о подписке на сервер. Они отправятся в конечную точку webpush/save_information, которую предоставляет пакет django-webpush. После subscribe вставьте такой код:

...
const sendSubData = async (subscription) => {
const browser = navigator.userAgent.match(/(firefox|msie|chrome|safari|trident)/ig)[0].toLowerCase();
const data = {
status_type: 'subscribe',
subscription: subscription.toJSON(),
browser: browser,
};
const res = await fetch('/webpush/save_information', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'content-type': 'application/json'
},
credentials: "include"
});
handleResponse(res);
};
const handleResponse = (res) => {
console.log(res.status);
};
registerSw();

Конечная точка save_information собирает данные о состоянии (subscribe или unsubscribe), собственно данные подписки и информацию о браузере. Функция registerSw() затем запускает процесс подписки пользователя.

В результате ваш файл должен выглядеть таким образом:

const registerSw = async () => {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.register('sw.js');
initialiseState(reg)
} else {
showNotAllowed("You can't send push notifications ☹️")
}
};
const initialiseState = (reg) => {
if (!reg.showNotification) {
showNotAllowed('Showing notifications isn\'t supported ☹️');
return
}
if (Notification.permission === 'denied') {
showNotAllowed('You prevented us from showing notifications ☹️');
return
}
if (!'PushManager' in window) {
showNotAllowed("Push isn't allowed in your browser ");
return
}
subscribe(reg);
}
const showNotAllowed = (message) => {
const button = document.querySelector('form>button');
button.innerHTML = `${message}`;
button.setAttribute('disabled', 'true');
};
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);
const outputData = outputArray.map((output, index) => rawData.charCodeAt(index));
return outputData;
}
const subscribe = async (reg) => {
const subscription = await reg.pushManager.getSubscription();
if (subscription) {
sendSubData(subscription);
return;
}
const vapidMeta = document.querySelector('meta[name="vapid-key"]');
const key = vapidMeta.content;
const options = {
userVisibleOnly: true,
// if key exists, create applicationServerKey property
...(key && {applicationServerKey: urlB64ToUint8Array(key)})
};
const sub = await reg.pushManager.subscribe(options);
sendSubData(sub)
};
const sendSubData = async (subscription) => {
const browser = navigator.userAgent.match(/(firefox|msie|chrome|safari|trident)/ig)[0].toLowerCase();
const data = {
status_type: 'subscribe',
subscription: subscription.toJSON(),
browser: browser,
};
const res = await fetch('/webpush/save_information', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'content-type': 'application/json'
},
credentials: "include"
});
handleResponse(res);
};
const handleResponse = (res) => {
console.log(res.status);
};
registerSw();

Затем добавьте тег script для файла registerSw.js в home.html. Откройте файл home.html:

nano ~/djangopush/templates/home.html

Вставьте тег перед закрывающимся тегом элемента body:

{% load static %}
<!DOCTYPE html>
<html lang="en">

<head>
...
</head>
<body>
...
<script src="{% static '/js/registerSw.js' %}"></script>
</body>
</html>

Если бы вы попробовали запустить приложение, вы получили бы сообщение об ошибке, потому что service worker еще нет. Давайте теперь напишем service worker.

2: Создание service worker

Чтобы показывать push-уведомления, нужно создать активный service worker для домашней страницы приложения. Сейчас мы напишем service worker, который будет слушать события push и отображать уведомления.

Чтобы service worker охватил весь домен, нужно поместить его в корневой каталог приложения. Более подробно процесс регистрации service worker описан в этом мануале. Мы создадим файл sw.js в каталоге templates, а затем зарегистрируем его как вид. Создайте необходимый файл:

nano ~/djangopush/templates/sw.js

Вставьте в файл этот код, который настраивает service worker на прослушивание событий push:

// Register event listener for the 'push' event.
self.addEventListener('push', function (event) {
// Retrieve the textual payload from event.data (a PushMessageData object).
// Other formats are supported (ArrayBuffer, Blob, JSON), check out the documentation
// on https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData.
const eventInfo = event.data.text();
const data = JSON.parse(eventInfo);
const head = data.head || 'New Notification ';
const body = data.body || 'This is default content. Your notification didn\'t have one ';
// Keep the service worker alive until the notification is created.
event.waitUntil(
self.registration.showNotification(head, {
body: body,
icon: 'https://i.imgur.com/MZM3K5w.png'
})
);
});

Service worker теперь прослушивает события push, а функция обратного вызова преобразовывает их в текст. Если в данных событиях строки title и body отсутствуют, используются строки по умолчанию. Функция showNotification принимает в качестве параметров название уведомления, заголовок, который нужно отобразить, и объект options (он содержит свойства для визуальной настройки уведомления).

Чтобы service worker охватывал весь домен, нужно установить его в корневом каталоге приложения. Используйте TemplateView, чтобы предоставить service worker такой доступ.

Откройте urls.py:

nano ~/djangopush/djangopush/urls.py

Добавьте в список urlpatterns новый импорт и путь, чтобы создать вид на основе классов:

...
from django.views.generic import TemplateView
urlpatterns = [
...,

path('sw.js', TemplateView.as_view(template_name='sw.js', content_type='application/x-javascript'))

] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

Виды на основе классов, такие как TemplateView, очень гибкие и их можно повторно использовать. В данном случае метод TemplateView.as_view создаст путь для service worker: он передает service worker как шаблон, а application/x-javascript как параметр content_type  шаблона.

3: Отправка push-уведомлений

Теперь с помощью формы на домашней странице пользователи смогут отправлять push-уведомления (если сервер работает). Также push-уведомления можно отправить с помощью любого сервиса RESTful, например, через Postman. При отправке уведомления через форму на домашней странице оно будет содержать head, body и id получателя. Данные нужно структурировать так:

{
head: "Title of the notification",
body: "Notification body",
id: "User's id"
}

Чтобы прослушивать события submit из формы и отправлять введенные пользователем данные на сервер, создайте файл site.js в каталоге ~/djangopush/static/js.

nano ~/djangopush/static/js/site.js

Сначала настройте прослушивание событий submit в форме, чтобы извлекать из нее значения и идентификатор пользователя, который находится в теге meta.

const pushForm = document.getElementById('send-push__form');
const errorMsg = document.querySelector('.error');
pushForm.addEventListener('submit', async function (e) {
e.preventDefault();
const input = this[0];
const textarea = this[1];
const button = this[2];
errorMsg.innerText = '';
const head = input.value;
const body = textarea.value;
const meta = document.querySelector('meta[name="user_id"]');
const id = meta ? meta.content : null;
...
// TODO: make an AJAX request to send notification
});

Функция pushForm извлекает input, textarea и button из формы. Кроме того, она извлекает данные из тега meta, в том числе атрибут user_id и id пользователя, что находится в атрибуте content. Собрав эти данные, функция может отправить POST запрос конечной точке /send_push на сервере.

Чтобы отправить запрос на сервер, нужно использовать встроенный API Fetch. Он поддерживается многими браузерами и не зависит от сторонних библиотек. Найдите функцию pushForm и добавьте в нее код для отправки запросов AJAX:

const pushForm = document.getElementById('send-push__form');
const errorMsg = document.querySelector('.error');
pushForm.addEventListener('submit', async function (e) {
...
const id = meta ? meta.content : null;
if (head && body && id) {
button.innerText = 'Sending...';
button.disabled = true;
const res = await fetch('/send_push', {
method: 'POST',
body: JSON.stringify({head, body, id}),
headers: {
'content-type': 'application/json'
}
});
if (res.status === 200) {
button.innerText = 'Send another !';
button.disabled = false;
input.value = '';
textarea.value = '';
} else {
errorMsg.innerText = res.message;
button.innerText = 'Something broke ..  Try again?';
button.disabled = false;
}
}
else {
let error;
if (!head || !body){
error = 'Please ensure you complete the form '
}
else if (!id){
error = "Are you sure you're logged in? . Make sure! "
}
errorMsg.innerText = error;
}
});

Если все три необходимых параметра — head, body и id – есть, можно отправить запрос и временно отключить кнопку submit.

Полный код выглядит так:

const pushForm = document.getElementById('send-push__form');
const errorMsg = document.querySelector('.error');
pushForm.addEventListener('submit', async function (e) {
e.preventDefault();
const input = this[0];
const textarea = this[1];
const button = this[2];
errorMsg.innerText = '';
const head = input.value;
const body = textarea.value;
const meta = document.querySelector('meta[name="user_id"]');
const id = meta ? meta.content : null;
if (head && body && id) {
button.innerText = 'Sending...';
button.disabled = true;
const res = await fetch('/send_push', {
method: 'POST',
body: JSON.stringify({head, body, id}),
headers: {
'content-type': 'application/json'
}
});
if (res.status === 200) {
button.innerText = 'Send another !';
button.disabled = false;
input.value = '';
textarea.value = '';
} else {
errorMsg.innerText = res.message;
button.innerText = 'Something broke ..  Try again?';
button.disabled = false;
}
}
else {
let error;
if (!head || !body){
error = 'Please ensure you complete the form '
}
else if (!id){
error = "Are you sure you're logged in? . Make sure! "
}
errorMsg.innerText = error;
}
});

Теперь добавьте файл site.js в home.html:

nano ~/djangopush/templates/home.html

Добавьте тег script:

{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
...
<script src="{% static '/js/site.js' %}"></script>
</body>
</html>

Если ваше приложение запущено (или вы попробуете запустить его снова), вы получите ошибку, так как service workers на данный момент работает только на защищенных доменах или на localhost. Давайте создадим туннель на веб-сервере с помощью ngrok.

4: Создание туннеля для тестирования приложения

Service worker-ы работают по защищенным соединениям со всеми доменами, кроме localhost, у них нет другой защиты от взлома и подмены ответов. Для этого давайте создадим надежный туннель с помощью ngrok.

Откройте второй терминал и перейдите в домашний каталог:

cd ~

Если вы используете свежий сервер Ubuntu 18.04, установите unzip:

sudo apt update && sudo apt install unzip

Затем загрузите ngrok:

wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip
unzip ngrok-stable-linux-amd64.zip

Переместите ngrok в каталог /usr/local/bin, чтобы иметь доступ к команде в командной строке:

sudo mv ngrok /usr/local/bin

Вернитесь в первый терминал и перейдите в каталог проекта, после чего запустите сервер:

cd ~/djangopush
python manage.py runserver your_server_ip:8000

Это нужно сделать до того, как вы создадите защищенный туннель для приложения.

Во втором терминале откройте каталог проекта и активируйте виртуальную среду:

cd ~/djangopush
source my_env/bin/activate

Теперь создайте защищенный туннель:

ngrok http your_server_ip:8000

Вы получите такой вывод, в котором содержатся данные о ngrok URL:

ngrok by @inconshreveable                                                                                                                     (Ctrl+C to quit)
Session Status                online
Session Expires               7 hours, 59 minutes
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://ngrok_secure_url -> 203.0.113.0:8000
Forwarding                    https://ngrok_secure_url -> 203.0.113.0:8000
Connections                   ttl     opn     rt1     rt5     p50     p90
0       0       0.00    0.00    0.00    0.00

Скопируйте значение ngrok_secure_url из этого вывода. Затем его нужно добавить в список ALLOWED_HOSTS в файле settings.py.

Откройте еще один терминал и перейдите в папку проекта, а затем активируйте виртуальную среду:

cd ~/djangopush
source my_env/bin/activate

Откройте файл settings.py:

nano ~/djangopush/djangopush/settings.py

Обновите ALLOWED_HOSTS, добавьте в список  туннель ngrok:

...
ALLOWED_HOSTS = ['your_server_ip', 'ngrok_secure_url']
...

Откройте защищенную страницу https://ngrok_secure_url/admin/. На ней появится форма входа.

Введите в эту форму учетные данные пользователя Django с правами администратора. Теперь все готово к отправке push-уведомлений.

Откройте в браузере https://ngrok_secure_url. Появится диалоговое окно и запросит разрешение на показ уведомлений. Нажмите кнопку Allow, чтобы разрешить показывать push-уведомления в браузере.

После этого на экране появится форма Send a Push Notification.

Примечание: Убедитесь, что сервер запущен, прежде чем отправить уведомление.

Если вы получили свои уведомления, значит, наше приложение работает правильно. Вы успешно создали веб-приложение, отправляющее push-уведомления.

Заключение

В данном мануале вы научились подписывать пользователей на push-уведомления, создавать service worker’ы и отображать push-уведомления с помощью Notifications API. Также вы получили ключи VAPID, которые нужны для отправки push-уведомлений с сервера приложения.

Исходный код этого мануала можно найти здесь.

Tags: , ,

Добавить комментарий