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.

Nein, lass das, ich hab nichtmal gefragt!

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 grobim 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:

Notification von Chrome in den Windows-Nachrichten
Notification von Firefox

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:

Chrome schneidet das Bild (“image”) ab.
Firefox nutzt das Attribut “icon”.

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

Björn Falszewski
28. Mai 2020
Disclaimer
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!