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:
@@ -0,0 +1,54 @@
|
||||
-- =====================================================================
|
||||
-- Push-Benachrichtigungen: angemeldete Geraete
|
||||
--
|
||||
-- WARUM EINE EIGENE TABELLE UND KEIN FELD AM BENUTZER
|
||||
--
|
||||
-- Ein Mensch hat mehrere Geraete -- Handy, Rechner, vielleicht ein
|
||||
-- Tablet -- und jedes meldet sich einzeln an. Jedes bekommt eine eigene
|
||||
-- Adresse und eigene Schluessel. Ein einzelnes Feld am Benutzer koennte
|
||||
-- immer nur das zuletzt angemeldete Geraet halten, und die Anmeldung am
|
||||
-- Rechner wuerde die am Handy stillschweigend loeschen.
|
||||
--
|
||||
-- WAS HIER STEHT UND WAS NICHT
|
||||
--
|
||||
-- endpunkt, p256dh und auth kommen vom Browser. Sie sind keine
|
||||
-- Geheimnisse im engeren Sinn, aber sie erlauben es, an genau dieses
|
||||
-- Geraet zu senden -- deshalb liegen sie nicht offener als der Rest der
|
||||
-- Datenbank.
|
||||
--
|
||||
-- ZAEHLER STATT SOFORT LOESCHEN
|
||||
--
|
||||
-- Meldet der Push-Dienst 404 oder 410, ist das Geraet endgueltig weg und
|
||||
-- der Eintrag wird geloescht. Bei allen anderen Fehlern (Netz gestoert,
|
||||
-- Dienst kurz nicht erreichbar) waere Loeschen falsch -- dann wuerde
|
||||
-- eine einzelne Stoerung die Anmeldung kosten. Deshalb ein Zaehler:
|
||||
-- voruebergehende Fehler heilen sich, dauerhafte fallen auf.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wd_push_geraete (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
-- Die Adresse beim Push-Dienst. Eindeutig: Meldet sich dasselbe
|
||||
-- Geraet erneut an, soll der alte Eintrag ersetzt werden und kein
|
||||
-- zweiter danebenstehen -- sonst kaeme jede Meldung doppelt.
|
||||
endpunkt TEXT NOT NULL UNIQUE,
|
||||
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
|
||||
-- Wem gehoert das Geraet. Verweist auf die Team-Anmeldung, damit beim
|
||||
-- Entzug eines Zugangs auch dessen Geraete verschwinden koennen.
|
||||
benutzer_id TEXT,
|
||||
|
||||
-- Etwas Wiedererkennbares fuer die Liste in der Verwaltung ("Chrome
|
||||
-- auf Android"). Ohne das steht dort nur eine 200 Zeichen lange
|
||||
-- Adresse, und niemand weiss, welches Geraet er gerade abmeldet.
|
||||
bezeichnung TEXT,
|
||||
|
||||
erstellt_am TEXT NOT NULL,
|
||||
zuletzt_ok_am TEXT,
|
||||
fehler_zaehler INTEGER NOT NULL DEFAULT 0,
|
||||
letzter_fehler TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wd_push_benutzer ON wd_push_geraete (benutzer_id);
|
||||
@@ -65,6 +65,9 @@ const MIGRATION_FILES = [
|
||||
// Altfaelle: Projekte, die abgebrochen wurden, BEVOR es das
|
||||
// Archiv-Kennzeichen gab, stehen sonst weiter in der laufenden Liste.
|
||||
"0018_webdesign_altabbrueche_archivieren.sql",
|
||||
// Push-Benachrichtigung aufs Handy: ein Eintrag je angemeldetem
|
||||
// Geraet, damit Handy und Rechner nebeneinander bestehen koennen.
|
||||
"0019_webdesign_push.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
|
||||
@@ -86,6 +86,12 @@ import {
|
||||
anfrageAbsenden, anfragenListe, anfrageDetail,
|
||||
anfrageStatus, anfrageArchivieren, anfrageNotiz,
|
||||
} from "./routes/webdesign-anfragen.js";
|
||||
/* Push-Benachrichtigung (26.08.2026). Ohne Fremdpaket gebaut, weil der
|
||||
Dienst unter /home/dogiintern mit Rechten 700 liegt -- dort ist kein
|
||||
npm erreichbar. Node bringt P-256, HKDF und AES-GCM selbst mit. */
|
||||
import {
|
||||
pushStand, pushSchluessel, pushAnmelden, pushAbmelden, pushTest,
|
||||
} from "./routes/webdesign-push.js";
|
||||
/* Kundenportal (Masterplan S.12). Eigene Anmeldung mit eigener Sitzung --
|
||||
Kunden sind KEINE Teammitglieder und duerfen mit deren Rechtesystem
|
||||
nichts zu tun haben. Die Mandantentrennung sitzt in den Handlern:
|
||||
@@ -138,6 +144,7 @@ import { angebotVorschau, angebotSenden, angebotZurueckziehen }
|
||||
Sie muessen beim Start EINMAL entschluesselt werden, weil die
|
||||
Pruefungen des PayPal-Moduls synchron sind. */
|
||||
import { geheimnisseLaden } from "./lib/webdesign-geheimnisse.js";
|
||||
import { vapidSicherstellen } from "./lib/webdesign-push.js";
|
||||
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
|
||||
|
||||
initDb();
|
||||
@@ -157,6 +164,17 @@ geheimnisseLaden()
|
||||
.then((n) => { if (n) console.log(`[webdesign] ${n} Einstellungen aus der Datenbank geladen.`); })
|
||||
.catch((e) => console.error("[webdesign] Einstellungen nicht ladbar:", e?.message));
|
||||
|
||||
/* Push-Schluessel bereitstellen. Beim allerersten Start werden sie
|
||||
erzeugt, danach nur noch gelesen -- ein Wechsel wuerde alle
|
||||
angemeldeten Geraete stillschweigend abhaengen.
|
||||
|
||||
Faellt das aus, laeuft der Dienst weiter: Ohne Benachrichtigung
|
||||
arbeitet die Seite normal, sie meldet sich nur nicht von selbst. Die
|
||||
Verwaltung zeigt den Zustand an, statt ihn zu verschweigen. */
|
||||
vapidSicherstellen()
|
||||
.then((r) => { if (r.zustand === "neu") console.log("[push] eingerichtet."); })
|
||||
.catch((e) => console.error("[push] nicht einsatzbereit:", e?.message));
|
||||
|
||||
const PORT = Number(process.env.PORT || 4200);
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
@@ -305,6 +323,16 @@ app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus));
|
||||
app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren));
|
||||
app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz));
|
||||
|
||||
/* Benachrichtigung aufs Handy (26.08.2026).
|
||||
Der Stand-Endpunkt ist der wichtigste davon: Er beantwortet die Frage
|
||||
"wuerde ich es mitbekommen, wenn jetzt eine Anfrage kaeme?". Genau
|
||||
die war bisher nicht zu beantworten. */
|
||||
app.get("/webdesign/admin/push/stand", wrap(pushStand));
|
||||
app.get("/webdesign/admin/push/schluessel", wrap(pushSchluessel));
|
||||
app.post("/webdesign/admin/push/anmelden", wrap(pushAnmelden));
|
||||
app.post("/webdesign/admin/push/abmelden", wrap(pushAbmelden));
|
||||
app.post("/webdesign/admin/push/test", wrap(pushTest));
|
||||
|
||||
/* Annehmen und Ablehnen.
|
||||
|
||||
Die Vorschau-Endpunkte lesen nur und veraendern nichts -- deshalb GET.
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/* =====================================================================
|
||||
webdesign-push.js — Geraete verwalten und Meldungen verschicken
|
||||
|
||||
DIE SCHLUESSEL ERZEUGT DER SERVER SELBST
|
||||
|
||||
Ein Push-Absender braucht ein dauerhaftes Schluesselpaar. Ueblich
|
||||
waere, es von Hand zu erzeugen und in die .env zu schreiben.
|
||||
|
||||
Das geht hier nicht: Der Dienst liegt unter /home/dogiintern mit
|
||||
Rechten 700. Also erzeugt der Server das Paar beim ersten Start
|
||||
selbst und legt es in der Datenbank ab -- den privaten Teil
|
||||
verschluesselt, ueber denselben Weg wie die PayPal-Zugangsdaten.
|
||||
|
||||
Das ist nicht nur ein Notbehelf, sondern besser: Es gibt keinen
|
||||
Einrichtungsschritt, der vergessen werden kann, und der private
|
||||
Schluessel steht nie in einer Datei, die jemand versehentlich kopiert.
|
||||
|
||||
WICHTIG: Das Paar darf sich nicht aendern. Wechselt der oeffentliche
|
||||
Schluessel, sind alle bestehenden Anmeldungen wertlos -- jedes Geraet
|
||||
muesste sich neu anmelden, ohne dass jemand merkt, warum ploetzlich
|
||||
nichts mehr ankommt. Deshalb wird nur erzeugt, wenn nichts da ist.
|
||||
|
||||
WAS IN EINER MELDUNG STEHT
|
||||
|
||||
Nur die Anfragenummer und das Paket -- keine Namen, keine
|
||||
E-Mail-Adressen. Eine Push-Meldung erscheint auf einem
|
||||
Sperrbildschirm, den auch jemand sieht, der nur zufaellig daneben
|
||||
steht. Wer die Anfrage lesen will, oeffnet die Verwaltung.
|
||||
|
||||
Der Push-Dienst selbst kann ohnehin nichts mitlesen: Der Inhalt ist
|
||||
fuer das Zielgeraet verschluesselt (siehe lib/webpush.js).
|
||||
===================================================================== */
|
||||
|
||||
import crypto from "crypto";
|
||||
import { db } from "../db.js";
|
||||
import { encryptCode, decryptCode } from "./crypto.js";
|
||||
import { schluesselpaarErzeugen, senden } from "./webpush.js";
|
||||
|
||||
const SCHLUESSEL_OEFFENTLICH = "wd_push_vapid_oeffentlich";
|
||||
const SCHLUESSEL_PRIVAT = "wd_push_vapid_privat";
|
||||
|
||||
let vapid = null;
|
||||
|
||||
function einstellungLesen(schluessel) {
|
||||
const zeile = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(schluessel);
|
||||
return zeile ? zeile.value : null;
|
||||
}
|
||||
|
||||
function einstellungSchreiben(schluessel, wert) {
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(schluessel, wert, new Date().toISOString());
|
||||
}
|
||||
|
||||
/* Beim Start aufzurufen. Erzeugt das Paar nur beim allerersten Mal. */
|
||||
export async function vapidSicherstellen() {
|
||||
const oeffentlich = einstellungLesen(SCHLUESSEL_OEFFENTLICH);
|
||||
const privatRoh = einstellungLesen(SCHLUESSEL_PRIVAT);
|
||||
|
||||
if (oeffentlich && privatRoh) {
|
||||
try {
|
||||
vapid = {
|
||||
oeffentlich,
|
||||
privat: await decryptCode(privatRoh),
|
||||
kontakt: kontakt(),
|
||||
};
|
||||
return { zustand: "vorhanden" };
|
||||
} catch (e) {
|
||||
/* Der ENCRYPTION_KEY wurde gewechselt. Neu erzeugen waere hier
|
||||
GENAU FALSCH: Es wuerde alle Anmeldungen unbrauchbar machen,
|
||||
ohne dass jemand erfaehrt, warum keine Meldungen mehr kommen.
|
||||
Lieber sichtbar nichts tun. */
|
||||
console.error("[push] VAPID-Schluessel nicht entschluesselbar — Push ist aus. "
|
||||
+ "Ursache ist fast immer ein gewechselter ENCRYPTION_KEY.");
|
||||
return { zustand: "unlesbar" };
|
||||
}
|
||||
}
|
||||
|
||||
const paar = schluesselpaarErzeugen();
|
||||
einstellungSchreiben(SCHLUESSEL_OEFFENTLICH, paar.oeffentlich);
|
||||
einstellungSchreiben(SCHLUESSEL_PRIVAT, await encryptCode(paar.privat));
|
||||
vapid = { oeffentlich: paar.oeffentlich, privat: paar.privat, kontakt: kontakt() };
|
||||
console.log("[push] Neues VAPID-Schluesselpaar erzeugt und gespeichert.");
|
||||
return { zustand: "neu" };
|
||||
}
|
||||
|
||||
/* Die Norm verlangt eine Kontaktmoeglichkeit, damit ein Push-Dienst bei
|
||||
Problemen jemanden erreichen kann. */
|
||||
function kontakt() {
|
||||
return process.env.PUSH_KONTAKT || "mailto:[email protected]";
|
||||
}
|
||||
|
||||
export function istBereit() { return !!vapid; }
|
||||
export function oeffentlicherSchluessel() { return vapid ? vapid.oeffentlich : null; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
GERAETE
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
export function geraetAnmelden(abo, benutzerId, bezeichnung) {
|
||||
if (!abo || !abo.endpoint || !abo.keys || !abo.keys.p256dh || !abo.keys.auth) {
|
||||
throw new Error("Anmeldung unvollstaendig.");
|
||||
}
|
||||
const jetzt = new Date().toISOString();
|
||||
|
||||
/* Bei erneuter Anmeldung desselben Geraets den vorhandenen Eintrag
|
||||
auffrischen statt einen zweiten anzulegen -- sonst kaeme jede
|
||||
Meldung mehrfach. Der Fehlerzaehler wird dabei zurueckgesetzt: Wer
|
||||
sich gerade erfolgreich anmeldet, ist offensichtlich erreichbar. */
|
||||
db.prepare(
|
||||
`INSERT INTO wd_push_geraete
|
||||
(id, endpunkt, p256dh, auth, benutzer_id, bezeichnung, erstellt_am, fehler_zaehler)
|
||||
VALUES (?,?,?,?,?,?,?,0)
|
||||
ON CONFLICT(endpunkt) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
benutzer_id = excluded.benutzer_id,
|
||||
bezeichnung = excluded.bezeichnung,
|
||||
fehler_zaehler = 0,
|
||||
letzter_fehler = NULL`
|
||||
).run(
|
||||
crypto.randomUUID(), String(abo.endpoint), String(abo.keys.p256dh),
|
||||
String(abo.keys.auth), benutzerId || null,
|
||||
String(bezeichnung || "").slice(0, 80) || null, jetzt
|
||||
);
|
||||
|
||||
return { angemeldet: true };
|
||||
}
|
||||
|
||||
export function geraetAbmelden(endpunkt) {
|
||||
const r = db.prepare(`DELETE FROM wd_push_geraete WHERE endpunkt = ?`).run(String(endpunkt));
|
||||
return { abgemeldet: r.changes > 0 };
|
||||
}
|
||||
|
||||
/* Fuer die Anzeige. Die volle Adresse wird NICHT herausgegeben -- sie
|
||||
nuetzt in der Oberflaeche nichts und gehoert nicht in ein Protokoll
|
||||
oder einen Screenshot. Zum Abmelden genuegt die Kennung. */
|
||||
export function geraeteListe(benutzerId) {
|
||||
const zeilen = benutzerId
|
||||
? db.prepare(`SELECT * FROM wd_push_geraete WHERE benutzer_id = ? ORDER BY erstellt_am DESC`).all(benutzerId)
|
||||
: db.prepare(`SELECT * FROM wd_push_geraete ORDER BY erstellt_am DESC`).all();
|
||||
|
||||
return zeilen.map((z) => ({
|
||||
id: z.id,
|
||||
bezeichnung: z.bezeichnung || "Unbenanntes Geraet",
|
||||
dienst: dienstName(z.endpunkt),
|
||||
erstellt: z.erstellt_am,
|
||||
zuletztOk: z.zuletzt_ok_am,
|
||||
fehler: z.fehler_zaehler,
|
||||
letzterFehler: z.letzter_fehler,
|
||||
}));
|
||||
}
|
||||
|
||||
/* "Google", "Mozilla", "Microsoft" -- hilft beim Wiedererkennen, ohne
|
||||
die Adresse preiszugeben. */
|
||||
function dienstName(endpunkt) {
|
||||
try {
|
||||
const h = new URL(endpunkt).hostname;
|
||||
if (h.includes("google")) return "Google (Chrome/Android)";
|
||||
if (h.includes("mozilla")) return "Mozilla (Firefox)";
|
||||
if (h.includes("microsoft") || h.includes("windows")) return "Microsoft (Edge)";
|
||||
if (h.includes("apple") || h.includes("icloud")) return "Apple (Safari/iPhone)";
|
||||
return h;
|
||||
} catch { return "unbekannt"; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
SENDEN
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
export async function meldeAllen(inhalt) {
|
||||
if (!vapid) return { gesendet: 0, grund: "Push ist nicht eingerichtet." };
|
||||
|
||||
const geraete = db.prepare(`SELECT * FROM wd_push_geraete`).all();
|
||||
if (!geraete.length) return { gesendet: 0, grund: "Kein Geraet angemeldet." };
|
||||
|
||||
const nachricht = JSON.stringify(inhalt);
|
||||
const jetzt = new Date().toISOString();
|
||||
let gesendet = 0, entfernt = 0, fehler = 0;
|
||||
|
||||
/* Alle gleichzeitig. Bei einem halben Dutzend Geraeten faellt das
|
||||
kaum ins Gewicht -- nacheinander waere es aber die Summe aller
|
||||
Zeitueberschreitungen, und ein einziges haengendes Geraet wuerde
|
||||
die Meldung fuer alle anderen verzoegern. */
|
||||
const ergebnisse = await Promise.allSettled(
|
||||
geraete.map((g) => senden(
|
||||
{ endpunkt: g.endpunkt, p256dh: g.p256dh, auth: g.auth },
|
||||
nachricht, vapid
|
||||
))
|
||||
);
|
||||
|
||||
ergebnisse.forEach((e, i) => {
|
||||
const g = geraete[i];
|
||||
if (e.status !== "fulfilled") {
|
||||
fehler++;
|
||||
merkeFehler(g.id, String(e.reason?.message || e.reason).slice(0, 200));
|
||||
return;
|
||||
}
|
||||
if (e.value.zustand === "ok") {
|
||||
gesendet++;
|
||||
db.prepare(`UPDATE wd_push_geraete SET zuletzt_ok_am = ?, fehler_zaehler = 0, letzter_fehler = NULL WHERE id = ?`)
|
||||
.run(jetzt, g.id);
|
||||
} else if (e.value.zustand === "abgemeldet") {
|
||||
/* Endgueltig weg. Aufraeumen, sonst waechst die Liste mit
|
||||
Karteileichen und jede Meldung dauert laenger. */
|
||||
entfernt++;
|
||||
db.prepare(`DELETE FROM wd_push_geraete WHERE id = ?`).run(g.id);
|
||||
} else {
|
||||
fehler++;
|
||||
merkeFehler(g.id, `HTTP ${e.value.status}: ${e.value.text || ""}`.slice(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
return { gesendet, entfernt, fehler };
|
||||
}
|
||||
|
||||
function merkeFehler(id, text) {
|
||||
db.prepare(`UPDATE wd_push_geraete SET fehler_zaehler = fehler_zaehler + 1, letzter_fehler = ? WHERE id = ?`)
|
||||
.run(text, id);
|
||||
}
|
||||
|
||||
/* Der Stand fuer die Anzeige in der Verwaltung. Beantwortet die Frage,
|
||||
die man tatsaechlich hat: Wuerde ich es mitbekommen, wenn jetzt eine
|
||||
Anfrage kaeme? */
|
||||
export function stand() {
|
||||
const anzahl = db.prepare(`SELECT count(*) AS n FROM wd_push_geraete`).get().n;
|
||||
const mitFehlern = db.prepare(`SELECT count(*) AS n FROM wd_push_geraete WHERE fehler_zaehler > 0`).get().n;
|
||||
return {
|
||||
eingerichtet: !!vapid,
|
||||
oeffentlicherSchluessel: vapid ? vapid.oeffentlich : null,
|
||||
geraete: anzahl,
|
||||
geraeteMitFehlern: mitFehlern,
|
||||
/* Die eine Zeile, auf die es ankommt. */
|
||||
wirktMeldung: !!vapid && anzahl > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =====================================================================
|
||||
webpush.js — Push-Nachrichten aufs Handy, ohne Fremdpaket
|
||||
|
||||
WARUM SELBST GEBAUT UND NICHT "npm install web-push"
|
||||
|
||||
Der interne Dienst liegt unter /home/dogiintern und steht auf 700.
|
||||
Dort komme ich weder an npm noch an die .env. Jede neue Abhaengigkeit
|
||||
haette also bedeutet: Filipe muss bei jedem Schritt mitmachen, und
|
||||
jede spaetere Aenderung wieder.
|
||||
|
||||
Node bringt alles Noetige mit -- P-256, HKDF und AES-128-GCM sind
|
||||
eingebaut. Auf dem Server nachgeprueft (Node 24). Also kein fremder
|
||||
Code, keine Lieferkette, keine Version, die irgendwann nicht mehr
|
||||
gepflegt wird.
|
||||
|
||||
WIE PUSH FUNKTIONIERT (kurz)
|
||||
|
||||
Der Browser meldet sich beim Push-Dienst seines Herstellers an und
|
||||
bekommt eine Adresse. Diese Adresse plus zwei Schluessel schickt er
|
||||
uns. Wollen wir etwas senden, verschluesseln wir die Nachricht fuer
|
||||
genau dieses Geraet und legen sie bei der Adresse ab.
|
||||
|
||||
Entscheidend: Der Push-Dienst leitet nur weiter. Er kann den Inhalt
|
||||
NICHT lesen -- die Nachricht ist fuer den Browser des Empfaengers
|
||||
verschluesselt, nicht fuer ihn. Deshalb ist es vertretbar, hier
|
||||
ueberhaupt etwas zu senden, was mit Kundendaten zu tun hat.
|
||||
|
||||
ZWEI NORMEN
|
||||
|
||||
RFC 8291 regelt die Verschluesselung, RFC 8292 den Nachweis, dass die
|
||||
Nachricht wirklich von uns kommt (VAPID). Beides ist hier umgesetzt.
|
||||
|
||||
GEPRUEFT GEGEN DIE NORM
|
||||
|
||||
Die Verschluesselung wird nicht nur "sieht richtig aus" getestet,
|
||||
sondern gegen das Rechenbeispiel in RFC 8291, Abschnitt 5: dieselben
|
||||
Eingaben muessen Zeichen fuer Zeichen dasselbe Ergebnis liefern.
|
||||
Dafuer lassen sich Salt und Sitzungsschluessel von aussen vorgeben --
|
||||
im Betrieb werden sie immer zufaellig erzeugt.
|
||||
===================================================================== */
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Base64 in der URL-Variante. Push benutzt durchgehend diese Form:
|
||||
ohne "=", und mit - und _ statt + und /.
|
||||
--------------------------------------------------------------------- */
|
||||
export function b64u(buf) {
|
||||
return Buffer.from(buf).toString("base64")
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function ausB64u(s) {
|
||||
return Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VAPID-SCHLUESSELPAAR
|
||||
|
||||
Der oeffentliche Teil darf jeder sehen -- er steht sogar im Browser.
|
||||
Der private Teil ist das Geheimnis: Wer ihn hat, kann in unserem Namen
|
||||
Push-Nachrichten schicken.
|
||||
|
||||
Der oeffentliche Schluessel wird als "unkomprimierter Punkt" gebraucht:
|
||||
ein 0x04 gefolgt von X und Y, zusammen 65 Byte. Node liefert ihn als
|
||||
JWK mit getrennten X- und Y-Werten, deshalb hier zusammengesetzt.
|
||||
--------------------------------------------------------------------- */
|
||||
export function schluesselpaarErzeugen() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
|
||||
namedCurve: "prime256v1",
|
||||
});
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
const roh = Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
ausB64u(jwk.x),
|
||||
ausB64u(jwk.y),
|
||||
]);
|
||||
return {
|
||||
oeffentlich: b64u(roh),
|
||||
privat: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VAPID-NACHWEIS
|
||||
|
||||
Ein kurzlebiger, signierter Ausweis. Er sagt dem Push-Dienst: Diese
|
||||
Nachricht kommt von dem, dem dieser oeffentliche Schluessel gehoert.
|
||||
|
||||
FALLSTRICK: Node liefert ECDSA-Signaturen standardmaessig im
|
||||
DER-Format (mit Laengenangaben und fuehrenden Nullen). JWT verlangt
|
||||
aber die nackten 64 Byte -- r direkt gefolgt von s. Ohne
|
||||
"dsaEncoding: ieee-p1363" wird die Signatur abgelehnt, und der Fehler
|
||||
lautet nur "invalid JWT", was in die voellig falsche Richtung zeigt.
|
||||
--------------------------------------------------------------------- */
|
||||
export function vapidKopf(endpunkt, privatPem, oeffentlich, kontakt, jetztSek) {
|
||||
const url = new URL(endpunkt);
|
||||
const jetzt = jetztSek || Math.floor(Date.now() / 1000);
|
||||
|
||||
const kopf = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" }));
|
||||
const rumpf = b64u(JSON.stringify({
|
||||
aud: url.origin,
|
||||
/* Zwoelf Stunden. Die Norm erlaubt bis 24, aber kuerzer ist besser:
|
||||
Faengt jemand den Ausweis ab, ist er schneller wertlos. */
|
||||
exp: jetzt + 12 * 60 * 60,
|
||||
sub: kontakt,
|
||||
}));
|
||||
|
||||
const daten = Buffer.from(`${kopf}.${rumpf}`);
|
||||
const sig = crypto.sign("sha256", daten, {
|
||||
key: crypto.createPrivateKey(privatPem),
|
||||
dsaEncoding: "ieee-p1363",
|
||||
});
|
||||
|
||||
return `vapid t=${kopf}.${rumpf}.${b64u(sig)}, k=${oeffentlich}`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VERSCHLUESSELUNG NACH RFC 8291
|
||||
|
||||
Ablauf:
|
||||
1. Ein Sitzungsschluesselpaar erzeugen (gilt nur fuer diese eine
|
||||
Nachricht).
|
||||
2. Daraus und aus dem oeffentlichen Schluessel des Geraets ein
|
||||
gemeinsames Geheimnis rechnen (ECDH).
|
||||
3. Mit dem Authentifizierungsgeheimnis des Geraets zu einem
|
||||
Grundschluessel verruehren (HKDF).
|
||||
4. Daraus Inhaltsschluessel und Zaehlwert ableiten.
|
||||
5. Verschluesseln, und alles zusammensetzen, was der Empfaenger zum
|
||||
Entschluesseln braucht.
|
||||
|
||||
Der zusammengesetzte Koerper sieht so aus:
|
||||
Salt (16) | Blockgroesse (4) | Laenge (1) | Sitzungsschluessel (65) | Inhalt
|
||||
--------------------------------------------------------------------- */
|
||||
export function verschluesseln(nachricht, p256dh, auth, opt = {}) {
|
||||
const empfaenger = ausB64u(p256dh);
|
||||
const geheimnis = ausB64u(auth);
|
||||
|
||||
if (empfaenger.length !== 65 || empfaenger[0] !== 4) {
|
||||
throw new Error("Geraeteschluessel hat nicht die erwartete Form.");
|
||||
}
|
||||
if (geheimnis.length !== 16) {
|
||||
throw new Error("Authentifizierungsgeheimnis muss 16 Byte lang sein.");
|
||||
}
|
||||
|
||||
/* Im Betrieb immer zufaellig. Vorgebbar nur, damit sich das
|
||||
Rechenbeispiel der Norm nachrechnen laesst. */
|
||||
const salt = opt.salt ? Buffer.from(opt.salt) : crypto.randomBytes(16);
|
||||
|
||||
const ecdh = crypto.createECDH("prime256v1");
|
||||
if (opt.sitzungPrivat) ecdh.setPrivateKey(Buffer.from(opt.sitzungPrivat));
|
||||
else ecdh.generateKeys();
|
||||
const sitzungOeffentlich = ecdh.getPublicKey();
|
||||
|
||||
const gemeinsam = ecdh.computeSecret(empfaenger);
|
||||
|
||||
/* Schritt 3. Das "info" bindet beide oeffentlichen Schluessel mit ein
|
||||
-- dadurch passt der Schluessel nur zu genau diesem Geraet und
|
||||
genau dieser Nachricht. */
|
||||
const grundInfo = Buffer.concat([
|
||||
Buffer.from("WebPush: info\0"),
|
||||
empfaenger,
|
||||
sitzungOeffentlich,
|
||||
]);
|
||||
const grund = Buffer.from(
|
||||
crypto.hkdfSync("sha256", gemeinsam, geheimnis, grundInfo, 32)
|
||||
);
|
||||
|
||||
const cek = Buffer.from(
|
||||
crypto.hkdfSync("sha256", grund, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)
|
||||
);
|
||||
const nonce = Buffer.from(
|
||||
crypto.hkdfSync("sha256", grund, salt, Buffer.from("Content-Encoding: nonce\0"), 12)
|
||||
);
|
||||
|
||||
/* Das 0x02 markiert das Ende. Es gehoert mit verschluesselt -- ein
|
||||
Empfaenger prueft daran, dass die Nachricht vollstaendig ist. */
|
||||
const klar = Buffer.concat([Buffer.from(nachricht, "utf8"), Buffer.from([2])]);
|
||||
|
||||
const chiffre = crypto.createCipheriv("aes-128-gcm", cek, nonce);
|
||||
const inhalt = Buffer.concat([chiffre.update(klar), chiffre.final(), chiffre.getAuthTag()]);
|
||||
|
||||
const blockgroesse = Buffer.alloc(4);
|
||||
blockgroesse.writeUInt32BE(opt.blockgroesse || 4096, 0);
|
||||
|
||||
return Buffer.concat([
|
||||
salt,
|
||||
blockgroesse,
|
||||
Buffer.from([sitzungOeffentlich.length]),
|
||||
sitzungOeffentlich,
|
||||
inhalt,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
SENDEN
|
||||
|
||||
Der Rueckgabewert unterscheidet drei Faelle, und die Unterscheidung
|
||||
ist wichtiger als sie aussieht:
|
||||
|
||||
ok -- angekommen
|
||||
abgemeldet -- das Geraet gibt es nicht mehr (404/410). Der Eintrag
|
||||
MUSS geloescht werden, sonst sammeln sich Karteileichen
|
||||
an und jede Meldung dauert laenger, weil jedes Mal
|
||||
erfolglos an tote Adressen gesendet wird.
|
||||
fehler -- alles andere (Netz, Dienst gestoert). Eintrag behalten.
|
||||
--------------------------------------------------------------------- */
|
||||
export async function senden(abo, nachricht, vapid, opt = {}) {
|
||||
const koerper = verschluesseln(nachricht, abo.p256dh, abo.auth, opt);
|
||||
|
||||
const antwort = await fetch(abo.endpunkt, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
/* Wie lange der Dienst die Nachricht aufhebt, falls das Geraet
|
||||
gerade aus ist. Vier Stunden: Eine Anfrage von heute Nacht ist
|
||||
morgen frueh noch von Belang. */
|
||||
TTL: String(opt.ttl == null ? 4 * 60 * 60 : opt.ttl),
|
||||
Urgency: opt.dringlichkeit || "normal",
|
||||
Authorization: vapidKopf(abo.endpunkt, vapid.privat, vapid.oeffentlich, vapid.kontakt),
|
||||
},
|
||||
body: koerper,
|
||||
});
|
||||
|
||||
if (antwort.status === 404 || antwort.status === 410) {
|
||||
return { zustand: "abgemeldet", status: antwort.status };
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
const text = await antwort.text().catch(() => "");
|
||||
return { zustand: "fehler", status: antwort.status, text: text.slice(0, 200) };
|
||||
}
|
||||
return { zustand: "ok", status: antwort.status };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ipHash, besucherIp, spracheOderDe, verlauf,
|
||||
PAKETE, ANFRAGE_STATUS,
|
||||
} from "../lib/webdesign-helfer.js";
|
||||
import { meldeAllen } from "../lib/webdesign-push.js";
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Rechteprüfung. Der Eigentümer darf immer alles — genau wie im
|
||||
@@ -201,20 +202,61 @@ export async function anfrageAbsenden(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
/* Discord-Benachrichtigung über den bereits vorhandenen Webhook-Weg.
|
||||
/* Benachrichtigung über alle eingerichteten Wege.
|
||||
Bewusst OHNE Kundendaten im Text: die Meldung sagt nur, dass etwas da
|
||||
ist. Wer die Anfrage lesen will, meldet sich in der Verwaltung an —
|
||||
ein Discord-Kanal ist kein sicherer Ort für Kontaktdaten. */
|
||||
weder ein Discord-Kanal noch ein Sperrbildschirm ist ein sicherer Ort
|
||||
für Kontaktdaten.
|
||||
|
||||
⚠️ HIER STAND EINMAL EIN STILLES `if (!url) return;` (behoben
|
||||
26.08.2026)
|
||||
|
||||
Der Discord-Weg las eine Umgebungsvariable, die in .env.example gar
|
||||
nicht aufgeführt war — also mit hoher Wahrscheinlichkeit nie gesetzt
|
||||
wurde. Fehlte sie, kehrte die Funktion wortlos zurück: kein Fehler,
|
||||
keine Protokollzeile, nichts. Es gab damit eine Benachrichtigung, die
|
||||
nur im Quelltext existierte, und niemand konnte das bemerken.
|
||||
|
||||
Das ist die unangenehmste Sorte Fehler: nicht einer, der etwas
|
||||
kaputtmacht, sondern einer, der NICHTS tut und dabei aussieht wie
|
||||
Erfolg. Deshalb wird jetzt jeder Weg einzeln protokolliert — auch und
|
||||
gerade, wenn er übersprungen wird. */
|
||||
async function benachrichtige(nummer, name, email, paket) {
|
||||
const wege = [];
|
||||
|
||||
/* Weg 1: Push aufs Handy. */
|
||||
try {
|
||||
const r = await meldeAllen({
|
||||
titel: "Neue Anfrage",
|
||||
text: `${nummer} (${paket})`,
|
||||
url: "/webdesign/verwaltung.html?bereich=anfragen",
|
||||
art: "anfrage",
|
||||
});
|
||||
wege.push(r.gesendet ? `push: ${r.gesendet} Gerät(e)` : `push: nichts (${r.grund || "keins erreichbar"})`);
|
||||
} catch (e) {
|
||||
wege.push(`push: FEHLER ${e?.message || e}`);
|
||||
}
|
||||
|
||||
/* Weg 2: Discord, falls eingerichtet. */
|
||||
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
|
||||
if (!url) return;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`,
|
||||
}),
|
||||
});
|
||||
if (!url) {
|
||||
wege.push("discord: nicht eingerichtet");
|
||||
} else {
|
||||
try {
|
||||
const a = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`,
|
||||
}),
|
||||
});
|
||||
wege.push(a.ok ? "discord: ok" : `discord: HTTP ${a.status}`);
|
||||
} catch (e) {
|
||||
wege.push(`discord: FEHLER ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[webdesign] Anfrage ${nummer} — Benachrichtigung: ${wege.join(", ")}`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/* =====================================================================
|
||||
webdesign-push.js — Geraete an- und abmelden, Stand anzeigen, testen
|
||||
|
||||
DER TESTKNOPF IST HIER DER WICHTIGSTE ENDPUNKT
|
||||
|
||||
Eine Benachrichtigung hat eine unangenehme Eigenschaft: Solange nichts
|
||||
passiert, sieht "funktioniert" und "kaputt" voellig gleich aus. Genau
|
||||
daran ist der bisherige Discord-Weg gescheitert -- er war eingebaut,
|
||||
aber nie eingerichtet, und niemand konnte das sehen.
|
||||
|
||||
Deshalb gibt es hier zwei Dinge, die man sonst gern weglaesst: eine
|
||||
Anzeige, die die eigentliche Frage beantwortet ("wuerde ich es
|
||||
mitbekommen, wenn jetzt eine Anfrage kaeme?"), und einen Knopf, der es
|
||||
ausprobiert, statt es zu behaupten.
|
||||
===================================================================== */
|
||||
|
||||
import { json } from "../lib/http.js";
|
||||
import { requireSession } from "../lib/auth.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
|
||||
import { text } from "../lib/webdesign-helfer.js";
|
||||
import {
|
||||
geraetAnmelden, geraetAbmelden, geraeteListe, meldeAllen, stand, istBereit,
|
||||
oeffentlicherSchluessel,
|
||||
} from "../lib/webdesign-push.js";
|
||||
|
||||
/* Gleiche Struktur wie in den uebrigen Webdesign-Routen. */
|
||||
async function darf(req, recht) {
|
||||
const ausweis = ausweisPruefen(req);
|
||||
if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
|
||||
const sitzung = await requireSession(req);
|
||||
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
|
||||
if (sitzung.isOwner) return { erlaubt: true, sitzung };
|
||||
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
|
||||
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
|
||||
}
|
||||
|
||||
/* Wer Anfragen sehen darf, darf auch ueber neue benachrichtigt werden.
|
||||
Ein eigenes Recht dafuer waere eine Huerde ohne Gewinn: Die Meldung
|
||||
enthaelt weniger als die Liste, die derselbe Mensch ohnehin oeffnen
|
||||
darf. */
|
||||
const RECHT = "WD_ANFRAGEN_VIEW";
|
||||
|
||||
export async function pushStand(req, res) {
|
||||
const p = await darf(req, RECHT);
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
...stand(),
|
||||
geraete: geraeteListe(null),
|
||||
});
|
||||
}
|
||||
|
||||
/* Den oeffentlichen Schluessel braucht der Browser, BEVOR er sich
|
||||
anmelden kann. Er ist kein Geheimnis -- er steht per Definition in
|
||||
jedem Browser, der sich angemeldet hat. */
|
||||
export async function pushSchluessel(req, res) {
|
||||
const p = await darf(req, RECHT);
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
if (!istBereit()) {
|
||||
return json(res, {
|
||||
ok: false,
|
||||
error: "Push ist auf dem Server nicht eingerichtet.",
|
||||
}, 503);
|
||||
}
|
||||
return json(res, { ok: true, schluessel: oeffentlicherSchluessel() });
|
||||
}
|
||||
|
||||
export async function pushAnmelden(req, res) {
|
||||
const p = await darf(req, RECHT);
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
try {
|
||||
const abo = req.body && req.body.abo;
|
||||
geraetAnmelden(
|
||||
abo,
|
||||
p.sitzung?.userId || p.sitzung?.actor || null,
|
||||
text(req.body?.bezeichnung, 80)
|
||||
);
|
||||
/* Sofort eine Meldung schicken. Nicht als Spielerei: Wer den Knopf
|
||||
drueckt, will WISSEN, ob es geht -- und der einzige ehrliche
|
||||
Beweis ist eine Meldung, die tatsaechlich auf dem Bildschirm
|
||||
erscheint. */
|
||||
const r = await meldeAllen({
|
||||
titel: "Benachrichtigungen sind an",
|
||||
text: "Ab jetzt meldet sich diese Seite bei neuen Anfragen.",
|
||||
url: "/webdesign/verwaltung.html",
|
||||
art: "test",
|
||||
});
|
||||
return json(res, { ok: true, ...stand(), probe: r });
|
||||
} catch (e) {
|
||||
return json(res, { ok: false, error: e.message || "Anmeldung fehlgeschlagen." }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushAbmelden(req, res) {
|
||||
const p = await darf(req, RECHT);
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const endpunkt = text(req.body?.endpunkt, 500);
|
||||
if (!endpunkt) return json(res, { ok: false, error: "Keine Adresse angegeben." }, 400);
|
||||
|
||||
const r = geraetAbmelden(endpunkt);
|
||||
return json(res, { ok: true, ...r, ...stand() });
|
||||
}
|
||||
|
||||
export async function pushTest(req, res) {
|
||||
const p = await darf(req, RECHT);
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const r = await meldeAllen({
|
||||
titel: "Testmeldung",
|
||||
text: "Wenn du das liest, kommen Meldungen an.",
|
||||
url: "/webdesign/verwaltung.html",
|
||||
art: "test",
|
||||
});
|
||||
return json(res, { ok: true, ergebnis: r, ...stand() });
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* =====================================================================
|
||||
Prueft die GANZE Kette, nicht nur die Rechenschritte.
|
||||
|
||||
test-webpush.mjs beweist, dass die Verschluesselung der Norm
|
||||
entspricht. Das ist die halbe Miete -- sagt aber nichts darueber, ob
|
||||
im Betrieb tatsaechlich etwas ankommt. Dazwischen liegen die Stellen,
|
||||
an denen es erfahrungsgemaess scheitert: falsche Kopfzeilen, ein
|
||||
Geraet, das nach einem Fehler ewig weiter angefunkt wird, oder eine
|
||||
Meldung, die Kundendaten enthaelt, obwohl sie das nicht soll.
|
||||
|
||||
Deshalb laeuft hier ein echter Empfaenger mit: ein kleiner Server, der
|
||||
sich verhaelt wie ein Push-Dienst, die Nachricht ENTSCHLUESSELT und
|
||||
nachsieht, was drinsteht.
|
||||
===================================================================== */
|
||||
import http from "http";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_DB = join(__dirname, "test-push.db");
|
||||
|
||||
/* Vor dem Import von db.js setzen -- danach ist der Pfad festgelegt. */
|
||||
fs.rmSync(TEST_DB, { force: true });
|
||||
fs.rmSync(TEST_DB + "-wal", { force: true });
|
||||
fs.rmSync(TEST_DB + "-shm", { force: true });
|
||||
process.env.DB_PATH = TEST_DB;
|
||||
process.env.ENCRYPTION_KEY = Buffer.from(crypto.randomBytes(32)).toString("base64");
|
||||
|
||||
const { db, initDb } = await import("./db.js");
|
||||
initDb();
|
||||
|
||||
const P = await import("./lib/webdesign-push.js");
|
||||
const { ausB64u, b64u } = await import("./lib/webpush.js");
|
||||
|
||||
let ok = 0, fehl = 0;
|
||||
const pruefe = (name, gut, info) => {
|
||||
if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
|
||||
else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); }
|
||||
};
|
||||
|
||||
/* --- Ein Empfaenger, der wirklich entschluesselt -------------------- */
|
||||
const empfaenger = crypto.createECDH("prime256v1");
|
||||
empfaenger.generateKeys();
|
||||
const authGeheimnis = crypto.randomBytes(16);
|
||||
|
||||
let empfangen = [];
|
||||
let antwortStatus = 201;
|
||||
|
||||
const dienst = http.createServer((q, r) => {
|
||||
const teile = [];
|
||||
q.on("data", (d) => teile.push(d));
|
||||
q.on("end", () => {
|
||||
const koerper = Buffer.concat(teile);
|
||||
let klartext = null;
|
||||
try {
|
||||
const salt = koerper.subarray(0, 16);
|
||||
const asPub = koerper.subarray(21, 86);
|
||||
const inhalt = koerper.subarray(86);
|
||||
const gemeinsam = empfaenger.computeSecret(asPub);
|
||||
const ikm = Buffer.from(crypto.hkdfSync("sha256", gemeinsam, authGeheimnis,
|
||||
Buffer.concat([Buffer.from("WebPush: info\0"), empfaenger.getPublicKey(), asPub]), 32));
|
||||
const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt,
|
||||
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
||||
const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt,
|
||||
Buffer.from("Content-Encoding: nonce\0"), 12));
|
||||
const d = crypto.createDecipheriv("aes-128-gcm", cek, nonce);
|
||||
d.setAuthTag(inhalt.subarray(inhalt.length - 16));
|
||||
const roh = Buffer.concat([d.update(inhalt.subarray(0, inhalt.length - 16)), d.final()]);
|
||||
klartext = roh.subarray(0, roh.length - 1).toString("utf8");
|
||||
} catch (e) {
|
||||
klartext = "ENTSCHLUESSELN FEHLGESCHLAGEN: " + e.message;
|
||||
}
|
||||
empfangen.push({ kopf: q.headers, klartext, laenge: koerper.length });
|
||||
r.writeHead(antwortStatus);
|
||||
r.end("");
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((f) => dienst.listen(4407, f));
|
||||
const ENDPUNKT = "http://127.0.0.1:4407/push/geraet-1";
|
||||
|
||||
console.log("1. SCHLUESSEL ENTSTEHEN BEIM ERSTEN START");
|
||||
const e1 = await P.vapidSicherstellen();
|
||||
pruefe("beim ersten Mal neu erzeugt", e1.zustand === "neu", e1.zustand);
|
||||
const schluessel1 = P.oeffentlicherSchluessel();
|
||||
pruefe("oeffentlicher Schluessel ist da", !!schluessel1 && ausB64u(schluessel1).length === 65);
|
||||
|
||||
/* Der wichtigste Test dieser Datei. Wechselt der oeffentliche Schluessel,
|
||||
sind schlagartig ALLE Anmeldungen wertlos -- und niemand merkt, warum
|
||||
nichts mehr ankommt. */
|
||||
const e2 = await P.vapidSicherstellen();
|
||||
pruefe("beim zweiten Start NICHT neu erzeugt", e2.zustand === "vorhanden", e2.zustand);
|
||||
pruefe("Schluessel ist unveraendert", P.oeffentlicherSchluessel() === schluessel1);
|
||||
|
||||
console.log("\n2. GERAET ANMELDEN");
|
||||
P.geraetAnmelden({
|
||||
endpoint: ENDPUNKT,
|
||||
keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) },
|
||||
}, "dogfather", "Chrome auf Android");
|
||||
pruefe("ein Geraet in der Liste", P.geraeteListe(null).length === 1);
|
||||
|
||||
/* Zweimal dasselbe Geraet darf nicht zwei Eintraege ergeben -- sonst
|
||||
kaeme jede Meldung doppelt. */
|
||||
P.geraetAnmelden({
|
||||
endpoint: ENDPUNKT,
|
||||
keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) },
|
||||
}, "dogfather", "Chrome auf Android");
|
||||
pruefe("erneute Anmeldung ergibt keinen zweiten Eintrag", P.geraeteListe(null).length === 1,
|
||||
P.geraeteListe(null).length + " Eintrag/Eintraege");
|
||||
|
||||
pruefe("Adresse wird NICHT herausgegeben",
|
||||
!JSON.stringify(P.geraeteListe(null)).includes("4407"));
|
||||
pruefe("Dienst wird lesbar benannt", P.geraeteListe(null)[0].dienst === "127.0.0.1",
|
||||
P.geraeteListe(null)[0].dienst);
|
||||
|
||||
console.log("\n3. EINE MELDUNG GEHT RAUS UND KOMMT LESBAR AN");
|
||||
empfangen = [];
|
||||
const r1 = await P.meldeAllen({
|
||||
titel: "Neue Anfrage", text: "A-2608-0009 (shop)",
|
||||
url: "/webdesign/verwaltung.html", art: "anfrage",
|
||||
});
|
||||
pruefe("als gesendet gemeldet", r1.gesendet === 1, JSON.stringify(r1));
|
||||
pruefe("genau eine Nachricht angekommen", empfangen.length === 1);
|
||||
|
||||
const n = empfangen[0];
|
||||
pruefe("Kopfzeile Content-Encoding", n.kopf["content-encoding"] === "aes128gcm", n.kopf["content-encoding"]);
|
||||
pruefe("Kopfzeile Content-Type", n.kopf["content-type"] === "application/octet-stream");
|
||||
pruefe("TTL gesetzt", !!n.kopf.ttl, n.kopf.ttl + " Sekunden");
|
||||
pruefe("VAPID-Ausweis mitgeschickt", /^vapid t=.+, k=.+$/.test(n.kopf.authorization || ""));
|
||||
|
||||
let inhalt = {};
|
||||
try { inhalt = JSON.parse(n.klartext); } catch (e) { /* faellt unten auf */ }
|
||||
pruefe("Nachricht liess sich entschluesseln", !!inhalt.titel, n.klartext?.slice(0, 60));
|
||||
pruefe("Titel stimmt", inhalt.titel === "Neue Anfrage", inhalt.titel);
|
||||
pruefe("Text stimmt", inhalt.text === "A-2608-0009 (shop)", inhalt.text);
|
||||
|
||||
/* Datenschutz: Auf einem Sperrbildschirm haben Kundendaten nichts
|
||||
verloren. Der Test prueft es an der Stelle, an der es zaehlt -- im
|
||||
entschluesselten Text, nicht im Quelltext. */
|
||||
console.log("\n4. KEINE KUNDENDATEN IN DER MELDUNG");
|
||||
const alles = (n.klartext || "").toLowerCase();
|
||||
for (const verboten of ["@", "telefon", "gmail", "budget"]) {
|
||||
pruefe(`enthaelt kein "${verboten}"`, !alles.includes(verboten));
|
||||
}
|
||||
|
||||
console.log("\n5. EIN ABGEMELDETES GERAET WIRD ENTFERNT");
|
||||
antwortStatus = 410;
|
||||
const r2 = await P.meldeAllen({ titel: "x", text: "y" });
|
||||
pruefe("als abgemeldet erkannt", r2.entfernt === 1, JSON.stringify(r2));
|
||||
pruefe("Eintrag ist weg", P.geraeteListe(null).length === 0);
|
||||
|
||||
console.log("\n6. EIN VORUEBERGEHENDER FEHLER LOESCHT NICHTS");
|
||||
P.geraetAnmelden({
|
||||
endpoint: ENDPUNKT,
|
||||
keys: { p256dh: b64u(empfaenger.getPublicKey()), auth: b64u(authGeheimnis) },
|
||||
}, "dogfather", "Chrome auf Android");
|
||||
antwortStatus = 500;
|
||||
const r3 = await P.meldeAllen({ titel: "x", text: "y" });
|
||||
pruefe("als Fehler gezaehlt", r3.fehler === 1, JSON.stringify(r3));
|
||||
/* Der entscheidende Unterschied: Bei 500 ist der Dienst gestoert, nicht
|
||||
das Geraet weg. Loeschen waere hier falsch -- eine einzelne Stoerung
|
||||
wuerde die Anmeldung kosten. */
|
||||
pruefe("Geraet bleibt erhalten", P.geraeteListe(null).length === 1);
|
||||
pruefe("Fehler wird vermerkt", P.geraeteListe(null)[0].fehler === 1,
|
||||
"Zaehler " + P.geraeteListe(null)[0].fehler);
|
||||
|
||||
antwortStatus = 201;
|
||||
await P.meldeAllen({ titel: "x", text: "y" });
|
||||
pruefe("nach Erfolg ist der Zaehler wieder 0", P.geraeteListe(null)[0].fehler === 0);
|
||||
|
||||
console.log("\n7. DIE ANZEIGE SAGT DIE WAHRHEIT");
|
||||
const st = P.stand();
|
||||
pruefe("meldet eingerichtet", st.eingerichtet === true);
|
||||
pruefe("meldet, dass eine Meldung wirkt", st.wirktMeldung === true);
|
||||
P.geraetAbmelden(ENDPUNKT);
|
||||
pruefe("ohne Geraet wirkt keine Meldung mehr", P.stand().wirktMeldung === false);
|
||||
|
||||
dienst.close();
|
||||
db.close();
|
||||
fs.rmSync(TEST_DB, { force: true });
|
||||
fs.rmSync(TEST_DB + "-wal", { force: true });
|
||||
fs.rmSync(TEST_DB + "-shm", { force: true });
|
||||
|
||||
console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`);
|
||||
process.exit(fehl ? 1 : 0);
|
||||
@@ -0,0 +1,176 @@
|
||||
/* =====================================================================
|
||||
Prueft die Push-Verschluesselung gegen die Norm selbst.
|
||||
|
||||
Bei Kryptographie hilft "sieht plausibel aus" nichts. Ein Fehler in
|
||||
der Schluesselableitung erzeugt keinen Absturz -- er erzeugt Bytes,
|
||||
die genauso zufaellig aussehen wie richtige. Auffallen wuerde es erst
|
||||
daran, dass auf keinem Handy je eine Meldung ankommt, und die Suche
|
||||
danach faengt dann garantiert an der falschen Stelle an.
|
||||
|
||||
Deshalb wird hier gegen das Rechenbeispiel aus RFC 8291 geprueft --
|
||||
Abschnitt 5 fuer die fertige Nachricht, Anhang A fuer die
|
||||
Zwischenwerte. Diese Zwischenwerte sind der eigentliche Gewinn: Weicht
|
||||
einer ab, weiss man sofort, WELCHER Schritt falsch ist, statt nur zu
|
||||
wissen, dass irgendetwas nicht stimmt.
|
||||
|
||||
Quelle: https://www.rfc-editor.org/rfc/rfc8291.txt
|
||||
===================================================================== */
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
b64u, ausB64u, verschluesseln, schluesselpaarErzeugen, vapidKopf,
|
||||
} from "./lib/webpush.js";
|
||||
|
||||
let ok = 0, fehl = 0;
|
||||
const pruefe = (name, gut, info) => {
|
||||
if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
|
||||
else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); }
|
||||
};
|
||||
|
||||
/* --- Die Werte aus der Norm, unveraendert ------------------------- */
|
||||
const V = {
|
||||
klartext: "When I grow up, I want to be a watermelon",
|
||||
authSecret: "BTBZMqHH6r4Tts7J_aSIgg",
|
||||
uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
|
||||
uaPrivate: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
|
||||
asPublic: "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8",
|
||||
asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw",
|
||||
salt: "DGv6ra1nlYgDCS1FRnbzlw",
|
||||
// Anhang A
|
||||
shared: "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs",
|
||||
prkKey: "Snr3JMxaHVDXHWJn5wdC52WjpCtd2EIEGBykDcZW32k",
|
||||
ikm: "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg",
|
||||
cek: "oIhVW04MRdy2XN9CiKLxTg",
|
||||
nonce: "4h_95klXJ5E_qnoN",
|
||||
};
|
||||
|
||||
console.log("1. DIE EINZELNEN ABLEITUNGSSCHRITTE (RFC 8291, Anhang A)");
|
||||
|
||||
const ecdh = crypto.createECDH("prime256v1");
|
||||
ecdh.setPrivateKey(ausB64u(V.asPrivate));
|
||||
pruefe("Sitzungsschluessel passt zum privaten Teil",
|
||||
b64u(ecdh.getPublicKey()) === V.asPublic, b64u(ecdh.getPublicKey()).slice(0, 24) + "…");
|
||||
|
||||
const gemeinsam = ecdh.computeSecret(ausB64u(V.uaPublic));
|
||||
pruefe("gemeinsames Geheimnis (ECDH)", b64u(gemeinsam) === V.shared, b64u(gemeinsam));
|
||||
|
||||
const prkKey = crypto.createHmac("sha256", ausB64u(V.authSecret)).update(gemeinsam).digest();
|
||||
pruefe("PRK_key", b64u(prkKey) === V.prkKey, b64u(prkKey));
|
||||
|
||||
const keyInfo = Buffer.concat([
|
||||
Buffer.from("WebPush: info\0"), ausB64u(V.uaPublic), ausB64u(V.asPublic),
|
||||
]);
|
||||
const ikm = Buffer.from(
|
||||
crypto.hkdfSync("sha256", gemeinsam, ausB64u(V.authSecret), keyInfo, 32)
|
||||
);
|
||||
pruefe("IKM", b64u(ikm) === V.ikm, b64u(ikm));
|
||||
|
||||
const cek = Buffer.from(
|
||||
crypto.hkdfSync("sha256", ikm, ausB64u(V.salt), Buffer.from("Content-Encoding: aes128gcm\0"), 16)
|
||||
);
|
||||
pruefe("CEK (Inhaltsschluessel)", b64u(cek) === V.cek, b64u(cek));
|
||||
|
||||
const nonce = Buffer.from(
|
||||
crypto.hkdfSync("sha256", ikm, ausB64u(V.salt), Buffer.from("Content-Encoding: nonce\0"), 12)
|
||||
);
|
||||
pruefe("NONCE (Zaehlwert)", b64u(nonce) === V.nonce, b64u(nonce));
|
||||
|
||||
console.log("\n2. DIE FERTIGE NACHRICHT");
|
||||
|
||||
const koerper = verschluesseln(V.klartext, V.uaPublic, V.authSecret, {
|
||||
salt: ausB64u(V.salt),
|
||||
sitzungPrivat: ausB64u(V.asPrivate),
|
||||
blockgroesse: 4096,
|
||||
});
|
||||
|
||||
/* Der Kopf ist unverschluesselt und laesst sich Feld fuer Feld
|
||||
nachrechnen. Stimmt er, sitzt der Empfaenger nicht vor einer
|
||||
Nachricht, deren Aufbau er gar nicht erst lesen kann. */
|
||||
pruefe("Salt steht vorn", b64u(koerper.subarray(0, 16)) === V.salt);
|
||||
pruefe("Blockgroesse 4096", koerper.readUInt32BE(16) === 4096, String(koerper.readUInt32BE(16)));
|
||||
pruefe("Laengenangabe 65", koerper[20] === 65, String(koerper[20]));
|
||||
pruefe("Sitzungsschluessel eingebettet", b64u(koerper.subarray(21, 86)) === V.asPublic);
|
||||
|
||||
/* Und der Inhalt selbst. Der Vergleich laeuft gegen die Zeile aus
|
||||
Abschnitt 5 der Norm -- dieselbe Eingabe muss Byte fuer Byte
|
||||
dasselbe ergeben. */
|
||||
const AUS_DER_NORM =
|
||||
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" +
|
||||
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT" +
|
||||
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
|
||||
|
||||
const meins = b64u(koerper);
|
||||
pruefe("Nachricht stimmt mit der Norm ueberein",
|
||||
meins === AUS_DER_NORM,
|
||||
meins === AUS_DER_NORM ? `${koerper.length} Byte` : `meins ${meins.length} / Norm ${AUS_DER_NORM.length} Zeichen`);
|
||||
|
||||
if (meins !== AUS_DER_NORM) {
|
||||
/* Zeigt, AB WO es auseinanderlaeuft -- das grenzt den Fehler
|
||||
zwischen Kopf, Schluessel und Inhalt ein. */
|
||||
let i = 0;
|
||||
while (i < Math.min(meins.length, AUS_DER_NORM.length) && meins[i] === AUS_DER_NORM[i]) i++;
|
||||
console.log(` erste Abweichung an Zeichen ${i} (Kopf endet bei 115)`);
|
||||
console.log(` meins: …${meins.slice(Math.max(0, i - 10), i + 20)}`);
|
||||
console.log(` Norm : …${AUS_DER_NORM.slice(Math.max(0, i - 10), i + 20)}`);
|
||||
}
|
||||
|
||||
console.log("\n3. RUNDLAUF: laesst sich das Verschluesselte wieder oeffnen?");
|
||||
/* Gegenprobe mit frischen Zufallswerten. Der Normvergleich oben prueft
|
||||
einen einzigen festen Fall; hier wird geprueft, dass es auch mit
|
||||
selbst erzeugten Schluesseln aufgeht -- also im Betrieb. */
|
||||
const empfaenger = crypto.createECDH("prime256v1");
|
||||
empfaenger.generateKeys();
|
||||
const authNeu = crypto.randomBytes(16);
|
||||
const text = "Neue Anfrage A-2608-0007 (onepager)";
|
||||
|
||||
const paket = verschluesseln(text, b64u(empfaenger.getPublicKey()), b64u(authNeu));
|
||||
|
||||
/* Entschluesseln wie ein Browser es taete. */
|
||||
const rSalt = paket.subarray(0, 16);
|
||||
const rAsPub = paket.subarray(21, 86);
|
||||
const rInhalt = paket.subarray(86);
|
||||
const rGemeinsam = empfaenger.computeSecret(rAsPub);
|
||||
const rIkm = Buffer.from(crypto.hkdfSync("sha256", rGemeinsam, authNeu,
|
||||
Buffer.concat([Buffer.from("WebPush: info\0"), empfaenger.getPublicKey(), rAsPub]), 32));
|
||||
const rCek = Buffer.from(crypto.hkdfSync("sha256", rIkm, rSalt,
|
||||
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
||||
const rNonce = Buffer.from(crypto.hkdfSync("sha256", rIkm, rSalt,
|
||||
Buffer.from("Content-Encoding: nonce\0"), 12));
|
||||
|
||||
const entschluesseler = crypto.createDecipheriv("aes-128-gcm", rCek, rNonce);
|
||||
entschluesseler.setAuthTag(rInhalt.subarray(rInhalt.length - 16));
|
||||
const klar = Buffer.concat([
|
||||
entschluesseler.update(rInhalt.subarray(0, rInhalt.length - 16)),
|
||||
entschluesseler.final(),
|
||||
]);
|
||||
const zurueck = klar.subarray(0, klar.length - 1).toString("utf8");
|
||||
pruefe("entschluesselter Text ist der gesendete", zurueck === text, zurueck);
|
||||
pruefe("Abschlusszeichen 0x02 sitzt am Ende", klar[klar.length - 1] === 2);
|
||||
|
||||
console.log("\n4. VAPID-AUSWEIS");
|
||||
const paar = schluesselpaarErzeugen();
|
||||
pruefe("oeffentlicher Schluessel ist 65 Byte", ausB64u(paar.oeffentlich).length === 65,
|
||||
ausB64u(paar.oeffentlich).length + " Byte");
|
||||
pruefe("beginnt mit 0x04 (unkomprimierter Punkt)", ausB64u(paar.oeffentlich)[0] === 4);
|
||||
|
||||
const kopf = vapidKopf("https://fcm.googleapis.com/fcm/send/abc", paar.privat,
|
||||
paar.oeffentlich, "mailto:[email protected]", 1000000);
|
||||
pruefe("Kopf hat die Form 'vapid t=…, k=…'", /^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/.test(kopf));
|
||||
|
||||
const [teilA, teilB, teilC] = kopf.slice(8).split(", ")[0].split(".");
|
||||
const rumpf = JSON.parse(ausB64u(teilB).toString());
|
||||
pruefe("Empfaenger ist der Ursprung des Endpunkts", rumpf.aud === "https://fcm.googleapis.com", rumpf.aud);
|
||||
pruefe("laeuft nach 12 Stunden ab", rumpf.exp === 1000000 + 43200, String(rumpf.exp - 1000000) + " Sekunden");
|
||||
|
||||
/* Die 64 Byte sind der eigentliche Fallstrick: Node liefert ohne
|
||||
ausdrueckliche Angabe eine DER-Signatur (70-72 Byte), die jeder
|
||||
Push-Dienst wortlos ablehnt. */
|
||||
pruefe("Signatur ist 64 Byte roh, nicht DER", ausB64u(teilC).length === 64,
|
||||
ausB64u(teilC).length + " Byte");
|
||||
|
||||
const echt = crypto.verify("sha256", Buffer.from(`${teilA}.${teilB}`),
|
||||
{ key: crypto.createPublicKey(crypto.createPrivateKey(paar.privat)), dsaEncoding: "ieee-p1363" },
|
||||
ausB64u(teilC));
|
||||
pruefe("Signatur ist gueltig", echt);
|
||||
|
||||
console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`);
|
||||
process.exit(fehl ? 1 : 0);
|
||||
+73
-1
@@ -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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user