diff --git a/.gitignore b/.gitignore index 500bfcd0..b41f3f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,12 @@ bild-*.png # Aenderung erscheinen. handy-*.png -# Laufprotokoll der Browserpruefung -- Ergebnis, kein Quelltext -pruef-browser-lauf.txt -server/pruef-browser-lauf.txt +# Laufprotokolle der Pruefungen -- Ergebnis, kein Quelltext. +# Ein Muster statt einer Liste: Sonst haette jede neue Pruefung ihre +# eigene Zeile gebraucht, und die vergisst man. Genau das ist am +# 05.09.2026 passiert -- vier neue Protokolle standen ploetzlich als +# Aenderung im Arbeitsstand. +pruef-*-lauf.txt +server/pruef-*-lauf.txt +# Ergebnis des Sammellaufs (tools/alles-pruefen.mjs) +gesamtlauf.txt diff --git a/server/index.js b/server/index.js index cf33ed60..2df9b908 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,7 @@ import { vorlagenRouter } from "./workspace-vorlagen.js"; import { schulungRouter } from "./workspace-schulung.js"; import { ampelRouter } from "./workspace-ampel.js"; import { checklisteRouter } from "./workspace-checkliste.js"; +import { pushRouter, pushStarten } from "./workspace-push.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -148,6 +149,10 @@ app.use(sicherungRouter); JSON in einem ergibt nur ein kaputtes Bild ohne jeden Hinweis. Der Steckbrief bringt seine eigene Schranke mit. */ app.use(steckbriefRouter); +/* Ebenfalls VOR aufgabenRouter: Der Service Worker holt den + oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die + Push-Wege bringen ihre eigene Schranke mit. */ +app.use(pushRouter); app.use(vorlagenRouter); app.use(schulungRouter); app.use(ampelRouter); @@ -182,6 +187,12 @@ sicherungStarten(); Call-Liste oder die Suche benutzt -- die lesen dieselbe Tabelle. */ serienStarten(); +/* Benachrichtigungen. Eigener Takt im Prozess -- wie die Sicherung und + der Serien-Nachfueller: kein Systemdienst, kein Cron, eine Sache + weniger, die stillschweigend ausfallen kann. Begruendung im Kopf von + workspace-push.js. */ +pushStarten(); + /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server- diff --git a/server/pruef-abbrechen-optik.mjs b/server/pruef-abbrechen-optik.mjs new file mode 100644 index 00000000..4e78ee77 --- /dev/null +++ b/server/pruef-abbrechen-optik.mjs @@ -0,0 +1,334 @@ +/* ABBRECHEN — WIE ES SICH BEDIENEN LÄSST. + + pruef-abbrechen.mjs beweist, dass der Server richtig entscheidet. + Diese Prüfung fragt das, was heute früh niemand gefragt hatte: + Kommt davon auch etwas auf dem Bildschirm an? + + Der Anlass ist derselbe Tag. Vierzig grüne Prüfungen zur + Teilnehmerwahl -- und auf Filipes Schirm klebten die Namen + aneinander, weil kein einziger Test je hingesehen hatte. Ein Server, + der alles richtig macht, nützt nichts, wenn der Knopf dazu fehlt. + + GEMESSEN WIRD: + * Sieht ein Creator den Abbrechen-Knopf ÜBERHAUPT NICHT? + (Der Server lehnt ihn ohnehin ab -- aber einen Knopf anzubieten, + der dann 403 sagt, ist eine Zumutung.) + * Steht er in JEDEM Status an der Karte? + * Verlangt der Dialog wirklich einen Grund? + * Verschwindet die Aufgabe aus dem Brett und taucht unten wieder + auf -- mit Grund, Person und dem Status, in dem sie war? + * Und der Rückweg. + + GEGENPROBEN: Jede Sperre wird auch von der erlaubten Seite versucht. */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-abopt-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4297, "die Optikpruefung des Abbrechens"); + +process.env.PORT = "4297"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4297"; + +const PROTOKOLL = join(process.cwd(), "pruef-abbrechen-optik-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } +}; +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetzt); + +/* Eine Aufgabe je Status -- der Wunsch lautet ausdrücklich "in JEDEM + Status", also muss auch jeder einzeln nachgewiesen werden. */ +for (const s of ["offen", "arbeit", "review", "erledigt"]) { + d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, + verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?)`) + .run(`Aufgabe ${s}`, "Beschreibung.", s, idLuna, idLuna, jetzt, idDogi); +} +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +/* Was der Browser meldet, wird mitgeschrieben. Beim ersten Lauf blieb + diese Prüfung beim Absenden des Dialogs stehen, und die stumme + Konsole sagte nicht, warum -- ein JavaScript-Fehler sieht von außen + genauso aus wie "der Server antwortet nicht". */ +const konsole = []; +async function alsRolle(rolle, code, breite = 1280) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 } }); + const seite = await ctx.newPage(); + seite.on("pageerror", (f) => konsole.push(`pageerror: ${f.message}`)); + seite.on("console", (m) => { + if (m.type() === "error") konsole.push(`console: ${m.text().slice(0, 160)}`); + }); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); + await seite.waitForSelector(".karte", { timeout: 15000 }); + return { ctx, seite }; +} + +/* ======================================================================= + 1. Der Knopf — bei wem, und in welchem Status + ======================================================================= */ +melde("\n=== Der Abbrechen-Knopf ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + const karten = await seite.$$(".karte"); + /* DIE ANZAHL IN DIE BEDINGUNG: Ohne Karten liefe alles Folgende über + eine leere Menge und meldete grün. */ + ok(karten.length === 4, `${karten.length} Karten auf dem Brett (erwartet 4, je Status eine)`); + + /* In JEDEM Status -- einzeln nachgewiesen, nicht als Summe. */ + for (const status of ["offen", "arbeit", "review", "erledigt"]) { + const da = await seite.$(`.karte[data-status="${status}"] .schritt--ab`); + ok(!!da, `Status "${status}": Abbrechen-Knopf ist da`); + } + + /* Er darf die täglichen Knöpfe nicht verdrängen. */ + const nebeneinander = await seite.$$eval('.karte[data-status="offen"] .schritt', + (b) => b.map((x) => x.textContent.trim())); + ok(nebeneinander.includes("starten ▶") && nebeneinander.includes("abbrechen"), + `auf der Karte stehen: ${nebeneinander.join(" | ")}`); + + await ctx.close(); +} + +melde("\n=== Ein Creator sieht ihn gar nicht erst ==="); +{ + const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001"); + const karten = (await seite.$$(".karte")).length; + const knoepfe = (await seite.$$(".schritt--ab")).length; + /* Die Karten müssen DA sein -- sonst bewiese "0 Knöpfe" nur, dass + die Seite leer ist. */ + ok(karten === 4 && knoepfe === 0, + `${karten} Karten, aber ${knoepfe} Abbrechen-Knöpfe`); + await ctx.close(); +} + +/* ======================================================================= + 2. Der Dialog + ======================================================================= */ +melde("\n=== Der Dialog verlangt einen Grund ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + await seite.click('.karte[data-status="review"] .schritt--ab'); + await seite.waitForSelector("#abbruch-dialog[open]", { timeout: 5000 }); + + const titel = await seite.$eval("#abbruch-titel", (e) => e.textContent); + ok(/Aufgabe review/.test(titel), `der Dialog nennt die Aufgabe: ${titel}`); + + /* Sagt er, was passiert -- und was NICHT passiert? Die Sorge bei so + einem Knopf ist immer, ob etwas verloren geht. */ + const was = await seite.$eval("#abbruch-was", (e) => e.textContent); + ok(/nicht gelöscht/i.test(was) && /Review/.test(was), + `er erklärt es: "${was.trim().slice(0, 95)}…"`); + + /* Ohne Grund abschicken -- der Dialog muss offen bleiben. */ + await seite.click("#abbruch-ok"); + await seite.waitForTimeout(300); + const nochOffen = await seite.$("#abbruch-dialog[open]"); + const meldung = await seite.$eval("#abbruch-fehler", (e) => e.textContent.trim()); + ok(!!nochOffen && meldung.length > 0, + `ohne Grund bleibt er offen und sagt: "${meldung}"`); + + /* GEGENPROBE: mit Grund geht er zu und die Aufgabe wandert. */ + await seite.fill("#abbruch-grund", "Kampagne verschoben – kommt im Oktober neu."); + await seite.click("#abbruch-ok"); + /* NICHT mit waitForSelector, sondern messen und melden: Bleibt der + Dialog offen, ist das ein Befund -- kein Grund, die ganze Prüfung + mit einer Zeitüberschreitung abbrechen zu lassen. So steht am Ende + da, WAS nicht ging, statt gar nichts. */ + /* `state: "attached"` ist hier Pflicht. waitForSelector wartet sonst + darauf, dass das Element SICHTBAR wird -- und ein geschlossener + ist per Definition unsichtbar. Die Bedingung wäre also nie + erfüllt worden, egal wie richtig der Code ist. Beim ersten Lauf + stand deshalb "der Dialog bleibt offen", während er längst zu war + und die Aufgabe schon in der richtigen Spalte stand. */ + const zu = await seite.waitForSelector("#abbruch-dialog:not([open])", + { state: "attached", timeout: 8000 }).then(() => true).catch(() => false); + ok(zu, zu ? "mit Grund schließt sich der Dialog" + : `der Dialog bleibt offen. Konsole: ${konsole.slice(-3).join(" | ") || "still"}`); + await seite.waitForTimeout(700); + + const nochImBrett = await seite.$('.karte[data-status="review"]'); + ok(!nochImBrett, "die Aufgabe steht nicht mehr in der Review-Spalte"); + + await ctx.close(); +} + +/* ======================================================================= + 3. Der Bereich unter dem Brett + ======================================================================= */ +melde("\n=== Der Bereich 'Abgebrochen' ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + const sichtbar = await seite.isVisible("#abgebrochen"); + ok(sichtbar, "der Bereich ist da"); + + const zahl = await seite.$eval("#abgebrochen-zahl", (e) => e.textContent); + ok(zahl === "1", `die Anzahl steht im Titel: ${zahl}`); + + /* ZUGEKLAPPT beim Ankommen -- er soll nicht im Weg stehen. */ + const offen = await seite.$eval("#abgebrochen", (e) => e.hasAttribute("open")); + ok(!offen, "er ist zugeklappt, wie gewollt"); + + /* Aufklappen und nachsehen, ob wirklich alles drinsteht. */ + await seite.click(".abgebrochen__kopf"); + await seite.waitForTimeout(350); + + const karte = await seite.$(".abbruch-karte"); + ok(!!karte, "aufgeklappt steht die Aufgabe da"); + + const grund = await seite.$eval(".abbruch-karte__grund", (e) => e.textContent.trim()); + ok(/Kampagne verschoben/.test(grund), `der Grund steht dran: "${grund}"`); + + const vorher = await seite.$eval(".abbruch-karte__vorher", (e) => e.textContent.trim()); + ok(vorher === "Review", `und in welchem Status sie war: "${vorher}"`); + + const wer = await seite.$eval(".abbruch-karte__wer", (e) => e.textContent.trim()); + ok(/Filipe/.test(wer), `und wer es war: "${wer}"`); + + /* Der Durchstrich ist hier das eigentliche Merkmal -- gemessen am + gerechneten Stil, nicht an der CSS-Datei. */ + const strich = await seite.$eval(".abbruch-karte__titel", + (e) => getComputedStyle(e).textDecorationLine); + ok(strich.includes("line-through"), `der Titel ist durchgestrichen (${strich})`); + + await ctx.close(); +} + +/* ======================================================================= + 4. Zählt Abgebrochenes noch als offen? + ======================================================================= */ +melde("\n=== Abgebrochenes mahnt nicht mehr ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + /* Drei Aufgaben laufen noch (offen, arbeit, erledigt), eine ist + abgebrochen. "Offen" sind davon zwei -- die abgebrochene darf + nicht mitgezählt werden, sonst mahnt die Seite etwas an, das + ausdrücklich niemand mehr tun soll. */ + const satz = await seite.$eval("#lage", (e) => e.textContent.trim()); + ok(/2 Aufgaben stehen an/.test(satz), `die Lage sagt: "${satz}"`); + + const alle = await seite.$eval('.schnitt[data-wert=""] .schnitt__zahl', (e) => e.textContent); + ok(alle === "4", `der Filter "Alle" zählt weiterhin alle ${alle} (auch die abgebrochene)`); + + await ctx.close(); +} + +/* ======================================================================= + 5. Der Rückweg + ======================================================================= */ +melde("\n=== Wieder aufnehmen ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + await seite.click(".abgebrochen__kopf"); + await seite.waitForSelector(".abbruch-karte", { timeout: 5000 }); + await seite.click(".abbruch-karte .schritt"); + await seite.waitForTimeout(900); + + /* ZURÜCK IN DEN REVIEW, nicht nach "offen". */ + const zurueck = await seite.$('.karte[data-status="review"]'); + ok(!!zurueck, "die Aufgabe steht wieder in der Review-Spalte (nicht bei Offen)"); + + const weg = await seite.isVisible("#abgebrochen"); + ok(!weg, "der Bereich verschwindet, wenn nichts mehr abgebrochen ist"); + + await ctx.close(); +} + +/* ======================================================================= + 6. Am Handy + ======================================================================= */ +melde("\n=== Am Handy (390 px) ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 390); + await seite.click('.karte[data-status="offen"] .schritt--ab'); + await seite.waitForSelector("#abbruch-dialog[open]", { timeout: 5000 }); + + const passt = await seite.evaluate(() => { + const dlg = document.querySelector("#abbruch-dialog"); + const r = dlg.getBoundingClientRect(); + return { ueber: Math.round(r.right - window.innerWidth), breite: Math.round(r.width) }; + }); + ok(passt.ueber <= 0, `der Dialog passt auf den Schirm (${passt.breite} px, ${passt.ueber} px Überstand)`); + + /* Das Textfeld muss groß genug sein, um mit dem Daumen getroffen zu + werden -- und um zu sehen, was man tippt. */ + const feld = await seite.$eval("#abbruch-grund", + (e) => Math.round(e.getBoundingClientRect().height)); + ok(feld >= 60, `das Grundfeld ist ${feld} px hoch`); + + await ctx.close(); +} + +/* ======================================================================= + GEGENPROBE — misst die Prüfung überhaupt etwas? + ======================================================================= */ +melde("\n=== Gegenprobe ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + /* Wenn der Selektor `.schritt--ab` gar nichts träfe, wären alle + Haken oben wertlos. Also: Gibt es ihn, und verschwindet er, wenn + man ihn absichtlich entfernt? */ + const vorher = (await seite.$$(".schritt--ab")).length; + await seite.evaluate(() => { + for (const b of document.querySelectorAll(".schritt--ab")) b.remove(); + }); + const nachher = (await seite.$$(".schritt--ab")).length; + ok(vorher === 4 && nachher === 0, + `der Selektor trifft wirklich (${vorher} gefunden, nach dem Entfernen ${nachher})`); + await ctx.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-abbrechen.mjs b/server/pruef-abbrechen.mjs new file mode 100644 index 00000000..d55a759e --- /dev/null +++ b/server/pruef-abbrechen.mjs @@ -0,0 +1,405 @@ +/* AUFGABEN ABBRECHEN — Umstellung, Rechte und Rückweg. + + Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die + aufgaben auch abbrechen kann. nur ich die manager und scouts sollen + auch die aufgaben abbrechen können."* + + ZWEI TEILE, und der erste ist der gefährlichere: + + 1. DIE UMSTELLUNG DER TABELLE. Der CHECK-Constraint einer Tabelle + lässt sich in SQLite nicht ändern -- die Tabelle muss neu gebaut + werden: anlegen, kopieren, alte löschen, umbenennen. Zwischen + "kopieren" und "löschen" steht der ganze Bestand an Aufgaben. + Hausregel dazu: *"Was sich nicht zurücknehmen lässt, wird vorher + auf einer Kopie durchgespielt."* Genau das passiert hier -- mit + einer Datenbank im ALTEN Zustand, gefüllt, und danach wird + gezählt. + + 2. DIE RECHTE. Ein Creator darf nicht abbrechen, auch nicht seine + eigene Aufgabe. Das ist der Punkt, an dem so etwas gern + durchrutscht: Der normale PATCH lässt ihn ändern, und stünde + "abgebrochen" einfach in der Statusliste, könnte er es darüber. + + GEGENPROBEN durchgehend: Jede Sperre wird auch von der erlaubten + Seite versucht -- eine Prüfung, die immer "verboten" sagt, beweist + nichts. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-abbruch-")); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ======================================================================= + TEIL 1 — Die Umstellung, auf einer Kopie im alten Zustand + ======================================================================= */ +console.log("\n=== Umstellung der Aufgabentabelle (auf einer Kopie) ==="); + +const { DatabaseSync } = await import("node:sqlite"); +const altPfad = join(ordner, "alt.db"); +{ + /* Eine Datenbank genau so, wie sie VOR der Umstellung aussah -- + mit dem alten CHECK ohne 'abgebrochen'. */ + const a = new DatabaseSync(altPfad); + a.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE personen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')), + code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL, + aktiv INTEGER NOT NULL DEFAULT 1, erstellt TEXT NOT NULL, letzter_login TEXT); + CREATE TABLE aufgaben ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, beschreibung TEXT, + status TEXT NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen','arbeit','review','erledigt')), + prioritaet TEXT NOT NULL DEFAULT 'mittel' + CHECK (prioritaet IN ('hoch','mittel','niedrig')), + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + frist TEXT, erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geaendert TEXT, erledigt_am TEXT); + `); + const jetzt = new Date().toISOString(); + /* Diese Person ist nur das Ziel der Fremdschlüssel. Ihre + scrypt-Parameter müssen trotzdem gültig sein: Mit code_n = 1 wirft + scrypt "Invalid scrypt params" -- und genau das hat beim ersten + Lauf dieser Prüfung JEDE Anmeldung als admin unmöglich gemacht. + Der Fehler steckte in dieser Zeile, aber er hat einen echten + Mangel im Server aufgedeckt (siehe unten, Abschnitt + "Ein defekter Datensatz"). */ + a.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, erstellt) VALUES (?,?,?,?,?,?)") + .run("Platzhalter", "creator", "x", "y", 16384, jetzt); + /* Genug Zeilen, dass ein Verlust auffiele -- und in jedem Status, + damit auch der Übergang jedes einzelnen geprüft ist. */ + for (let i = 0; i < 40; i++) { + a.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, + verantwortlich_id, frist, erstellt, erstellt_von, geaendert, erledigt_am) + VALUES (?,?,?,?,1,1,?,?,1,?,?)`) + .run(`Aufgabe ${i}`, `Text ${i}`, ["offen", "arbeit", "review", "erledigt"][i % 4], + ["hoch", "mittel", "niedrig"][i % 3], "2026-09-30", jetzt, jetzt, + i % 4 === 3 ? jetzt : null); + } + a.close(); +} + +/* Der Server wird jetzt AUF DIESER Datei gestartet -- die Umstellung + läuft beim Import. */ +process.env.WORKSPACE_DB = altPfad; +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4295, "die Abbruchpruefung"); +process.env.PORT = "4295"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1400)); +const BASIS = "http://127.0.0.1:4295"; + +{ + const p = new DatabaseSync(altPfad); + const plan = p.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='aufgaben'").get()?.sql || ""; + ok(plan.includes("'abgebrochen'"), "der CHECK erlaubt jetzt 'abgebrochen'"); + + /* DIE ANZAHL IST DER EIGENTLICHE BEWEIS. Ein Tabellentausch, der + Zeilen verliert, sieht ohne diese Zahl aus wie ein Erfolg. */ + const n = p.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; + ok(n === 40, `alle ${n} von 40 Aufgaben sind noch da`); + + /* Und ihr INHALT, nicht nur ihre Zahl. Eine vergessene Spalte in der + INSERT...SELECT-Liste füllt sie still mit NULL. */ + const eine = p.prepare("SELECT * FROM aufgaben WHERE titel = 'Aufgabe 7'").get(); + ok(eine?.beschreibung === "Text 7" && eine?.status === "erledigt" + && eine?.prioritaet === "mittel" && eine?.frist === "2026-09-30" + && eine?.erledigt_am, `Inhalte erhalten (Text, Status, Priorität, Frist, erledigt_am)`); + + const spalten = p.prepare("PRAGMA table_info(aufgaben)").all().map((s) => s.name); + const noetig = ["abbruch_grund", "abgebrochen_am", "abbruch_von", "status_vorher"]; + const fehlend = noetig.filter((s) => !spalten.includes(s)); + ok(fehlend.length === 0, fehlend.length + ? `es fehlen: ${fehlend.join(", ")}` : `alle vier Abbruchspalten sind da`); + + /* Verweise: Der Tausch lief mit ausgeschalteten Fremdschlüsseln. */ + const kaputt = p.prepare("PRAGMA foreign_key_check").all(); + ok(kaputt.length === 0, `${kaputt.length} verwaiste Verweise nach dem Tausch`); + + /* Und die Indizes -- sie hängen an der Tabelle und wären beim DROP + mitgegangen. Ohne sie liest jede Brettabfrage die ganze Tabelle. */ + const idx = p.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='aufgaben'").all() + .map((z) => z.name); + ok(idx.includes("idx_aufgaben_status") && idx.includes("idx_aufgaben_creator"), + `Indizes wieder da: ${idx.filter((x) => x.startsWith("idx_")).join(", ")}`); + + /* GEGENPROBE: Der alte CHECK hätte 'abgebrochen' abgelehnt. Lässt + die Tabelle jetzt ALLES zu, wäre der CHECK verlorengegangen -- das + wäre schlimmer als der alte Zustand. */ + let unsinnGing = false; + try { + p.prepare("UPDATE aufgaben SET status = 'quatsch' WHERE id = 1").run(); + unsinnGing = true; + } catch { unsinnGing = false; } + ok(!unsinnGing, "ein erfundener Status wird weiterhin abgelehnt — der CHECK lebt"); + p.close(); +} + +/* ======================================================================= + TEIL 2 — Rechte, am laufenden Server + ======================================================================= */ +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(altPfad); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Dogi", "admin", "CODE-DOGI-0001"); +const idMana = anlegen("Cigdem", "manager", "CODE-MANA-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idCrea = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idCrea, idScout, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idScout, idMana, jetzt); + +/** Legt eine Aufgabe an und gibt ihre Nummer zurück. */ +function aufgabe(status = "offen") { + d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id, + erstellt, erstellt_von) VALUES (?,?,'hoch',?,?,?,?)`) + .run(`Probe ${status} ${Math.abs(Number(process.hrtime.bigint() % 100000n))}`, + status, idCrea, idCrea, jetzt, idDogi); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/** Meldet sich an und gibt die Kekse zurück. */ +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); + return { ok: a.ok, keks }; +} +const rufe = (keks, weg, koerper, methode = "POST") => + fetch(BASIS + weg, { + method: methode, + headers: { "Content-Type": "application/json", Cookie: keks }, + body: koerper === undefined ? undefined : JSON.stringify(koerper), + }); + +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const mana = await anmelden("manager", "CODE-MANA-0001"); +const scout = await anmelden("scout", "CODE-SCOU-0001"); +const crea = await anmelden("creator", "CODE-CREA-0001"); +/* Ohne diese Prüfung liefe alles Folgende gegen nicht angemeldete + Aufrufe -- und "403" käme dann aus dem falschen Grund. */ +ok([dogi, mana, scout, crea].every((x) => x.ok && x.keks), + "alle vier Rollen sind angemeldet"); + +console.log("\n=== Wer darf abbrechen? ==="); +{ + for (const [name, wer, sollGehen] of [ + ["DogFather", dogi, true], ["Manager", mana, true], + ["Scout", scout, true], ["Creator", crea, false], + ]) { + const id = aufgabe("offen"); + const a = await rufe(wer.keks, `/workspace/api/aufgaben/${id}/abbrechen`, + { grund: "Kampagne verschoben." }); + const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status; + ok(a.ok === sollGehen && (stand === "abgebrochen") === sollGehen, + `${name}: ${a.status} → Status "${stand}" (${sollGehen ? "darf" : "darf NICHT"})`); + } +} + +console.log("\n=== Aus JEDEM Status heraus ==="); +{ + for (const status of ["offen", "arbeit", "review", "erledigt"]) { + const id = aufgabe(status); + const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, + { grund: `Aus ${status} heraus abgebrochen.` }); + const z = d.prepare( + "SELECT status, status_vorher, abbruch_grund, abbruch_von, erledigt_am FROM aufgaben WHERE id = ?").get(id); + ok(a.ok && z.status === "abgebrochen" && z.status_vorher === status + && z.abbruch_grund && z.abbruch_von === idDogi, + `aus "${status}": abgebrochen, status_vorher="${z.status_vorher}", Grund und Person festgehalten`); + /* Ein abgebrochenes "erledigt" darf kein Erledigt-Datum behalten -- + sonst zählt es in Auswertungen weiter als geschafft. */ + if (status === "erledigt") { + ok(z.erledigt_am === null, "beim Abbrechen eines erledigten wird erledigt_am geleert"); + } + } +} + +console.log("\n=== Der Grund ist Pflicht ==="); +{ + const id = aufgabe("offen"); + for (const [was, koerper] of [ + ["ganz ohne Grund", {}], + ["leerer Grund", { grund: "" }], + ["nur Leerzeichen", { grund: " " }], + ["zu kurz", { grund: "x" }], + ]) { + const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, koerper); + const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status; + ok(a.status === 400 && stand === "offen", `${was}: abgelehnt (${a.status}), Aufgabe unverändert`); + } + /* GEGENPROBE: Mit Grund geht es -- sonst prüfte das oben nichts. */ + const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, + { grund: "Jetzt mit Grund." }); + ok(a.ok, "mit Grund geht es — die Prüfung unterscheidet also"); + + /* Zu lang wird ebenfalls abgelehnt, nicht abgeschnitten. */ + const id2 = aufgabe("offen"); + const b = await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, + { grund: "z".repeat(501) }); + ok(b.status === 400, `501 Zeichen: abgelehnt (${b.status})`); +} + +console.log("\n=== Der Weg zurück ==="); +{ + const id = aufgabe("review"); + await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, { grund: "Doch nicht nötig." }); + const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/wiederaufnehmen`); + const z = d.prepare( + "SELECT status, status_vorher, abbruch_grund, abgebrochen_am, abbruch_von FROM aufgaben WHERE id = ?").get(id); + /* ZURÜCK IN DEN ALTEN STATUS, nicht nach "offen". Wer im Review + abbricht und wieder aufnimmt, will dort weitermachen. */ + ok(a.ok && z.status === "review", `zurück in "${z.status}" (nicht nach "offen")`); + ok(!z.status_vorher && !z.abbruch_grund && !z.abgebrochen_am && !z.abbruch_von, + "die Abbruchfelder sind wieder leer — keine Reste"); + + /* Ein Creator darf auch das nicht. */ + const id2 = aufgabe("arbeit"); + await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, { grund: "Test." }); + const b = await rufe(crea.keks, `/workspace/api/aufgaben/${id2}/wiederaufnehmen`); + ok(b.status === 403, `Creator darf nicht wiederaufnehmen (${b.status})`); + + /* Zweimal abbrechen ergibt keinen Sinn und wird erkannt. */ + const c = await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, { grund: "Nochmal." }); + ok(c.status === 409, `zweimal abbrechen: abgelehnt (${c.status})`); + + /* Und etwas Laufendes wiederaufnehmen ebenso wenig. */ + const id3 = aufgabe("offen"); + const e = await rufe(dogi.keks, `/workspace/api/aufgaben/${id3}/wiederaufnehmen`); + ok(e.status === 409, `etwas Laufendes wiederaufnehmen: abgelehnt (${e.status})`); +} + +console.log("\n=== Der Umweg über den normalen PATCH ist zu ==="); +{ + /* DER PUNKT, AN DEM ES DURCHRUTSCHEN WÜRDE: Stünde "abgebrochen" + einfach in der STATUS-Liste, könnte ein Creator seine eigene + Aufgabe über den gewöhnlichen PATCH abbrechen -- ohne Grund, ohne + Protokoll, und an der Rollenregel vorbei. */ + const id = aufgabe("offen"); + const a = await rufe(crea.keks, `/workspace/api/aufgaben/${id}`, + { status: "abgebrochen" }, "PATCH"); + const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status; + ok(a.status === 400 && stand === "offen", + `Creator per PATCH: abgelehnt (${a.status}), Status bleibt "${stand}"`); + + /* Auch DogFather kommt hier nicht durch -- der Grund wäre sonst + umgehbar, und ohne Grund ist ein Abbruch ein Löschen. */ + const b = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}`, + { status: "abgebrochen" }, "PATCH"); + ok(b.status === 400, `auch DogFather per PATCH: abgelehnt (${b.status}) — der Grund ist nicht umgehbar`); + + /* GEGENPROBE: Ein echter Statuswechsel geht weiterhin. */ + const c = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}`, + { status: "arbeit" }, "PATCH"); + ok(c.ok, "ein normaler Statuswechsel geht weiterhin — der PATCH ist nicht kaputt"); +} + +console.log("\n=== Sichtbarkeit bleibt gewahrt ==="); +{ + /* Ein Scout darf keine Aufgabe abbrechen, die er gar nicht sehen + darf -- sonst wäre die Nummer ein Weg, fremde Aufgaben anzufassen. + Dafür eine Aufgabe eines Creators, der niemandem zugeteilt ist. */ + const idFremd = anlegen("Nora", "creator", "CODE-CREA-0002"); + d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id, + erstellt, erstellt_von) VALUES ('Fremde Aufgabe','offen','hoch',?,?,?,?)`) + .run(idFremd, idFremd, jetzt, idDogi); + const id = d.prepare("SELECT last_insert_rowid() AS id").get().id; + + const a = await rufe(scout.keks, `/workspace/api/aufgaben/${id}/abbrechen`, + { grund: "Sollte nicht gehen." }); + const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status; + /* 404 und nicht 403: Was man nicht sehen darf, existiert nicht -- + sonst liesse sich durch Ausprobieren herausfinden, welche Nummern + es gibt. */ + ok(a.status === 404 && stand === "offen", + `Scout an einer fremden Aufgabe: ${a.status}, Status bleibt "${stand}"`); + + /* GEGENPROBE: An einer EIGENEN geht es. */ + const idEigen = aufgabe("offen"); + const b = await rufe(scout.keks, `/workspace/api/aufgaben/${idEigen}/abbrechen`, + { grund: "Bei seinem Creator." }); + ok(b.ok, "an der Aufgabe seines eigenen Creators geht es — die Sperre trifft das Richtige"); +} + +console.log("\n=== Ein defekter Datensatz sperrt nicht alle aus ==="); +{ + /* GEFUNDEN DURCH EINEN EIGENEN FEHLER, 05.09.2026. + + Im ersten Entwurf dieser Prüfung legte ich eine Person mit + code_n = 1 an. scrypt lehnt das ab ("Invalid scrypt params"), die + Anmeldeschleife hatte keinen Schutz, und die Ausnahme flog bis in + den catch am Ende der Route: 503 für JEDEN mit dieser Rolle. + + Das ist keine Kleinigkeit. Ein einziger krummer Datensatz -- aus + einem Import, einer Migration, einer Reparatur von Hand -- hätte + ein ganzes Team ausgesperrt, mit der Meldung "nicht verfügbar". + Da sucht man lange. + + Deshalb wird es hier absichtlich herbeigeführt und geprüft. */ + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run("Kaputt", "manager", "aa", "bb", 1, jetzt); + + const trotzdem = await anmelden("manager", "CODE-MANA-0001"); + ok(trotzdem.ok && trotzdem.keks, + trotzdem.ok ? "trotz eines defekten Datensatzes kommt die gesunde Person herein" + : `AUSGESPERRT — ein krummer Datensatz blockiert die ganze Rolle`); + + /* GEGENPROBE: Ein falscher Code wird trotzdem noch abgelehnt. Ein + try/catch, das zu viel verschluckt, wäre schlimmer als das + Problem -- es könnte jeden hereinlassen. */ + const falsch = await anmelden("manager", "CODE-FALSCH-9999"); + ok(!falsch.ok, `ein falscher Code wird weiterhin abgelehnt (${falsch.ok ? "NEIN!" : "ja"})`); + + d.prepare("DELETE FROM personen WHERE name = 'Kaputt'").run(); +} + +console.log("\n=== Was die Oberfläche zu sehen bekommt ==="); +{ + const id = aufgabe("arbeit"); + await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, { grund: "Sichtbarer Grund." }); + const a = await fetch(BASIS + "/workspace/api/aufgaben", { headers: { Cookie: dogi.keks } }); + const antwort = await a.json(); + /* Die Liste kann unter verschiedenen Namen kommen -- steht sie gar + nicht da, ist das ein Fehler und kein Grund zum Abstürzen. */ + const liste = antwort.aufgaben || antwort.eintraege || antwort.liste; + ok(Array.isArray(liste), Array.isArray(liste) + ? `die Liste kam an (${liste.length} Aufgaben)` + : `unerwartete Antwort: ${JSON.stringify(antwort).slice(0, 90)}`); + const eine = (liste || []).find((x) => x.id === id); + ok(eine?.abbruch_grund === "Sichtbarer Grund." && eine?.status_vorher === "arbeit" + && eine?.abbruch_von_name === "Dogi" && eine?.abgebrochen_am, + `Grund, vorheriger Status, Name und Zeitpunkt kommen in der Liste mit`); + + /* Und sie steht nicht mehr in einer der vier Brettspalten. */ + ok(eine?.status === "abgebrochen", + `Status "${eine?.status}" — fällt damit aus allen vier Spalten heraus`); +} + +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-css-klassen.mjs b/server/pruef-css-klassen.mjs new file mode 100644 index 00000000..927c453f --- /dev/null +++ b/server/pruef-css-klassen.mjs @@ -0,0 +1,327 @@ +/* STEHT DAS CSS AUCH AUF DER SEITE, DIE ES BENUTZT? + + ENTSTANDEN AUS EINEM ECHTEN FEHLER (05.09.2026). + + Die Teilnehmerwahl im Kalender benutzte `.wahl__knopf` und + `.wahl__rolle`. Beide sind sauber definiert -- in dateien.css. Und + kalender.html bindet dateien.css nicht ein. + + Ohne `display:inline-flex` und `gap` stehen zwei eben direkt + nebeneinander. Auf Filipes Bildschirm stand deshalb: + + DogfatherDogFather VanVanDogFather CigdemManager + + Vierzig Prüfungen zur Teilnehmerwahl waren grün. Sie prüften, dass + die richtigen Personen ankommen, dass Fremde weggefiltert werden, + dass die Sichtbarkeit stimmt -- alles richtig, und alles am Problem + vorbei. KEINE hat gefragt, ob das Ergebnis auch aussieht wie + gedacht. Das ist die dritte Sorte Fehler aus der Projektnotiz: nicht + der übersprungene Test, nicht der grüne, der das Falsche prüft, + sondern die Frage, die niemand gestellt hat. + + WAS HIER GEMESSEN WIRD + + Für jede Workspace-Seite: + 1. Welche Stylesheets bindet sie ein? (rekursiv über @import) + 2. Welche Klassen vergibt sie -- im HTML *und* in ihrem eigenen + JavaScript (className, classList.add, setAttribute('class')). + 3. Ist jede davon in einem der eingebundenen Stylesheets definiert? + + WAS BEWUSST NICHT GEMESSEN WIRD + + Klassen, die es nur zum Anfassen gibt (`js-`), reine Datenanker und + Klassen aus gemeinsamen Bausteinen, die jede Seite lädt. Und die + Gegenrichtung ("CSS ohne Benutzer") gehört nicht hierher: Ungenutztes + CSS ist Ballast, kein Fehler. + + DRITTER AUSGANG: Findet die Prüfung keine Seiten oder keine Klassen, + ist das kein "bestanden" -- dann hat sie nichts gemessen und sagt es. + + GEGENPROBE am Ende: Eine erfundene Klasse muss auffallen, und eine + echte darf es nicht. */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = resolve(HIER, ".."); +const WS = join(WURZEL, "workspace"); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---------- Welche Stylesheets bindet eine Seite ein? -------------------- */ + +/** Liest ein Stylesheet und folgt seinen @import-Zeilen. */ +function cssMitImporten(pfad, gesehen = new Set()) { + const echt = resolve(pfad); + if (gesehen.has(echt) || !existsSync(echt)) return ""; + gesehen.add(echt); + const text = readFileSync(echt, "utf8"); + let alles = text; + for (const m of text.matchAll(/@import\s+(?:url\()?["']([^"')]+)["']\)?/g)) { + alles += "\n" + cssMitImporten(join(dirname(echt), m[1].split("?")[0]), gesehen); + } + return alles; +} + +/** Alle Klassennamen, die ein CSS-Text definiert. */ +function definierteKlassen(css) { + const raus = new Set(); + /* Erst Kommentare weg: In diesem Haus stehen lange Erklärungen im + CSS, und dort kommen Klassennamen als Fließtext vor ("wie + .wahl__knopf in dateien.css"). Die zählen nicht als Definition -- + sonst würde ausgerechnet der Kommentar, der den Fehler beschreibt, + ihn verdecken. */ + const ohne = css.replace(/\/\*[\s\S]*?\*\//g, " "); + for (const m of ohne.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) raus.add(m[1]); + return raus; +} + +/* ---------- Welche Klassen vergibt eine Seite? --------------------------- */ + +/** Klassen aus dem HTML. */ +function klassenAusHtml(html) { + const raus = new Set(); + const ohneKommentar = html.replace(//g, " "); + for (const m of ohneKommentar.matchAll(/\bclass\s*=\s*["']([^"']+)["']/g)) { + for (const k of m[1].split(/\s+/)) if (k) raus.add(k); + } + return raus; +} + +/** Klassen aus JavaScript. Nur die Formen, die dieses Haus benutzt -- + * ein vollständiger Parser wäre hier Aufwand ohne Ertrag. */ +function klassenAusJs(js) { + const raus = new Set(); + const ohne = js.replace(/\/\*[\s\S]*?\*\//g, " "); + const muster = [ + /\.className\s*=\s*['"]([^'"]+)['"]/g, + /setAttribute\(\s*['"]class['"]\s*,\s*['"]([^'"]+)['"]/g, + /class\s*=\s*\\?["']([^"'\\]+)/g, // in Template-Literalen + ]; + for (const m of muster) { + for (const t of ohne.matchAll(m)) { + for (const stueck of t[1].split(/['",\s]+/)) { + const k = stueck.trim(); + /* Nur echte Klassennamen -- keine Variablen, keine Ausdrücke. */ + if (k && /^-?[A-Za-z_][\w-]*$/.test(k)) raus.add(k); + } + } + } + + /* classList.add() BRAUCHT eine eigene Behandlung. + Der erste Entwurf nahm den ganzen Klammerinhalt und zerlegte ihn + an Anführungszeichen und Kommas. Bei + + classList.add(i === 0 ? 'marke__haupt' : 'marke__leise') + + fiel dabei `i` als angebliche Klasse heraus -- und die Prüfung + meldete auf sechzehn Seiten eine fehlende Klasse `.i`, die es nie + gab. Eine Warnung, die falsch ist, ist schlimmer als keine: Sie + kostet Zeit und wird beim nächsten Mal überlesen. + Deshalb werden hier NUR die Zeichenketten gelesen, nie der + Ausdruck drumherum. */ + for (const t of ohne.matchAll(/classList\.(?:add|remove|toggle)\(([^)]*)\)/g)) { + for (const s of t[1].matchAll(/['"]([^'"]+)['"]/g)) { + for (const k of s[1].trim().split(/\s+/)) { + if (k && /^-?[A-Za-z_][\w-]*$/.test(k)) raus.add(k); + } + } + } + return raus; +} + +/** Welche eigenen Skripte bindet die Seite ein? */ +function skripteVon(html) { + const raus = []; + for (const m of html.matchAll(/]+src\s*=\s*["']([^"']+)["']/g)) { + const p = m[1].split("?")[0]; + if (/^https?:/.test(p)) continue; // fremde Skripte nicht + raus.push(join(WS, p.replace(/^\//, "").replace(/^workspace\//, ""))); + } + return raus; +} + +function stylesheetsVon(html) { + const raus = []; + for (const m of html.matchAll(/]+rel\s*=\s*["']stylesheet["'][^>]*>/g)) { + const h = /href\s*=\s*["']([^"']+)["']/.exec(m[0]); + if (!h || /^https?:/.test(h[1])) continue; + raus.push(join(WS, h[1].split("?")[0].replace(/^\//, "").replace(/^workspace\//, ""))); + } + return raus; +} + +/* Klassen, die absichtlich kein CSS haben. Jede Ausnahme braucht einen + Grund -- eine Liste ohne Begründung wächst, bis sie alles enthält. */ +const OHNE_CSS = new Set([ + "leise", // Grundstil, steht in gate.css als Elementregel +]); +const OHNE_CSS_PRAEFIX = ["js-", "u-"]; // reine Anfasser, nie gestaltet + +/* ======================================================================= + Die Messung + ======================================================================= */ +console.log("\n=== Ist jede benutzte Klasse auf ihrer Seite auch definiert? ==="); + +const seiten = readdirSync(WS).filter((n) => n.endsWith(".html")); +/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext. + Findet die Prüfung keine Seiten, hat sie nichts gemessen -- und + "0 von 0 Klassen fehlen" wäre grün und wertlos. */ +ok(seiten.length >= 10, `${seiten.length} Workspace-Seiten gefunden`); + +/* ALLES CSS DES HAUSES -- für die entscheidende Unterscheidung. + + Eine Klasse ohne CSS ist zweierlei, und nur eines davon ist ein + Fehler: + + (a) NIRGENDS gestaltet. Ein reiner Struktur-Anker wie + `.k-wdh__ende`, der nur ein
benennt. Kein Fehler -- + sonst müsste man jedem Container Deko andichten. + + (b) Gestaltet, aber in einer Datei, die DIESE SEITE NICHT LÄDT. + Das ist der Fehler von heute: Jemand hat es gestaltet, es + sieht im Editor richtig aus, und auf der Seite kommt nichts + an. Niemandem fällt es auf, weil das CSS ja existiert. + + Nur (b) schlägt an. */ +const ALLE_CSS = definierteKlassen( + readdirSync(join(WS, "assets/css")).filter((n) => n.endsWith(".css")) + .map((n) => readFileSync(join(WS, "assets/css", n), "utf8")).join("\n")); + +let geprueft = 0; +const luecken = []; // (b) -- gestaltet, aber nicht hier +const ungestaltet = []; // (a) -- nur zur Ansicht + +for (const seite of seiten) { + const html = readFileSync(join(WS, seite), "utf8"); + + const css = stylesheetsVon(html).map((p) => cssMitImporten(p)).join("\n"); + const definiert = definierteKlassen(css); + + const benutzt = klassenAusHtml(html); + for (const skript of skripteVon(html)) { + if (!existsSync(skript)) continue; + for (const k of klassenAusJs(readFileSync(skript, "utf8"))) benutzt.add(k); + } + + for (const k of benutzt) { + if (OHNE_CSS.has(k)) continue; + if (OHNE_CSS_PRAEFIX.some((p) => k.startsWith(p))) continue; + geprueft++; + if (definiert.has(k)) continue; + if (ALLE_CSS.has(k)) luecken.push(`${seite}: .${k}`); + else ungestaltet.push(`${seite}: .${k}`); + } +} + +/* Auch hier die Anzahl in die Bedingung: Wären alle Muster kaputt und + fänden nichts, stünde sonst "0 Lücken" da -- grün, ohne je eine + Klasse angesehen zu haben. */ +ok(geprueft > 200, `${geprueft} Klassenverwendungen geprüft`); + +/* NACH KLASSE GRUPPIERT, nicht nach Seite. Eine fehlende Klasse auf + sechzehn Seiten ist EIN Fehler, nicht sechzehn -- und wer sechzehn + fast gleiche Zeilen liest, überliest die siebzehnte, die etwas + anderes sagt. */ +const nachKlasse = new Map(); +for (const eintrag of luecken) { + const [seite, klasse] = eintrag.split(": ."); + if (!nachKlasse.has(klasse)) nachKlasse.set(klasse, []); + nachKlasse.get(klasse).push(seite); +} +const bericht = [...nachKlasse.entries()] + .sort((a, b) => b[1].length - a[1].length) + .map(([k, s]) => `.${k} — fehlt auf ${s.length} Seite${s.length === 1 ? "" : "n"}` + + ` (${s.slice(0, 3).join(", ")}${s.length > 3 ? " …" : ""})`); + +ok(luecken.length === 0 && geprueft > 200, + luecken.length + ? `${nachKlasse.size} Klasse${nachKlasse.size === 1 ? "" : "n"} sind gestaltet, aber nicht auf ihrer Seite:` + + `\n ${bericht.join("\n ")}` + : `jede gestaltete Klasse kommt auch auf ihrer Seite an`); + +/* Kein Fehler, nur zur Kenntnis: Klassen, die es nirgends gibt. Meist + Struktur-Anker; gelegentlich aber ein Tippfehler, der hier auffällt, + bevor jemand sich wundert, warum nichts passiert. */ +const nurAnker = new Set(ungestaltet.map((e) => e.split(": .")[1])); +console.log(` -- ${nurAnker.size} Klassen sind nirgends gestaltet ` + + `(Struktur-Anker, kein Fehler): ${[...nurAnker].slice(0, 8).join(", ")}` + + (nurAnker.size > 8 ? " …" : "")); + +/* ======================================================================= + GEGENPROBE — die Messung muss beides können + ======================================================================= */ +console.log("\n=== Gegenprobe ==="); +{ + /* 1. Der echte Fehler von heute, nachgestellt: eine Seite, die eine + Klasse benutzt, deren CSS in einem nicht eingebundenen + Stylesheet steht. */ + const definiert = definierteKlassen(".k-dabei__knopf { color: red; }"); + ok(definiert.has("k-dabei__knopf"), "eine definierte Klasse wird erkannt"); + ok(!definiert.has("gibt-es-nicht"), "eine erfundene Klasse wird NICHT erkannt"); + + /* 2. Klassen aus JavaScript werden wirklich mitgelesen -- sonst wäre + genau der heutige Fall unsichtbar geblieben, denn die Wahl wird + komplett aus JS gebaut und steht in keinem HTML. */ + const ausJs = klassenAusJs("b.className = 'k-dabei__knopf';\nx.classList.add('offen');"); + ok(ausJs.has("k-dabei__knopf") && ausJs.has("offen"), + "Klassen aus className und classList.add werden gefunden"); + + /* 3. Ein Klassenname, der nur im KOMMENTAR steht, gilt nicht als + definiert. Sonst hätte ausgerechnet die Erklärung dieses Fehlers + ihn zugedeckt. */ + const nurKommentar = definierteKlassen("/* wie .wahl__knopf in dateien.css */\n.echt { color: red; }"); + ok(!nurKommentar.has("wahl__knopf") && nurKommentar.has("echt"), + "ein Klassenname aus einem Kommentar zählt nicht als Definition"); + + /* 4. Und der Beweis am echten Bestand: Die Wahl im Kalender muss + JETZT gedeckt sein -- vor der Reparatur wäre genau das rot. */ + const kal = readFileSync(join(WS, "kalender.html"), "utf8"); + const kalCss = definierteKlassen(stylesheetsVon(kal).map((p) => cssMitImporten(p)).join("\n")); + const noetig = ["k-dabei", "k-dabei__knopf", "k-dabei__zeichen", "k-dabei__name", + "k-dabei__titel", "k-dabei__liste", "k-dabei__gruppe", "k-dabei__haken", "k-dabei__punkt"]; + const fehlend = noetig.filter((k) => !kalCss.has(k)); + ok(fehlend.length === 0 && noetig.length === 9, + fehlend.length ? `im Kalender fehlt CSS für: ${fehlend.join(", ")}` + : `alle ${noetig.length} Klassen der Teilnehmerwahl sind in kalender.css definiert`); + + /* 5. Und die alten Klassen sind wirklich weg -- nicht nur die neuen + da. Ein halber Umbau hinterlässt beides. */ + const kalJs = readFileSync(join(WS, "assets/js/kalender.js"), "utf8"); + ok(!/wahl__knopf|wahl__rolle|wahl__name/.test(kalJs.replace(/\/\*[\s\S]*?\*\//g, " ")), + "die alten .wahl__*-Klassen stehen nicht mehr im Kalender-Skript"); + + /* 6. DIE WICHTIGSTE GEGENPROBE: Unterscheidet die Prüfung die beiden + Fälle wirklich? Der ganze Wert hängt daran -- ohne sie könnte + die Bedingung "ALLE_CSS.has(k)" verdreht sein, und die Prüfung + wäre grün, während sie den echten Fehler durchwinkt. + + Nachgestellt wird der Fall von heute an einem Miniaturbeispiel: + Zwei Klassen, beide ohne CSS auf ihrer Seite -- eine davon + woanders gestaltet, die andere nirgends. */ + const seitenCss = definierteKlassen(".nur-hier { color: red; }"); + const hausCss = definierteKlassen(".nur-hier { color: red; } .woanders { color: blue; }"); + + const fallB = !seitenCss.has("woanders") && hausCss.has("woanders"); + ok(fallB, fallB + ? "gestaltet, aber nicht auf dieser Seite → wird als Fehler erkannt" + : "MISSLUNGEN — der eigentliche Fehlerfall würde durchgehen"); + + const fallA = !seitenCss.has("blosser-anker") && !hausCss.has("blosser-anker"); + ok(fallA, fallA + ? "nirgends gestaltet → gilt als Struktur-Anker, kein Fehler" + : "MISSLUNGEN — Struktur-Anker würden fälschlich anschlagen"); + + /* 7. Und der Fehlalarm, den es schon gab, darf nicht zurückkommen: + `classList.add(i === 0 ? 'a' : 'b')` hatte `i` als Klasse + gemeldet -- auf sechzehn Seiten. */ + const ausAusdruck = klassenAusJs("x.classList.add(i === 0 ? 'marke__haupt' : 'marke__leise');"); + ok(!ausAusdruck.has("i") && ausAusdruck.has("marke__haupt") && ausAusdruck.has("marke__leise"), + "aus classList.add(bedingung ? 'a' : 'b') werden beide Klassen gelesen, aber nicht die Variable"); +} + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-dabei-optik.mjs b/server/pruef-dabei-optik.mjs new file mode 100644 index 00000000..875be318 --- /dev/null +++ b/server/pruef-dabei-optik.mjs @@ -0,0 +1,274 @@ +/* WIE SIEHT DIE TEILNEHMERWAHL WIRKLICH AUS? + + WARUM ES DIESE PRÜFUNG GIBT. Am 05.09.2026 waren vierzig Prüfungen + zur Teilnehmerwahl grün -- und auf Filipes Bildschirm stand: + + DogfatherDogFather VanVanDogFather CigdemManager + + Alle vierzig prüften Daten: Kommen die richtigen Personen an? Werden + Fremde weggefiltert? Stimmt die Sichtbarkeit? Alles richtig. Keine + einzige hat gefragt, ob das Ergebnis auch AUSSIEHT wie gedacht. + + pruef-css-klassen.mjs fängt die Ursache (CSS auf einer Seite, die es + nicht lädt). Diese hier misst die Wirkung -- im echten Browser, an + den echten Pixeln: + + * Stehen Name und Rolle getrennt, oder kleben sie? + * Ist der Knopf hoch genug für einen Daumen? + * Sieht man einem ausgewählten Knopf an, dass er an ist -- und + zwar nicht nur an der Farbe? + * Und die eigentliche Frage: Erscheint die Wahl bei JEDER Rolle? + + Gemessen wird mit getComputedStyle und getBoundingClientRect, nicht + an der CSS-Datei. Ein Wert, der im Stylesheet steht, kann von einer + anderen Regel überschrieben sein -- was zählt, ist, was am Ende auf + dem Schirm ankommt. + + GEGENPROBE am Ende: Die Messung muss "klebt" auch erkennen können. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-dabei-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4293, "die Optikpruefung der Teilnehmerwahl"); + +process.env.PORT = "4293"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4293"; + +/* LAUFEND MITSCHREIBEN. Node puffert stdout, sobald es umgeleitet wird + -- bei einem Lauf mit sechs Browser-Anmeldungen steht bis zum Schluss + NICHTS in der Datei. Beim ersten Versuch hing die Prüfung (ein + geratener Selektor), und die leere Datei sagte nicht, wie weit sie + gekommen war. Dieselbe Lehre steht schon in pruef-browser.mjs. */ +const { appendFileSync } = await import("node:fs"); +const PROTOKOLL = join(process.cwd(), "pruef-dabei-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Protokoll ist Zugabe */ } +}; + +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand: eine Kette, wie sie im Betrieb aussieht ------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +/* Namen wie im echten Bestand -- ein zweiteiliger Name ist der Fall, + an dem das Kleben aufgefallen ist. */ +const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001"); +const idCig = anlegen("Cigdem", "manager", "CODE-MANA-0001"); +const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCig, jetzt); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +/** Meldet sich an und öffnet den Kalender mit geöffnetem Formular. + * + * ERST DIE ROLLE, DANN DER CODE. Das war der Grund, warum der erste + * Versuch dieser Prüfung hängen blieb: Ich hatte den Rollenklick + * geraten statt nachgesehen, das Formular nahm den Code nicht an, und + * waitForURL wartete auf eine Adresse, die nie kam. */ +async function alsRolle(rolle, code, breite = 1280) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" }); + /* Das Formular öffnen -- die Wahl steht darin. */ + await seite.click("#neu-oeffnen"); + await seite.waitForSelector("#neu:not([hidden])", { timeout: 10000 }); + await seite.waitForTimeout(400); + return { ctx, seite }; +} + +/* ======================================================================= + 1. Als DogFather -- sieht er alle vier Gruppen? + ======================================================================= */ +console.log("\n=== Die Wahl aus Sicht von DogFather ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + const sichtbar = await seite.isVisible("#feld-dabei"); + ok(sichtbar, sichtbar ? "das Feld 'Wer ist noch dabei?' ist da" : "das Feld fehlt"); + + const knoepfe = await seite.$$("#dabei-wahl .k-dabei__knopf"); + /* DIE ANZAHL IN DIE BEDINGUNG: Wären es null, würde jede folgende + Messung über eine leere Menge laufen und grün melden. */ + ok(knoepfe.length === 3, + `${knoepfe.length} Personen zur Auswahl (erwartet 3 -- alle außer ihm selbst)`); + + const gruppen = await seite.$$eval(".k-dabei__gruppe", (g) => g.map((x) => x.dataset.rolle)); + ok(gruppen.length === 3 && !gruppen.includes("admin"), + `Gruppen: ${gruppen.join(", ") || "keine"} — er selbst ist die einzige admin-Person und steht nicht drin`); + + /* DER KERN: Kleben Name und Zeichen? Gemessen wird der echte + Abstand zwischen den zwei Kästchen, nicht der CSS-Wert. */ + const abstand = await seite.evaluate(() => { + const k = document.querySelector("#dabei-wahl .k-dabei__knopf"); + if (!k) return null; + const z = k.querySelector(".k-dabei__zeichen")?.getBoundingClientRect(); + const n = k.querySelector(".k-dabei__name")?.getBoundingClientRect(); + if (!z || !n) return null; + return Math.round(n.left - z.right); + }); + ok(abstand !== null && abstand >= 6, + abstand === null ? "Zeichen oder Name nicht gefunden" + : `${abstand} px zwischen Zeichen und Name (der alte Fehler war 0)`); + + /* Und die Rolle steht NICHT mehr am Namen -- sonst hieße es wieder + "CigdemManager". */ + const ersterText = await seite.$eval("#dabei-wahl .k-dabei__knopf", (k) => k.textContent.trim()); + ok(!/DogFather|Manager|Scout|Creator/.test(ersterText), + `der Knopf trägt nur den Namen: "${ersterText}"`); + + /* Die Rolle steht stattdessen über der Gruppe. */ + const titel = await seite.$$eval(".k-dabei__titel", (t) => t.map((x) => x.textContent.trim())); + ok(titel.length === 3 && titel.some((t) => /Manager/.test(t)), + `Gruppenüberschriften: ${titel.join(" · ")}`); + + /* Daumengröße. 40 px ist die Grenze, die dieses Haus überall nutzt. */ + const hoehe = await seite.$eval("#dabei-wahl .k-dabei__knopf", + (k) => Math.round(k.getBoundingClientRect().height)); + ok(hoehe >= 40, `Knopfhöhe ${hoehe} px (mindestens 40 für den Daumen)`); + + /* Auswählen: Ändert sich etwas Sichtbares -- und zwar mehr als die + Farbe? Ein Häkchen erkennt auch, wer Farben schlecht sieht. */ + const vorher = await seite.$eval("#dabei-wahl .k-dabei__knopf .k-dabei__haken", + (h) => getComputedStyle(h).opacity); + await seite.click("#dabei-wahl .k-dabei__knopf"); + /* ERST DIE ÜBERBLENDUNG ABWARTEN. Beim ersten Lauf stand hier + "0 → 0" und sah aus wie ein defektes Häkchen -- gemessen wurde + aber mitten in der 0,16-Sekunden-Blende, als es tatsächlich noch + unsichtbar war. Nicht der Code war falsch, sondern der Zeitpunkt + der Messung. 250 ms sind reichlich über den 160. */ + await seite.waitForTimeout(250); + const nachher = await seite.$eval("#dabei-wahl .k-dabei__knopf .k-dabei__haken", + (h) => getComputedStyle(h).opacity); + ok(Number(vorher) < 0.1 && Number(nachher) > 0.9, + `Häkchen: ${vorher} → ${nachher} (erscheint beim Anwählen)`); + + const gedrueckt = await seite.$eval("#dabei-wahl .k-dabei__knopf", + (k) => k.getAttribute("aria-pressed")); + ok(gedrueckt === "true", `aria-pressed = ${gedrueckt} (ein Vorleseprogramm sagt den Zustand an)`); + + const hinweis = await seite.$eval("#dabei-hinweis", (p) => p.textContent.trim()); + ok(/1 Person sieht/.test(hinweis), `Hinweis: "${hinweis}"`); + + await ctx.close(); +} + +/* ======================================================================= + 2. DIE EIGENTLICHE FRAGE: Bekommt jede Rolle die Wahl? + ======================================================================= */ +console.log("\n=== 'für jeden verfügbar' — jede Rolle einzeln ==="); +{ + const faelle = [ + ["Manager", "manager", "CODE-MANA-0001", 3], // DogFather, Patrick, Luna + ["Scout", "scout", "CODE-SCOU-0001", 3], // DogFather, Luna, Cigdem (sein Manager) + ["Creator", "creator", "CODE-CREA-0001", 3], // DogFather, Patrick, Cigdem + ]; + for (const [name, rolle, code, erwartet] of faelle) { + const { ctx, seite } = await alsRolle(rolle, code); + const sichtbar = await seite.isVisible("#feld-dabei"); + const anzahl = (await seite.$$("#dabei-wahl .k-dabei__knopf")).length; + ok(sichtbar && anzahl === erwartet, + `${name}: Feld ${sichtbar ? "sichtbar" : "FEHLT"}, ${anzahl} Personen zur Auswahl (erwartet ${erwartet})`); + + /* Und niemand sieht jemanden, den er nicht sehen darf. Bei einem + Creator ist das der Punkt: Er darf seinen Scout markieren, aber + keinen fremden Creator. */ + const namen = await seite.$$eval("#dabei-wahl .k-dabei__name", (n) => n.map((x) => x.textContent)); + ok(!namen.includes("Luna") || name !== "Creator", + `${name}: sieht ${namen.join(", ") || "niemanden"}`); + await ctx.close(); + } +} + +/* ======================================================================= + 3. Am Handy + ======================================================================= */ +console.log("\n=== Am Handy (390 px) ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 390); + const ueber = await seite.evaluate(() => { + const w = document.querySelector("#dabei-wahl"); + if (!w) return null; + return Math.round(w.scrollWidth - w.clientWidth); + }); + ok(ueber !== null && ueber <= 1, `nichts steht seitlich über (${ueber} px)`); + + const hoehe = await seite.$eval("#dabei-wahl .k-dabei__knopf", + (k) => Math.round(k.getBoundingClientRect().height)); + ok(hoehe >= 40, `Knopfhöhe am Handy ${hoehe} px`); + await ctx.close(); +} + +/* ======================================================================= + GEGENPROBE — kann die Messung "klebt" überhaupt erkennen? + ======================================================================= */ +console.log("\n=== Gegenprobe ==="); +{ + const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001"); + + /* Das CSS wird absichtlich kaputt gemacht -- genau so, wie es vorher + WAR: kein Flex, kein gap. Schlägt die Messung jetzt nicht an, misst + sie nichts und alle Haken oben sind wertlos. */ + await seite.addStyleTag({ content: + ".k-dabei__knopf { display: inline !important; gap: 0 !important; }" }); + await seite.waitForTimeout(120); + + const abstand = await seite.evaluate(() => { + const k = document.querySelector("#dabei-wahl .k-dabei__knopf"); + const z = k.querySelector(".k-dabei__zeichen")?.getBoundingClientRect(); + const n = k.querySelector(".k-dabei__name")?.getBoundingClientRect(); + return Math.round(n.left - z.right); + }); + ok(abstand < 6, + `mit kaputtem CSS misst die Prüfung ${abstand} px — sie erkennt das Kleben also`); + + await ctx.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-glocke.mjs b/server/pruef-glocke.mjs new file mode 100644 index 00000000..89411aec --- /dev/null +++ b/server/pruef-glocke.mjs @@ -0,0 +1,493 @@ +/* DER BENACHRICHTIGUNGS-KNOPF, IM ECHTEN BROWSER. + + Wunsch Filipe, 05.09.2026, mit dem Bild eines "Benachrichtigungen + aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert + auf der seite für jeden!!!!"* + + --------------------------------------------------------------------- + WAS HIER NICHT GEMESSEN WERDEN KANN — und warum das dasteht + + `pushManager.subscribe()` braucht einen echten Push-Dienst. Chromium + spricht dafür mit Googles FCM, Firefox mit Mozillas autopush. Ein + Browser, der in einer Prüfung startet, hat diese Verbindung nicht -- + der Aufruf scheitert, und daran ist nichts zu ändern. + + Das ist der DRITTE AUSGANG aus der Projektnotiz: nicht in Ordnung ist + etwas anderes als nicht nachgesehen. Diese Prüfung tut deshalb + zweierlei: + + 1. Sie MISST ZUERST, ob echtes subscribe hier geht. Geht es, wird + der echte Weg geprüft. Geht es nicht, wird das ausdrücklich + gemeldet -- nicht verschwiegen und nicht als "bestanden". + + 2. Für alles andere tritt ein Test-Doppel an die Stelle des + Browser-Kanals: Der PushManager wird ersetzt, alles davor und + dahinter bleibt echt. Damit sind Knopf, Zustände, Server-Aufrufe + und Speicherung wirklich geprüft -- nur die Strecke zwischen + Browser und Google nicht. + + Was die Rechnung selbst angeht, ist die Lage besser als es klingt: + pruef-push.mjs prüft sie gegen die Testvektoren aus RFC 8291 und + 8292, pruef-push-weg.mjs schickt eine Nachricht durch einen + nachgebauten Dienst und macht sie wieder auf. Was hier fehlt, ist + allein der Schritt "Browser holt sich eine Adresse bei Google". + + --------------------------------------------------------------------- + GEMESSEN WIRD + + * Steht der Knopf auf JEDER Seite (außer der Anmeldeseite)? + * Sagt er in jeder Lage die Wahrheit -- auch wenn sie unbequem ist? + (abgelehnt, iPhone im Reiter, alter Browser) + * Legt ein Klick wirklich eine Anmeldung an, und nimmt ein zweiter + sie wieder weg? + * Bekommt jede ROLLE ihn -- "für jeden" war der Wunsch. + + GEGENPROBEN durchgehend. */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-glocke-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4299, "die Glockenpruefung"); + +process.env.PORT = "4299"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4299"; + +const PROTOKOLL = join(process.cwd(), "pruef-glocke-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } +}; +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const hinweis = (t) => melde(" -- " + t); + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Cigdem", "manager", "CODE-MANA-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idScout, jetzt); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + +/** Ein Test-Doppel für den Browser-Push-Kanal. + * + * Ersetzt NUR pushManager -- Berechtigung, Service Worker, Knopf, + * fetch und Server bleiben echt. Der Endpunkt sieht aus wie einer von + * Google und ist https, weil der Server das verlangt (und verlangen + * soll: eine http-Zustelladresse wäre im Klartext erreichbar). */ +const DOPPEL = ` + (() => { + const abo = { + endpoint: 'https://fcm.googleapis.com/fcm/send/PRUEF-' + Math.random().toString(36).slice(2), + getKey: (art) => new Uint8Array(art === 'auth' ? 16 : 65).fill(7), + toJSON: () => ({ keys: { + p256dh: 'BQ'.padEnd(87, 'A'), auth: 'AQ'.padEnd(22, 'A'), + } }), + unsubscribe: async () => true, + }; + let angemeldet = null; + const manager = { + subscribe: async () => { angemeldet = abo; return abo; }, + getSubscription: async () => angemeldet, + }; + /* Auch die Registrierung wird abgefangen: Der Service Worker + selbst wird hier nicht gebraucht, und ein fehlgeschlagenes + register() würde den ganzen Ablauf abbrechen. */ + const reg = { pushManager: manager, showNotification: async () => {} }; + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + register: async () => reg, + getRegistration: async () => reg, + ready: Promise.resolve(reg), + addEventListener: () => {}, + }, + }); + window.__doppel = true; + })(); +`; + +/* DEN BERECHTIGUNGSSTAND SETZEN — und warum das nötig ist. + + Ein Chromium, der in einer Prüfung startet, meldet + `Notification.permission === "denied"`. Ein echter Browser meldet + beim ersten Besuch "default" und fragt erst, wenn man klickt. + + Ohne diese Zeile misst die Prüfung also überall den Zustand + "schon einmal abgelehnt" -- und der Knopf sagt dann völlig zu Recht + "Vom Browser blockiert". Beim ersten Lauf standen deshalb acht + Fehler da, von denen keiner ein Fehler war: Der Knopf tat genau das + Richtige, nur zur falschen Frage. + + `grantPermissions` allein genügt nicht -- es setzt die Berechtigung + im Browser, aber der Ausgangszustand "noch nicht gefragt" lässt sich + damit nicht herstellen. Deshalb wird beides gesetzt: der Stand und, + wo nötig, die Antwort auf die Nachfrage. */ +function berechtigung(stand) { + return ` + (() => { + Object.defineProperty(Notification, 'permission', + { value: '${stand}', configurable: true }); + Notification.requestPermission = async () => '${stand === "default" ? "granted" : stand}'; + })(); + `; +} + +async function anmelden(ctx, rolle, code) { + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + return seite; +} + +/* ======================================================================= + 1. Geht ECHTES subscribe in dieser Umgebung? + ======================================================================= */ +melde("\n=== Lässt sich der echte Browser-Kanal hier messen? ==="); +let echtGeht = false; +{ + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext({ permissions: ["notifications"] }); + const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001"); + + const ergebnis = await seite.evaluate(async () => { + try { + const reg = await navigator.serviceWorker.register('/workspace/sw.js', { scope: '/workspace/' }); + await navigator.serviceWorker.ready; + const a = await fetch('/workspace/api/push/schluessel', { credentials: 'same-origin' }); + const { schluessel } = await a.json(); + const roh = atob(schluessel.replace(/-/g, '+').replace(/_/g, '/') + + '='.repeat((4 - schluessel.length % 4) % 4)); + const abo = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: Uint8Array.from(roh, (c) => c.charCodeAt(0)), + }); + return { ok: true, endpunkt: abo.endpoint.slice(0, 40) }; + } catch (f) { return { ok: false, grund: String(f?.message || f).slice(0, 80) }; } + }); + + echtGeht = ergebnis.ok; + if (echtGeht) { + ok(true, `echtes subscribe GEHT hier — geprüft wird der echte Weg (${ergebnis.endpunkt}…)`); + } else { + /* KEIN FEHLER, aber auch kein Haken: der dritte Ausgang. */ + hinweis(`echtes subscribe geht hier NICHT: ${ergebnis.grund}`); + hinweis(` Grund: ein Prüf-Browser hat keine Verbindung zu Googles Push-Dienst.`); + hinweis(` Die Verschlüsselung selbst ist in pruef-push.mjs gegen die`); + hinweis(` RFC-Testvektoren geprüft, die Zustellung in pruef-push-weg.mjs.`); + hinweis(` Hier wird der PushManager ersetzt; alles davor und dahinter ist echt.`); + } + await browser.close(); +} + +/* ======================================================================= + 2. Steht der Knopf da — auf jeder Seite, bei jeder Rolle? + ======================================================================= */ +melde("\n=== Der Knopf ist überall ==="); +{ + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext(); + await ctx.addInitScript(berechtigung("default")); + const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001"); + + const seitenNamen = ["start", "aufgaben", "kalender", "bereich", "content", + "dateien", "wissen", "report"]; + let gefunden = 0; + const ohne = []; + for (const n of seitenNamen) { + await seite.goto(`${BASIS}/workspace/${n}.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(250); + if (await seite.$("#glocke")) gefunden++; else ohne.push(n); + } + ok(gefunden === seitenNamen.length, + ohne.length ? `fehlt auf: ${ohne.join(", ")}` + : `auf allen ${gefunden} geprüften Seiten`); + + /* Und NICHT auf der Anmeldeseite -- dort ist niemand angemeldet, + den man benachrichtigen könnte. */ + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.waitForTimeout(250); + ok(!(await seite.$("#glocke")), "auf der Anmeldeseite steht er nicht"); + + await browser.close(); +} + +melde("\n=== Jede Rolle bekommt ihn ==="); +{ + const browser = await pw.chromium.launch(); + for (const [name, rolle, code] of [ + ["DogFather", "admin", "CODE-DOGI-0001"], ["Manager", "manager", "CODE-MANA-0001"], + ["Scout", "scout", "CODE-SCOU-0001"], ["Creator", "creator", "CODE-CREA-0001"], + ]) { + const ctx = await browser.newContext(); + await ctx.addInitScript(berechtigung("default")); + const seite = await anmelden(ctx, rolle, code); + await seite.waitForTimeout(400); + const da = await seite.$("#glocke"); + const text = da ? await seite.$eval("#glocke .glocke__text", (e) => e.textContent) : ""; + ok(!!da, `${name}: ${da ? `Knopf da ("${text}")` : "FEHLT"}`); + await ctx.close(); + } + await browser.close(); +} + +/* ======================================================================= + 3. Sagt er in jeder Lage die Wahrheit? + ======================================================================= */ +melde("\n=== Die Lagen ==="); +{ + const browser = await pw.chromium.launch(); + + /* AUS -- noch nie gefragt worden. Das ist der Zustand, in dem jeder + zum ersten Mal auf die Seite kommt. */ + { + const ctx = await browser.newContext(); + await ctx.addInitScript(berechtigung("default")); + const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001"); + await seite.waitForTimeout(400); + const lage = await seite.$eval("#glocke", (e) => e.dataset.lage); + const text = await seite.$eval("#glocke .glocke__text", (e) => e.textContent); + ok(lage === "aus" && /aus/i.test(text), `ohne Berechtigung: "${text}" (${lage})`); + + /* Der Strich muss sichtbar sein -- er sagt es ohne Lesen. */ + const strich = await seite.$eval(".glocke__strich", (e) => getComputedStyle(e).opacity); + ok(Number(strich) > 0.5, `die Glocke ist durchgestrichen (${strich})`); + await ctx.close(); + } + + /* VERBOTEN -- schon einmal abgelehnt. Der wichtigste Fall: Per + JavaScript lässt sich das NICHT zurücknehmen, also muss der Knopf + sagen, wo man es tut. Ein Knopf, der hier nur nichts tut, ist der + sichere Weg zu "das funktioniert nicht". */ + { + const ctx = await browser.newContext(); + await ctx.grantPermissions([]); // alles verweigert + const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001"); + await seite.evaluate(() => { + Object.defineProperty(Notification, "permission", { value: "denied", configurable: true }); + }); + await seite.reload({ waitUntil: "networkidle" }); + await seite.evaluate(() => { + Object.defineProperty(Notification, "permission", { value: "denied", configurable: true }); + }); + await seite.waitForTimeout(400); + const lage = await seite.evaluate(() => { + document.getElementById("glocke")?.click(); + return document.getElementById("glocke")?.dataset.lage; + }); + const titel = await seite.$eval("#glocke", (e) => e.title); + ok(lage === "verboten", `abgelehnt: Lage "${lage}"`); + ok(/Browsereinstellungen/i.test(titel), + `und er sagt, WO man es ändert: "${titel.slice(0, 80)}…"`); + await ctx.close(); + } + + /* GEHT NICHT -- ein Browser ohne Push. */ + { + const ctx = await browser.newContext(); + const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001"); + await seite.addInitScript(() => { + delete window.PushManager; + }); + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(400); + const lage = await seite.$eval("#glocke", (e) => e.dataset.lage); + const aus = await seite.$eval("#glocke", (e) => e.disabled); + ok(lage === "geht-nicht" && aus, + `Browser ohne Push: Lage "${lage}", Knopf abgeschaltet (${aus})`); + await ctx.close(); + } + + /* IPHONE im Reiter -- dort gibt es die Berechtigung überhaupt erst + nach "Zum Home-Bildschirm". Das muss dastehen, sonst tippt jemand + zehnmal und hält es für kaputt. */ + { + const ctx = await browser.newContext({ + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + }); + const seite = await anmelden(ctx, "admin", "CODE-DOGI-0001"); + await seite.waitForTimeout(400); + const lage = await seite.$eval("#glocke", (e) => e.dataset.lage); + const titel = await seite.$eval("#glocke", (e) => e.title); + ok(lage === "iphone", `iPhone im Reiter: Lage "${lage}"`); + ok(/Home-Bildschirm/i.test(titel), `mit Anleitung: "${titel.slice(0, 70)}…"`); + await ctx.close(); + } + + await browser.close(); +} + +/* ======================================================================= + 4. An- und abmelden — mit dem Test-Doppel + ======================================================================= */ +melde("\n=== Anmelden und wieder abmelden ==="); +{ + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext({ permissions: ["notifications"] }); + await ctx.addInitScript(berechtigung("granted")); + await ctx.addInitScript(DOPPEL); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.waitForTimeout(500); + + /* Steht das Doppel überhaupt? Ohne diese Prüfung liefe alles + Folgende womöglich gegen den echten (hier kaputten) Kanal und + meldete Fehler, die nichts bedeuten. */ + ok(await seite.evaluate(() => window.__doppel === true), + "das Test-Doppel steht (der Rest der Kette ist echt)"); + + const p = new DatabaseSync(process.env.WORKSPACE_DB); + const zahl = () => p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n; + ok(zahl() === 0, `vorher ${zahl()} Anmeldungen in der Datenbank`); + + await seite.click("#glocke"); + await seite.waitForTimeout(1200); + + ok(zahl() === 1, `nach dem Klick: ${zahl()} Anmeldung`); + + const eintrag = p.prepare("SELECT * FROM push_anmeldungen").get(); + ok(eintrag?.person_id === idDogi && /^https:\/\//.test(eintrag?.endpunkt || ""), + `sie gehört der richtigen Person und hat eine https-Adresse`); + /* Der Gerätename hilft beim Wiedererkennen in der Liste. */ + ok(/Chrome|Edge|Browser/.test(eintrag?.geraet || ""), + `Gerät erkannt: "${eintrag?.geraet}"`); + + const lage = await seite.$eval("#glocke", (e) => e.dataset.lage); + const text = await seite.$eval("#glocke .glocke__text", (e) => e.textContent); + ok(lage === "an" && /an/i.test(text), `der Knopf steht jetzt auf "${text}" (${lage})`); + + const strich = await seite.$eval(".glocke__strich", (e) => getComputedStyle(e).opacity); + ok(Number(strich) < 0.2, `der Strich ist weg (${strich})`); + + /* NOCH EINMAL KLICKEN darf nicht verdoppeln -- INSERT OR REPLACE auf + dem Endpunkt. Sonst käme jede Nachricht mehrfach an. */ + await seite.click("#glocke"); // aus + await seite.waitForTimeout(900); + ok(zahl() === 0, `nach dem Abmelden: ${zahl()} Anmeldungen`); + + await seite.click("#glocke"); // wieder an + await seite.waitForTimeout(1200); + await seite.click("#glocke"); // aus + await seite.waitForTimeout(900); + await seite.click("#glocke"); // an + await seite.waitForTimeout(1200); + ok(zahl() === 1, `nach mehrfachem Hin und Her: genau ${zahl()} Anmeldung, keine Dubletten`); + + /* Und der Stand-Weg zeigt sie -- ohne den vollen Endpunkt zu + verraten, der die Zustelladresse dieses Geräts ist. */ + const stand = await seite.evaluate(async () => { + const a = await fetch("/workspace/api/push/stand", { credentials: "same-origin" }); + return a.json(); + }); + ok(stand.geraete?.length === 1 && !stand.geraete[0].endpunkt, + `der Stand nennt ${stand.geraete?.length} Gerät, aber nicht dessen volle Adresse`); + ok(Array.isArray(stand.arten) && stand.arten.length >= 3, + `${stand.arten?.length} Arten von Benachrichtigungen einstellbar`); + + p.close(); + await browser.close(); +} + +/* ======================================================================= + GEGENPROBE — misst das oben überhaupt etwas? + ======================================================================= */ +melde("\n=== Gegenprobe ==="); +{ + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext({ permissions: ["notifications"] }); + await ctx.addInitScript(berechtigung("granted")); + const seite = await ctx.newPage(); + /* Ein Doppel, dessen subscribe SCHEITERT. Der Knopf darf dann NICHT + auf "an" springen -- täte er es, wäre die Anzeige eine Lüge, und + alle Haken oben wären wertlos. */ + await seite.addInitScript(` + (() => { + const reg = { pushManager: { + subscribe: async () => { throw new Error('Registration failed'); }, + getSubscription: async () => null, + } }; + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { register: async () => reg, getRegistration: async () => reg, + ready: Promise.resolve(reg), addEventListener: () => {} }, + }); + })(); + `); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.waitForTimeout(500); + + const p = new DatabaseSync(process.env.WORKSPACE_DB); + const vorher = p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n; + await seite.click("#glocke"); + await seite.waitForTimeout(1200); + const nachher = p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n; + const lage = await seite.$eval("#glocke", (e) => e.dataset.lage); + + ok(lage !== "an" && nachher === vorher, + `wenn subscribe scheitert: Knopf bleibt auf "${lage}", ` + + `Anmeldungen ${vorher} → ${nachher} (keine erfundene)`); + + /* Und die Seite darf daran nicht zerbrechen. */ + const lebt = await seite.evaluate(() => !!document.getElementById("glocke")); + ok(lebt, "die Seite läuft weiter, der Knopf ist noch da"); + + p.close(); + await browser.close(); +} + +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +if (!echtGeht) { + melde(`HINWEIS: Der Schritt "Browser holt eine Adresse beim Push-Dienst" war`); + melde(`hier nicht messbar (kein Zugang zu FCM). Alles andere wurde geprüft.`); +} +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-push-weg.mjs b/server/pruef-push-weg.mjs new file mode 100644 index 00000000..6415e0d3 --- /dev/null +++ b/server/pruef-push-weg.mjs @@ -0,0 +1,209 @@ +/* DER GANZE WEG EINER BENACHRICHTIGUNG. + + pruef-push.mjs beweist, dass die RECHNUNG stimmt -- gegen die + Testvektoren von RFC 8291 und 8292. Diese Pruefung geht einen Schritt + weiter und fragt: Kommt am anderen Ende auch wirklich der Text an? + + Dafuer wird ein Browser nachgespielt. Er erzeugt sein Schluesselpaar + wie ein echter, der Server verschickt eine Nachricht an einen + nachgebauten Push-Dienst, und dieser "Browser" macht sie mit seinem + privaten Schluessel wieder auf. + + WARUM DAS NOETIG IST, obwohl die Testvektoren schon stimmen: Der + Vektor prueft EINEN festen Fall mit festem Salz und festem + Schluesselpaar. Im Betrieb werden beide je Nachricht neu erzeugt -- + und ein Fehler genau dort (etwa ein Schluessel in der falschen + Reihenfolge) faellt gegen einen festen Vektor nicht auf. + + Dazu die zwei Dinge, die im Alltag zaehlen: + * Der Push-Dienst darf den Inhalt NICHT lesen koennen. + * Eine tote Anmeldung muss als tot erkannt werden, sonst scheitert + jeder weitere Lauf an derselben Stelle. + + GEGENPROBE am Ende: Mit dem falschen Schluessel darf sich die + Nachricht NICHT aufmachen lassen. */ + +import { createServer } from "node:http"; +import { createECDH, hkdfSync, createDecipheriv } from "node:crypto"; +import { + verschluesseln, schluesselErzeugen, schicken, b64u, +} from "./workspace-push-krypto.js"; + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Ein Browser, wie er sich anmelden wuerde -------------------------- */ +const browser = createECDH("prime256v1"); +browser.generateKeys(); +const auth = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); +const anmeldung = { p256dh: b64u(browser.getPublicKey()), auth: b64u(auth) }; + +/** Macht die Nachricht auf -- genau wie es der echte Browser tut + * (RFC 8291, in die andere Richtung gelesen). */ +function entschluesseln(paket, wer = browser, geheim16 = auth) { + const salz = paket.subarray(0, 16); + const len = paket.readUInt8(20); + const senderOeff = paket.subarray(21, 21 + len); + const rest = paket.subarray(21 + len); + + const gemeinsam = wer.computeSecret(senderOeff); + const prkInfo = Buffer.concat([ + Buffer.from("WebPush: info\0"), wer.getPublicKey(), senderOeff, + ]); + const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, geheim16, prkInfo, 32)); + const cek = Buffer.from(hkdfSync("sha256", ikm, salz, + Buffer.from("Content-Encoding: aes128gcm\0"), 16)); + const nonce = Buffer.from(hkdfSync("sha256", ikm, salz, + Buffer.from("Content-Encoding: nonce\0"), 12)); + + const tag = rest.subarray(rest.length - 16); + const daten = rest.subarray(0, rest.length - 16); + const dec = createDecipheriv("aes-128-gcm", cek, nonce); + dec.setAuthTag(tag); + const klar = Buffer.concat([dec.update(daten), dec.final()]); + return klar.subarray(0, klar.length - 1).toString("utf8"); // Polsterbyte weg +} + +const TEXT = JSON.stringify({ + titel: "Es funktioniert", + text: "Probe aus dem Workspace", + ziel: "/workspace/start.html", +}); + +/* ======================================================================= + 1. Verschluesseln und wieder aufmachen + ======================================================================= */ +console.log("\n=== Verschluesseln -> aufmachen ==="); +{ + const paket = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth); + ok(paket.length > 86, `verschluesselt (${paket.length} Bytes)`); + const zurueck = entschluesseln(paket); + ok(zurueck === TEXT, zurueck === TEXT + ? "der Browser bekommt GENAU den Text zurueck" + : `ABWEICHUNG: ${zurueck.slice(0, 60)}`); +} + +/* ======================================================================= + 2. Zehnmal hintereinander -- jedes Mal neue Schluessel + ======================================================================= */ +console.log("\n=== Zehn Nachrichten, jede mit eigenem Schluessel ==="); +{ + const gesehen = new Set(); + let alleLesbar = true; + for (let i = 0; i < 10; i++) { + const p = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth); + gesehen.add(b64u(p)); + if (entschluesseln(p) !== TEXT) alleLesbar = false; + } + ok(gesehen.size === 10, `${gesehen.size} von 10 sind verschieden (Salz und Paar sind neu)`); + ok(alleLesbar, "und trotzdem alle zehn lesbar"); +} + +/* ======================================================================= + 3. Ueber HTTP, wie beim echten Dienst + ======================================================================= */ +console.log("\n=== Zustellung ueber HTTP ==="); +let empfangen = null, kopfzeilen = null; +{ + const dienst = createServer((q, a) => { + const stuecke = []; + q.on("data", (c) => stuecke.push(c)); + q.on("end", () => { + empfangen = Buffer.concat(stuecke); + kopfzeilen = q.headers; + a.writeHead(201); a.end(); + }); + }); + await new Promise((r) => dienst.listen(4341, "127.0.0.1", r)); + + const paar = schluesselErzeugen(); + const e = await schicken({ endpunkt: "http://127.0.0.1:4341/push/abc", ...anmeldung }, + TEXT, paar, "mailto:test@dogfather-universe.com"); + + ok(e.ok, `zugestellt (Status ${e.status})`); + ok(!!empfangen && empfangen.length > 86, `der Dienst hat ${empfangen?.length || 0} Bytes bekommen`); + ok(kopfzeilen?.["content-encoding"] === "aes128gcm", + `Kodierung: ${kopfzeilen?.["content-encoding"]}`); + ok(String(kopfzeilen?.authorization || "").startsWith("vapid t="), + "die VAPID-Signatur ist dabei"); + ok(kopfzeilen?.ttl === "3600", `TTL: ${kopfzeilen?.ttl}`); + ok(entschluesseln(empfangen) === TEXT, "und der Inhalt kommt unveraendert an"); + dienst.close(); +} + +/* ======================================================================= + 4. Der Push-Dienst sieht nichts vom Inhalt + ======================================================================= */ +console.log("\n=== Was der Push-Dienst zu sehen bekommt ==="); +{ + const alsText = empfangen.toString("utf8"); + const alsLatin = empfangen.toString("latin1"); + ok(!alsText.includes("Es funktioniert") && !alsLatin.includes("Es funktioniert"), + "der Titel steht nicht im Klartext im Paket"); + ok(!alsText.includes("Workspace") && !alsLatin.includes("Workspace"), + "auch sonst kein lesbares Wort"); + /* Das ist der eigentliche Grund, warum dieser Weg vertretbar ist: + Google, Mozilla und Apple leiten weiter, ohne mitlesen zu koennen. */ +} + +/* ======================================================================= + 5. Tote Anmeldungen + ======================================================================= */ +console.log("\n=== Ein Geraet, das es nicht mehr gibt ==="); +{ + const paar = schluesselErzeugen(); + for (const [code, erwartetWeg] of [[410, true], [404, true], [500, false], [429, false]]) { + const d = createServer((q, a) => { a.writeHead(code); a.end(); }); + await new Promise((r) => d.listen(4342, "127.0.0.1", r)); + const e = await schicken({ endpunkt: "http://127.0.0.1:4342/x", ...anmeldung }, + TEXT, paar, "mailto:t@dogfather-universe.com"); + ok(e.weg === erwartetWeg, + `${code} -> weg=${e.weg} (erwartet ${erwartetWeg})` + + (erwartetWeg ? " — die Anmeldung wird geloescht" : " — sie bleibt, es kann voruebergehend sein")); + d.close(); + await new Promise((r) => setTimeout(r, 60)); + } +} + +/* ======================================================================= + 6. Ein Dienst, der gar nicht antwortet + ======================================================================= */ +console.log("\n=== Wenn der Push-Dienst schweigt ==="); +{ + const paar = schluesselErzeugen(); + /* Adresse, die niemand bedient. Der Aufruf darf NICHT werfen und + nicht ewig haengen -- sonst bliebe der ganze Lauf stehen und die + uebrigen Empfaenger bekaemen nichts. */ + const e = await schicken({ endpunkt: "http://127.0.0.1:4399/tot", ...anmeldung }, + TEXT, paar, "mailto:t@dogfather-universe.com"); + ok(e.ok === false && e.weg === false, + `kein Absturz, die Anmeldung bleibt (${e.grund?.slice(0, 40) || e.status})`); +} + +/* ======================================================================= + GEGENPROBE — mit dem falschen Schluessel darf es NICHT gehen + ======================================================================= */ +console.log("\n=== Gegenprobe ==="); +{ + const fremder = createECDH("prime256v1"); + fremder.generateKeys(); + const paket = verschluesseln(TEXT, anmeldung.p256dh, anmeldung.auth); + + let ging = false; + try { ging = entschluesseln(paket, fremder, auth) === TEXT; } catch { ging = false; } + ok(!ging, "ein fremder Schluessel kann die Nachricht NICHT aufmachen"); + + let mitFalschemGeheimnis = false; + try { + const anderes = Buffer.from(auth); anderes[0] ^= 0xff; + mitFalschemGeheimnis = entschluesseln(paket, browser, anderes) === TEXT; + } catch { mitFalschemGeheimnis = false; } + ok(!mitFalschemGeheimnis, "auch mit falschem Geheimnis nicht"); + + /* Und der Beweis, dass diese Messung ueberhaupt "geht auf" erkennt. */ + ok(entschluesseln(paket) === TEXT, + "mit dem richtigen Schluessel dagegen schon — die Messung unterscheidet also"); +} + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-push.mjs b/server/pruef-push.mjs new file mode 100644 index 00000000..ca5ab801 --- /dev/null +++ b/server/pruef-push.mjs @@ -0,0 +1,193 @@ +/* WEB PUSH — gerechnet gegen die Testvektoren der RFCs. + + WARUM DAS DIE WICHTIGSTE PRUEFUNG DIESES BAUSTEINS IST. + + Die Verschluesselung fuer Web Push ist an einer Stelle besonders + unangenehm: Stimmt irgendetwas nicht -- ein Byte im Info-Text, die + Reihenfolge zweier Schluessel, das Polster am Ende --, dann verwirft + der Browser die Nachricht STILL. Kein Fehler, keine Meldung, keine + Konsolenzeile. Der Push-Dienst antwortet mit 201 "angenommen", und + trotzdem kommt nie etwas an. + + Man kann so etwas nicht durch Ausprobieren richtig bekommen. Deshalb + wird hier nicht "sieht gut aus" geprueft, sondern gegen die + vollstaendigen Beispieldaten aus + + RFC 8291, Anhang A (Message Encryption for Web Push) + RFC 8292 (VAPID) + + Trifft die eigene Rechnung diese Zahlen, ist sie richtig -- nicht + wahrscheinlich richtig, sondern richtig. Und wenn sich morgen jemand + vertippt, faellt es in derselben Sekunde auf. + + GEGENPROBE am Ende: ein absichtlich veraenderter Schluessel MUSS ein + anderes Ergebnis liefern. Eine Pruefung, die immer bestaetigt, + bestaetigt nichts. */ + +import { + verschluesseln, vapidKopf, schluesselErzeugen, b64u, ausB64u, +} from "./workspace-push-krypto.js"; +import { createECDH, createVerify, createPublicKey } from "node:crypto"; + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ======================================================================= + 1. RFC 8291, Anhang A — der vollstaendige Testvektor + ======================================================================= */ +console.log("\n=== RFC 8291 Anhang A: Verschluesselung ==="); +{ + /* Die Werte stehen woertlich im RFC. */ + const klartext = "When I grow up, I want to be a watermelon"; + const empfaengerOeff = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + const auth = "BTBZMqHH6r4Tts7J_aSIgg"; + const salz = ausB64u("DGv6ra1nlYgDCS1FRnbzlw"); + const eigenerPrivat = ausB64u("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"); + + /* Das erwartete Ergebnis, woertlich aus dem RFC (dort ueber mehrere + Zeilen umbrochen, hier zusammengesetzt). + + ⚠️ AUS DER QUELLE ABSCHREIBEN, NICHT AUS DEM GEDAECHTNIS. Der erste + Entwurf dieser Pruefung trug hier einen Wert, der ab Zeichen 61 + abwich -- die Pruefung meldete einen Fehler, und der lag in ihr + selbst, nicht in der Rechnung. Der Kopf (Salz, Groesse, + Schluessel) stimmte, weil er unmittelbar aus den Eingaben folgt; + nur der verschluesselte Teil war erfunden. Eine Pruefung mit einer + falschen Erwartung ist schlimmer als keine: Sie schickt die + Fehlersuche an die falsche Stelle. */ + const erwartet = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27" + + "mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6" + + "cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulc" + + "y4a-fN"; + + const ergebnis = verschluesseln(klartext, empfaengerOeff, auth, salz, eigenerPrivat); + const alsText = b64u(ergebnis); + + ok(alsText === erwartet, + alsText === erwartet + ? `die Rechnung trifft den Testvektor auf das Byte genau (${ergebnis.length} Bytes)` + : `ABWEICHUNG\n erwartet: ${erwartet.slice(0, 60)}…\n` + + ` bekommen: ${alsText.slice(0, 60)}…`); + + /* Die Form des Ergebnisses, unabhaengig vom Inhalt: 16 Byte Salz, + 4 Byte Groesse, 1 Byte Laenge, 65 Byte Schluessel, dann der Rest. */ + ok(ergebnis.length === 16 + 4 + 1 + 65 + klartext.length + 1 + 16, + `die Laenge stimmt (${ergebnis.length} Bytes: Kopf 86 + Text ${klartext.length} + Polster 1 + Pruefsumme 16)`); + ok(ergebnis.readUInt8(20) === 65, "die Schluessellaenge im Kopf steht auf 65"); + ok(ergebnis.readUInt32BE(16) === 4096, "die Datensatzgroesse steht auf 4096"); +} + +/* ======================================================================= + 2. Jede Nachricht bekommt ein eigenes Salz und ein eigenes Paar + ======================================================================= */ +console.log("\n=== Kein Schluessel wird zweimal benutzt ==="); +{ + const empf = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + const auth = "BTBZMqHH6r4Tts7J_aSIgg"; + const a = verschluesseln("dieselbe Nachricht", empf, auth); + const b = verschluesseln("dieselbe Nachricht", empf, auth); + + /* Zwei Verschluesselungen desselben Textes muessen VERSCHIEDEN + aussehen. Waeren sie gleich, wuerde entweder das Salz oder das + Schluesselpaar wiederverwendet -- der klassische Fehler an dieser + Stelle, und einer, der die Verschluesselung angreifbar macht. */ + ok(!a.equals(b), "zweimal derselbe Text ergibt zwei verschiedene Nachrichten"); + ok(!a.subarray(0, 16).equals(b.subarray(0, 16)), "das Salz ist jedes Mal neu"); + ok(!a.subarray(21, 86).equals(b.subarray(21, 86)), "das Schluesselpaar ist jedes Mal neu"); +} + +/* ======================================================================= + 3. VAPID — ist das JWT echt und gueltig? + ======================================================================= */ +console.log("\n=== RFC 8292: VAPID-Signatur ==="); +{ + const paar = schluesselErzeugen(); + ok(ausB64u(paar.oeffentlich).length === 65, + `oeffentlicher Schluessel: ${ausB64u(paar.oeffentlich).length} Bytes (65 erwartet)`); + ok(ausB64u(paar.privat).length === 32, + `privater Schluessel: ${ausB64u(paar.privat).length} Bytes (32 erwartet)`); + + const kopf = vapidKopf("https://fcm.googleapis.com/fcm/send/abc123", paar, + "mailto:kontakt@dogfather-universe.com"); + + ok(kopf.Authorization.startsWith("vapid t="), "die Kopfzeile hat die vorgeschriebene Form"); + ok(kopf["Content-Encoding"] === "aes128gcm", "die Kodierung ist aes128gcm"); + + const jwt = kopf.Authorization.match(/t=([^,]+)/)[1]; + const [t1, t2, sig] = jwt.split("."); + const kopfDaten = JSON.parse(ausB64u(t1).toString()); + const inhalt = JSON.parse(ausB64u(t2).toString()); + + ok(kopfDaten.alg === "ES256", `Verfahren: ${kopfDaten.alg}`); + ok(inhalt.aud === "https://fcm.googleapis.com", + `Empfaenger: ${inhalt.aud} (nur Schema und Host, kein Pfad)`); + ok(inhalt.sub === "mailto:kontakt@dogfather-universe.com", `Absender: ${inhalt.sub}`); + const stunden = (inhalt.exp - Math.floor(Date.now() / 1000)) / 3600; + ok(stunden > 0 && stunden <= 24, `laeuft in ${stunden.toFixed(1)} Stunden ab (hoechstens 24)`); + + /* DER EIGENTLICHE BEWEIS: Laesst sich die Signatur mit dem + oeffentlichen Schluessel pruefen? Genau das tut der Push-Dienst. + Alles davor waere hinfaellig, wenn das hier nicht stimmt. */ + const roh = ausB64u(paar.oeffentlich); + const der = Buffer.concat([ + Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex"), roh, + ]); + const pub = createPublicKey({ key: der, format: "der", type: "spki" }); + const echt = createVerify("SHA256").update(`${t1}.${t2}`) + .verify({ key: pub, dsaEncoding: "ieee-p1363" }, ausB64u(sig)); + ok(echt, "die Signatur laesst sich mit dem oeffentlichen Schluessel pruefen"); + ok(ausB64u(sig).length === 64, `Signatur: ${ausB64u(sig).length} Bytes (64, also r||s roh)`); +} + +/* ======================================================================= + 4. Unbrauchbare Angaben werden abgewiesen + ======================================================================= */ +console.log("\n=== Kaputte Anmeldedaten ==="); +{ + const guteAuth = "BTBZMqHH6r4Tts7J_aSIgg"; + for (const [wie, p256dh, auth] of [ + ["zu kurzer Schluessel", b64u(Buffer.alloc(30)), guteAuth], + ["Schluessel ohne 0x04 vorn", b64u(Buffer.alloc(65)), guteAuth], + ["zu kurzes Geheimnis", "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", b64u(Buffer.alloc(8))], + ["leer", "", ""], + ]) { + let geworfen = false; + try { verschluesseln("test", p256dh, auth); } catch { geworfen = true; } + ok(geworfen, `${wie} wird abgewiesen`); + } +} + +/* ======================================================================= + GEGENPROBE — merkt die Pruefung ueberhaupt einen Fehler? + ======================================================================= */ +console.log("\n=== Gegenprobe (muss abweichen) ==="); +{ + const klartext = "When I grow up, I want to be a watermelon"; + const empf = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + const auth = "BTBZMqHH6r4Tts7J_aSIgg"; + const salz = ausB64u("DGv6ra1nlYgDCS1FRnbzlw"); + const privat = ausB64u("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"); + const richtig = b64u(verschluesseln(klartext, empf, auth, salz, privat)); + + /* Ein einziges verändertes Byte im Geheimnis muss ein voellig + anderes Ergebnis liefern. Waere es dasselbe, ginge das Geheimnis + gar nicht in die Rechnung ein -- und die Uebereinstimmung oben + waere Zufall. */ + const andereAuth = b64u(Buffer.concat([ausB64u(auth).subarray(0, 15), Buffer.from([0xff])])); + const anders = b64u(verschluesseln(klartext, empf, andereAuth, salz, privat)); + ok(anders !== richtig, "ein veraendertes Geheimnis ergibt ein anderes Ergebnis"); + + const andererText = b64u(verschluesseln(klartext + "!", empf, auth, salz, privat)); + ok(andererText !== richtig, "ein veraenderter Text ergibt ein anderes Ergebnis"); + + const anderesSalz = b64u(verschluesseln(klartext, empf, auth, + Buffer.concat([salz.subarray(0, 15), Buffer.from([0x00])]), privat)); + ok(anderesSalz !== richtig, "ein veraendertes Salz ergibt ein anderes Ergebnis"); +} + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs index 4d416f71..694738ea 100644 --- a/server/pruef-struktur.mjs +++ b/server/pruef-struktur.mjs @@ -401,6 +401,95 @@ console.log(` } } +/* ---------- 11. Laesst sich jedes Server-Modul ueberhaupt laden? -------- + + `node --check datei.js` prueft eine .js-Datei als CommonJS. Dieses + Haus faehrt aber ESM ("type": "module" in package.json). Der + Unterschied ist keine Formsache: Ein Syntaxfehler kann dabei + durchrutschen und die Datei trotzdem beim Start abstuerzen lassen. + + Genau das ist am 05.09.2026 passiert. In einem SQL-Block -- einem + Template-Literal mit Backticks -- stand in einem KOMMENTAR ein Wort + in Backticks. Das beendete das Literal mitten im Text. `node --check` + meldete "Syntax ok", der Import scheiterte mit "missing ) after + argument list", und der Server waere beim naechsten Start gar nicht + hochgekommen. + + Deshalb wird hier jedes Modul WIRKLICH geladen. Das ist die einzige + Pruefung, die dieselbe Frage stellt wie der Serverstart. */ +console.log(`\n=== Laedt jedes Server-Modul? ===`); +{ + /* GEPRUEFT WIRD MIT NODE SELBST, nicht mit einem Suchmuster. + + Der erste Entwurf suchte per regulaerem Ausdruck nach Backticks in + SQL-Bloecken -- und schlug bei drei Bloecken an, die voellig in + Ordnung sind: Enthaelt ein Block ein ${...}, grenzt das Muster ihn + falsch ab. Eine Warnung, die falsch ist, ist schlimmer als keine; + sie kostet beim naechsten Mal zehn Minuten und wird danach + ignoriert. + + Node kann die Frage selbst beantworten. Die Datei wird als .mjs + kopiert und mit `node --check` geprueft -- damit gilt dieselbe + Regel wie beim Start (ESM statt CommonJS). Genau darin lag der + Fehler vom 05.09.2026: `node --check datei.js` meldete "ok", + der Import scheiterte trotzdem. */ + const { execFileSync } = await import("node:child_process"); + const { writeFileSync, unlinkSync, mkdtempSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + + const module = readdirSync(SERVER).filter((n) => n.endsWith(".js")); + ok(module.length >= 20, `${module.length} Server-Module gefunden`); + + const ordner = mkdtempSync(join(tmpdir(), "modulcheck-")); + const kaputt = []; + for (const datei of module) { + const kopie = join(ordner, datei.replace(/\.js$/, ".mjs")); + writeFileSync(kopie, readFileSync(join(SERVER, datei), "utf8")); + try { + execFileSync(process.execPath, ["--check", kopie], { stdio: "pipe" }); + } catch (f) { + const meldung = String(f.stderr || f.message).split(/\r?\n/) + .find((z) => z.includes("Error")) || "Syntaxfehler"; + kaputt.push(`${datei}: ${meldung.trim().slice(0, 70)}`); + } + try { unlinkSync(kopie); } catch { /* egal */ } + } + ok(kaputt.length === 0, + kaputt.length + ? `als ESM NICHT ladbar: ${kaputt.slice(0, 5).join(" · ")}` + : `alle ${module.length} Module lassen sich als ESM einlesen`); + + /* GEGENPROBE mit genau dem Fehler von heute. + + Nachgebaut wird der echte Fall: ein Backtick in einem KOMMENTAR, + der innerhalb eines Template-Literals steht. Sieht harmlos aus, + beendet aber die Zeichenkette. */ + const boese = join(ordner, "gegenprobe.mjs"); + writeFileSync(boese, [ + "const d = { exec: () => {} };", + "d.exec(`", + " CREATE TABLE x (a TEXT);", + " /* Der Wert `endpunkt` ist der Schluessel. */", + "`);", + ].join("\n")); + let erkannt = false; + try { execFileSync(process.execPath, ["--check", boese], { stdio: "pipe" }); } + catch { erkannt = true; } + ok(erkannt, erkannt + ? "Gegenprobe: ein Backtick im SQL-Kommentar wird als Fehler erkannt" + : "Gegenprobe MISSLUNGEN — die Pruefung wuerde den Fehler durchlassen"); + + /* Und der Beweis, dass sie nicht einfach alles ablehnt. */ + const brav = join(ordner, "gegenprobe-ok.mjs"); + writeFileSync(brav, "export const x = `alles gut ${1 + 1}`;\n"); + let bravOk = true; + try { execFileSync(process.execPath, ["--check", brav], { stdio: "pipe" }); } + catch { bravOk = false; } + ok(bravOk, "Gegenprobe: eine saubere Datei wird durchgelassen"); + + for (const f of [boese, brav]) { try { unlinkSync(f); } catch { /* egal */ } } +} + /* ---------- Gegenprobe --------------------------------------------------- */ console.log(`\n=== Gegenprobe (muss anschlagen) ===`); diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 81b9177a..2e3368c6 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -107,12 +107,19 @@ const SPALTEN = ` a.creator_id, a.verantwortlich_id, a.erstellt, a.geaendert, a.erledigt_am, a.creator_extern, a.verantwortlich_extern, ${externSql("pc.name", "a.creator_extern")} AS creator_name, - ${externSql("pv.name", "a.verantwortlich_extern")} AS verantwortlich_name`; + ${externSql("pv.name", "a.verantwortlich_extern")} AS verantwortlich_name, + /* Abbruch (05.09.2026). Der Grund gehört mit in die Liste, nicht + hinter einen zweiten Aufruf: Wer den Bereich aufklappt, will genau + das lesen -- ein Nachladen je Zeile wäre bei zwanzig abgebrochenen + Aufgaben zwanzig Anfragen. */ + a.abbruch_grund, a.abgebrochen_am, a.status_vorher, + pa.name AS abbruch_von_name`; const VERBUND = ` FROM aufgaben a LEFT JOIN personen pc ON pc.id = a.creator_id - LEFT JOIN personen pv ON pv.id = a.verantwortlich_id`; + LEFT JOIN personen pv ON pv.id = a.verantwortlich_id + LEFT JOIN personen pa ON pa.id = a.abbruch_von`; /* ---------- Lesen ------------------------------------------------------- */ @@ -493,6 +500,123 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) } }); +/* ---------- Abbrechen ---------------------------------------------------- + + Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die + aufgaben auch abbrechen kann. nur ich die manager und scouts sollen + auch die aufgaben abbrechen können."* + + WARUM EIN EIGENER WEG und nicht einfach `status: "abgebrochen"` über + den PATCH oben: Dort entscheidet darfAendern() -- und das lässt auch + den zuständigen Creator ändern. Stünde "abgebrochen" bloss in der + STATUS-Liste, könnte ein Creator seine eigene Aufgabe abbrechen, + ohne dass es irgendwo auffällt. Die Rollenregel gehört an EINE + Stelle, an der man sie sieht. + + Deshalb steht "abgebrochen" bewusst NICHT in STATUS: Der normale + Statuswechsel kann diesen Zustand gar nicht erreichen. + + Der Grund ist Pflicht. Ohne ihn weiss in vier Wochen niemand mehr, + warum etwas wegfiel -- und dann ist es von Löschen nicht mehr zu + unterscheiden. */ + +/** Wer darf abbrechen? DogFather, Manager, Scouts -- Creator nicht. */ +const DARF_ABBRECHEN = new Set(["admin", "manager", "scout"]); +const ABBRUCH_GRUND_MAX = 500; + +aufgabenRouter.post("/workspace/api/aufgaben/:id/abbrechen", gleicheHerkunft, (req, res) => { + try { + if (!DARF_ABBRECHEN.has(req.person.rolle)) { + return res.status(403).json({ fehler: "Nur DogFather, Manager und Scouts können Aufgaben abbrechen." }); + } + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const grund = String(req.body?.grund ?? "").trim(); + if (grund.length < 3) { + return res.status(400).json({ fehler: "Bitte kurz sagen, warum die Aufgabe abgebrochen wird." }); + } + if (grund.length > ABBRUCH_GRUND_MAX) { + return res.status(400).json({ fehler: `Der Grund ist länger als ${ABBRUCH_GRUND_MAX} Zeichen.` }); + } + + /* Wie beim PATCH: erst durch die Sichtbarkeitsregel. Was jemand + nicht sehen darf, existiert für ihn nicht -- 404, nicht 403. */ + const { wo, werte } = sichtbar(req.person); + const aufgabe = db().prepare( + `SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id); + if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" }); + + if (aufgabe.status === "abgebrochen") { + return res.status(409).json({ fehler: "Diese Aufgabe ist schon abgebrochen." }); + } + + /* status_vorher hält fest, WIE WEIT es war. "Im Review + abgebrochen" ist eine ganz andere Aussage als "nie angefangen" + -- und beim Wiederaufnehmen geht es genau dorthin zurück. */ + db().prepare(` + UPDATE aufgaben + SET status = 'abgebrochen', status_vorher = ?, abbruch_grund = ?, + abgebrochen_am = ?, abbruch_von = ?, geaendert = ?, erledigt_am = NULL + WHERE id = ?`) + .run(aufgabe.status, grund, jetzt(), req.person.id, jetzt(), id); + + protokolliere("aufgabe_abgebrochen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} (${aufgabe.status}) ${grund}`.slice(0, 120), + }); + res.json({ ok: true, status_vorher: aufgabe.status }); + } catch (fehler) { + console.error("[workspace] Aufgabe abbrechen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Wieder aufnehmen. Geht zurück in den Status, in dem abgebrochen + * wurde -- nicht nach "offen". Wer eine Aufgabe im Review abbricht + * und wieder aufnimmt, will sie im Review zurück, nicht von vorn. + * + * Dieselbe Rollenregel: Wer abbrechen darf, darf auch zurücknehmen. + * Eine Handlung ohne Umkehr wäre hier falsch -- ein Abbruch aus + * Versehen soll niemanden zwingen, die Aufgabe neu zu schreiben. */ +aufgabenRouter.post("/workspace/api/aufgaben/:id/wiederaufnehmen", gleicheHerkunft, (req, res) => { + try { + if (!DARF_ABBRECHEN.has(req.person.rolle)) { + return res.status(403).json({ fehler: "Nur DogFather, Manager und Scouts können das." }); + } + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const { wo, werte } = sichtbar(req.person); + const aufgabe = db().prepare( + `SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id); + if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (aufgabe.status !== "abgebrochen") { + return res.status(409).json({ fehler: "Diese Aufgabe läuft doch." }); + } + + /* Fällt status_vorher aus (alte Zeile, von Hand geändert), geht es + nach "offen" -- ein bekannter Zustand ist besser als ein leeres + Feld, das den CHECK verletzt und die Aufgabe unerreichbar macht. */ + const zurueck = STATUS.includes(aufgabe.status_vorher) ? aufgabe.status_vorher : "offen"; + + db().prepare(` + UPDATE aufgaben + SET status = ?, status_vorher = NULL, abbruch_grund = NULL, + abgebrochen_am = NULL, abbruch_von = NULL, geaendert = ? + WHERE id = ?`).run(zurueck, jetzt(), id); + + protokolliere("aufgabe_wiederaufgenommen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} -> ${zurueck}`.slice(0, 120), + }); + res.json({ ok: true, status: zurueck }); + } catch (fehler) { + console.error("[workspace] Aufgabe wiederaufnehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Löschen ------------------------------------------------------ Nur Management. Das Konzept will ausdrücklich, dass Erledigtes sichtbar bleibt ("Erledigte Aufgaben verschwinden nicht") -- Löschen ist deshalb diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index 757ffc71..c78df009 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -14,7 +14,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, istDogFather, betreutWo, termineSichtbar, heuteLokal, - externPruefen, externSql, betreuteIds, ROLLEN_SORTIERUNG, + externPruefen, externSql, betreuteIds, einladbareIds, ROLLEN_SORTIERUNG, } from "./workspace.js"; import { nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen, @@ -96,10 +96,24 @@ const VERBUND = ` und nicht in der Oberflaeche. ===================================================================== */ -/** Die Nummern, die diese Person an einem Termin eintragen darf. */ +/** Die Nummern, die diese Person an einem Termin eintragen darf. + * + * ERWEITERT AM 05.09.2026, und zwar aus einem Fehler heraus: Hier + * stand nur `person.id` plus die BETREUTEN. Für einen Creator ergab + * das genau eine Nummer -- seine eigene. Er hätte in der neuen Wahl + * seinen Scout gesehen, ihn angeklickt, gespeichert, und der Server + * hätte ihn still weggelassen. Ein Knopf, der nichts tut und nichts + * sagt, ist schlimmer als ein fehlender. + * + * einladbareIds() nimmt jetzt beide Richtungen: wen ich führe UND wer + * mich führt, dazu DogFather. Die Begründung, warum das keine + * Aufweichung der Sichtbarkeit ist, steht dort. */ function darfEintragen(person) { if (istLeitung(person)) return null; // null = alle - const erlaubt = new Set([person.id]); + const ids = einladbareIds(person); + if (ids === null) return null; + const erlaubt = new Set(ids); + erlaubt.add(person.id); // sich selbst immer for (const id of betreuteIds(person)) erlaubt.add(id); return erlaubt; } @@ -183,6 +197,41 @@ export function teilnehmerZu(terminIds) { /* ---------- Lesen ------------------------------------------------------- */ +/** WEN KANN ICH ZU EINEM TERMIN DAZUSTELLEN? + * + * Eigener Weg statt /api/personen (05.09.2026). Der dortige liefert + * die Liste für ZUWEISUNGEN und zeigt deshalb nur nach unten -- ein + * Creator fände sich dort allein wieder und hätte ein leeres Feld. + * + * Die beiden Listen dürfen nicht dieselbe sein: Wer /api/personen + * erweitert, gibt einem Creator nebenbei die Möglichkeit, seinem + * Scout Aufgaben zuzuweisen. Deshalb zwei Wege mit zwei Regeln -- + * und beide enden bei derselben Prüfung, die teilnehmerSetzen() + * ohnehin noch einmal anwendet. Diese Liste ist eine Bequemlichkeit, + * keine Sicherung. */ +kalenderRouter.get("/workspace/api/einladbar", (req, res) => { + try { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + + const ids = einladbareIds(person); + const wo = ids === null ? "" : ` AND id IN (${ids.map(() => "?").join(",")})`; + const werte = ids === null ? [] : ids; + /* Ist die Liste leer, wäre "id IN ()" ungültiges SQL -- der Fall + tritt ein, sobald jemand niemandem zugeordnet ist. */ + if (ids !== null && !ids.length) return res.json({ personen: [] }); + + res.json({ + personen: db().prepare( + `SELECT id, name, rolle FROM personen WHERE aktiv = 1${wo} + ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte), + }); + } catch (fehler) { + console.error("[workspace] Einladbar:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + kalenderRouter.get("/workspace/api/termine", (req, res) => { try { /* Wiederkehrende Termine nachlegen, BEVOR gelesen wird -- sonst diff --git a/server/workspace-push-krypto.js b/server/workspace-push-krypto.js new file mode 100644 index 00000000..a2b69525 --- /dev/null +++ b/server/workspace-push-krypto.js @@ -0,0 +1,233 @@ +/* ===================================================================== + workspace-push-krypto.js — Web Push, verschlüsselt und signiert. + + Der reine Rechenteil: Er nimmt eine Anmeldung (Endpunkt + zwei + Schlüssel des Browsers) und einen Text, und macht daraus das, was + der Push-Dienst von Google/Mozilla/Apple entgegennimmt. + + --------------------------------------------------------------------- + WARUM SELBST UND NICHT `web-push` + + Der Perfektionsplan nannte die Bibliothek `web-push`. Beim Nachsehen + sprachen zwei Dinge dagegen: + + LIZENZ web-push steht unter MPL-2.0. Die Hausregel lautet + "bevorzugt MIT oder Apache 2.0, die dem Nutzer selbst + gehört". MPL ist schwaches Copyleft -- benutzbar, aber + nicht das, was hier vorgegeben ist. + + UMFANG Sie bringt fünf weitere Pakete mit (asn1.js, http_ece, + https-proxy-agent, jws, minimist), die ihrerseits welche + haben. Der Workspace läuft bisher mit DREI Abhängigkeiten + insgesamt; Anmeldung, Sitzungen und Datenbank kommen + vollständig aus Node selbst. + + Node 24 bringt jede benötigte Rechenart mit -- nachgemessen: + createECDH, hkdfSync, createCipheriv (aes-128-gcm), createSign mit + ES256 und ieee-p1363-Kodierung. Damit ist der Eigenbau kein + Selbst-Erfinden von Kryptographie, sondern das Zusammensetzen + vorhandener, geprüfter Bausteine nach zwei RFCs. + + --------------------------------------------------------------------- + WAS HIER PASSIERT, IN ZWEI SCHRITTEN + + 1. VERSCHLÜSSELN (RFC 8291, "aes128gcm") + Der Server erzeugt für JEDE Nachricht ein neues Schlüsselpaar, + rechnet daraus mit dem öffentlichen Schlüssel des Browsers ein + gemeinsames Geheimnis (ECDH), leitet daraus über HKDF den + eigentlichen Schlüssel ab und verschlüsselt damit. + + Der Push-Dienst selbst sieht dabei NUR Zeichensalat. Weder Google + noch Apple erfahren, was in der Benachrichtigung steht -- das ist + der Grund, warum dieser Weg überhaupt vertretbar ist. + + 2. SIGNIEREN (RFC 8292, "VAPID") + Ein kurzlebiges JWT, signiert mit dem privaten Serverschlüssel. + Es beweist dem Push-Dienst, dass die Nachricht wirklich von + dogfather-universe.com kommt. + + --------------------------------------------------------------------- + GEPRÜFT WIRD GEGEN DIE TESTVEKTOREN DER RFCs, nicht gegen das eigene + Gefühl. RFC 8291, Anhang A enthält vollständige Beispieldaten samt + erwartetem Ergebnis. Wenn diese Rechnung sie trifft, ist sie richtig + -- und wenn sich morgen jemand vertippt, fällt es sofort auf. + Siehe pruef-push.mjs. + ===================================================================== */ + +import { + createECDH, hkdfSync, createCipheriv, createSign, randomBytes, + createPublicKey, createPrivateKey, +} from "node:crypto"; + +/* ---------- Kleinkram --------------------------------------------------- */ + +/** Base64 ohne Polster und mit URL-tauglichen Zeichen -- die einzige + * Schreibweise, die im Web-Push-Umfeld vorkommt. */ +export const b64u = (buf) => Buffer.from(buf).toString("base64") + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +export const ausB64u = (s) => Buffer.from( + String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64"); + +/* ---------- 1. Verschlüsseln (RFC 8291) --------------------------------- */ + +/** + * Verschlüsselt einen Text für genau eine Anmeldung. + * + * @param {string} text was ankommen soll (JSON) + * @param {string} p256dhB64u öffentlicher Schlüssel des Browsers + * @param {string} authB64u das Zufallsgeheimnis des Browsers + * @param {Buffer} [salt] nur für die Prüfung gegen den Testvektor + * @param {object} [eigenes] dito -- sonst wird je Nachricht neu erzeugt + */ +export function verschluesseln(text, p256dhB64u, authB64u, salt = null, eigenes = null) { + const empfaenger = ausB64u(p256dhB64u); // 65 Byte, unkomprimiert + const auth = ausB64u(authB64u); // 16 Byte + + if (empfaenger.length !== 65 || empfaenger[0] !== 4) { + throw new Error("Der Schluessel des Browsers hat nicht die erwartete Form."); + } + if (auth.length !== 16) { + throw new Error("Das Geheimnis des Browsers hat nicht die erwartete Laenge."); + } + + /* Ein eigenes Paar JE NACHRICHT. Ein wiederverwendetes waere der + klassische Fehler an dieser Stelle: Aus zwei Nachrichten mit + demselben Schluessel und demselben Salz laesst sich der Klartext + angreifen. */ + const ecdh = createECDH("prime256v1"); + if (eigenes) ecdh.setPrivateKey(eigenes); + else ecdh.generateKeys(); + const eigenerOeff = ecdh.getPublicKey(); // 65 Byte + const gemeinsam = ecdh.computeSecret(empfaenger); + + const salz = salt || randomBytes(16); + + /* Schritt 1: aus dem gemeinsamen Geheimnis und `auth` wird das + "pseudo random key". Der Info-Text ist im RFC woertlich + vorgeschrieben -- ein Tippfehler hier ergibt eine Nachricht, die + der Browser stumm verwirft. */ + const prkInfo = Buffer.concat([ + Buffer.from("WebPush: info\0"), empfaenger, eigenerOeff, + ]); + const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, auth, prkInfo, 32)); + + /* Schritt 2: daraus Schluessel (16 Byte) und Startwert (12 Byte). */ + const cek = Buffer.from(hkdfSync("sha256", ikm, salz, + Buffer.from("Content-Encoding: aes128gcm\0"), 16)); + const nonce = Buffer.from(hkdfSync("sha256", ikm, salz, + Buffer.from("Content-Encoding: nonce\0"), 12)); + + /* Das Polster: Der Klartext bekommt eine 0x02 angehaengt (letzter + Datensatz). Ohne dieses Byte verwirft der Browser die Nachricht -- + ohne jede Meldung, und man sucht den Fehler im eigenen Code. */ + const klartext = Buffer.concat([Buffer.from(text, "utf8"), Buffer.from([2])]); + + const c = createCipheriv("aes-128-gcm", cek, nonce); + const geheim = Buffer.concat([c.update(klartext), c.final(), c.getAuthTag()]); + + /* Der Kopf des Datensatzes: Salz, Datensatzgroesse, Laenge und + Inhalt des eigenen oeffentlichen Schluessels. */ + const kopf = Buffer.alloc(21); + salz.copy(kopf, 0); + kopf.writeUInt32BE(4096, 16); + kopf.writeUInt8(eigenerOeff.length, 20); + + return Buffer.concat([kopf, eigenerOeff, geheim]); +} + +/* ---------- 2. Signieren (RFC 8292, VAPID) ------------------------------ */ + +/** Erzeugt ein neues VAPID-Schluesselpaar. Einmal je Server. */ +export function schluesselErzeugen() { + const ecdh = createECDH("prime256v1"); + ecdh.generateKeys(); + return { + oeffentlich: b64u(ecdh.getPublicKey()), + privat: b64u(ecdh.getPrivateKey()), + }; +} + +/** Der private Schluessel als PKCS8, damit createSign ihn annimmt. + * Node kann aus rohen 32 Byte nichts machen -- der Umweg ueber DER + * ist der uebliche und braucht keine ASN.1-Bibliothek, weil das + * Geruest fuer P-256 immer gleich ist. */ +function privatAlsSchluessel(privatRoh, oeffentlichRoh) { + const kopf = Buffer.from("308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420", "hex"); + const mitte = Buffer.from("a144034200", "hex"); + const der = Buffer.concat([kopf, privatRoh, mitte, oeffentlichRoh]); + return createPrivateKey({ key: der, format: "der", type: "pkcs8" }); +} + +/** + * Baut die Kopfzeilen, die der Push-Dienst sehen will. + * + * @param {string} endpunkt die Adresse aus der Anmeldung + * @param {object} paar { oeffentlich, privat } als base64url + * @param {string} absender "mailto:..." -- wen der Dienst anschreibt, + * wenn etwas nicht stimmt + * @param {number} stunden wie lange das JWT gilt + */ +export function vapidKopf(endpunkt, paar, absender, stunden = 12) { + const ziel = new URL(endpunkt); + const jetzt = Math.floor(Date.now() / 1000); + + const teil1 = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" })); + const teil2 = b64u(JSON.stringify({ + aud: `${ziel.protocol}//${ziel.host}`, + /* Nicht laenger als 24 Stunden -- das schreibt der RFC vor, und + Firefox lehnt laengere ab. */ + exp: jetzt + Math.min(stunden, 23) * 3600, + sub: absender, + })); + + const schluessel = privatAlsSchluessel(ausB64u(paar.privat), ausB64u(paar.oeffentlich)); + /* `ieee-p1363` ist die rohe Form (r||s, 64 Byte). Die Voreinstellung + waere DER -- die nimmt kein Push-Dienst an. */ + const sig = createSign("SHA256") + .update(`${teil1}.${teil2}`) + .sign({ key: schluessel, dsaEncoding: "ieee-p1363" }); + + return { + Authorization: `vapid t=${teil1}.${teil2}.${b64u(sig)}, k=${paar.oeffentlich}`, + "Content-Encoding": "aes128gcm", + "Content-Type": "application/octet-stream", + }; +} + +/* ---------- 3. Verschicken ---------------------------------------------- */ + +/** + * Schickt eine Nachricht an eine Anmeldung. + * + * Gibt IMMER ein Ergebnis zurueck statt zu werfen -- der Aufrufer + * verschickt an viele Menschen, und eine abgelaufene Anmeldung darf den + * ganzen Lauf nicht abbrechen. + * + * `weg: true` heisst: Diese Anmeldung ist tot (Browser deinstalliert, + * Berechtigung entzogen) und gehoert geloescht. Der Push-Dienst sagt + * das mit 404 oder 410. + */ +export async function schicken(anmeldung, text, paar, absender, { ttl = 3600 } = {}) { + try { + const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth); + const kopf = vapidKopf(anmeldung.endpunkt, paar, absender); + + const a = await fetch(anmeldung.endpunkt, { + method: "POST", + headers: { ...kopf, TTL: String(ttl), Urgency: "normal" }, + body: koerper, + /* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */ + signal: AbortSignal.timeout(15_000), + }); + + if (a.status === 404 || a.status === 410) return { ok: false, weg: true, status: a.status }; + if (!a.ok) { + const grund = await a.text().catch(() => ""); + return { ok: false, weg: false, status: a.status, grund: grund.slice(0, 200) }; + } + return { ok: true, status: a.status }; + } catch (fehler) { + return { ok: false, weg: false, status: 0, grund: String(fehler?.message || fehler).slice(0, 200) }; + } +} diff --git a/server/workspace-push.js b/server/workspace-push.js new file mode 100644 index 00000000..29a66c6e --- /dev/null +++ b/server/workspace-push.js @@ -0,0 +1,442 @@ +/* ===================================================================== + workspace-push.js — Benachrichtigungen, die wirklich ankommen. + + Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen + aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert + auf der seite fuer jeden."* + + --------------------------------------------------------------------- + WAS DAS LOEST + + Bis hierher erscheinen Hinweise NUR, wenn man die Seite aufruft. Wer + nicht hinschaut, verpasst sie -- und genau das ist der Normalfall: + Man macht den Workspace auf, wenn man ohnehin daran denkt. Der + Perfektionsplan sagt dazu: *"Das Deck verspricht Erinnerungen. Wer + nicht hinschaut, verpasst sie."* + + --------------------------------------------------------------------- + OHNE FREMDEN DIENST, OHNE KONTO, OHNE GEBUEHR + + Push API und Service Worker sind Browserstandard. Der Server braucht + nur ein eigenes Schluesselpaar (VAPID); die Nachricht wird fuer jedes + Geraet einzeln verschluesselt. Google, Mozilla und Apple leiten sie + weiter, sehen aber nur Zeichensalat -- der Inhalt bleibt zwischen + diesem Server und dem Browser. + + Die Rechnung dafuer steht in workspace-push-krypto.js und ist gegen + die Testvektoren von RFC 8291 und 8292 geprueft. + + --------------------------------------------------------------------- + VIER REGELN, DIE DARUEBER ENTSCHEIDEN, OB ES BENUTZT WIRD + + 1. NICHTS ZWEIMAL. Jede Erinnerung wird je Person und Sache genau + einmal verschickt (Tabelle push_verschickt). Viermal am Tag + dieselbe Meldung ist der schnellste Weg, dass jemand alles + abschaltet -- und dann fehlt auch die wichtige. + + 2. NACHTS NICHT. Zwischen 22 und 7 Uhr wird nichts zugestellt. + Aufgehoben wird nichts: Was morgens noch gilt, geht morgens raus; + was sich erledigt hat, ist zu Recht weg. + + 3. JE ART ABSCHALTBAR, nicht alles oder nichts. + + 4. EINE BENACHRICHTIGUNG HAT IMMER EIN ZIEL. Wer sie antippt, landet + an der Stelle, um die es geht -- nicht auf der Startseite. + + --------------------------------------------------------------------- + WICHTIG FUERS IPHONE + + Dort funktioniert Push NUR, wenn die Seite zum Home-Bildschirm + hinzugefuegt wurde. Im Safari-Reiter gibt es keine Berechtigung -- + der Knopf sagt das dann auch genau so, statt einen Fehler zu zeigen. + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, sitzungLesen, istDogFather, + einstellung, einstellungSetzen, heuteLokal, +} from "./workspace.js"; +import { schluesselErzeugen, schicken } from "./workspace-push-krypto.js"; + +export const pushRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); + +/* --------------------------------------------------------------------- + DIE ARTEN. Was es gibt, steht hier -- nicht in der Datenbank: Es ist + eine bewusste Auswahl, keine Nutzdaten. Wer eine neue Art braucht, + traegt sie hier ein und sie erscheint von selbst in den + Einstellungen. + ------------------------------------------------------------------ */ +export const ARTEN = [ + { schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true }, + { schluessel: "aufgabe_ueberfaellig", name: "Aufgabe ist überfällig", vorgabe: true }, + { schluessel: "termin_gleich", name: "Termin in einer Stunde", vorgabe: true }, + { schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true }, + { schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true }, + { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true }, +]; +const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel)); + +/* Ruhezeit. Nichts zwischen 22 und 7 Uhr. */ +const RUHE_AB = 22, RUHE_BIS = 7; +export function istRuhezeit(d = new Date()) { + const s = d.getHours(); + return s >= RUHE_AB || s < RUHE_BIS; +} + +/* --------------------------------------------------------------------- + Das Schluesselpaar des Servers. Einmal erzeugt, danach in den + Einstellungen -- NICHT im Code und nicht in einer Datei im Repo. + Der private Teil verlaesst den Server nie. + ------------------------------------------------------------------ */ +export function paarHolen() { + let roh = einstellung("vapid_paar"); + if (roh) { + try { + const p = JSON.parse(roh); + if (p?.oeffentlich && p?.privat) return p; + } catch { /* kaputt -- unten neu erzeugen */ } + } + const neu = schluesselErzeugen(); + einstellungSetzen("vapid_paar", JSON.stringify(neu)); + console.log("[push] Neues VAPID-Schluesselpaar erzeugt."); + return neu; +} + +/** Die Absenderangabe fuer VAPID. Der Push-Dienst schreibt hierhin, + * wenn dauerhaft etwas nicht stimmt. */ +const ABSENDER = process.env.PUSH_ABSENDER || "mailto:kontakt@dogfather-universe.com"; + +/* --------------------------------------------------------------------- + Will diese Person diese Art bekommen? + ------------------------------------------------------------------ */ +export function willHaben(personId, art) { + const z = db().prepare( + "SELECT an FROM push_einstellungen WHERE person_id = ? AND art = ?").get(personId, art); + if (z) return !!z.an; + return ARTEN.find((a) => a.schluessel === art)?.vorgabe ?? true; +} + +/* --------------------------------------------------------------------- + VERSCHICKEN — der einzige Weg nach draussen. + + Alles laeuft hier durch: Ruhezeit, Einstellung, Doppelsperre, + Aufraeumen toter Anmeldungen. Eine zweite Stelle, die selbst + verschickt, wuerde eine dieser Regeln irgendwann vergessen. + ------------------------------------------------------------------ */ +export async function benachrichtige(personId, art, { titel, text, ziel, merkmal }) { + if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" }; + if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" }; + + /* Die Probe darf immer durch -- sie beantwortet die Frage "kommt + ueberhaupt etwas an?", und die stellt man sich auch um 23 Uhr. */ + if (art !== "test" && istRuhezeit()) return { verschickt: 0, grund: "ruhezeit" }; + + /* Schon einmal geschickt? */ + if (merkmal) { + const da = db().prepare( + "SELECT 1 FROM push_verschickt WHERE person_id = ? AND merkmal = ?").get(personId, merkmal); + if (da) return { verschickt: 0, grund: "schon_geschickt" }; + } + + const anmeldungen = db().prepare( + "SELECT * FROM push_anmeldungen WHERE person_id = ?").all(personId); + if (!anmeldungen.length) return { verschickt: 0, grund: "keine_geraete" }; + + const paar = paarHolen(); + const inhalt = JSON.stringify({ titel, text, ziel: ziel || "/workspace/start.html", art }); + + let raus = 0; + for (const a of anmeldungen) { + const e = await schicken(a, inhalt, paar, ABSENDER); + if (e.ok) { + raus++; + db().prepare("UPDATE push_anmeldungen SET zuletzt_ok = ?, fehler = 0 WHERE endpunkt = ?") + .run(jetzt(), a.endpunkt); + } else if (e.weg) { + /* Der Push-Dienst sagt: dieses Geraet gibt es nicht mehr. + Aufheben waere sinnlos und wuerde bei jedem Lauf erneut + scheitern. */ + db().prepare("DELETE FROM push_anmeldungen WHERE endpunkt = ?").run(a.endpunkt); + } else { + db().prepare("UPDATE push_anmeldungen SET fehler = fehler + 1 WHERE endpunkt = ?") + .run(a.endpunkt); + } + } + + if (raus && merkmal) { + db().prepare("INSERT OR REPLACE INTO push_verschickt (person_id, merkmal, zeit) VALUES (?,?,?)") + .run(personId, merkmal, jetzt()); + } + return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt" }; +} + +/* --------------------------------------------------------------------- + WAS STEHT AN? — der Takt. + + Fragt dieselben Daten ab, aus denen auch die Hinweisliste auf der + Startseite entsteht. Bewusst KEINE eigenen Daten: Was erledigt wird, + verschwindet von selbst, und es gibt nichts zu pflegen, das + veralten kann. + ------------------------------------------------------------------ */ +export async function laufen() { + const d = db(); + const heute = heuteLokal(); + const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400_000).toISOString().slice(0, 10); + let gesamt = 0; + + const senden = async (personId, art, sache) => { + const e = await benachrichtige(personId, art, sache); + gesamt += e.verschickt; + }; + + try { + /* --- Aufgaben, die morgen faellig sind ---------------------------- */ + for (const a of d.prepare(` + SELECT a.id, a.titel, a.verantwortlich_id FROM aufgaben a + WHERE a.status <> 'erledigt' AND a.frist = ? AND a.verantwortlich_id IS NOT NULL`).all(morgen)) { + await senden(a.verantwortlich_id, "aufgabe_faellig", { + titel: "Morgen fällig", + text: a.titel, + ziel: "/workspace/aufgaben.html", + merkmal: `aufgabe_faellig:${a.id}:${morgen}`, + }); + } + + /* --- Aufgaben, die ueberfaellig sind ------------------------------ */ + for (const a of d.prepare(` + SELECT a.id, a.titel, a.frist, a.verantwortlich_id FROM aufgaben a + WHERE a.status <> 'erledigt' AND a.frist IS NOT NULL AND a.frist < ? + AND a.verantwortlich_id IS NOT NULL`).all(heute)) { + await senden(a.verantwortlich_id, "aufgabe_ueberfaellig", { + titel: "Überfällig", + text: a.titel, + ziel: "/workspace/aufgaben.html", + /* Das Datum gehoert ins Merkmal: Eine Aufgabe, die drei Wochen + liegen bleibt, soll nicht dreimal am Tag erinnern -- aber + einmal am Tag schon. */ + merkmal: `aufgabe_ueberfaellig:${a.id}:${heute}`, + }); + } + + /* --- Termine in der naechsten Stunde ------------------------------ */ + const gleich = new Date(Date.now() + 60 * 60_000); + const p = (n) => String(n).padStart(2, "0"); + const bis = `${gleich.getFullYear()}-${p(gleich.getMonth() + 1)}-${p(gleich.getDate())}` + + `T${p(gleich.getHours())}:${p(gleich.getMinutes())}`; + const jetztText = (() => { + const n = new Date(); + return `${n.getFullYear()}-${p(n.getMonth() + 1)}-${p(n.getDate())}` + + `T${p(n.getHours())}:${p(n.getMinutes())}`; + })(); + + for (const t of d.prepare(` + SELECT t.id, t.titel, t.beginn, t.art FROM termine t + WHERE t.erledigt = 0 AND t.beginn >= ? AND t.beginn <= ?`).all(jetztText, bis)) { + /* An ALLE Teilnehmer, nicht nur an das Haupt-Gegenueber -- seit + ein Termin mehrere haben kann (05.09.2026). */ + const leute = d.prepare( + "SELECT person_id FROM termin_teilnehmer WHERE termin_id = ?").all(t.id).map((r) => r.person_id); + for (const pid of new Set(leute)) { + await senden(pid, "termin_gleich", { + titel: t.art === "call" ? "Call in Kürze" : "Termin in Kürze", + text: `${t.titel} · ${t.beginn.slice(11, 16)} Uhr`, + ziel: t.art === "call" ? "/workspace/calls.html" : "/workspace/kalender.html", + merkmal: `termin_gleich:${t.id}`, + }); + } + } + + /* --- Gespraeche ohne Protokoll ------------------------------------ */ + for (const t of d.prepare(` + SELECT t.id, t.titel, t.erstellt_von FROM termine t + LEFT JOIN protokolle pr ON pr.termin_id = t.id + WHERE t.art IN ('call','review') AND t.beginn < ? AND pr.id IS NULL + AND t.beginn > ? AND t.erstellt_von IS NOT NULL`) + .all(jetztText, heute + "T00:00")) { + await senden(t.erstellt_von, "protokoll_fehlt", { + titel: "Protokoll fehlt", + text: t.titel, + ziel: "/workspace/calls.html", + merkmal: `protokoll_fehlt:${t.id}`, + }); + } + + /* --- Faellige Follow-ups in der Pipeline --------------------------- */ + for (const l of d.prepare(` + SELECT l.id, l.name, l.scout_id FROM leads l + WHERE l.naechster_followup IS NOT NULL AND l.naechster_followup <= ? + AND l.status NOT IN ('uebergeben','abgelehnt') AND l.scout_id IS NOT NULL`).all(heute)) { + await senden(l.scout_id, "followup", { + titel: "Nachfassen", + text: l.name, + ziel: "/workspace/scouting.html", + merkmal: `followup:${l.id}:${heute}`, + }); + } + + /* Alte Merkmale aufraeumen -- nach 60 Tagen braucht sie niemand + mehr, und die Tabelle soll nicht ewig wachsen. */ + const grenze = new Date(Date.now() - 60 * 86400_000).toISOString(); + d.prepare("DELETE FROM push_verschickt WHERE zeit < ?").run(grenze); + } catch (fehler) { + console.error("[push] Lauf:", fehler?.message); + } + return gesamt; +} + +/* --------------------------------------------------------------------- + Der Takt. Wie bei der Sicherung: im Prozess, kein Systemdienst, kein + Cron -- eine Sache weniger, die stillschweigend ausfallen kann. + ------------------------------------------------------------------ */ +let takt = null; +export function pushStarten() { + if (takt) return; + /* Alle fuenf Minuten. Feiner braucht es nicht: Die feinste + Erinnerung ist "Termin in einer Stunde". */ + takt = setInterval(() => { laufen().catch(() => {}); }, 5 * 60_000); + takt.unref?.(); + setTimeout(() => { laufen().catch(() => {}); }, 20_000).unref?.(); +} + +/* ===================================================================== + Die Schnittstellen + ===================================================================== */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +pushRouter.use("/workspace/api/push", angemeldet); + +/** Was der Browser braucht, um sich anzumelden: der oeffentliche + * Schluessel. Der private bleibt hier. */ +pushRouter.get("/workspace/api/push/schluessel", (req, res) => { + try { + res.json({ schluessel: paarHolen().oeffentlich }); + } catch (fehler) { + console.error("[push] Schluessel:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Der eigene Stand: angemeldete Geraete und die Einstellungen. */ +pushRouter.get("/workspace/api/push/stand", (req, res) => { + try { + const geraete = db().prepare(` + SELECT endpunkt, geraet, erstellt, zuletzt_ok FROM push_anmeldungen + WHERE person_id = ? ORDER BY erstellt`).all(req.person.id); + const arten = ARTEN.map((a) => ({ + ...a, an: willHaben(req.person.id, a.schluessel), + })); + res.json({ + /* Nur die Anzahl und ein Kuerzel nach aussen -- der volle + Endpunkt ist die Zustelladresse dieses Geraets und hat in der + Oberflaeche nichts zu suchen. */ + geraete: geraete.map((g) => ({ + kennung: g.endpunkt.slice(-12), + geraet: g.geraet || "Unbekanntes Gerät", + erstellt: g.erstellt, zuletzt_ok: g.zuletzt_ok, + })), + arten, + ruhe: { ab: RUHE_AB, bis: RUHE_BIS, jetzt: istRuhezeit() }, + }); + } catch (fehler) { + console.error("[push] Stand:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Ein Geraet anmelden. */ +pushRouter.post("/workspace/api/push/anmelden", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { + try { + const { endpunkt, p256dh, auth, geraet } = req.body || {}; + if (typeof endpunkt !== "string" || !/^https:\/\//.test(endpunkt) || endpunkt.length > 1000) { + return res.status(400).json({ fehler: "Die Adresse sieht nicht richtig aus." }); + } + if (typeof p256dh !== "string" || typeof auth !== "string" || !p256dh || !auth) { + return res.status(400).json({ fehler: "Es fehlen die Schlüssel des Browsers." }); + } + + /* INSERT OR REPLACE auf dem Endpunkt: Meldet sich derselbe Browser + erneut, wird der Eintrag ersetzt statt verdoppelt -- sonst kaeme + jede Nachricht mehrfach an. */ + db().prepare(` + INSERT OR REPLACE INTO push_anmeldungen + (endpunkt, person_id, p256dh, auth, geraet, erstellt, zuletzt_ok, fehler) + VALUES (?,?,?,?,?,?,NULL,0)`).run( + endpunkt, req.person.id, p256dh, auth, + String(geraet || "").slice(0, 80) || null, jetzt()); + + protokolliere("push_angemeldet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: String(geraet || "").slice(0, 60), + }); + res.status(201).json({ ok: true }); + } catch (fehler) { + console.error("[push] Anmelden:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Ein Geraet abmelden. */ +pushRouter.post("/workspace/api/push/abmelden", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { + try { + const endpunkt = String(req.body?.endpunkt || ""); + /* NUR die eigenen -- sonst koennte man fremde Geraete stumm + schalten, wenn man deren Adresse kennt. */ + const e = db().prepare("DELETE FROM push_anmeldungen WHERE endpunkt = ? AND person_id = ?") + .run(endpunkt, req.person.id); + protokolliere("push_abgemeldet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + }); + res.json({ ok: true, entfernt: Number(e.changes) }); + } catch (fehler) { + console.error("[push] Abmelden:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Eine Art an- oder abschalten. */ +pushRouter.put("/workspace/api/push/art", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { + try { + const art = String(req.body?.art || ""); + if (!ARTEN_SCHLUESSEL.has(art)) return res.status(400).json({ fehler: "Diese Art gibt es nicht." }); + const an = req.body?.an ? 1 : 0; + db().prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,?) + ON CONFLICT(person_id, art) DO UPDATE SET an = excluded.an`).run(req.person.id, art, an); + res.json({ ok: true, art, an: !!an }); + } catch (fehler) { + console.error("[push] Art:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Eine Probe an die eigenen Geraete. Beantwortet die einzige Frage, + * die man wirklich hat: Kommt etwas an? */ +pushRouter.post("/workspace/api/push/probe", gleicheHerkunft, async (req, res) => { + try { + const e = await benachrichtige(req.person.id, "test", { + titel: "Es funktioniert", + text: "So sieht eine Benachrichtigung aus dem Workspace aus.", + ziel: "/workspace/start.html", + }); + res.json(e); + } catch (fehler) { + console.error("[push] Probe:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 77887d79..bdc179b3 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -225,6 +225,27 @@ function umstellungen(d) { ["aufgaben", "verantwortlich_extern", "TEXT"], ["eintraege", "creator_extern", "TEXT"], ["dateien", "creator_extern", "TEXT"], + + /* ABBRECHEN (05.09.2026). Wunsch: *"ich will dass man in jedem + status die aufgaben auch abbrechen kann."* + + Drei Spalten, weil "abgebrochen" allein zu wenig sagt: + abbruch_grund WARUM. Pflichtfeld in der Oberflaeche -- ohne + Grund weiss in vier Wochen niemand mehr, warum + etwas wegfiel, und es sieht aus wie Loeschen. + abgebrochen_am WANN. + abbruch_von WER. Nur Leitung und Scouts duerfen es, und + wer es war, gehoert dazu. + status_vorher IN WELCHEM ZUSTAND. Diese Spalte ist der Grund, + warum "abgebrochen" ein eigener Status wurde + und nicht bloss ein Haken: Ohne sie ginge beim + Wechsel verloren, ob die Arbeit schon lief. + "Im Review abgebrochen" ist eine ganz andere + Aussage als "nie angefangen". */ + ["aufgaben", "abbruch_grund", "TEXT"], + ["aufgaben", "abgebrochen_am", "TEXT"], + ["aufgaben", "abbruch_von", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"], + ["aufgaben", "status_vorher", "TEXT"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -321,6 +342,108 @@ function umstellungen(d) { console.error("[workspace] Content-Arten:", fehler?.message); } + /* ---- Status "abgebrochen" erlauben (05.09.2026) ---- + + Der CHECK-Constraint einer Tabelle laesst sich in SQLite nicht + aendern -- kein ALTER TABLE der Welt hilft, die Tabelle muss neu + gebaut werden. Deshalb derselbe Weg wie bei der Rolle "manager" + darunter: erst sichern, dann tauschen, danach die Verweise pruefen. + + WARUM UEBERHAUPT EIN NEUER STATUS und nicht bloss ein Haken + "abgebrochen": Weil die vier Spalten des Bretts nach Status + gruppieren. Eine abgebrochene Aufgabe faellt damit von selbst aus + allen vieren heraus -- ohne dass irgendeine Abfrage angefasst + werden muss. Ein zusaetzlicher Haken haette bedeutet, JEDE Abfrage + um "AND nicht abgebrochen" zu ergaenzen, und die eine vergessene + waere ein stiller Fehler gewesen: Die Aufgabe stuende weiter im + Brett, und niemand wuesste warum. */ + const aufgabenPlan = d.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || ""; + if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) { + const sicherung = `${DB_PFAD}.vor-abbruch-${jetztStempel}`; + try { + d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); + console.log("[workspace] Sicherung vor der Umstellung:", sicherung); + } catch (fehler) { + console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message); + return; + } + + d.exec("PRAGMA foreign_keys = OFF"); + try { + /* Die Spalten stehen hier vollstaendig, weil die Schleife oben + sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine + vergisst, verliert ihren Inhalt still -- deshalb wird nach dem + Tausch die Zeilenzahl verglichen. */ + const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; + d.exec("BEGIN"); + d.exec(` + CREATE TABLE aufgaben_neu ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, + beschreibung TEXT, + status TEXT NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')), + prioritaet TEXT NOT NULL DEFAULT 'mittel' + CHECK (prioritaet IN ('hoch','mittel','niedrig')), + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + frist TEXT, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geaendert TEXT, + erledigt_am TEXT, + creator_extern TEXT, + verantwortlich_extern TEXT, + abbruch_grund TEXT, + abgebrochen_am TEXT, + abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + status_vorher TEXT + ); + INSERT INTO aufgaben_neu + (id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + frist, erstellt, erstellt_von, geaendert, erledigt_am, + creator_extern, verantwortlich_extern, + abbruch_grund, abgebrochen_am, abbruch_von, status_vorher) + SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + frist, erstellt, erstellt_von, geaendert, erledigt_am, + creator_extern, verantwortlich_extern, + abbruch_grund, abgebrochen_am, abbruch_von, status_vorher + FROM aufgaben; + DROP TABLE aufgaben; + ALTER TABLE aufgaben_neu RENAME TO aufgaben; + CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status); + CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id); + `); + /* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" -- + gezaehlt wird also unter dem alten Namen, und der Vergleich + laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist + ein ROLLBACK noch moeglich. */ + const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; + /* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das + Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */ + if (nachher !== vorher) { + d.exec("ROLLBACK"); + console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, ` + + `${nachher} nachher. Sicherung: ${sicherung}`); + } else { + d.exec("COMMIT"); + const kaputt = d.prepare("PRAGMA foreign_key_check").all(); + if (kaputt.length) { + console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length, + "verwaiste Verweise. Sicherung liegt unter", sicherung); + } else { + console.log(`[workspace] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`); + } + } + } catch (fehler) { + try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } + console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message); + } finally { + d.exec("PRAGMA foreign_keys = ON"); + } + } + /* ---- Rolle "manager" erlauben ---- */ const bauplan = d.prepare( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; @@ -435,7 +558,7 @@ export function db() { titel TEXT NOT NULL, beschreibung TEXT, status TEXT NOT NULL DEFAULT 'offen' - CHECK (status IN ('offen','arbeit','review','erledigt')), + CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')), prioritaet TEXT NOT NULL DEFAULT 'mittel' CHECK (prioritaet IN ('hoch','mittel','niedrig')), creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, @@ -461,6 +584,71 @@ export function db() { von INTEGER REFERENCES personen(id) ON DELETE SET NULL ); + /* BENACHRICHTIGUNGEN (05.09.2026) --------------------------------- + + Wunsch Filipe, mit dem Bild eines "Benachrichtigungen aus"- + Knopfes: *"ich will auch sowas und dass es perfekt funktioniert + auf der seite fuer jeden."* + + Eine Anmeldung ist die Adresse, unter der ein bestimmter + BROWSER auf einem bestimmten GERAET erreichbar ist -- nicht die + Person. Wer den Workspace auf dem Rechner und auf dem Handy + benutzt, hat zwei; beide sollen klingeln, und beide muessen + einzeln abschaltbar sein. + + Der Endpunkt ist der Schluessel, nicht eine eigene Nummer: Meldet + sich derselbe Browser erneut an (nach dem Leeren der + Website-Daten etwa), soll daraus KEIN zweiter Eintrag werden, + sonst kaeme jede Nachricht doppelt. + + p256dh und auth sind die Schluessel des Browsers. Ohne sie + laesst sich nichts verschluesseln -- und ohne Verschluesselung + nimmt kein Push-Dienst etwas an. */ + CREATE TABLE IF NOT EXISTS push_anmeldungen ( + endpunkt TEXT PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + geraet TEXT, + erstellt TEXT NOT NULL, + zuletzt_ok TEXT, + fehler INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_push_person ON push_anmeldungen (person_id); + + /* Was jemand bekommen WILL -- je Art einzeln. + + Der Plan ist an dieser Stelle deutlich: *"je Person abschaltbar + -- pro Art, nicht alles oder nichts. Eine Benachrichtigung, die + nervt, wird abgeschaltet und dann fehlt auch die wichtige."* + + Fehlt eine Zeile, gilt die Voreinstellung aus workspace-push.js + (an). Damit muss niemand erst etwas einstellen, um etwas zu + bekommen -- und wer etwas abstellt, bekommt genau das nicht + mehr. */ + CREATE TABLE IF NOT EXISTS push_einstellungen ( + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + art TEXT NOT NULL, + an INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (person_id, art) + ); + + /* Was schon verschickt wurde. Ohne dieses Gedaechtnis bekaeme + jemand dieselbe Erinnerung bei jedem Lauf erneut -- viermal am + Tag "Aufgabe ist ueberfaellig" ist der schnellste Weg, dass + jemand Benachrichtigungen komplett abschaltet. + + Das Merkmal ist die Sache selbst (z. B. "aufgabe-faellig:42"), + nicht der Zeitpunkt: Dieselbe Aufgabe erinnert einmal, nicht + einmal je Stunde. */ + CREATE TABLE IF NOT EXISTS push_verschickt ( + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + merkmal TEXT NOT NULL, + zeit TEXT NOT NULL, + PRIMARY KEY (person_id, merkmal) + ); + CREATE INDEX IF NOT EXISTS idx_push_verschickt_zeit ON push_verschickt (zeit); + /* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback"). Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in WhatsApp, und damit ausserhalb dessen, was spaeter noch @@ -1073,9 +1261,37 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => { .prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1") .all(rolle); + /* EIN KAPUTTER DATENSATZ DARF NICHT ALLE AUSSPERREN (05.09.2026). + + Hier stand die Schleife ohne Absicherung. Wirft `hashe()` bei + EINER Person -- etwa weil ihr code_n keine Zweierpotenz ist und + scrypt "Invalid scrypt params" meldet --, dann flog die ganze + Anmeldung in den catch am Ende: 503 "nicht_verfuegbar", für + JEDEN mit dieser Rolle, auch für die, deren Daten in Ordnung + sind. + + Aufgefallen ist es beim Bau der Abbruchpruefung, wo ich zum + Testen versehentlich eine Person mit code_n = 1 angelegt hatte. + Ab da kam kein einziger DogFather mehr herein -- und die Meldung + sagte "nicht verfügbar", nicht "ein Datensatz ist defekt". + Danach hätte man lange gesucht. + + Ein Datenfehler bei einer Person ist jetzt ein Problem DIESER + Person: Sie wird übersprungen, der Rest der Anmeldung läuft + normal weiter. Und sie wird laut protokolliert, denn sie kann + sich selbst nicht mehr anmelden -- das muss auffallen. */ let gefunden = null; for (const k of kandidaten) { - if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; } + try { + if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; } + } catch (f) { + console.error(`[workspace] Zugangsdaten von Person #${k.id} (${k.rolle}) sind defekt ` + + `-- sie kann sich nicht anmelden. Grund: ${f?.message}`); + protokolliere("zugangsdaten_defekt", { + personId: k.id, rolle: k.rolle, ip, + detail: String(f?.message || "").slice(0, 80), + }); + } } if (!gefunden) { @@ -1362,6 +1578,76 @@ export function sichtbarePersonenIds(person) { return [...new Set([person.id, ...creator, ...scouts])]; } +/** WER BETREUT MICH -- die Blickrichtung nach oben. + * + * betreuteIds() fragt "wen führe ich?". Diese Funktion fragt das + * Gegenteil: "wer führt mich?" -- also der eigene Scout, dessen + * Manager, und bei einem Scout sein Manager. + * + * Es gab die Richtung bisher nicht, weil sie für Sichtbarkeit nie + * gebraucht wurde: Ein Creator soll die Daten seines Scouts nicht + * sehen. Für eine EINLADUNG ist die Frage aber eine andere -- siehe + * einladbareIds(). */ +export function betreuerIds(person) { + if (!person) return []; + try { + const d = db(); + if (person.rolle === "creator") { + const betreuer = d.prepare("SELECT betreuer_id FROM betreuung WHERE creator_id = ?") + .all(person.id).map((z) => z.betreuer_id); + if (!betreuer.length) return []; + /* Über den Scout auch dessen Manager: Wer mit seinem Scout einen + Call hat, hat ihn oft mit dessen Manager zusammen. */ + const manager = d.prepare( + `SELECT manager_id FROM scout_zuteilung + WHERE scout_id IN (${betreuer.map(() => "?").join(",")})`) + .all(...betreuer).map((z) => z.manager_id); + return [...new Set([...betreuer, ...manager])]; + } + if (person.rolle === "scout") { + return d.prepare("SELECT manager_id FROM scout_zuteilung WHERE scout_id = ?") + .all(person.id).map((z) => z.manager_id); + } + return []; + } catch { + return []; // im Zweifel niemand -- nie mehr, als sicher ist + } +} + +/** WEN DARF ICH ZU EINEM TERMIN DAZUSTELLEN? + * + * Eingeführt am 05.09.2026 auf den Wunsch, die Teilnehmerwahl solle + * *"für jeden verfügbar sein"*. + * + * WARUM NICHT EINFACH sichtbarePersonenIds: Die Frage ist eine + * andere. Sichtbarkeit heißt "wessen Daten darf ich sehen" und zeigt + * bewusst nach unten -- ein Creator sieht dort nur sich selbst. + * Einladen heißt "mit wem arbeite ich zusammen" und geht in BEIDE + * Richtungen: Ein Creator lädt seinen Scout ein, ein Scout seinen + * Manager. + * + * Und warum nicht die eine Funktion erweitern: sichtbarePersonenIds + * hängt auch an der AUFGABENZUWEISUNG. Wer dort einen Betreuer + * hinzufügt, gibt einem Creator nebenbei die Möglichkeit, seinem + * Scout Aufgaben zu erteilen. Zwei Fragen, zwei Funktionen. + * + * Was ein Einladen bewirkt, ist eng: Die Person sieht diesen einen + * Termin in ihrem Kalender. Keine Daten, keine Rechte, kein Zugriff. + * Deshalb ist die weitere Menge hier vertretbar. + * + * null = alle (nur DogFather), wie überall in dieser Datei. */ +export function einladbareIds(person) { + if (!person) return []; + if (istDogFather(person)) return null; + const sichtbar = sichtbarePersonenIds(person) || []; + /* DogFather gehört immer dazu: Er ist der Einzige, mit dem jede + Rolle zu tun hat, und ihn nicht einladen zu können wäre der erste + Fall, der auffällt. */ + const chefs = db().prepare("SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1") + .all().map((z) => z.id); + return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])]; +} + /** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen * darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */ export function personenWo(person, spalte) { diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index 756f8366..1d6e7584 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -511,3 +511,148 @@ .call-raum { align-items: flex-start; } .call-raum button { margin-left: 0; } } + +/* ===================================================================== + ABBRECHEN — Knopf, Bereich und Dialog + ===================================================================== + + Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die + aufgaben auch abbrechen kann."* + + GESTALTUNGSREGEL HIER: zurückhaltend. Abbrechen ist die seltene + Handlung neben "starten" und "erledigt" -- sie darf auf der Karte + nicht lauter sein als die beiden, die man täglich braucht. Kein + Signalrot, kein Ausrufezeichen: ein gedeckter Ton, der sich vom + Weiterschalten unterscheidet, ohne die Karte an sich zu reißen. + (Augenschonend ist hier ohnehin Hausregel.) */ + +/* Ein warmer, gedämpfter Ton statt Alarmrot. --f ist die Farbvariable, + die .schritt in gate.css für Rand, Schrift und Schimmer benutzt. */ +.schritt--ab { --f: #c98a7d; } +.schritt--ab:hover { color: color-mix(in srgb, #c98a7d 40%, #ffffff); } + +/* ---------- Der Bereich unter dem Brett ---------------------------------- */ + +.abgebrochen { + margin: 22px 0 0; + border: 1px solid var(--rand); + border-radius: 14px; + background: rgba(255, 255, 255, .022); +} + +.abgebrochen__kopf { + display: flex; align-items: center; gap: 10px; + padding: 13px 17px; + cursor: pointer; + font-size: .82rem; font-weight: 700; + letter-spacing: .06em; text-transform: uppercase; + color: var(--text-still); + /* Das eigene Dreieck von weg -- es wird unten selbst + gezeichnet, damit es links steht und sich mitdreht. */ + list-style: none; +} +.abgebrochen__kopf::-webkit-details-marker { display: none; } +.abgebrochen__kopf:hover { color: var(--text-leise); } +.abgebrochen__kopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; border-radius: 14px; } + +/* Das Dreieck. Dreht sich beim Aufklappen -- die Bewegung sagt, dass + der Inhalt darunter erscheint und nicht woanders. */ +.abgebrochen__kopf::before { + content: ""; flex: none; + width: 0; height: 0; + border-left: 5px solid currentColor; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform .18s ease; +} +.abgebrochen[open] .abgebrochen__kopf::before { transform: rotate(90deg); } + +.abgebrochen__name { flex: 1; } +.abgebrochen__zahl { + padding: 2px 9px; border-radius: 999px; + font-size: .78rem; font-weight: 700; letter-spacing: 0; + color: var(--text-leise); + background: rgba(255, 255, 255, .06); + border: 1px solid var(--rand); + font-variant-numeric: tabular-nums; +} + +.abgebrochen__unter { + margin: 0; padding: 0 17px 4px; + font-size: .8rem; color: var(--text-still); max-width: 72ch; +} + +.abgebrochen__liste { + display: grid; gap: 10px; + /* Mehrspaltig, sobald Platz ist: Abgebrochenes liest man am Stück, + nicht einzeln -- eine lange Spalte zwänge zum Scrollen durch etwas, + das man ohnehin nur überfliegt. */ + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + padding: 10px 17px 17px; +} + +.abbruch-karte { + padding: 13px 15px; + border: 1px solid var(--rand); + border-radius: 12px; + background: rgba(255, 255, 255, .028); +} +.abbruch-karte__kopf { + display: flex; align-items: baseline; gap: 9px; + margin-bottom: 6px; +} +/* Durchgestrichen sagt "gilt nicht mehr" schneller als jedes Wort -- + und bleibt lesbar, anders als bloßes Ausgrauen. */ +.abbruch-karte__titel { + margin: 0; flex: 1; + font-size: .92rem; font-weight: 600; + color: var(--text-leise); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--text-still) 70%, transparent); +} +/* Wo es stand, als es abgebrochen wurde. */ +.abbruch-karte__vorher { + flex: none; + padding: 2px 8px; border-radius: 999px; + font-size: .68rem; font-weight: 700; + letter-spacing: .04em; text-transform: uppercase; + color: var(--text-still); + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); +} +.abbruch-karte__grund { + margin: 0 0 7px; + font-size: .84rem; color: var(--text-leise); + /* Der Grund ist der eigentliche Inhalt dieser Karte -- er steht in + normaler Textfarbe, während der Titel durchgestrichen zurücktritt. */ +} +.abbruch-karte__wer { + margin: 0 0 9px; + font-size: .74rem; color: var(--text-still); +} +.abbruch-karte .schritt { font-size: .8rem; padding: 7px 13px; } + +/* ---------- Der Dialog --------------------------------------------------- */ + +.dialog__unter { + margin: -4px 0 14px; + font-size: .85rem; color: var(--text-still); + max-width: 60ch; line-height: 1.5; +} + +#abbruch-grund { + width: 100%; + resize: vertical; + min-height: 72px; +} + +@media (prefers-reduced-motion: reduce) { + .abgebrochen__kopf::before { transition: none; } +} + +/* Ohne Farbverläufe trägt die Kante allein. Der Durchstrich bleibt -- + er ist hier das eigentliche Merkmal und keine Deko. */ +@media (forced-colors: active) { + .abgebrochen, .abbruch-karte { border: 1px solid CanvasText; } + .abgebrochen__zahl, .abbruch-karte__vorher { border: 1px solid CanvasText; } +} diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css index 72accb22..f365e53f 100644 --- a/workspace/assets/css/kalender.css +++ b/workspace/assets/css/kalender.css @@ -672,3 +672,133 @@ bleibt das Unterscheidungsmerkmal einer pausierten Serie. */ .k-serie[data-aktiv="nein"] { opacity: 1; border-style: dashed; } } + +/* ===================================================================== + WER IST NOCH DABEI — die Teilnehmerwahl + ===================================================================== + + Vorgeschichte (05.09.2026, Filipe mit Bildschirmfoto): *"das soll + viel besser aussehen bitte und dass soll für jeden verfügbar sein."* + + Auf dem Bild klebten die Namen aneinander -- "DogfatherDogFather", + "CigdemManager", "BananaStiftScout". Zwei Fehler auf einmal: + + 1. DAS CSS WAR GAR NICHT DA. Die Wahl benutzte `.wahl__knopf` aus + dateien.css -- und kalender.html bindet dateien.css nicht ein. + Ohne `display:inline-flex` und `gap` stehen zwei eben + direkt nebeneinander. Vierzig grüne Prüfungen zur Teilnehmerwahl + hatten das nicht gemerkt: Keine davon hat je gemessen, ob es auch + AUSSIEHT wie gedacht. Deshalb gibt es jetzt eigene Klassen im + eigenen Stylesheet -- eine Seite, die ihr CSS selbst mitbringt, + kann es nicht verlieren. + + 2. DIE ROLLE STAND AN JEDEM NAMEN. Auch mit Abstand bliebe es eine + lange graue Wurst, in der man niemanden findet. Jetzt tragen die + GRUPPEN die Rolle ("Manager", "Scouts"), die Namen darunter nur + noch den Namen. Das Wort steht einmal statt neunmal, und man + sucht nicht mehr, sondern springt zur richtigen Gruppe. + + AUGENSCHONEND (Projektregel): gedeckte Rollentöne, kein Neon, keine + harten Weißflächen. Ausgewählt wird über Rand, Häkchen UND Schrift + erkennbar -- nicht über Farbe allein, sonst ist es für + Farbfehlsichtige nicht zu unterscheiden. */ + +.k-dabei { margin-top: 6px; } + +.k-dabei__gruppe + .k-dabei__gruppe { margin-top: 12px; } + +.k-dabei__titel { + margin: 0 0 6px; + font-size: .68rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; + color: var(--text-still); + display: flex; align-items: center; gap: 7px; +} +/* Der Punkt trägt die Rollenfarbe -- ein ruhiger Anker fürs Auge, + ohne die ganze Überschrift einzufärben. */ +.k-dabei__punkt { + width: 7px; height: 7px; border-radius: 50%; + background: var(--r, var(--text-still)); flex: none; +} + +.k-dabei__liste { display: flex; flex-wrap: wrap; gap: 7px; } + +.k-dabei__knopf { + --r: rgba(255, 255, 255, .5); + display: inline-flex; align-items: center; gap: 9px; + /* 40px Mindesthöhe: am Handy mit dem Daumen sicher zu treffen. */ + min-height: 40px; + padding: 5px 14px 5px 6px; + cursor: pointer; font: inherit; + color: var(--text-leise); + background: rgba(255, 255, 255, .035); + border: 1px solid var(--rand); border-radius: 999px; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.k-dabei__knopf:hover { + color: var(--text); + border-color: rgba(255, 255, 255, .24); + background: rgba(255, 255, 255, .06); +} +.k-dabei__knopf:focus-visible { + outline: 2px solid var(--r); outline-offset: 2px; +} + +/* Die Initiale. Ersetzt ein Bild, das es hier nicht gibt, und macht + aus einer Textzeile ein Gesicht, das man wiedererkennt. */ +.k-dabei__zeichen { + flex: none; + width: 28px; height: 28px; border-radius: 50%; + display: grid; place-items: center; + font-size: .76rem; font-weight: 700; line-height: 1; + color: var(--r); + background: color-mix(in srgb, var(--r) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--r) 34%, transparent); +} +.k-dabei__name { font-size: .86rem; font-weight: 550; } + +/* Das Häkchen steht NEBEN der Farbe, nicht statt ihr. Wer Farben + schlecht unterscheidet, sieht trotzdem sofort, wer dabei ist. */ +.k-dabei__haken { + flex: none; width: 14px; height: 14px; + opacity: 0; transform: scale(.7); + transition: opacity .16s ease, transform .16s ease; +} +.k-dabei__haken path { stroke: var(--r); stroke-width: 2.4; fill: none; + stroke-linecap: round; stroke-linejoin: round; } + +.k-dabei__knopf[data-an="ja"] { + color: var(--text); font-weight: 600; + border-color: color-mix(in srgb, var(--r) 55%, transparent); + background: color-mix(in srgb, var(--r) 13%, transparent); +} +.k-dabei__knopf[data-an="ja"] .k-dabei__zeichen { + color: #12151b; + background: var(--r); + border-color: var(--r); +} +.k-dabei__knopf[data-an="ja"] .k-dabei__haken { opacity: 1; transform: none; } + +/* Rollentöne. Dieselben wie in personen.css -- eine Farbe pro Rolle im + ganzen Workspace, sonst muss man sie auf jeder Seite neu lernen. */ +.k-dabei__gruppe[data-rolle="admin"], +.k-dabei__knopf[data-rolle="admin"] { --r: #e8c07d; } +.k-dabei__gruppe[data-rolle="manager"], +.k-dabei__knopf[data-rolle="manager"] { --r: #8a76ff; } +.k-dabei__gruppe[data-rolle="scout"], +.k-dabei__knopf[data-rolle="scout"] { --r: #5fc99a; } +.k-dabei__gruppe[data-rolle="creator"], +.k-dabei__knopf[data-rolle="creator"] { --r: #3fbdf5; } + +@media (prefers-reduced-motion: reduce) { + .k-dabei__knopf, .k-dabei__haken { transition: none; } +} + +/* Ohne Farbverläufe trägt die Kante allein -- und das Häkchen, das + hier zum eigentlichen Merkmal wird. */ +@media (forced-colors: active) { + .k-dabei__knopf { border: 1px solid CanvasText; } + .k-dabei__knopf[data-an="ja"] { border-width: 2px; } + .k-dabei__zeichen { border: 1px solid CanvasText; } + .k-dabei__haken path { stroke: CanvasText; } +} diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 7f3cd55a..522ed918 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -2785,6 +2785,7 @@ body.start #protokoll { flex: 0 1 auto; min-width: 0; } + .sicht__schild { flex: none; /* 0,75 rem = 12 px. Nicht kleiner: Unter 11,5 px meldet die @@ -2879,6 +2880,47 @@ body[data-fremde-sicht="ja"]::after { .sicht { padding: 3px 4px; } .wer__name { display: none; } } + +/* SEHR SCHMAL: die Kopfleiste darf umbrechen (05.09.2026). + + Die Rechnung darueber ging genau auf, solange rechts vier Dinge + standen. Mit der Glocke sind es fuenf, und bei 320 px (iPhone SE) + passte es nicht mehr: Die Lupe des Suchknopfes lag danach mitten auf + dem Sicht-Umschalter -- ein Knopf, der auf zwoelf Seiten ins Leere + tippt. Gemeldet von pruef-handy, bevor es jemand gesehen hat. + + WARUM UMBRECHEN und nicht weiter schrumpfen: Jedes Stueck hier hat + seine Mindestgroesse aus einem guten Grund -- 104 px, damit der + Umschalter mit dem Daumen zu treffen ist, und die Glocke ist bereits + auf das blosse Zeichen zusammengefallen. Wer hier weiter kuerzt, + tauscht ein sichtbares Problem gegen ein unsichtbares: Knoepfe, die + zwar nebeneinander liegen, aber zu klein zum Treffen sind. + + Eine zweite Zeile auf einem 320-Pixel-Schirm kostet 44 Pixel Hoehe. + Das ist der guenstigste Preis, der hier zu haben ist -- und er faellt + nur auf Geraeten an, die wirklich so schmal sind. */ +@media (max-width: 380px) { + .kopfleiste { flex-wrap: wrap; row-gap: 8px; } + /* Die Marke nimmt die erste Zeile ganz ein, die Bedienelemente + ruecken geschlossen in die zweite. Ohne das brechen sie mitten + zwischen Suche und Glocke um. */ + .kopfleiste > .marke { flex: 1 0 100%; } + .kopfleiste__rechts { + width: 100%; justify-content: flex-end; + /* AUCH DIE ZWEITE ZEILE DARF UMBRECHEN. Ohne das reichten die + 294 Pixel weiterhin nicht: Umschalter (104) + Suche (47) + + Glocke (37) + Plakette + Abmelden sind zusammen mehr. Die + Elemente wurden dann zwar nicht mehr ueberdeckt, aber der + Umschalter ragte aus seinem eigenen Kasten heraus -- ein + stilles Ueberlaufen, das keine Pruefung meldet und trotzdem + falsch aussieht. */ + flex-wrap: wrap; row-gap: 6px; + } + /* Und der Umschalter behaelt seine Breite, statt gequetscht zu + werden: Sein Inhalt kann ohnehin nicht unter 104 px, also waere + jedes Schrumpfen nur ein Ueberlauf mit anderem Namen. */ + .sicht { flex: 0 0 auto; } +} @media (forced-colors: active) { body[data-fremde-sicht="ja"]::after { border-color: Highlight; } } @@ -3012,3 +3054,100 @@ body[data-fremde-sicht="ja"]::after { @media (forced-colors: active) { .kachel::after { display: none; } } + +/* ---------- Der Benachrichtigungs-Knopf ------------------------------ + Wunsch Filipe (05.09.2026): ein Knopf wie "Benachrichtigungen aus", + auf jeder Seite erreichbar. + + Gebaut wie `.abmelden` daneben, damit die Kopfleiste eine Sprache + spricht -- und mit denselben Umbruchregeln: Auf schmalen Schirmen + bleibt nur die Glocke stehen, der Text weicht. Genau daran ist die + Kopfleiste am 31.08.2026 schon einmal gescheitert (214 px aus dem + Bild); deshalb hier von vornherein `min-width: 0` und ein Text, der + verschwinden darf. */ +.glocke { + display: inline-flex; align-items: center; gap: 7px; + min-width: 0; flex: none; + padding: 7px 13px; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); + border-radius: 999px; + color: var(--text-leise); + font: inherit; font-size: .82rem; + cursor: pointer; + transition: border-color var(--tempo), background var(--tempo), color var(--tempo); +} +.glocke:hover:not(:disabled) { background: rgba(255, 255, 255, .07); border-color: var(--rand-hell); } +.glocke:disabled { opacity: .55; cursor: not-allowed; } + +.glocke__zeichen { width: 17px; height: 17px; flex: none; } +.glocke__zeichen path { + fill: none; stroke: currentColor; stroke-width: 1.7; + stroke-linecap: round; stroke-linejoin: round; +} +.glocke__strich { opacity: 0; } + +/* AN: die Glocke bekommt Farbe, der Strich bleibt weg. */ +.glocke[data-lage="an"] { + color: var(--akzent); + border-color: color-mix(in srgb, var(--akzent) 42%, transparent); + background: color-mix(in srgb, var(--akzent) 10%, transparent); +} + +/* AUS: durchgestrichene Glocke -- man sieht auf einen Blick, dass + nichts kommt, ohne den Text zu lesen. */ +.glocke[data-lage="aus"] .glocke__strich { opacity: .85; } + +/* Die drei Faelle, in denen der Knopf nichts schalten kann, sind + gedaempft und tragen ebenfalls den Strich: Sie sind naeher an "aus" + als an "an", und genau so soll es aussehen. */ +.glocke[data-lage="iphone"], .glocke[data-lage="verboten"], +.glocke[data-lage="geht-nicht"] { + color: var(--text-still); +} +.glocke[data-lage="iphone"] .glocke__strich, +.glocke[data-lage="verboten"] .glocke__strich, +.glocke[data-lage="geht-nicht"] .glocke__strich { opacity: .7; } + +/* Schmale Schirme: nur die Glocke. Der Text steht weiterhin im + aria-label, Vorleseprogramme verlieren also nichts. + + 900 PIXEL, NICHT 760 (korrigiert 05.09.2026). Die erste Fassung + nahm 760 -- und ein Tablet hochkant hat 768. Dort stand der Text + also noch, die Glocke war rund 200 statt 37 Pixel breit, und der + Abmelden-Knopf wurde um bis zu 15 Pixel aus dem Bild geschoben. Auf + vier Seiten, gemeldet von pruef-breiten. + + Die Grenze liegt jetzt auf derselben Stufe wie die uebrigen Regeln + fuer die enge Kopfleiste (der Sicht-Umschalter wird ab 900 px + ebenfalls schmaler). Eine eigene Schwelle daneben waere genau die + Art Zahl, die beim naechsten Mal wieder knapp danebenliegt. */ +@media (max-width: 900px) { + .glocke { padding: 7px 9px; } + .glocke__text { display: none; } +} + +/* ERKLÄRSATZ UNTER EINER ÜBERSCHRIFT — der Grundwert. + + Gefunden am 05.09.2026 von pruef-css-klassen.mjs, beim allerersten + Lauf. `.block__frage` war viermal fast gleich definiert -- in + automation.css, calls.css, report.css und wissen.css -- und wurde + auf content.html benutzt, das keine dieser vier Dateien lädt. Der + Absatz stand dort seit jeher unformatiert. + + Genau dasselbe Muster wie bei der Teilnehmerwahl am selben Tag: + Das CSS existiert, es sieht im Editor richtig aus, und auf der Seite + kommt nichts an. Solange es nur in Seiten-Stylesheets steht, findet + es niemand. + + Hier steht deshalb der Grundwert, den jede Seite bekommt. Die vier + Seiten-Stylesheets werden NACH start.css geladen und behalten damit + ihre Feinheiten (12, 13 oder 14 Pixel Abstand) -- sie bleiben + unverändert. Neu ist nur, dass eine Seite ohne eigene Fassung nicht + mehr leer ausgeht. */ +.block__frage { + margin: 0 0 13px; + font-size: .85rem; + color: var(--text-still); + max-width: 76ch; +} diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index 03f5c39d..1a665020 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -31,6 +31,17 @@ const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' }; const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' }; + /* Wer darf abbrechen (05.09.2026): *"nur ich die manager und scouts + sollen auch die aufgaben abbrechen können."* Ein Creator nicht. + + Dieselbe Menge steht im Server (DARF_ABBRECHEN in + workspace-aufgaben.js) und DORT sitzt die Sperre. Hier geht es nur + darum, niemandem einen Knopf hinzustellen, der dann 403 sagt. */ + const DARF_ABBRECHEN = new Set(['admin', 'manager', 'scout']); + const STATUS_NAME = { + offen: 'Offen', arbeit: 'In Arbeit', review: 'Review', erledigt: 'Erledigt', + }; + const $ = (id) => document.getElementById(id); /* ORTSZEIT, nicht UTC -- sonst gilt nachts eine heute faellige Aufgabe noch nicht als faellig, und "Heute faellig" zeigt den Vortag. */ @@ -181,6 +192,20 @@ const schritte = el('div', 'karte__schritte'); if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status])); if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status])); + /* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten: + Auch etwas Abgehaktes kann sich als hinfällig herausstellen. + + Nur DogFather, Manager und Scouts. Ein Creator sieht den Knopf + gar nicht erst -- die eigentliche Sperre sitzt aber im Server, + hier geht es nur darum, niemandem etwas anzubieten, das dann + abgelehnt wird. */ + if (DARF_ABBRECHEN.has(ich.rolle)) { + const ab = el('button', 'schritt schritt--ab', 'abbrechen'); + ab.type = 'button'; + ab.setAttribute('aria-label', `„${a.titel}" abbrechen`); + ab.addEventListener('click', () => abbruchOeffnen(a)); + schritte.append(ab); + } if (schritte.childElementCount) k.append(schritte); /* Rückmeldungen ("Aufgaben & Feedback" aus dem Konzept). Der Knopf @@ -202,6 +227,127 @@ return k; } + /* ---------- Abbrechen ---------------------------------------------------- + + Wunsch 05.09.2026: *"ich will dass man in jedem status die aufgaben + auch abbrechen kann."* + + MIT PFLICHT-GRUND. Ohne ihn weiss in vier Wochen niemand mehr, + warum etwas wegfiel -- und dann ist ein Abbruch von einem Loeschen + nicht mehr zu unterscheiden. Der Grund steht spaeter an der + Aufgabe, zusammen mit dem Namen dessen, der abgebrochen hat. + + Ein und kein confirm(): confirm blockiert die ganze Seite, + nimmt kein Textfeld auf und sieht auf jedem Rechner anders aus. */ + + function abbruchOeffnen(a) { + const dlg = $('abbruch-dialog'); + if (!dlg) return; + dlg.dataset.id = String(a.id); + $('abbruch-titel').textContent = `„${a.titel}" abbrechen?`; + /* Sagt, was gleich passiert -- und was NICHT passiert. Die Sorge + bei so einem Knopf ist immer, ob etwas verloren geht. */ + $('abbruch-was').textContent = `Die Aufgabe steht danach unter „Abgebrochen" ` + + `statt in der Spalte ${STATUS_NAME[a.status] || a.status}. ` + + `Sie wird nicht gelöscht und lässt sich jederzeit wieder aufnehmen.`; + $('abbruch-grund').value = ''; + $('abbruch-fehler').textContent = ''; + dlg.showModal(); + $('abbruch-grund').focus(); + } + + async function abbruchSenden(e) { + e.preventDefault(); + const dlg = $('abbruch-dialog'); + const id = Number(dlg.dataset.id); + const grund = $('abbruch-grund').value.trim(); + if (grund.length < 3) { + $('abbruch-fehler').textContent = 'Bitte kurz sagen, warum.'; + $('abbruch-grund').focus(); + return; + } + const knopf = $('abbruch-ok'); + knopf.disabled = true; + try { + const antwort = await hole(`/workspace/api/aufgaben/${id}/abbrechen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grund }), + }); + if (!antwort.ok) { + const d = await antwort.json().catch(() => ({})); + $('abbruch-fehler').textContent = d.fehler || 'Hat nicht geklappt.'; + return; + } + dlg.close(); + await laden(); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + } + + async function wiederAufnehmen(a, b) { + b.disabled = true; + melde(''); + try { + const antwort = await hole(`/workspace/api/aufgaben/${a.id}/wiederaufnehmen`, + { method: 'POST' }); + if (!antwort.ok) { + const d = await antwort.json().catch(() => ({})); + melde(d.fehler || 'Hat nicht geklappt.'); + b.disabled = false; + return; + } + await laden(); + } catch { b.disabled = false; } + } + + /** Der Bereich UNTER dem Brett -- zugeklappt, mit Anzahl im Titel. + * + * Bewusst keine fuenfte Spalte: Am Handy werden dadurch alle fuenf + * so schmal, dass keine mehr lesbar ist. Und abgebrochene Aufgaben + * sind nichts, was man taeglich sieht -- aber sie duerfen auch nicht + * verschwinden, sonst waere es Loeschen mit Zwischenschritt. */ + function abgebrocheneZeigen(alle) { + const kasten = $('abgebrochen'); + if (!kasten) return; + const eigene = alle.filter((a) => a.status === 'abgebrochen'); + + if (!eigene.length) { kasten.hidden = true; return; } + kasten.hidden = false; + + $('abgebrochen-zahl').textContent = String(eigene.length); + const liste = $('abgebrochen-liste'); + liste.textContent = ''; + + for (const a of eigene) { + const k = el('article', 'abbruch-karte'); + const kopf = el('div', 'abbruch-karte__kopf'); + kopf.append(el('h3', 'abbruch-karte__titel', a.titel)); + /* Wo es stand, als es abgebrochen wurde. "Im Review abgebrochen" + ist eine andere Aussage als "nie angefangen". */ + if (a.status_vorher) { + kopf.append(el('span', 'abbruch-karte__vorher', + STATUS_NAME[a.status_vorher] || a.status_vorher)); + } + k.append(kopf); + + if (a.abbruch_grund) k.append(el('p', 'abbruch-karte__grund', a.abbruch_grund)); + + const wer = []; + if (a.abbruch_von_name) wer.push(a.abbruch_von_name); + if (a.abgebrochen_am) wer.push(datumHuebsch(a.abgebrochen_am.slice(0, 10))); + if (wer.length) k.append(el('p', 'abbruch-karte__wer', wer.join(' · '))); + + if (DARF_ABBRECHEN.has(ich.rolle)) { + const zurueck = el('button', 'schritt', 'wieder aufnehmen ↩'); + zurueck.type = 'button'; + zurueck.setAttribute('aria-label', `„${a.titel}" wieder aufnehmen`); + zurueck.addEventListener('click', () => wiederAufnehmen(a, zurueck)); + k.append(zurueck); + } + liste.append(k); + } + } + const beschriftung = (status) => ({ offen: 'starten ▶', arbeit: 'zum Review ▶', review: 'erledigt ▶', })[status]; @@ -262,7 +408,11 @@ let nurCreator = 0; /* 0 = alle */ let alleAufgaben = []; - const istOffen = (a) => a.status !== 'erledigt'; + /* Abgebrochenes zählt NICHT als offen (05.09.2026). Ohne diese + Ergänzung stünde eine abgebrochene Aufgabe weiter in "3 Aufgaben + stehen an" und in "Überfällig" -- und würde als etwas gemahnt, das + ausdrücklich niemand mehr tun soll. */ + const istOffen = (a) => a.status !== 'erledigt' && a.status !== 'abgebrochen'; const istFaellig = (a) => istOffen(a) && a.frist && a.frist < heute; const istHeute = (a) => istOffen(a) && a.frist === heute; const istMeine = (a) => a.verantwortlich_id === ich.id; @@ -395,6 +545,13 @@ ausschnitteBauen(alleAufgaben); creatorReiheBauen(alleAufgaben); zeichne(alleAufgaben.filter(passt)); + /* Der Bereich folgt dem Creator-Filter mit -- wer sich eine Person + herausgreift, will auch nur deren Abgebrochenes sehen. Die + Ausschnitte (heute/überfällig) gelten dagegen nicht: Sie fragen + nach Fristen, und eine abgebrochene Aufgabe hat keine mehr. */ + abgebrocheneZeigen(nurCreator + ? alleAufgaben.filter((a) => a.creator_id === nurCreator) + : alleAufgaben); } function zeichne(aufgaben) { @@ -543,6 +700,12 @@ $('b-abbrechen').addEventListener('click', () => dialog.close()); + /* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf: + So löst auch Enter im Textfeld aus, und Esc schließt von selbst + (beides bringt mit, wenn man es lässt). */ + $('abbruch-form').addEventListener('submit', abbruchSenden); + $('abbruch-zurueck').addEventListener('click', () => $('abbruch-dialog').close()); + $('b-speichern').addEventListener('click', async () => { const daten = { titel: $('b-titel').value, diff --git a/workspace/assets/js/glocke.js b/workspace/assets/js/glocke.js new file mode 100644 index 00000000..92c372c9 --- /dev/null +++ b/workspace/assets/js/glocke.js @@ -0,0 +1,234 @@ +/* ===================================================================== + Der Benachrichtigungs-Knopf — auf JEDER Seite, in der Kopfleiste. + + Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen + aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert + auf der seite fuer jeden."* + + --------------------------------------------------------------------- + WARUM IN DER KOPFLEISTE UND NICHT AUF EINER EINSTELLUNGSSEITE + + Dasselbe Argument wie bei der Suche, die hier schon so gebaut ist: + Was man nur auf einer Extraseite findet, benutzt niemand. Der Knopf + sagt jederzeit, woran man ist -- Glocke an oder durchgestrichen. + + --------------------------------------------------------------------- + DER KNOPF SAGT IMMER DIE WAHRHEIT, auch wenn sie unbequem ist + + Es gibt mehr Zustaende als "an" und "aus", und jeder braucht einen + anderen Satz. Ein Knopf, der bei einem abgelehnten Recht einfach + nichts tut, ist der sichere Weg zu "das funktioniert nicht": + + geht nicht Der Browser kann kein Push (aeltere Fassungen). + iPhone Safari kann es NUR nach "Zum Home-Bildschirm". + Im Reiter gibt es die Berechtigung ueberhaupt nicht. + verboten Schon einmal abgelehnt. Das laesst sich per + JavaScript NICHT zuruecknehmen -- man muss es in den + Browsereinstellungen tun. Also wird genau das gesagt. + aus / an der Normalfall. + ===================================================================== */ +(() => { + 'use strict'; + + /* Nur im Workspace, und nicht auf der Anmeldeseite -- dort ist + niemand angemeldet, den man benachrichtigen koennte. */ + if (!location.pathname.startsWith('/workspace/')) return; + if (/\/workspace\/(index\.html)?$/.test(location.pathname)) return; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + const kannPush = 'serviceWorker' in navigator && 'PushManager' in window + && 'Notification' in window; + + /* Laeuft die Seite als installierte App? Auf dem iPhone ist das die + Bedingung dafuer, dass es ueberhaupt geht. */ + const alsApp = window.matchMedia('(display-mode: standalone)').matches + || window.navigator.standalone === true; + const istApple = /iPad|iPhone|iPod/.test(navigator.userAgent) + || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + + let anmeldung = null; + let knopf = null; + + const b64uZuBytes = (s) => { + const roh = atob(String(s).replace(/-/g, '+').replace(/_/g, '/') + + '='.repeat((4 - String(s).length % 4) % 4)); + return Uint8Array.from(roh, (c) => c.charCodeAt(0)); + }; + + const bytesZuB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + /** Ein Name, an dem man sein Geraet in der Liste wiedererkennt. */ + function geraetName() { + const u = navigator.userAgent; + const os = /Windows/.test(u) ? 'Windows' + : /Android/.test(u) ? 'Android' + : istApple ? (/iPad/.test(u) ? 'iPad' : 'iPhone') + : /Mac/.test(u) ? 'Mac' : /Linux/.test(u) ? 'Linux' : 'Gerät'; + const browser = /Edg\//.test(u) ? 'Edge' + : /Chrome\//.test(u) ? 'Chrome' + : /Firefox\//.test(u) ? 'Firefox' + : /Safari\//.test(u) ? 'Safari' : 'Browser'; + return `${os} · ${browser}${alsApp ? ' (App)' : ''}`; + } + + /** Was ist gerade der Fall? */ + function lage() { + if (!kannPush) return 'geht-nicht'; + if (istApple && !alsApp) return 'iphone'; + if (Notification.permission === 'denied') return 'verboten'; + return anmeldung ? 'an' : 'aus'; + } + + const TEXTE = { + 'geht-nicht': { text: 'Hier nicht möglich', titel: 'Dieser Browser kann keine Benachrichtigungen.' }, + iphone: { + text: 'Erst zum Home-Bildschirm', + titel: 'Auf dem iPhone gehen Benachrichtigungen nur, wenn die Seite ' + + 'zum Home-Bildschirm hinzugefügt wurde: Teilen-Knopf → „Zum Home-Bildschirm“. ' + + 'Danach hier noch einmal antippen.', + }, + verboten: { + text: 'Vom Browser blockiert', + titel: 'Du hast Benachrichtigungen für diese Seite einmal abgelehnt. Das lässt sich ' + + 'nur in den Browsereinstellungen wieder erlauben — bei Chrome über das Symbol ' + + 'links in der Adresszeile.', + }, + aus: { text: 'Benachrichtigungen aus', titel: 'Antippen, um Erinnerungen zu bekommen.' }, + an: { text: 'Benachrichtigungen an', titel: 'Antippen, um sie auf diesem Gerät wieder abzuschalten.' }, + }; + + function zeichnen() { + if (!knopf) return; + const l = lage(); + const t = TEXTE[l]; + knopf.dataset.lage = l; + knopf.title = t.titel; + knopf.setAttribute('aria-label', t.titel); + knopf.querySelector('.glocke__text').textContent = t.text; + /* aria-pressed sagt einem Vorleseprogramm, dass es ein Schalter + ist und wie er gerade steht. */ + knopf.setAttribute('aria-pressed', l === 'an' ? 'true' : 'false'); + knopf.disabled = l === 'geht-nicht'; + } + + async function anmelden() { + const recht = await Notification.requestPermission(); + if (recht !== 'granted') { zeichnen(); return; } + + const reg = await navigator.serviceWorker.register('/workspace/sw.js', { scope: '/workspace/' }); + await navigator.serviceWorker.ready; + + const a = await fetch('/workspace/api/push/schluessel', { credentials: 'same-origin' }); + const { schluessel } = await a.json(); + + const abo = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: b64uZuBytes(schluessel), + }); + + const roh = abo.toJSON(); + await fetch('/workspace/api/push/anmelden', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpunkt: abo.endpoint, + p256dh: roh.keys?.p256dh || bytesZuB64u(abo.getKey('p256dh')), + auth: roh.keys?.auth || bytesZuB64u(abo.getKey('auth')), + geraet: geraetName(), + }), + }); + + anmeldung = abo; + zeichnen(); + + /* Sofort eine Probe. Das ist der Unterschied zwischen "sollte + gehen" und "geht": Wer den Knopf drueckt, will WISSEN, dass es + ankommt -- und nicht bis morgen warten. */ + await fetch('/workspace/api/push/probe', { + method: 'POST', credentials: 'same-origin', + }).catch(() => {}); + } + + async function abmelden() { + try { + if (anmeldung) { + await fetch('/workspace/api/push/abmelden', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpunkt: anmeldung.endpoint }), + }); + await anmeldung.unsubscribe(); + } + } catch { /* der Servereintrag ist weg, das genuegt */ } + anmeldung = null; + zeichnen(); + } + + async function bauen() { + const kopf = document.querySelector('.kopfleiste__rechts'); + if (!kopf || document.getElementById('glocke')) return; + + knopf = el('button', 'glocke'); + knopf.type = 'button'; + knopf.id = 'glocke'; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'glocke__zeichen'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.innerHTML = '' + /* "kloeppel" ohne Umlaut: Ein ö in einem Klassennamen ist erlaubt, + aber es überlebt nicht jede Datei, die zwischendurch in einer + anderen Kodierung gespeichert wird -- und im ganzen Haus wird + ohnehin "oe"/"ue" geschrieben. */ + + '' + + ''; + knopf.append(svg); + knopf.append(el('span', 'glocke__text', '…')); + + /* VOR dem Abmelden-Knopf, nicht dahinter: Abmelden ist die letzte + Handlung auf einer Seite und bleibt ganz rechts. */ + const abmeldenKnopf = kopf.querySelector('.abmelden'); + if (abmeldenKnopf) kopf.insertBefore(knopf, abmeldenKnopf); + else kopf.append(knopf); + + knopf.addEventListener('click', async () => { + const l = lage(); + if (l === 'geht-nicht') return; + /* Bei 'iphone' und 'verboten' nichts tun ausser erklaeren -- der + Titel steht schon dran, und ein Klick, der nichts bewirkt und + nichts sagt, wirkt kaputt. */ + if (l === 'iphone' || l === 'verboten') { window.alert(TEXTE[l].titel); return; } + knopf.disabled = true; + try { + if (l === 'an') await abmelden(); else await anmelden(); + } catch (f) { + console.warn('[glocke]', f); + zeichnen(); + } + knopf.disabled = false; + }); + + /* Steht hier schon eine Anmeldung? */ + if (kannPush && Notification.permission === 'granted') { + try { + const reg = await navigator.serviceWorker.getRegistration('/workspace/'); + anmeldung = reg ? await reg.pushManager.getSubscription() : null; + } catch { anmeldung = null; } + } + zeichnen(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bauen); + } else bauen(); +})(); diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index e4bf8f94..ef3f36cd 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -83,9 +83,37 @@ let alleLeute = []; const ROLLENKURZ = { admin: 'DogFather', manager: 'Manager', scout: 'Scout', creator: 'Creator' }; + /* Mehrzahl für die Gruppenüberschriften. "Scout" über vier Namen + liest sich falsch -- über der Gruppe steht, was die Leute SIND. */ + const ROLLENGRUPPE = { + admin: 'DogFather', manager: 'Manager', scout: 'Scouts', creator: 'Creator', + }; + const ROLLENFOLGE = ['admin', 'manager', 'scout', 'creator']; - /** Baut die Schalterreihe. `gewaehlt` sind die Nummern, die an - * sein sollen -- beim Bearbeiten die des Termins, sonst nichts. */ + /** Ein bis zwei Buchstaben, die als Zeichen im Kreis stehen. + * "Filipe Fernandes Ribeiro" -> "FR", "Luna" -> "L". + * Absichtlich Anfang UND Ende: Bei "Patrick von Hohenstein" wäre + * "PV" nichtssagend, "PH" nicht. */ + function initialen(name) { + const teile = String(name || '').trim().split(/\s+/).filter(Boolean); + if (!teile.length) return '?'; + /* [...] statt [0]: Ein Name kann mit einem Zeichen beginnen, das + aus zwei Code-Einheiten besteht (Emoji, manche Schriften). + name[0] würde davon die Hälfte nehmen und Bruch anzeigen. */ + const erst = [...teile[0]][0] || ''; + const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : ''; + return (erst + letzt).toLocaleUpperCase('de-DE'); + } + + /** Baut die Wahl -- nach Rollen gruppiert. + * + * `gewaehlt` sind die Nummern, die an sein sollen: beim Bearbeiten + * die des Termins, sonst nichts. + * + * WARUM GRUPPIERT: Vorher stand die Rolle an JEDEM Namen, neunmal + * hintereinander, als eine lange graue Reihe. Jetzt trägt die + * Überschrift das Wort einmal, und man springt zur Gruppe statt zu + * suchen. */ function baueDabei(gewaehlt = []) { const ziel = $('dabei-wahl'); if (!ziel) return; @@ -95,29 +123,76 @@ $('dabei-hinweis').textContent = ''; return; } - for (const p of alleLeute) { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'wahl__knopf'; - b.dataset.id = String(p.id); - b.dataset.rolle = p.rolle; - if (an.has(p.id)) b.dataset.an = 'ja'; - const n = document.createElement('span'); n.className = 'wahl__name'; n.textContent = p.name; - const r = document.createElement('span'); r.className = 'wahl__rolle'; - r.textContent = ROLLENKURZ[p.rolle] || p.rolle; - b.append(n, r); - b.addEventListener('click', () => { - if (b.dataset.an === 'ja') delete b.dataset.an; else b.dataset.an = 'ja'; - dabeiHinweis(); - }); - ziel.append(b); + + for (const rolle of ROLLENFOLGE) { + const leute = alleLeute.filter((p) => p.rolle === rolle); + if (!leute.length) continue; // leere Gruppe gar nicht erst zeigen + + const gruppe = document.createElement('div'); + gruppe.className = 'k-dabei__gruppe'; + gruppe.dataset.rolle = rolle; + + const titel = document.createElement('p'); + titel.className = 'k-dabei__titel'; + const punkt = document.createElement('span'); + punkt.className = 'k-dabei__punkt'; + punkt.setAttribute('aria-hidden', 'true'); + titel.append(punkt, document.createTextNode(ROLLENGRUPPE[rolle] || rolle)); + + const liste = document.createElement('div'); + liste.className = 'k-dabei__liste'; + /* Die Gruppe ist eine Gruppe von Schaltern -- ein Vorleseprogramm + soll die Überschrift dazu ansagen, sonst hört man neun Namen + ohne zu wissen, wovon. */ + liste.setAttribute('role', 'group'); + liste.setAttribute('aria-label', ROLLENGRUPPE[rolle] || rolle); + + for (const p of leute) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'k-dabei__knopf'; + b.dataset.id = String(p.id); + b.dataset.rolle = p.rolle; + if (an.has(p.id)) b.dataset.an = 'ja'; + + const z = document.createElement('span'); + z.className = 'k-dabei__zeichen'; + z.setAttribute('aria-hidden', 'true'); // der Name steht direkt daneben + z.textContent = initialen(p.name); + + const n = document.createElement('span'); + n.className = 'k-dabei__name'; + n.textContent = p.name; + + const haken = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + haken.setAttribute('class', 'k-dabei__haken'); + haken.setAttribute('viewBox', '0 0 16 16'); + haken.setAttribute('aria-hidden', 'true'); + haken.innerHTML = ''; + + b.append(z, n, haken); + /* aria-pressed sagt einem Vorleseprogramm, dass das ein + Schalter ist und wie er steht -- ohne das hört man nur + "Knopf, Cigdem" und weiß nie, ob sie dabei ist. */ + b.setAttribute('aria-pressed', an.has(p.id) ? 'true' : 'false'); + b.addEventListener('click', () => { + const jetztAn = b.dataset.an !== 'ja'; + if (jetztAn) b.dataset.an = 'ja'; else delete b.dataset.an; + b.setAttribute('aria-pressed', jetztAn ? 'true' : 'false'); + dabeiHinweis(); + }); + liste.append(b); + } + + gruppe.append(titel, liste); + ziel.append(gruppe); } dabeiHinweis(); } /** Die Nummern, die gerade angehakt sind. */ const dabeiGewaehlt = () => - [...$('dabei-wahl').querySelectorAll('.wahl__knopf[data-an="ja"]')].map((b) => Number(b.dataset.id)); + [...$('dabei-wahl').querySelectorAll('.k-dabei__knopf[data-an="ja"]')].map((b) => Number(b.dataset.id)); function dabeiHinweis() { const n = dabeiGewaehlt().length; @@ -1178,10 +1253,16 @@ /* Ein frei eingetragener Name statt einer Person. Der Server raeumt dann creator_id ab -- siehe workspace-kalender.js. */ koerper.teilnehmer_extern = $('f-teilnehmer').dataset.extern || null; - /* Wer sonst noch dabei ist. Das Haupt-Gegenueber muss hier NICHT - mitgeschickt werden -- der Server legt es ohnehin dazu. */ - koerper.teilnehmer = dabeiGewaehlt(); } + /* Wer sonst noch dabei ist -- fuer JEDE Rolle, nicht nur die + Leitung. Stand vorher im Block darueber und wurde damit bei + Scouts und Creators nie mitgeschickt. + + Das Haupt-Gegenueber gehoert hier NICHT hinein: Der Server legt + es ohnehin dazu (teilnehmerSetzen mit hauptId), und zweimal + dieselbe Nummer zu schicken hiesse, sich auf die Reihenfolge zu + verlassen. */ + if (!$('feld-dabei').hidden) koerper.teilnehmer = dabeiGewaehlt(); /* Mit Rhythmus entsteht keine einzelne Zeile, sondern eine Regel -- der Server legt daraus die Termine an, den ersten eingeschlossen. Deshalb wird hier nichts zusätzlich abgeschickt: Sonst stünde der @@ -1226,9 +1307,31 @@ } catch { return; } window.werZeigen?.(ich); - if (LEITUNG.has(ich.rolle)) { + /* WER DARF WEN MARKIEREN — und wer entscheidet das. + + "Mit wem" (die Haupt-Zuordnung) bleibt der Leitung vorbehalten: + Das verschiebt einen Termin in einen fremden Kalender als dessen + Eigentümer, und das ist eine Zuordnung, keine Einladung. + + "Wer ist noch dabei?" bekommt dagegen JEDE Rolle (05.09.2026: + *"das soll für jeden verfügbar sein"*). Sicher ist das, weil + nicht diese Zeile über die Auswahl entscheidet, sondern der + Server an zwei Stellen: + * /api/personen liefert über sichtbarePersonenIds nur die + zugeteilten Namen -- ein Creator sieht seine Betreuer, keine + fremden Creator. + * teilnehmerSetzen() prüft jede Nummer noch einmal gegen + darfEintragen() und lässt Unerlaubtes still weg. Wer die + Anfrage von Hand baut, kommt damit keinen Schritt weiter. + Die Oberfläche zu verstecken war also nie die Sicherung -- sie + hat nur denen etwas weggenommen, die es brauchen. */ + const darfZuordnen = LEITUNG.has(ich.rolle); + + /* "Mit wem" -- die Haupt-Zuordnung. Nur Leitung: Sie macht die + andere Person zum Gegenüber des Termins, das ist eine Zuordnung + und keine Einladung. Liste: /api/personen (zeigt nach unten). */ + if (darfZuordnen) { $('feld-teilnehmer').hidden = false; - $('feld-dabei').hidden = false; try { const { personen } = await (await hole('/workspace/api/personen')).json(); for (const p of personen) { @@ -1237,12 +1340,26 @@ o.textContent = p.name + ' (' + p.rolle + ')'; $('f-teilnehmer').append(o); } - alleLeute = personen; - baueDabei(); } catch { /* Zuordnung bleibt leer */ } window.personenwahl?.('f-teilnehmer', { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag." }); } + /* "Wer ist noch dabei?" -- für jede Rolle. Eigene Liste über + /api/einladbar, die auch nach oben zeigt: Ein Creator findet + dort seinen Scout, ein Scout seinen Manager. */ + try { + const { personen } = await (await hole('/workspace/api/einladbar')).json(); + /* Sich selbst braucht niemand zu markieren -- der eigene Termin + steht ohnehin im eigenen Kalender. Ein Knopf, der nichts + bewirkt, verwirrt nur. */ + alleLeute = (personen || []).filter((p) => p.id !== ich.id); + /* Bleibt niemand übrig (jemand ganz ohne Zuordnung), hat das + Feld keinen Zweck -- ein leerer Kasten mit Überschrift sieht + aus wie ein Fehler. Sonst: zeigen. */ + $('feld-dabei').hidden = !alleLeute.length; + baueDabei(); + } catch { $('feld-dabei').hidden = true; } + /* Vom Bericht kommend: VOR dem ersten Laden auf die Liste stellen, sonst baut die Seite erst den Monat auf und wirft ihn gleich wieder weg. Der Ansichtsknopf wird mitgesetzt -- sonst zeigt die diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index c14ffa65..12fbd347 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -8,9 +8,9 @@ - - - + + + @@ -104,6 +104,32 @@

Aufgaben werden geladen …

+ + + + +
+

Aufgabe abbrechen?

+ +

+ +
+ + +
+ + + +
+ + +
+
+
+ + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index e327fe47..b2fabb40 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -133,9 +133,10 @@ - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index a1715bac..eeb982b5 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -116,13 +116,14 @@ - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 66197f0f..8448afbe 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -75,10 +75,11 @@ - - - - - + + + + + + diff --git a/workspace/content.html b/workspace/content.html index f45cfe11..c11f3691 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -165,11 +165,12 @@ - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index c397b9f9..d8263d4a 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -94,9 +94,10 @@ - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index da8df680..9ca88b1b 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -26,7 +26,7 @@ - + @@ -144,6 +144,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 26224650..670d1019 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -8,10 +8,10 @@ - - - - + + + + @@ -112,14 +112,23 @@ Einzelne Schalter statt eines