Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

165
web/html/js/push.js Normal file
View File

@@ -0,0 +1,165 @@
// An- und Abmeldung für Push-Benachrichtigungen.
//
// Ablauf: Der Browser holt sich beim Push-Dienst seines Herstellers einen
// Endpunkt, den er zusammen mit Schlüsselmaterial an unseren Server
// meldet. Der Server kann darüber verschlüsselte Nachrichten schicken -
// lesen kann sie nur dieses Gerät, auch der Push-Dienst nicht.
"use strict";
import { get, post } from "./api.js";
/** Der öffentliche VAPID-Schlüssel kommt als base64url ohne
* Auffüllzeichen; die Browser-API will ein Uint8Array. */
function decodeKey(base64url) {
const padded = base64url.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
/** Kurze Gerätebezeichnung, damit man mehrere Anmeldungen unterscheiden
* kann. Bewusst grob - der vollständige User-Agent wäre ein
* Wiedererkennungsmerkmal und wird nicht gebraucht. */
function deviceLabel() {
const ua = navigator.userAgent;
const system =
/Android/i.test(ua) ? "Android"
: /iPhone|iPad|iPod/i.test(ua) ? "iPhone/iPad"
: /Macintosh/i.test(ua) ? "Mac"
: /Windows/i.test(ua) ? "Windows"
: /Linux/i.test(ua) ? "Linux"
: "Gerät";
const browser =
/Firefox\//i.test(ua) ? "Firefox"
: /Edg\//i.test(ua) ? "Edge"
: /Chrome\//i.test(ua) ? "Chrome"
: /Safari\//i.test(ua) ? "Safari"
: "Browser";
return `${browser} auf ${system}`.slice(0, 80);
}
/** Läuft die App als installierte PWA? Auf iOS ist das die
* Voraussetzung dafür, dass Push überhaupt funktioniert. */
export function isInstalled() {
return (
window.matchMedia?.("(display-mode: standalone)")?.matches === true ||
navigator.standalone === true
);
}
export function isApple() {
return /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
}
/** Was der Browser hier grundsätzlich kann. */
export function supported() {
return (
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window &&
window.isSecureContext
);
}
/**
* Aktueller Zustand.
* @returns {Promise<{
* supported: boolean, serverEnabled: boolean, permission: string,
* subscribed: boolean, needsInstall: boolean, throttleHours: number
* }>}
*/
export async function status() {
const base = {
supported: supported(),
serverEnabled: false,
permission: "Notification" in window ? Notification.permission : "unsupported",
subscribed: false,
// iOS lässt Push nur in der installierten App zu.
needsInstall: isApple() && !isInstalled(),
throttleHours: 2,
};
try {
const config = await get("/api/push/config");
base.serverEnabled = config.enabled;
base.throttleHours = config.throttle_hours;
} catch {
return base;
}
if (!base.supported) return base;
try {
const registration = await navigator.serviceWorker.ready;
base.subscribed = Boolean(await registration.pushManager.getSubscription());
} catch {
// Kein Service Worker bereit - dann eben nicht angemeldet.
}
return base;
}
/** Fragt die Erlaubnis ab und meldet das Gerät an.
* @returns {Promise<{ok: boolean, reason?: string}>} */
export async function subscribe() {
if (!supported()) {
return { ok: false, reason: "Dieser Browser unterstützt keine Benachrichtigungen." };
}
const config = await get("/api/push/config");
if (!config.enabled || !config.public_key) {
return { ok: false, reason: "Auf diesem Server sind Benachrichtigungen nicht eingerichtet." };
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
return {
ok: false,
reason: permission === "denied"
? "Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt sich nur in den Browsereinstellungen wieder ändern."
: "Keine Erlaubnis erteilt.",
};
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
// Pflicht bei allen aktuellen Browsern: Nachrichten ohne
// sichtbare Meldung sind nicht erlaubt.
userVisibleOnly: true,
applicationServerKey: decodeKey(config.public_key),
});
}
const json = subscription.toJSON();
await post("/api/push/subscribe", {
endpoint: json.endpoint,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
label: deviceLabel(),
});
return { ok: true };
}
/** Meldet dieses Gerät ab - lokal und auf dem Server. */
export async function unsubscribe() {
if (!supported()) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
const endpoint = subscription.endpoint;
// Erst lokal abmelden: Schlägt der Server fehl, sollen trotzdem keine
// Meldungen mehr ankommen.
await subscription.unsubscribe().catch(() => {});
await post("/api/push/unsubscribe", { endpoint }).catch(() => {});
}
export function sendTest() {
return post("/api/push/test");
}
export function listDevices() {
return get("/api/push/subscriptions");
}