/* ===================================================================== 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:kontakt@dogfather-universe.com"; } 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, }; }