Benachrichtigung bei neuer Anfrage: Push aufs Handy

BEFUND ZUERST, DENN ER WAR ANDERS ALS ERWARTET

Es gab sehr wohl eine Benachrichtigung -- ueber einen Discord-Webhook,
sauber gebaut, bewusst ohne Kundendaten im Text. Sie begann aber mit:

    const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
    if (!url) return;

Und diese Variable ist in .env.example nirgends aufgefuehrt. Eine
Variable, die niemand kennt, wird nicht gesetzt; dann kehrte die
Funktion wortlos zurueck. Kein Fehler, keine Protokollzeile. Es gab
eine Benachrichtigung, die nur im Quelltext existierte -- und niemand
konnte das bemerken, weil "funktioniert" und "kaputt" identisch
aussehen, solange nichts passiert.

WAS JETZT DA IST

Push aufs Handy, ohne Fremdpaket. Der interne Dienst liegt unter
/home/dogiintern mit Rechten 700 -- dort ist kein npm erreichbar, jede
Abhaengigkeit haette dauerhafte Handarbeit bedeutet. Node bringt P-256,
HKDF und AES-128-GCM selbst mit.

Die Schluessel erzeugt der Server beim ersten Start selbst und legt den
privaten Teil verschluesselt in der Datenbank ab (derselbe Weg wie die
PayPal-Zugangsdaten). Damit gibt es keinen Einrichtungsschritt, der
vergessen werden kann.

GEPRUEFT

Gegen RFC 8291 statt gegen ein Bauchgefuehl: alle fuenf Zwischenwerte
aus Anhang A stimmen (ECDH-Geheimnis, PRK_key, IKM, CEK, NONCE), und
die fertige Nachricht ist Byte fuer Byte die aus Abschnitt 5 der Norm.
Bei Kryptographie erzeugt ein Ableitungsfehler keinen Absturz, sondern
Bytes, die genauso zufaellig aussehen wie richtige.

Dazu ein Kettentest mit einem echten Empfaenger, der entschluesselt:
Kopfzeilen, Inhalt, keine Kundendaten in der Meldung, 410 loescht das
Geraet, 500 loescht es NICHT (sonst kostet eine einzelne Stoerung die
Anmeldung). 50 Pruefungen, alle gruen.

DREI ENTSCHEIDUNGEN

1. In der Meldung stehen nur Nummer und Paket. Sie erscheint auf einem
   Sperrbildschirm, den auch jemand sieht, der zufaellig danebensteht.

2. Ist der Schluessel unlesbar, wird KEIN neuer erzeugt. Das waere der
   bequeme Weg und der schlimmste: Ein neuer oeffentlicher Schluessel
   macht schlagartig jede Anmeldung wertlos, ohne dass jemand erfaehrt,
   warum nichts mehr ankommt.

3. Die Verwaltung zeigt den Zustand an und hat einen Testknopf. Genau
   das fehlte dem Discord-Weg. Bei verweigerter Erlaubnis erscheint
   kein Knopf, der nichts bewirkt, sondern der Weg ueber die
   Browsereinstellungen.

Das stille "if (!url) return;" ist ersetzt: Jeder Weg wird einzeln
protokolliert -- auch und gerade, wenn er uebersprungen wird.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-26 10:50:33 +02:00
co-authored by Claude Opus 5
parent 75ab3f713f
commit f435d89a16
11 changed files with 1408 additions and 11 deletions
+73 -1
View File
@@ -34,7 +34,7 @@
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
Stand fest. */
const CACHE_NAME = "dogfather-webdesign-v51";
const CACHE_NAME = "dogfather-webdesign-v52";
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
HTML-Datei in dieser Liste. */
@@ -189,3 +189,75 @@ self.addEventListener("fetch", (ereignis) => {
/* Alles Übrige: kein respondWith -> der Browser macht es ganz normal
selbst. Das ist die sicherste Voreinstellung. */
});
/* =====================================================================
PUSH-MELDUNGEN (26.08.2026)
Ab hier geht es nicht mehr um Zwischenspeicher, sondern um
Benachrichtigungen. Beides liegt im selben Service Worker, weil der
Browser nur einen je Geltungsbereich zulaesst.
WARUM IMMER ETWAS ANGEZEIGT WIRD
Browser verlangen: Wer die Erlaubnis fuer Benachrichtigungen hat und
eine Push-Nachricht empfaengt, MUSS etwas sichtbar machen. Wer still
empfaengt, verliert die Erlaubnis wieder -- zu Recht, denn sonst
waere Push ein bequemer Weg, ein Geraet unbemerkt anzufunken.
Deshalb gibt es unten auch fuer den Fall einer unlesbaren Nachricht
eine Ersatzmeldung, statt einfach nichts zu tun.
===================================================================== */
self.addEventListener("push", (ereignis) => {
let daten = {};
try {
daten = ereignis.data ? ereignis.data.json() : {};
} catch (e) {
/* Kam etwas an, das kein JSON ist, wird trotzdem gemeldet -- siehe
oben. Lieber eine unspezifische Meldung als eine verlorene
Erlaubnis. */
daten = { titel: "Neue Meldung", text: "In der Verwaltung nachsehen." };
}
const titel = daten.titel || "Dogfather Webdesign";
const optionen = {
body: daten.text || "",
icon: "/assets/img/wd-app-192.png",
badge: "/assets/img/wd-app-192.png",
/* Gleiche Kennung = neue Meldung ERSETZT die alte. Kommen fuenf
Anfragen hintereinander, soll nicht fuenfmal dasselbe untereinander
stehen. */
tag: daten.art || "allgemein",
renotify: false,
/* Bewusst NICHT requireInteraction: Eine Meldung, die stehen bleibt,
bis man sie wegklickt, ist auf dem Sperrbildschirm eine Zumutung. */
data: { url: daten.url || "/webdesign/verwaltung.html" },
/* Sanft. Die Seite soll augenschonend sein, und das gilt auch fuer
das, was sie nachts auf einem Sperrbildschirm veranstaltet. */
silent: false,
vibrate: [80, 40, 80],
};
ereignis.waitUntil(self.registration.showNotification(titel, optionen));
});
self.addEventListener("notificationclick", (ereignis) => {
ereignis.notification.close();
const ziel = (ereignis.notification.data && ereignis.notification.data.url)
|| "/webdesign/verwaltung.html";
/* Ist die Verwaltung schon offen, wird dieses Fenster nach vorn geholt
statt ein zweites zu oeffnen. Sonst sammeln sich mit jeder Meldung
weitere Fenster derselben Seite an. */
ereignis.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => {
for (const f of fenster) {
if (f.url.includes("/webdesign/") && "focus" in f) {
if ("navigate" in f && !f.url.endsWith(ziel)) f.navigate(ziel).catch(() => {});
return f.focus();
}
}
return self.clients.openWindow(ziel);
})
);
});
+244
View File
@@ -2114,6 +2114,16 @@
erst nach dem Scrollen, und der Sinn ist dahin. -->
<div id="vw-meldungen" hidden></div>
<!-- Benachrichtigung aufs Handy (26.08.2026).
Steht bewusst hier oben und nicht in einem
Einstellungsreiter: Der bisherige Discord-Weg war
eingebaut, aber nie eingerichtet, und man konnte das
nirgends sehen. Eine Benachrichtigung, deren Zustand
versteckt ist, ist keine -- solange nichts passiert,
sehen "funktioniert" und "kaputt" voellig gleich aus. -->
<div id="vw-push-karte" hidden></div>
<h2 class="vw-ub-h">Was auf dich wartet</h2>
<div class="vw-ub-gitter" id="vw-ub-beimir"></div>
@@ -2606,6 +2616,234 @@
var uebersichtGeladen = 0;
/* =====================================================================
BENACHRICHTIGUNG AUFS HANDY (26.08.2026)
Die Karte beantwortet EINE Frage: Würde ich es mitbekommen, wenn
jetzt eine Anfrage käme? Alles andere ist Beiwerk.
Vier Zustände, und jeder bekommt einen anderen Text — denn was zu
tun ist, unterscheidet sich völlig:
1. Der Browser kann kein Push (z.B. iPhone-Safari im Reiter,
ohne dass die Seite auf dem Home-Bildschirm liegt).
2. Push ist möglich, aber noch nicht eingeschaltet.
3. Eingeschaltet und es kommt an.
4. Der Nutzer hat es im Browser dauerhaft verboten. Dann hilft
kein Knopf mehr — das lässt sich nur in den
Browsereinstellungen zurücknehmen, und genau das muss dort
stehen, statt einen Knopf anzubieten, der nichts bewirkt.
===================================================================== */
var pushKarteBeschaeftigt = 0;
function pushMoeglich() {
return ("serviceWorker" in navigator) && ("PushManager" in window)
&& ("Notification" in window);
}
async function ladePushKarte() {
var kasten = document.getElementById("vw-push-karte");
if (!kasten) return;
var d;
try {
d = await api("/webdesign/admin/push/stand");
} catch (fehler) {
/* Der Server ist älter als diese Seite — kein Grund, die
Übersicht mit einer Fehlermeldung zu belasten. */
kasten.hidden = true;
return;
}
if (!d || !d.ok) { kasten.hidden = true; return; }
/* Ist DIESER Browser angemeldet? Das ist nicht dasselbe wie "es gibt
angemeldete Geräte": Am Rechner eingeschaltet zu haben, sagt nichts
darüber, ob das Handy meldet. */
var diesesGeraet = 0;
if (pushMoeglich()) {
try {
var reg = await navigator.serviceWorker.getRegistration("/webdesign/");
var abo = reg && await reg.pushManager.getSubscription();
diesesGeraet = !!abo;
} catch (e) { diesesGeraet = 0; }
}
zeichnePushKarte(d, diesesGeraet);
}
function zeichnePushKarte(d, diesesGeraet) {
var kasten = document.getElementById("vw-push-karte");
var erlaubnis = ("Notification" in window) ? Notification.permission : "unsupported";
var h = "";
if (!pushMoeglich()) {
h = '<p class="wd-klein wd-leise">Dieser Browser kann keine Benachrichtigungen empfangen. ' +
'Auf dem iPhone geht es erst, wenn die Seite über „Teilen → Zum Home-Bildschirm“ ' +
'installiert und von dort geöffnet wird.</p>';
kasten.innerHTML = kachel("Benachrichtigung", h, "leise");
kasten.hidden = false;
return;
}
if (erlaubnis === "denied") {
h = '<p class="wd-klein">Benachrichtigungen sind für diese Seite im Browser <strong>blockiert</strong>. ' +
'Ein Knopf hilft hier nicht weiter — das lässt sich nur in den Browsereinstellungen ' +
'zurücknehmen (Schloss-Symbol neben der Adresse → Benachrichtigungen → Zulassen).</p>';
kasten.innerHTML = kachel("Benachrichtigung blockiert", h, "warn");
kasten.hidden = false;
return;
}
if (diesesGeraet && d.wirktMeldung) {
h = '<p class="wd-klein">Dieses Gerät bekommt eine Meldung, sobald eine neue Anfrage eingeht.' +
(d.geraete > 1 ? ' Insgesamt sind <strong>' + s(String(d.geraete)) + '</strong> Geräte angemeldet.' : '') +
'</p>' +
(d.geraeteMitFehlern
? '<p class="wd-klein wd-leise">' + s(String(d.geraeteMitFehlern)) +
' Gerät(e) haben zuletzt nicht geantwortet.</p>'
: '') +
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-push-test">Probemeldung schicken</button>' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-push-aus">Auf diesem Gerät ausschalten</button>' +
'</div>';
kasten.innerHTML = kachel("Benachrichtigung ist an", h, "gut");
} else {
h = '<p class="wd-klein">Neue Anfragen fallen im Moment nur auf, wenn du hier nachsiehst. ' +
'Mit einer Meldung aufs Handy bekommst du es sofort mit — im Text stehen nur ' +
'Nummer und Paket, keine Kundendaten.</p>' +
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<button type="button" class="wd-btn wd-btn--klein" id="vw-push-an">Auf diesem Gerät einschalten</button>' +
'</div>';
kasten.innerHTML = kachel("Benachrichtigung einschalten", h, "warn");
}
kasten.hidden = false;
verdrahtePushKnoepfe();
}
function kachel(titel, inhalt, art) {
var farbe = art === "gut" ? "var(--wd-aqua)"
: art === "warn" ? "var(--wd-lila-hell)" : "var(--wd-rand)";
return '<div class="wd-karte" style="padding:1rem 1.1rem;margin-bottom:1.1rem;' +
'border-left:3px solid ' + farbe + '">' +
'<h3 style="font-size:1rem;margin:0 0 .4rem">' + s(titel) + '</h3>' + inhalt + '</div>';
}
function verdrahtePushKnoepfe() {
var an = document.getElementById("vw-push-an");
if (an) an.addEventListener("click", pushEinschalten);
var aus = document.getElementById("vw-push-aus");
if (aus) aus.addEventListener("click", pushAusschalten);
var test = document.getElementById("vw-push-test");
if (test) test.addEventListener("click", pushProbe);
}
/* Der Base64-Schlüssel muss als Byte-Feld übergeben werden. Ein
Zeichenkettenwert wird ohne Fehlermeldung abgelehnt — der Aufruf
schlägt einfach fehl und nennt keinen Grund. */
function schluesselZuBytes(text) {
var voll = (text + "=".repeat((4 - text.length % 4) % 4))
.replace(/-/g, "+").replace(/_/g, "/");
var roh = atob(voll);
var feld = new Uint8Array(roh.length);
for (var i = 0; i < roh.length; i++) feld[i] = roh.charCodeAt(i);
return feld;
}
async function pushEinschalten() {
if (pushKarteBeschaeftigt) return;
pushKarteBeschaeftigt = 1;
var knopf = document.getElementById("vw-push-an");
if (knopf) { knopf.disabled = true; knopf.textContent = "Einen Moment…"; }
try {
var erlaubnis = await Notification.requestPermission();
if (erlaubnis !== "granted") { await ladePushKarte(); return; }
var k = await api("/webdesign/admin/push/schluessel");
if (!k || !k.ok) throw new Error(k && k.error ? k.error : "Kein Schlüssel vom Server.");
var reg = await navigator.serviceWorker.register("/webdesign/sw.js", { scope: "/webdesign/" });
await navigator.serviceWorker.ready;
var abo = await reg.pushManager.subscribe({
/* Ohne diese Angabe verweigern alle Browser die Anmeldung: Sie
verlangen die Zusage, dass jede Nachricht auch sichtbar wird. */
userVisibleOnly: true,
applicationServerKey: schluesselZuBytes(k.schluessel),
});
await api("/webdesign/admin/push/anmelden", {
method: "POST",
body: { abo: abo.toJSON(), bezeichnung: geraeteName() },
});
} catch (fehler) {
var kasten = document.getElementById("vw-push-karte");
if (kasten) {
kasten.innerHTML = kachel("Einschalten hat nicht geklappt",
'<p class="wd-klein">' + s((fehler && fehler.message) || "Unbekannter Fehler") + '</p>' +
'<div class="wd-btn-reihe" style="margin-top:.7rem">' +
'<button type="button" class="wd-btn wd-btn--zweit wd-btn--klein" id="vw-push-an">Nochmal versuchen</button>' +
'</div>', "warn");
verdrahtePushKnoepfe();
}
return;
} finally {
pushKarteBeschaeftigt = 0;
}
await ladePushKarte();
}
async function pushAusschalten() {
if (pushKarteBeschaeftigt) return;
pushKarteBeschaeftigt = 1;
try {
var reg = await navigator.serviceWorker.getRegistration("/webdesign/");
var abo = reg && await reg.pushManager.getSubscription();
if (abo) {
/* Erst dem Server sagen, dann im Browser abmelden. Andersherum
wäre die Adresse schon weg, wenn der Server sie noch bräuchte
— und der Eintrag bliebe als Karteileiche zurück. */
await api("/webdesign/admin/push/abmelden", {
method: "POST", body: { endpunkt: abo.endpoint },
}).catch(function () {});
await abo.unsubscribe();
}
} catch (e) { /* nicht schlimm — die Anzeige unten korrigiert sich */ }
pushKarteBeschaeftigt = 0;
await ladePushKarte();
}
async function pushProbe() {
var knopf = document.getElementById("vw-push-test");
if (knopf) { knopf.disabled = true; knopf.textContent = "Wird geschickt…"; }
try {
var r = await api("/webdesign/admin/push/test", { method: "POST", body: {} });
if (knopf) {
var e = r && r.ergebnis;
knopf.textContent = (e && e.gesendet)
? "An " + e.gesendet + " Gerät(e) geschickt"
: "Nichts erreicht";
}
} catch (fehler) {
if (knopf) knopf.textContent = "Hat nicht geklappt";
}
setTimeout(function () {
if (knopf) { knopf.disabled = false; knopf.textContent = "Probemeldung schicken"; }
}, 3500);
}
/* Etwas Wiedererkennbares für die Geräteliste. Ohne das steht dort nur
eine 200 Zeichen lange Adresse. */
function geraeteName() {
var u = navigator.userAgent;
var browser = /Edg\//.test(u) ? "Edge" : /Chrome\//.test(u) ? "Chrome"
: /Firefox\//.test(u) ? "Firefox" : /Safari\//.test(u) ? "Safari" : "Browser";
var system = /Android/.test(u) ? "Android" : /iPhone|iPad/.test(u) ? "iPhone/iPad"
: /Windows/.test(u) ? "Windows" : /Mac OS/.test(u) ? "Mac" : "";
return browser + (system ? " auf " + system : "");
}
async function ladeUebersicht() {
var laedt = document.getElementById("vw-ub-laedt");
var inhalt = document.getElementById("vw-ub-inhalt");
@@ -2649,6 +2887,12 @@
laedt.hidden = true;
inhalt.hidden = false;
/* Bewusst OHNE await: Die Übersicht soll nicht darauf warten. Die
Abfrage fragt den Browser nach dem Anmeldestand, und das kann auf
einem gerade erst gestarteten Service Worker einen Moment dauern.
Die Karte erscheint dann eben eine Sekunde später. */
ladePushKarte();
var m = d.beiMir, k = d.beiKunde, g = d.geld;
document.getElementById("vw-ub-beimir").innerHTML =