Web-Push-Notifications mit PHP (und JS)
Notifications – das sind die Dinger, die auf dem Smartphone immer aufpoppen und bei denen man zu faul ist, sie zu deaktiveren – helfen dabei, immer auf dem neuesten Stand zu sein.
Nicht nur Apps können das, Webseiten auch. Das dürfte dir schon häufiger aufgefallen sein, denn mittlerweile bekommt man sowas ungefragt aufgedrängt.
Das ist natürlich sehr schlechte User-Experience. Sowas solltest du auch nicht machen. Man kann den User aber dezent darauf hinweisen, dass er diese Benachrichtigungen aktivieren kann, die äußerst nützlich sein können.
Willkommen in der Push-Hölle
So habe ich mich jedenfalls gefühlt, als ich das Web-Push-System für unterricht.cloud entwickelt habe. Damit dir das nicht passiert, gebe ich dir ein paar Verständnisgrundlagen und Nachschlagehilfen.
Was du benötigst:
- Geduld. Viel Geduld.
- Eine Aversität zu iOS, denn diese Apple-Geräte unterstützen keine Web-Push-Notification.
- Eine Webseite mit SSL. Falls du lokal arbeitest, erkläre ich dir hier, wie du ein Trusted-Self-Signed-Zertifikat erstellst. Tust du das nicht, hast du definitiv Probleme mit Chrome, denn dann geht das absolut gar nicht (nur self-signed reicht nicht).
Die Grundlagen
Sicherlich hast du schon grob im Kopf, wie so ein Notification-Workflow funktionieren könnte. So sah das bei mir aus, bevor ich mich durch endlos lange Websites gequält habe:
- Der User klickt ‘nen Knopf und bestätigt, dass er Notifications erhalten will (siehe Bild oben).
- Der Browser/das Frontend liefert dann irgendwelche IDs oder Tokens, die man auf seinem Server für den User speichert.
- Wenn man eine Notification an den User senden will, nimmt man diese IDs und sendet diese inkl. seiner Nachricht an irgendeinen anderen Server (z.B. von Google), der wiederrum dafür sorgt, dass diese auf dem Endgerät beim User angezeigt wird.
Das ist so halb richtig. Folgendes ist viel korrekter und mit wichtigen Fremdwörtern bestückt; keine Angst, alles wird noch erklärt.
- Zunächst muss man für seinen Server ein Server-Key-Paar genieren, mit dem man seine Notifications signiert.
- Das Frontend fragt den User, ob er Notifications erhalten will. Das geschieht über JavaScript, genauer gesagt den PushManager (Funktion
subscribe()
). - Wenn der User dem zugestimmt hat, erhält man einen Endpoint, einen Auth-Token und einen Public-Key. Das alles ist nur für diesen User gültig und sollte z.B. per POST (also HTTP-Post, nicht Deutsche Post, das dauert zu lange) an den Server geschickt und in einer Datenbank gespeichert werden.
- Wenn der Server an den User eine Notification senden soll, nutzt man z.B. die PHP-Lib minishlink/webpush, der man alle o.a. Daten übergibt.
- Jetzt kommt aber der Clou: Damit ist die eigene Arbeit noch nicht abgeschlossen, denn diese Notification muss man im Browser über einen ServiceWorker abfangen und verarbeiten. Erst hier ruft man die JS-Funktion
showNotification()
auf, um die Notification tatsächlich vom Betriebssystem anzeigen zu lassen.
Server-Key-Paar erstellen
Als erstes benötigen wir Keys für den Server, bzw. für die Applikation. Das muss man nur einmal machen, denn damit werden die Notifications signiert. Der Public-Key wird im Browser genutzt, der Private-Key bleibt geheim auf dem Server.
Key-Erstellung geht über viele Wege, z.B.:
- Online über https://web-push-codelab.glitch.me
- Über das Tool web-push:
yarn add global web-push
web-push generate-vapid-keys
- Über die o.g. PHP-Lib:
var_dump(\Minishlink\WebPush\VAPID::createVapidKeys());
Beispiel-Keys:
Public Key: BP7imydV5BFVWYBRl3xXwl38NoHryXpCQVK5hirnIBtxjlw4qSZbuteFQYLPDL5WlyDjMAqNIuksSpsAJ63gpJA Private Key: KN0_U5rha5MwbuN935Ka1UmccgL-hNOoumk0ohgvD0E
User um Erlaubnis bitten
Bevor wir dieses kleine Popup, das den User um Erlaubnis bittet, Notifications zu empfangen, über eine JS-Funktion aufrufen können, müssen wir zunächst den Service-Worker registrieren.
Service-Worker registrieren
WTF ist ein Service-Worker? Jo. Gleiche Frage hatte ich auch. Hat ein Weilchen gedauert, das zu verstehen, ist aber relativ einfach.
Ein Service-Worker wird über eine eigene Java-Script-Datei im Browser registriert und läuft praktisch “im Hintergrund” und fängt Notifications ab. Wenn z.B. Chrome eine Notification erhält, ruft es zunächst diesen Service-Worker auf, der dann entscheidet, was mit den Daten passiert.
Im ersten Schritt kann diese JS-Datei (sw.js
) leer sein – die füllen wir später, wenn wir Notifications empfangen wollen.
<script> navigator.serviceWorker.register('/sw.js'); </script>
Ganz wichtig: Funktioniert nur im https-Modus! Chrome kennt das Objekt serviceWorker
sonst nicht.
Wenn du diesen Code in deine Seite packst, kannst du die Web-Developer-Tools (z.B. Chrome) dazu nutzen, um den Service-Worker zu debuggen.
Hier kannst du sehen, dass Chrome die Datei registriert hat. Außerdem kannst du Test-Notifications senden. Probiere folgende sw.js
mal aus:
function receivePushNotification(event) { console.log('Push erhalten:' + event.data.text()); } self.addEventListener("push", receivePushNotification);
Wenn du jetzt in den Developer-Tools auf “Push” klickst, siehst du, dass der Service-Worker arbeitet:
Üblicherweise empfängt er aber ein JSON-Objekt, nicht Text – dazu aber später mehr.
User um Erlaubnis bitten (jetzt wirklich)
Ne, doch nicht. An dieser Stelle macht es Sinn, das komplette JS-Objekt einzubauen, das alle Funktionen beinhaltet, die wir jetzt benötigen. Das sieht so aus:
var notificationHelper = { isSupported() { if (!window.Notification) { return false; } if (!('serviceWorker' in navigator)) { return false; } if (!('PushManager' in window)) { return false; } return true; }, urlBase64ToUint8Array(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; }, createNotificationSubscription(pushServerPublicKey) { return navigator.serviceWorker.ready.then(function(serviceWorker) { return serviceWorker.pushManager .subscribe({ userVisibleOnly: true, applicationServerKey: module.exports.urlBase64ToUint8Array(pushServerPublicKey) }) .then(function(pushSubscription) { var subJSObject = JSON.parse(JSON.stringify(pushSubscription)); var subscription = { 'endpoint': subJSObject.endpoint, 'authToken': subJSObject.keys.auth, 'publicKey': subJSObject.keys.p256dh } return subscription; }); }); }, registerServiceWorker(file) { if (!navigator.serviceWorker) return; navigator.serviceWorker.register(file); } }
Das kannst du zum Testen gerne in das obige <script>-Tag einfügen.
Um den User (jetzt wirklich) nach der Erlaubnis zu fragen, und zwar direkt beim Seitenladen (das solltest du wirklich nicht tun), sind nur folgende Aufrufe nötig:
// Service Worker registrieren notificationHelper.registerServiceWorker('/sw.js'); // User um Erlabunis fragen Notification.requestPermission(function(status) { // status kann folgende Werte haben: // default: User wurde noch nicht gefragt // granted: User hat zugestimmt // denied: User hat abgelehnt if (that.pushStatus == 'granted') { // Subscription erstellen und alles nötige ermitteln notificationHelper .createNotificationSubscription(<PUBLIC_SERVER_KEY>) .then(function(subscription) { // Alle Werte ausgeben console.log(subscriptin); }); } });
Es erscheint dann folgende Nachfrage (iOS würde meckern, da er die Variable Notification
nicht kennt):
Anschließend sollten alle Daten (also endpoint, authToken, publicKey) an deinen Server geschickt werden.
Was passiert, wenn der User ablehnt?
Du solltest den User in deinem Frontend genau über den Stand seiner Subscription informieren. Den aktuellen Status erhälst du über:
var status = Notification.permission;
Falls du zu Testzwecken deinen Status ändern möchtest, geht das am schnellsten hier:
Falls der User ablehnt (“denied”), hast du keine Möglichkeit, den User nochmals aufzufordern. Notification.requestPermission()
funktioniert dann nicht mehr. Am besten teilst du ihm in einem Text mit, wo er dies ändern kann.
Notifications senden
Hier ist es wichtig zu verstehen, dass das, was wir als Payload in die Notification packen (ein JSON-Objekt), frei wählbar ist.
Erst, wenn diese Daten an den Service-Worker gelangen, kann dieser die Daten entsprechend den Notifications-Beschreibungen aufbereiten.
Aus meiner Sicht macht es aber absolut Sinn, die Payload schon so zu formen, dass wir diese direkt an den Browser weitergeben können.
Service-Worker anpassen
Bevor wir also unsere erste Notification senden, passen wir den Service-Worker folgendermaßen an:
function receivePushNotification(event) { // Payload entgegennehmen var options = event.data.json(); // Notification anzeigen event.waitUntil(self.registration.showNotification(options.title, options)); } self.addEventListener("push", receivePushNotification);
Das, was der Server absendet, hat also genau das Format, das die JS-Funktion erwartet.
Notifications senden (jetzt wirklich)
Eine PHP-Datei zum Absenden einer Notification könnte z.B. so aussehen (wenn du https://github.com/web-push-libs/web-push-php nutzen möchtest):
<?php // Daten des Servers $auth = [ 'VAPID' => [ 'subject' => 'https://<DOMAIN>', 'publicKey' => <PUBLIC_SERVER_KEY>, 'privateKey' => <PRIVATE_SERVER_KEY>, ], ]; // Web-Push-Objekt initialisieren $webPush = new \Minishlink\WebPush\WebPush($auth); // Subscription des Users $subscription = \Minishlink\WebPush\Subscription::create([ "endpoint" => <USER_ENDPOINT>, "publicKey" => <USER_PUBLICKEY>, "authToken" => <USER_AUTHTOKEN>, ]); // Payload vorbereiten $payload = [ "title" => "Hallo Push-Welt!" ]; // Notification vorbereiten $result = $webPush->sendNotification( $subscription, json_encode($payload) ); // Notification senden foreach ($webPush->flush() as $report) { if ($report->isSuccess()) { echo "OK"; } else { echo "Fehler: {$report->getReason()}"; } }
Wenn du dieses Skript ausführst und alles korrekt registriert wurde, sieht das so aus:
Wie Microsoft Edge das macht? Iiiiiih. Weiß ich nicht. IIiiiiiiiihhhhh.
Notifications mit Actions und Bildern
Eine Notification kann mehr als nur Text. Die genaue Beschreibung findest du hier: https://developer.mozilla.org/…/showNotification.
Wie wäre es mit einer entsprechenden Payload?
<?php $payload = [ "title" => "Hallo Push-Welt!", "body" => "Hi! Dies ist eine Testnachricht!", "badge" => "/assets/img/test1.svg", "icon" => "/assets/img/test2.svg", "image" => "https://unterricht.cloud/wp-content/themes/bueffel/assets/img/unterricht-logo.png", "tag" => "test", "actions" => [ [ "action" => "show1", "title" => "Test-Action1" ], [ "action" => "show2", "title" => "Test-Action2" ] ], "data" => [ "show1" => "/test1", "show2" => "/test2", "default" => "/test3" ], "vibrate" => [300, 100, 400] ];
Ein wichtiger Hinweis: Das Feld data
ist frei definierbar. In diesem Fall nutze ich es, um die Links mitzusenden, mit denen die jeweiligen Action-Buttons show1
und show2
ausgestattet werden sollen. default
ist der Link, wenn die Notification selbst geklickt wird.
Diese Payload sieht dann so aus:
Links verarbeiten
Um die Actions, die über Chrome an Windows weitergeleitet werden, zu nutzen, muss der Service-Worker wieder angepasst werden:
function receivePushNotification(event) { // Payload entgegennehmen var options = event.data.json(); // Notification anzeigen event.waitUntil(self.registration.showNotification(options.title, options)); } function openPushNotification(event) { // Notification schließen event.notification.close(); // Fenster öffnen aufgrund der Action var action = event.action ? event.action : 'default'; event.waitUntil(clients.openWindow(event.notification.data[action])); } self.addEventListener("push", receivePushNotification); self.addEventListener("notificationclick", openPushNotification);
Hier sieht man, dass das vorher definierte Feld data dazu genutzt wird, den richtigen Link aufzurufen. Klickt der User auf die Notification direkt (ohne Action), ist event.action
ein leerer String.
Feinheiten
Das ist natürlich alles nur ein Grundgerüst für die perfekte Web-Push-Applikation. In der Praxis muss man prüfen, ob die Endpoints noch valide sind (es gibt Expiration-Dates) und dem User mitteilen, ob es Probleme gab.
Dabei sollte man beachten, dass ein User mehrere Devices nutzen kann (z.B. Browser und Mobil). Tritt hier ein Fehler auf, muss man sich überlegen, wie man den User darüber informiert.
Ein paar Tipps zum Schluss
- Ein Benutzer kann mehrere Devices oder Browser nutzen, um Notifications zu aktivieren (z.B. Desktop und Mobile). Achte darauf, das auch so in deiner DB zu speichern.
- Du kannst leider nicht ermitteln, welche Subscription zu welchem Device gehört – es sei denn, du bittest den User darum, der Subscription einen Namen zu geben und speicherst es dann in einem Cookie – das ist aber auch nicht so toll.
- Du erfährst nicht wirklich, wenn ein User deine Seite geblockt hast. Das passiert erst ganz sicher, wenn du die Push-Nachricht rausschickst. In diesem Fall solltest du die Subscription aus deiner Datenbank löschen.
- Prüfe regelmäßig, ob der Benutzer noch eine aktive Subscription hat, wenn er die Seite lädt. Hat er keine, könnte diese abgelaufen sein. In diesem Fall rufst du
createNotificationSubscription()
einfach noch einmal im Hintergrund auf. Falls der User zwischendurch die Seite geblockt hat, kanst du ihn darüber informieren. - Esse niemals gelben Schnee.
Quellen
Google Fundamentals: https://developers.google.com/web/fundamentals/codelabs/push-notifications
Einfache Erklärung von allem: https://itnext.io/an-introduction-to-web-push-notifications-a701783917ce
Notifications testen: https://tests.peter.sh/notification-generator/
Payload Beschreibung: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification
28. Mai 2020
Alle meine Artikel entstehen mit bestem Wissen und Gewissen, sind aber nicht perfekt und sollten immer nur als Ausgangspunkt für deine eigenen Recherchen bilden.
Sollte dir etwas Fehlerhaftes auffallen, freue ich mich über deine Nachricht!