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.