PageSourceSearch

https://eb-energiekonzepte.de/js/sys/push.js?1789650266

js eb-energiekonzepte.de collected 2026-10-06 21:49:08 UTC 20,085 bytes, 482 lines download raw bytes

1/**
2 * Zustellung an Abwesende — die Seite.
3 *
4 * Gegenstueck zu public/push-sw.js und cms/includes/push.php. Hier passiert
5 * nur, was ein Fenster tun kann: die Erlaubnis erfragen, den Worker anmelden,
6 * das Abonnement holen und dem Server geben.
7 *
8 * Zwei Regeln, die hier eingehalten werden und die man leicht verletzt:
9 *
10 *   1. Die Erlaubnis wird NIE von selbst erfragt. Notification.requestPermission()
11 *      ohne Zutun des Benutzers ist in den meisten Browsern ohnehin wirkungslos
12 *      und in den uebrigen ein guter Weg, dauerhaft abgelehnt zu werden — das
13 *      laesst sich danach nicht mehr aus der Seite heraus zuruecknehmen.
14 *
15 *   2. Der oeffentliche Schluessel kommt ueber die Schnittstelle, nicht aus dem
16 *      HTML. Seiten werden zwischengespeichert; ein Schluessel aus einem alten
17 *      Zwischenspeicher erzeugte Abonnements, an die sich nichts zustellen
18 *      liesse. Dieselbe Ueberlegung wie beim Ticket der Vermittlung.
19 *
20 * Absichtlich NICHT im Template-Buendel, sondern als eigene Datei unter
21 * public/js/sys/ — aus demselben Grund wie sectoken.js: Die Buendel sind nicht
22 * versioniert und koennen nach einem Deployment aelter sein als die Quelle.
23 */
24(function () {
25    'use strict';
26
27    var GELTUNGSBEREICH = '/indice-push/';
28    var WORKER = '/push-sw.js';
29    var MERKER_AUFFRISCHUNG = 'indice_push_aufgefrischt';
30    var MERKER_WUENSCHE = 'indice_push_wuensche';
31    var MERKER_GERAET   = 'indice_push_geraet';
32    var WUENSCHE_HALTBAR = 600000;   // zehn Minuten
33
34    var stand = {
35        verfuegbar: false,
36        grund: '',
37        erlaubnis: (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported',
38        angemeldet: false,
39        geraete: 0
40    };
41
42    var schluessel = null;   // oeffentlicher VAPID-Schluessel, einmal geholt
43    var zuhoerer = [];
44
45    // ---------------------------------------------------------------- Grundlagen
46
47    function post(pfad, koerper) {
48        return fetch('/api/push/' + pfad, {
49            method: 'POST',
50            credentials: 'same-origin',
51            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
52            body: JSON.stringify(koerper || {})
53        }).then(function (r) { return r.json(); });
54    }
55
56    function melden() {
57        zuhoerer.forEach(function (f) { try { f(zustand()); } catch (e) {} });
58    }
59
60    function zustand() {
61        return {
62            verfuegbar: stand.verfuegbar,
63            grund: stand.grund,
64            erlaubnis: stand.erlaubnis,
65            angemeldet: stand.angemeldet,
66            geraete: stand.geraete
67        };
68    }
69
70    /**
71     * Geht das hier ueberhaupt?
72     *
73     * Der Sonderfall, der die meisten Rueckfragen erzeugt, ist das iPhone:
74     * Safari kennt PushManager dort ausschliesslich, wenn die Seite ueber
75     * „Zum Home-Bildschirm" installiert wurde. Im normalen Reiter fehlt das
76     * Objekt schlicht — und ohne diese Erklaerung sieht das aus wie ein Fehler
77     * des Systems statt wie eine Eigenschaft des Geraets.
78     */
79    function pruefen() {
80        if (!('serviceWorker' in navigator)) {
81            stand.grund = 'Dieser Browser unterstuetzt keine Hintergrund-Benachrichtigungen.';
82            return false;
83        }
84        if (!('PushManager' in window)) {
85            stand.grund = istApple()
86                ? 'Auf iPhone und iPad nur möglich, wenn die Seite über „Zum Home-Bildschirm“ installiert ist.'
87                : 'Dieser Browser unterstuetzt keine Hintergrund-Benachrichtigungen.';
88            return false;
89        }
90        if (typeof Notification === 'undefined') {
91            stand.grund = 'Dieser Browser kann keine Benachrichtigungen anzeigen.';
92            return false;
93        }
94        if (!window.isSecureContext) {
95            stand.grund = 'Benachrichtigungen brauchen eine verschlüsselte Verbindung.';
96            return false;
97        }
98        return true;
99    }
100
101    function istApple() {
102        return /iPad|iPhone|iPod/.test(navigator.userAgent)
103            || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
104    }
105
106    function rohSchluessel(text) {
107        var voll = (text + '='.repeat((4 - text.length % 4) % 4)).replace(/-/g, '+').replace(/_/g, '/');
108        var roh = window.atob(voll);
109        var feld = new Uint8Array(roh.length);
110        for (var i = 0; i < roh.length;
110 i++) { feld[i] = roh.charCodeAt(i); }
111        return feld;
112    }
113
114    // ---------------------------------------------------------------- Worker
115
116    function anmeldung() {
117        return navigator.serviceWorker.register(WORKER, { scope: GELTUNGSBEREICH });
118    }
119
120    function holeSchluessel() {
121        if (schluessel) { return Promise.resolve(schluessel); }
122
123        return post('stand').then(function (a) {
124            if (!a || !a.success || !a.result || !a.result.aktiv || !a.result.schluessel) {
125                throw new Error('Benachrichtigungen sind auf diesem System nicht eingerichtet.');
126            }
127            stand.geraete = a.result.geraete || 0;
128            schluessel = a.result.schluessel;
129            return schluessel;
130        });
131    }
132
133    /**
134     * Kennung DIESES Geraets — einmal vergeben, danach unveraendert.
135     *
136     * Der Server erkennt ein Geraet sonst nur an seinem Endpunkt, und der ist
137     * nach jeder Neuanmeldung ein anderer: App neu installiert, Erlaubnis
138     * zurueckgenommen und wieder erteilt, Browserdaten geloescht. Die alten
139     * Endpunkte bleiben in der Tabelle stehen und werden von den
140     * Zustelldiensten noch wochenlang angenommen — dasselbe Telefon klingelt
141     * dann mehrfach bei einer einzigen Nachricht.
142     *
143     * Geht der Speicher verloren, entsteht eine neue Kennung und damit wieder
144     * eine Karteileiche. Das ist der seltene Fall und behebt sich, sobald der
145     * Zustelldienst den alten Endpunkt abweist.
146     */
147    function geraeteKennung() {
148        try {
149            var da = localStorage.getItem(MERKER_GERAET);
150            if (da) { return da; }
151
152            var neu = 'g' + Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
153            localStorage.setItem(MERKER_GERAET, neu);
154            return neu;
155        } catch (e) {
156            return '';
157        }
158    }
159
160    function abonnementMelden(abo) {
161        var j = abo.toJSON ? abo.toJSON() : {};
162
163        return post('abo/setzen', {
164            endpunkt: abo.endpoint,
165            p256dh: (j.keys && j.keys.p256dh) || '',
166            auth: (j.keys && j.keys.auth) || '',
167            kennung: geraeteKennung()
168        }).then(function (a) {
169            if (!a || !a.success) {
170                throw new Error((a && a.msg) || 'Das Gerät konnte nicht angemeldet werden.');
171            }
172            stand.angemeldet = true;
173            stand.geraete = (a.result && a.result.geraete) || stand.geraete;
174            try { localStorage.setItem(MERKER_AUFFRISCHUNG, String(Date.now())); } catch (e) {}
175            try { sessionStorage.setItem(MERKER_AUFFRISCHUNG, '1'); } catch (e) {}
176            melden();
177            return a.result;
178        });
179    }
180
181    // ---------------------------------------------------------------- Nach aussen
182
183    /**
184     * Anmelden. Muss aus einer Benutzergeste heraus aufgerufen werden —
185     * andernfalls verweigert der Browser die Erlaubnisfrage.
186     */
187    function anmelden() {
188        if (!stand.verfuegbar) {
189            return Promise.reject(new Error(stand.grund || 'Nicht möglich.'));
190        }
191
192        return Promise.resolve()
193            .then(function () {
194                if (Notification.permission === 'granted') { return 'granted'; }
195                if (Notification.permission === 'denied') {
196                    // Endgueltig: Zuruecknehmen geht nur ueber die
197                    // Browsereinstellungen, nicht aus der Seite heraus.
198                    throw new Error('Benachrichtigungen sind für diese Seite im Browser blockiert. '
199                        + 'Das lässt sich nur in den Browsereinstellungen ändern.');
200                }
201                return Notification.requestPermission();
202            })
203            .then(function (erlaubnis) {
204                stand.erlaubnis = erlaubnis;
205                melden();
206
207                if (erlaubnis !== 'granted') {
208                    throw new Error('Ohne Erlaubnis können keine Benachrichtigungen zugestellt werden.');
209                }
210
211                return Promise.all([anmeldung(), holeSchluessel()]);
212            })
213            .then(function (teile) {
214                var reg = teile[0], key = teile[1];
215
216                return reg.pushManager.getSubscription().then(function (vorhanden) {
217                    if (vorhanden) { return vorhanden; }
218
219                    return reg.pushManager.subscribe({
220                        // Pflicht: Der Browser verlangt die Zusage, dass jede
221                        // Zustellung auch sichtbar wird. Stille Zustellungen
222                        // gibt es im Web nicht, und das ist gut so.
223                        userVisibleOnly: true,
224                        applicationServerKey: rohSchluessel(key)
225                    });
226                });
227            })
228            .then(abonnementMelden);
229    }
230
231    /** Dieses Geraet abmelden. Andere Geraete derselben Person bleiben. */
232    function abmelden() {
233        if (!('serviceWorker' in navigator)) { return Promise.resolve(); }
234
235        return navigator.serviceWorker.getRegistration(GELTUNGSBEREICH)
236            .then(function (reg) { return reg ? reg.pushManager.getSubscription() : null; })
237            .then(function (abo) {
238                if (!abo) { return post('abo/loeschen', {}); }
239
240                var endpunkt = abo.endpoint;
241                return abo.unsubscribe().then(function () { return post('abo/loeschen', { endpunkt: endpunkt }); });
242            })
243            .then(function (a) {
244                stand.angemeldet = false;
245                stand.geraete = (a && a.result && a.result.geraete) || 0;
246                melden();
247                return stand.geraete;
248            });
249    }
250
251    /**
252     * Hat der Browser die Meldung tatsaechlich angelegt?
253     *
254     * Der Unterschied, an dem eine Fehlersuche sonst zerbricht: Ein Browser
255     * fuehrt seine Meldungen in eigener Buchhaltung. Ob das Betriebssystem sie
256     * anschliessend auf den Bildschirm bringt, weiss er nicht und sagt es
257     * niemandem — showNotification() loest auch dann auf, wenn dem Browser
258     * selbst die Freigabe im System fehlt.
259     *
260     * Damit laesst sich die Kette an der einzigen Stelle auftrennen, die von
261     * aussen nicht sichtbar ist:
262     *
263     *   angelegt = 0  ->  bis hierher kam nichts (Zustellung, Entschluesselung)
264     *   angelegt > 0  ->  alles bis zum Browser stimmt; sieht der Benutzer
265     *                     trotzdem nichts, unterdrueckt es das System
266     *
267     * @return {Promise<number>} Anzahl, oder -1 wenn nicht feststellbar
268     */
269    function angelegt(marke) {
270        if (!('serviceWorker' in navigator)) { return Promise.resolve(-1); }
271
272        return navigator.serviceWorker.getRegistration(GELTUNGSBEREICH)
273            .then(function (reg) { return reg ? reg.getNotifications(marke ? { tag: marke } : {}) : []; })
274            .then(function (liste) { return liste.length; })
275            .catch(function () { return -1; });
276    }
277
278    /**
279     * Beim Laden nachsehen, ob noch alles steht.
280     *
281     * Zwei Faelle, und der zweite ist der wichtigere:
282     *
283     * Ein Abonnement kann serverseitig verschwunden sein — als tot aussortiert,
284     * bei einem Umzug verloren. Der Browser weiss davon nichts und meldet sich
285     * nicht neu an. Deshalb einmal je Tag stillschweigend auffrischen.
286     *
287     * Und: Wer die Erlaubnis lange VOR dieser Funktion erteilt hat — im Chat
288     * gab es die Nachfrage schon —, hat sie zwar, aber kein Abonnement. Die
289     * Aktivieren-Leiste erscheint bei ihm nie, weil sie an „noch nicht
290     * gefragt" haengt. Ohne den Zweig hier bekaeme also ausgerechnet der
291     * Bestand nie eine Benachrichtigung, und niemand haette einen Anhaltspunkt.
292     * Das Nachholen braucht keine Geste: Die Erlaubnis liegt ja bereits vor.
293     */
294    function auffrischen() {
295        if (!stand.verfuegbar || Notification.permission !== 'granted') { return; }
296
297        navigator.serviceWorker.getRegistration(GELTUNGSBEREICH)
298            .then(function (reg) { return reg || anmeldung(); })
299            .then(function (reg) {
300                return reg.pushManager.getSubscription().then(function (abo) {
301                    if (abo) { return { abo: abo, neu: false }; }
302
303                    // Erlaubnis da, Abonnement fehlt: nachholen.
304                    return holeSchluessel().then(function (key) {
305                        return reg.pushManager.subscribe({
306                            userVisibleOnly: true,
307                            applicationServerKey: rohSchluessel(key)
308                        });
309                    }).then(function (frisch) { return { abo: frisch, neu: true }; });
310                });
311            })
312            .then(function (ergebnis) {
313                if (!ergebnis || !ergebnis.abo) { return; }
314
315                stand.angemeldet = true;
316                melden();
317
318                // Ein frisches Abonnement MUSS gemeldet werden, ganz gleich wann
319                // zuletzt aufgefrischt wurde — sonst kennt es der Server nicht,
320                // und das Geraet bliebe bis morgen still.
321                if (!ergebnis.neu && bereitsBestaetigt()) { return; }
322
323                return abonnementMelden(ergebnis.abo);
324            })
325            .catch(function () {});
326    }
327
328    /**
329     * Wurde dieses Abonnement dem Server schon bestaetigt?
330     *
331     * Zwei Bedingungen, und die erste ist die wichtige: EINMAL je
332     * Browsersitzung wird auf jeden Fall gemeldet. Der Zeitstempel allein
333     * genuegte naemlich nicht — verschwindet die Zeile serverseitig (Tabelle
334     * geleert, Aufraeumlauf, Umzug), weiss der Browser nichts davon. Er hat ja
335     * noch sein Abonnement und haette bis zum Ablauf der Frist geschwiegen:
336     * bis zu einem Tag ohne Benachrichtigungen, ohne erkennbaren Grund.
337     *
338     * Die Meldung ist ein Upsert und damit billig; einmal je Sitzung faellt
339     * neben dem Seitenaufbau nicht ins Gewicht.
340     */
341    function bereitsBestaetigt() {
342        var sitzung = false;
343        try { sitzung = sessionStorage.getItem(MERKER_AUFFRISCHUNG) === '1'; } catch (e) {}
344        if (!sitzung) { return false; }
345
346        var zuletzt = 0;
347        try { zuletzt = parseInt(localStorage.getItem(MERKER_AUFFRISCHUNG), 10) || 0; } catch (e) {}
348
349        return (Date.now() - zuletzt) < 86400000;
350    }
351
352    function merkeWuensche(wert) {
353        try {
354            sessionStorage.setItem(MERKER_WUENSCHE, JSON.stringify({ zeit: Date.now(), wert: wert }));
355        } catch (e) {}
356    }
357
358    /**
359     * Der Worker sagt, wohin nach einem Klick auf eine Benachrichtigung.
360     *
361     * Gegenstueck zum notificationclick-Handler in push-sw.js. Dort steht auch
362     * der Grund, warum nicht der Worker selbst navigiert: WindowClient.navigate()
363     * gilt nur fuer Fenster, die er kontrolliert, und unserer kontrolliert
364     * absichtlich keines.
365     *
366     * Der Worker schickt diesen Zuruf NUR an ein Fenster, das bereits auf der
367     * Zielseite steht — denn nur dort ist er ein Gewinn: Es wechselt bloss die
368     * Sprungmarke, und die Chat-App macht daraus einen Wechsel der Unterhaltung,
369     * ohne irgendetwas neu zu laden (siehe chat-app.js, Abschnitt NAVIGATION).
370     *
371     * Fuer jede andere Lage nimmt er openWindow und kommt damit ohne Mitwirkung
372     * der Zielseite aus. Das ist wichtig: Nicht jede Seite dieses Auftritts hat
373     * push.js geladen, und eine, die den Zuruf nicht annimmt, laesst den Klick
374     * folgenlos verpuffen.
375     *
376     * Der Zweig auf einen anderen Pfad bleibt trotzdem stehen — als Auffangnetz,
377     * falls doch einmal ein Ziel auf einer anderen Seite hier ankommt.
378     */
379    function zielAnnehmen(ereignis) {
380        var d = ereignis && ereignis.data;
381        if (!d || d.indicePush !== 'ziel' || !d.url) return;
382
383        var ziel;
384        try { ziel = new URL(d.url, window.location.origin); } catch (e) { return; }
385
386        if (ziel.origin !== window.location.origin) return;
387
388        // Bevor navigiert wird: der Seite Bescheid geben. Eine App, die bereits
389        // offen ist, kann daraus mehr machen als ein Sprung zur Marke — sie
390        // kennt den Datensatz aus `daten` und kann ihn oeffnen, ohne die Adresse
391        // ueberhaupt anzufassen. Wer nicht zuhoert, verliert nichts.
392        try {
393            window.dispatchEvent(new CustomEvent('indice:meldung-klick', {
394                detail: { url: ziel.href, daten: d.daten || null }
395            }));
396        } catch (e) {}
397
398        // Gleiche Seite: nur die Marke. Ein location.href waere hier ein
399        // vollstaendiger Neuaufbau — inklusive erneutem Laden der
400        // Unterhaltungen, und das ausgerechnet auf dem Telefon.
401        if (ziel.pathname === window.location.pathname) {
402            if (window.location.hash !== ziel.hash) {
403                window.location.hash = ziel.hash;
404            }
405            return;
406        }
407
408        window.location.href = ziel.href;
409    }
410
411    if ('serviceWorker' in navigator) {
412        navigator.serviceWorker.addEventListener('message', zielAnnehmen);
413    }
414
415    stand.verfuegbar = pruefen();
416
417    window.indicePush = {
418        zustand: zustand,
419        anmelden: anmelden,
420        abmelden: abmelden,
421        /** Ruft f bei jeder Aenderung des Zustands auf. */
422        beiAenderung: function (f) { if (typeof f === 'function') { zuhoerer.push(f); f(zustand()); } },
423        angelegt: angelegt,
424        kennung: geraeteKennung,
425        stand: function () { return post('stand', { kennung: geraeteKennung() }); },
426        /** Ein einzelnes Geraet abmelden (Nummer aus der Liste in `stand`). */
427        geraetLoeschen: function (id) { return post('abo/loeschen', { id: id }); },
428
429        /**
430         * Die persoenlichen Wuensche.
431         *
432         * Gemerkt fuer die Dauer der Sitzung, weil comms-hub.js sie auf JEDER
433         * Seite braucht und sie sich fast nie aendern. Ohne diesen Zwischenhalt
434         * kaeme je Seitenaufruf und Chat-Berechtigtem eine Abfrage hinzu — genau
435         * die Art von Dauerlast, wegen der der alte SSE-Strom weichen musste.
436         *
437         * `frisch` umgeht ihn; das braucht die Einstellungskarte, die ja
438         * gerade den Stand bearbeitet.
439         */
440        einstellungen: function (frisch) {
441            if (!frisch) {
442                try {
443                    var roh = sessionStorage.getItem(MERKER_WUENSCHE);
444                    if (roh) {
445                        var d = JSON.parse(roh);
446                        if (d && (Date.now() - d.zeit) < WUENSCHE_HALTBAR) {
447                            return Promise.resolve({ success: true, result: d.wert });
448                        }
449                    }
450                } catch (e) {}
451            }
452
453            return post('einstellungen').then(function (a) {
454                if (a && a.success) { merkeWuensche(a.result); }
455                return a;
456            });
457        },
458
459        einstellungenSpeichern: function (w) {
460            var d = w || {};
461            d.speichern = 1;
462
463            return post('einstellungen', d).then(function (a) {
464                // Der gespeicherte Stand ist die Wahrheit, nicht der gesendete:
465                // Eine halbe Ruhezeit verwirft der Endpunkt. Wer den Wunsch
466                // statt der Antwort merkt, zeigt auf der naechsten Seite etwas
467                // an, das so nie gespeichert wurde.
468                if (a && a.success) { merkeWuensche(a.result); }
469                return a;
470            });
471        },
472        probe: function () { return post('probe'); }
473    };
474
475    if (stand.verfuegbar) {
476        if (document.readyState === 'loading') {
477            document.addEventListener('DOMContentLoaded', auffrischen
477);
478        } else {
479            auffrischen();
480        }
481    }
482})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.