diff --git a/server/pruef-modi-verborgen.mjs b/server/pruef-modi-verborgen.mjs new file mode 100644 index 00000000..5ec2ced9 --- /dev/null +++ b/server/pruef-modi-verborgen.mjs @@ -0,0 +1,326 @@ +/* ===================================================================== + DER VERBORGENE MODI-ZUGANG + + Zwei Fragen, und sie sind nicht dieselbe: + + 1. KOMMT EIN MODI HEREIN, ohne dass es dafuer eine Kachel gibt? + 2. MERKT SONST JEMAND ETWAS DAVON? + + Die zweite ist die schwierigere. Eine Liste zu filtern ist leicht; + uebersehen wird die ZAHL daneben, die weiter alles mitzaehlt -- und + eine Personenzahl, die nicht zur Liste passt, verraet genauso viel + wie ein Name. + + MIT GEGENPROBE. Ganz unten wird die Regel absichtlich ausgehebelt + (ein Modi bekommt in der Datenbank eine andere Rolle), und dann MUSS + der Manager ihn sehen. Ohne diesen Teil waere nicht bewiesen, dass + die Pruefung ueberhaupt anschlagen kann -- eine Pruefung, die immer + bestaetigt, bestaetigt nichts. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-modi-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4359"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +/* NACH index.js: Der Server haengt dort zwei Auffangnetze ein, die + einen Absturz nur protokollieren. Fuer den Betrieb richtig, fuer eine + Pruefung fatal -- ohne diese Zeile bliebe der Prozess ewig stehen. */ +notbremse(180_000, "pruef-modi-verborgen"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4359"; + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +/* Der dritte Ausgang: Konnte nicht nachgesehen werden. Kein Befund, + aber auch keine Bestaetigung -- und es wird gesagt, damit niemand + glaubt, hier sei etwas geprueft worden. */ +let offen = 0; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +/* ---------- Personen anlegen ---------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +/* Der Suchschluessel muss VOR dem Anlegen dastehen -- die Personen + werden hier von Hand eingetragen, nicht ueber personAnlegen(). */ +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); +const kennung = (code) => createHmac("sha256", schluessel).update(code).digest("hex"); + +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, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, kennung(code), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idVan = anlegen("VanVan", "admin", "CODE-VAN-0001"); +const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001"); +const idMan = anlegen("Schulle", "manager", "CODE-MAN-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idModi1 = anlegen("Marina", "modi", "CODE-MODI-0001"); +const idModi2 = anlegen("Kim", "modi", "CODE-MODI-0002"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") + .run(idCreat, idScout, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)") + .run(idScout, idMan, jetzt); +d.close(); + +const MODIS = [idModi1, idModi2]; +void idSpicy; + +/* ---------- Anmelden ueber die Schnittstelle ------------------------- */ +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 daten = await a.json().catch(() => ({})); + const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + return { status: a.status, daten, keks }; +} + +console.log("\n=== 1. Der Eingang: eine Kachel gibt es nicht ===\n"); + +for (const kachel of ["creator", "scout", "manager", "spicy", "admin"]) { + const a = await anmelden(kachel, "CODE-MODI-0001"); + ok(a.status === 200 && a.daten?.rolle === "modi", + `Kachel "${kachel}" + Modi-Code -> angemeldet als ${a.daten?.rolle || a.status}`); +} + +/* Der Umkehrschluss, und er ist der wichtigere: Der stille Weg darf + NICHT dazu fuehren, dass auch alle anderen die Kachel ignorieren + duerfen. Ein Manager mit der Kachel "creator" muss weiterhin + scheitern -- sonst haette ich nebenbei die Rollenpruefung + abgeschafft, ohne es zu merken. */ +{ + const a = await anmelden("creator", "CODE-MAN-0001"); + ok(a.status === 401, `Manager-Code auf der Creator-Kachel bleibt abgewiesen (${a.status})`); +} + +/* Wortgleichheit: "modi" von aussen anzugeben darf nicht anders klingen + als eine erfundene Rolle. */ +{ + const m = await anmelden("modi", "FALSCH-FALSCH-1"); + const x = await anmelden("gibtesnicht", "FALSCH-FALSCH-1"); + ok(m.status === 401 && x.status === 401 + && JSON.stringify(m.daten) === JSON.stringify(x.daten), + "Rolle \"modi\" von aussen klingt wie eine erfundene " + + `(${m.status} ${JSON.stringify(m.daten)} vs. ${x.status} ${JSON.stringify(x.daten)})`); +} + +{ + const a = await anmelden("creator", "CODE-MODI-FALSCH"); + ok(a.status === 401, `ein falscher Modi-Code wird abgewiesen (${a.status})`); +} + +/* ---------- 2. Die Unsichtbarkeit ------------------------------------ */ +console.log("\n=== 2. Wer sieht die Modis? ===\n"); + +/* Jede Schnittstelle, die Personen ausgibt. Dieselbe Landkarte wie in + pruef-verborgen.mjs -- wer eine neue baut, traegt sie in BEIDE ein. */ +const WEGE = [ + ["Chat-Partner", "/workspace/api/chat/partner", (x) => x.personen], + ["Personenwahl", "/workspace/api/personen", (x) => x.personen], + ["Personenverwaltung", "/workspace/api/verwaltung/personen", (x) => x.personen], + ["Betreuer-Auswahl", "/workspace/api/verwaltung/personen", (x) => x.betreuer], + /* NACHGESEHEN, NICHT ANGENOMMEN: Die Segmente der Zentrale tragen + KEINE Nummer -- nur `{ name, rolle, stufe, heute, alt }`, und der + Name ist auf den Vornamen gekuerzt. Wer hier nur nach `id` sucht, + bekommt einen gruenen Haken, ohne etwas angesehen zu haben. Genau + deshalb vergleicht der Treffer unten BEIDES. */ + ["Zentrale (Team)", "/workspace/api/zentrale", (x) => x.segmente], +]; + +/* Die Namen der Modis -- fuer die Wege, die keine Nummern liefern. */ +const MODI_NAMEN = ["Marina", "Kim"]; + +const LEUTE = [ + ["DogFather", "admin", "CODE-DOGI-0001", true], + ["VanVan", "admin", "CODE-VAN-0001", true], + ["Spicy Media", "spicy", "CODE-SPIC-0001", false], + ["Manager", "manager", "CODE-MAN-0001", false], + ["Scout", "scout", "CODE-SCOU-0001", false], + ["Creator", "creator", "CODE-ANNA-0001", false], + ["Modi Marina", "creator", "CODE-MODI-0001", true], +]; + +async function hole(keks, pfad) { + const a = await fetch(BASIS + pfad, { headers: { cookie: keks } }); + if (!a.ok) return { status: a.status, daten: null }; + return { status: 200, daten: await a.json().catch(() => null) }; +} + +for (const [name, kachel, code, darf] of LEUTE) { + const an = await anmelden(kachel, code); + if (an.status !== 200) { unklar(`${name}: Anmeldung misslang (${an.status})`); continue; } + for (const [was, pfad, ziehen] of WEGE) { + const { status, daten } = await hole(an.keks, pfad); + if (status !== 200) { + unklar(`${name.padEnd(12)} ${was.padEnd(20)} kein Zugriff (${status})`); + continue; + } + let liste = []; + try { liste = ziehen(daten) || []; } catch { liste = []; } + const drin = liste.filter((z) => z + && (MODIS.includes(z.id ?? z.person_id) + || MODI_NAMEN.includes(String(z.name || "").trim().split(/\s+/)[0]))); + if (darf) { + ok(true, `${name.padEnd(12)} ${was.padEnd(20)} sieht ${drin.length} Modi(s)`); + } else { + ok(drin.length === 0, + `${name.padEnd(12)} ${was.padEnd(20)} ` + + (drin.length ? `SIEHT ${drin.length} MODI -- FEHLER` : "sieht keinen Modi")); + } + } +} + +/* ---------- 3. Die Zahl daneben -------------------------------------- */ +console.log("\n=== 3. Auch die ZAHL darf sie nicht mitzaehlen ===\n"); +{ + const an = await anmelden("manager", "CODE-MAN-0001"); + const { status, daten } = await hole(an.keks, "/workspace/api/personen"); + if (status !== 200 || !Array.isArray(daten?.personen)) { + unklar(`Personenwahl fuer den Manager nicht lesbar (${status})`); + } else { + const namen = daten.personen.map((z) => z.name); + /* Die Zahl der geprueften Dinge gehoert in die BEDINGUNG, nicht nur + in den Meldetext: Eine leere Liste wuerde sonst als "keine Modis + drin" durchgehen, und die Pruefung waere wertlos. */ + ok(namen.length > 0 && !namen.includes("Marina") && !namen.includes("Kim"), + `der Manager sieht ${namen.length} Personen, keine davon ein Modi ` + + `(${namen.join(", ")})`); + } +} + +/* ---------- 4. Sehen die Modis einander? ----------------------------- */ +console.log("\n=== 4. Die Modis sind untereinander ein Team ===\n"); +{ + const an = await anmelden("creator", "CODE-MODI-0001"); + const { status, daten } = await hole(an.keks, "/workspace/api/personen"); + if (status !== 200 || !Array.isArray(daten?.personen)) { + unklar(`Personenwahl fuer den Modi nicht lesbar (${status})`); + } else { + const ids = daten.personen.map((z) => z.id); + ok(ids.includes(idModi2), "Marina sieht Kim"); + ok(ids.includes(idDogi), "Marina sieht DogFather"); + /* VanVan bleibt fuer alle ausser DogFather verborgen -- das ist die + Regel vom 09.09.2026, und sie gilt auch gegenueber Modis. */ + ok(!ids.includes(idVan), "Marina sieht VanVan NICHT (verborgener Zugang)"); + ok(!ids.includes(idMan) && !ids.includes(idScout) && !ids.includes(idCreat), + "Marina sieht weder Manager noch Scout noch Creator"); + } +} + +/* ---------- 5. Der echte Weg durch die Anwendung --------------------- */ +console.log("\n=== 5. Anlegen wie im Betrieb: DogFather legt einen Modi an ===\n"); +{ + const an = await anmelden("admin", "CODE-DOGI-0001"); + const kopf = { "Content-Type": "application/json", cookie: an.keks }; + + /* a) Steht die Rolle ueberhaupt zur Auswahl -- und kommt sie vom + Server, nicht aus einer Datei im Browser? */ + const { status, daten } = await hole(an.keks, "/workspace/api/verwaltung/personen"); + if (status !== 200) { + unklar(`Verwaltung fuer DogFather nicht lesbar (${status})`); + } else { + const zusatz = daten?.zusatzrollen || []; + ok(zusatz.some((z) => z.wert === "modi"), + "DogFather bekommt die Rolle zur Auswahl angeboten " + + `(${zusatz.map((z) => z.wert).join(", ") || "keine"})`); + } + + /* b) Anlegen -- ueber genau den Weg, den die Oberflaeche benutzt. */ + const a = await fetch(BASIS + "/workspace/api/verwaltung/personen", { + method: "POST", headers: kopf, + body: JSON.stringify({ name: "Testmodi Neu", rolle: "modi" }), + }); + const neuDaten = await a.json().catch(() => ({})); + ok(a.status === 201 && neuDaten?.rolle === "modi" && typeof neuDaten?.code === "string", + `angelegt (${a.status}, Rolle ${neuDaten?.rolle}, ` + + `Code ${neuDaten?.code ? "erhalten" : "FEHLT"})`); + + /* c) DER EIGENTLICHE PUNKT: Kommt der so angelegte Modi auch herein? + + Die Abschnitte oben haben die Personen VON HAND eingetragen und + den Suchschluessel selbst ausgerechnet. Damit ist nicht bewiesen, + dass personAnlegen() ihn im Betrieb auch wirklich schreibt -- und + ohne ihn findet der stille Zugang niemanden. Waere die Zeile dort + vergessen worden, waeren oben trotzdem alle Haken gruen und kein + einziger echter Modi kaeme herein. */ + if (neuDaten?.code) { + const b = await anmelden("creator", neuDaten.code); + ok(b.status === 200 && b.daten?.rolle === "modi", + "und meldet sich mit seinem Code an, ueber eine fremde Kachel " + + `(${b.status}, ${b.daten?.rolle})`); + } else { + unklar("ohne Code laesst sich die Anmeldung nicht pruefen"); + } + + /* d) Und ist auch der Neue fuer den Manager unsichtbar? */ + const m = await anmelden("manager", "CODE-MAN-0001"); + const sicht = await hole(m.keks, "/workspace/api/personen"); + if (sicht.status !== 200 || !Array.isArray(sicht.daten?.personen)) { + unklar(`Personenwahl fuer den Manager nicht lesbar (${sicht.status})`); + } else { + const namen = sicht.daten.personen.map((z) => z.name); + ok(namen.length > 0 && !namen.includes("Testmodi Neu"), + `der Manager sieht ihn nicht (${namen.length} Personen: ${namen.join(", ")})`); + } +} +/* ---------- 6. Die Gegenprobe ---------------------------------------- + Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person + absichtlich aus der Regel aus. Lief sie weiter oben, tauchte + diese Person in jeder Liste DANACH auf -- und jeder spaetere + Abschnitt maesse einen Zustand, den es im Betrieb nicht gibt. + -------------------------------------------------------------------- */ +console.log("\n=== 6. Gegenprobe: kann diese Pruefung ueberhaupt anschlagen? ===\n"); +{ + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + d2.prepare("UPDATE personen SET rolle = 'creator' WHERE id = ?").run(idModi1); + /* Betreuung dazu, sonst waere der Creator fuer den Manager auch ohne + die Verbergen-Regel unsichtbar -- und die Gegenprobe pruefte nichts. */ + d2.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") + .run(idModi1, idScout, jetzt); + d2.close(); + + const an = await anmelden("manager", "CODE-MAN-0001"); + const { status, daten } = await hole(an.keks, "/workspace/api/personen"); + if (status !== 200 || !Array.isArray(daten?.personen)) { + unklar(`Gegenprobe nicht durchfuehrbar (${status})`); + } else { + const sichtbar = daten.personen.some((z) => z.id === idModi1); + ok(sichtbar, sichtbar + ? "ohne die Rolle 'modi' sieht der Manager dieselbe Person sehr wohl " + + "-- die Pruefung oben kann also anschlagen" + : "GEGENPROBE FEHLGESCHLAGEN: auch ohne die Regel bleibt die Person " + + "unsichtbar. Die Pruefung oben beweist damit nichts."); + } +} + +console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- ` + + `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-personen-formular.mjs b/server/pruef-personen-formular.mjs index 29167738..862d3615 100644 --- a/server/pruef-personen-formular.mjs +++ b/server/pruef-personen-formular.mjs @@ -108,17 +108,33 @@ const mass = await dogi.evaluate(() => { wahlOben: Math.round(w.top), listeOben: dn ? Math.round(dn.top) : null, karten: karten.length, + kartenRollen: karten.map((k) => k.dataset.rolle), kartenBreiten: karten.map((k) => Math.round(k.getBoundingClientRect().width)), /* Liegt eine Karte weiter rechts als das Formular breit ist? */ ueberRand: karten.some((k) => k.getBoundingClientRect().right > f.right + 1), }; }); -/* FÜNF, seit es Spicy Media gibt. Die Zahl steht hier absichtlich fest - und wird nicht aus der Oberfläche abgeleitet: Sie ist die Erwartung, - gegen die gemessen wird. Fiele eine Rolle aus der Wahl, müsste das - auffallen -- eine Zahl, die sich selbst nachzählt, kann das nicht. */ -ok(mass.karten === 5, `alle fünf Rollen stehen zur Wahl (${mass.karten})`); +/* SECHS, seit es die verborgene Modi-Rolle gibt (09.09.2026) -- vorher + fünf, seit es Spicy Media gibt. Die Erwartung steht hier absichtlich + fest und wird nicht aus der Oberfläche abgeleitet: Fiele eine Rolle + aus der Wahl, müsste das auffallen -- eine Zahl, die sich selbst + nachzählt, kann das nicht. + + DIE SECHSTE IST NICHT WIE DIE ANDEREN. Die fünf stehen in + assets/js/personen.js, also in einer Datei, die jeder bekommt, der + die Seite öffnet. "Modi" darf dort nicht stehen -- ein Blick in den + Quelltext würde sonst genügen, um den verborgenen Zugang zu + entdecken. Sie kommt deshalb aus der Antwort des Servers und nur an + die DogFather-Rolle. Dass sie hier auftaucht, ist gleichzeitig der + Nachweis, dass dieser Weg funktioniert. + + Geprüft wird ab jetzt die LISTE und nicht nur die Anzahl: Sechs + Karten könnten auch fünf richtige und eine doppelte sein. */ +ok(mass.karten === 6, `alle sechs Rollen stehen zur Wahl (${mass.karten})`); +ok(JSON.stringify([...mass.kartenRollen].sort()) + === JSON.stringify(["admin", "creator", "manager", "modi", "scout", "spicy"]), + `und es sind die richtigen (${mass.kartenRollen.join(", ")})`); ok(mass.wahlUnten <= mass.formUnten, `die Rollenauswahl endet INNERHALB des Formulars (${mass.wahlUnten} ≤ ${mass.formUnten})`); ok(!mass.ueberRand, "keine Karte ragt seitlich aus dem Formular"); diff --git a/server/workspace-personen.js b/server/workspace-personen.js index c81c5734..f21ebef4 100644 --- a/server/workspace-personen.js +++ b/server/workspace-personen.js @@ -211,6 +211,33 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { `SELECT id, name, rolle FROM personen WHERE rolle IN ('admin', 'manager', 'scout') AND aktiv = 1 ORDER BY ${ROLLEN_SORTIERUNG}, name`).all().filter((z) => !weg.has(z.id)), + + /* ============================================================= + ROLLEN, DIE NICHT IM BROWSER STEHEN DUERFEN (09.09.2026) + + Die Rollenauswahl im Formular kommt sonst aus einer Liste in + assets/js/personen.js. Diese Datei bekommt JEDER ausgeliefert, + der die Seite oeffnet -- auch ein Manager, ein Scout, ein + Creator. Steht dort `modi: 'Modi'`, genuegt ein Blick in den + Quelltext, und der ganze verborgene Zugang ist verraten. + + Deshalb kommt dieser eine Eintrag vom Server und nur an die + DogFather-Rolle. Wer die Rolle nicht hat, bekommt eine leere + Liste -- nicht eine gefilterte Liste, sondern gar keine + Angabe. Ein Feld, das mal Inhalt hat und mal nicht, verraet + nichts; ein Feld mit einem "darfst du nicht" darin schon. + + Text und Zeichen stehen deshalb ebenfalls hier und nicht im + Browser. `schutz` ist das vorhandene Schild-Zeichen -- es passt + zur Moderation und spart ein neues, das in der Zeichenliste + wieder fuer alle sichtbar waere. */ + zusatzrollen: istDogFather(req.person) + ? [{ + wert: "modi", name: "Modi", symbol: "schutz", + text: "Moderation und Team-Aufgaben. Sichtbar nur fuer die " + + "DogFather-Rolle und fuer die Modis untereinander.", + }] + : [], }); } catch (fehler) { console.error("[workspace] Personen lesen:", fehler?.message); @@ -502,7 +529,23 @@ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, const rolle = String(req.body?.rolle ?? ""); if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." }); if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Name ist zu lang." }); - if (!ROLLEN.includes(rolle)) return res.status(400).json({ fehler: "Unbekannte Rolle." }); + /* 'MODI' IST FUER ALLE AUSSER DOGFATHER KEINE BEKANNTE ROLLE + (09.09.2026). + + EHRLICHERWEISE: Diese Zeile schliesst heute keine Luecke. Der + ganze Zweig /workspace/api/verwaltung haengt an nurAdmin, und + nurAdmin laesst nur die DogFather-Rolle durch -- weiter kommt + ohnehin niemand. Sie steht hier fuer den Tag, an dem jemand + nurAdmin lockert, etwa damit Manager Leute verwalten duerfen. + Dann waere sonst genau hier der stille Weg zu einem Modi-Zugang. + + WARUM DIESELBE ANTWORT WIE BEI EINER ERFUNDENEN ROLLE: Ein + "das darfst du nicht" waere schlimmer als das Recht selbst -- es + verriete, dass es die Rolle gibt. Deshalb wortgleich, mit + demselben Statuscode. */ + const unbekannt = !ROLLEN.includes(rolle) + || (rolle === "modi" && !istDogFather(req.person)); + if (unbekannt) return res.status(400).json({ fehler: "Unbekannte Rolle." }); /* ERSTER VORBEHALT: Eine Leitung anlegen darf nur DogFather. Duerfte ein Manager das, koennte er sich einen zweiten Zugang mit diff --git a/server/workspace.js b/server/workspace.js index 63b11691..2492d224 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -21,7 +21,7 @@ import express from "express"; import { - randomBytes, scryptSync, timingSafeEqual, createHash, + randomBytes, scryptSync, timingSafeEqual, createHash, createHmac, } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -57,19 +57,34 @@ const COOKIE = "dfw_sitzung"; const SITZUNG_STUNDEN = 12; const VERSUCHE_MAX = 8; // pro IP const VERSUCHE_FENSTER_MIN = 10; -const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator"]); +const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "modi"]); /* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal, damit keine Liste eine eigene Reihenfolge erfindet. */ -export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator"]; +export const ROLLEN_REIHE = ["spicy", "admin", "manager", "scout", "creator", "modi"]; + +/* DIE ROLLEN MIT EINER KACHEL AUF DER ANMELDESEITE. + + 'modi' steht hier NICHT drin, und das ist der Kern des verborgenen + Zugangs: Es gibt keine sechste Kachel, und es laesst sich auch keine + erzwingen. Wer von aussen `rolle: "modi"` schickt, bekommt genau + dieselbe Antwort wie bei einer erfundenen Rolle -- er erfaehrt also + nicht einmal, dass es sie gibt. + + Zwei Listen statt einer, weil es zwei verschiedene Fragen sind: + ROLLEN sagt, welche Rollen es GIBT (die Datenbank laesst nur diese + zu). Diese hier sagt, mit welchen man sich ANMELDEN kann, indem man + sie anklickt. Waere es eine Liste, haette 'modi' entweder eine Kachel + -- oder es gaebe die Rolle gar nicht. */ +const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"]); /* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch (admin, creator, manager, scout, spicy) -- also fast genau falsch herum. */ export const ROLLEN_SORTIERUNG = "CASE rolle WHEN 'spicy' THEN 0 WHEN 'admin' THEN 1 WHEN 'manager' THEN 2" - + " WHEN 'scout' THEN 3 ELSE 4 END"; + + " WHEN 'scout' THEN 3 WHEN 'creator' THEN 4 ELSE 5 END"; /* LEITUNG = DogFather und Manager. Ein Manager darf alles, was DogFather darf -- mit genau zwei Ausnahmen, die in @@ -168,6 +183,7 @@ export const ROLLEN_NAME = { manager: "Manager", scout: "Scout", creator: "Creator", + modi: "Modi", }; /* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar @@ -194,6 +210,122 @@ let _dbFehler = null; SQLite selbst und in sich konsistent, anders als ein Dateikopie waehrend laufender Schreibvorgaenge). ===================================================================== */ +/* ===================================================================== + DIE ROLLENLISTE DER DATENBANK ERWEITERN (09.09.2026) + + Welche Rollen es geben darf, steht als CHECK-Regel in der Tabelle + `personen`. SQLite kann eine CHECK-Regel nicht aendern -- der einzige + saubere Weg ist: neue Tabelle, Daten hinueber, alte weg, neue + umbenennen. Das ist der Moment, in dem eine Datenbank kaputtgehen + kann. Deshalb steht der Ablauf ab jetzt EINMAL hier. + + WARUM ALS FUNKTION: Er stand vorher zweimal da -- einmal fuer + 'manager', einmal fuer 'spicy'. Fuer 'modi' waere es die dritte + Abschrift geworden, und jede Abschrift ist eine Gelegenheit, eine der + vier Absicherungen zu vergessen: die Sicherung VORHER, die Zaehlung + INNERHALB der Transaktion, die Spaltenliste aus der Tabelle statt aus + dem Gedaechtnis, die Pruefung auf verwaiste Verweise DANACH. Der + manager-Block darueber bleibt unangetastet: Er baut die Tabelle mit + einer fest hingeschriebenen Spaltenliste auf und ist damit ein + anderer Fall -- ihn mit einzufangen waere ein zweiter Umbau an einer + Stelle, an der ein Fehler Daten kostet. + + GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026, gefunden von + pruef-spicy): SQLite hebt den CREATE-Text woertlich auf, mitsamt + Kommentaren. Ein erklaerender Satz mit dem Wort 'spicy' genuegte, und + die Umstellung hielt die Tabelle fuer schon umgestellt, obwohl die + CHECK-Regel noch die alte war. Deshalb wird die Regel herausgeschnitten + und NUR darin gesucht. + + @param marker Die Rolle, an der erkannt wird, ob schon umgestellt ist. + @param rollen Die vollstaendige neue Liste erlaubter Rollen. + ===================================================================== */ +function rollenRegelUmstellen(d, marker, rollen, jetztStempel) { + const rollenPlan = d.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; + const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || ""; + /* Keine Regel gefunden heisst: Es gibt nichts umzustellen. Nicht + etwa "dann bauen wir sie neu" -- eine Tabelle ohne CHECK ist ein + Zustand, den jemand ansehen sollte, kein Fall fuer Automatik. */ + if (!rollenPlan || !regel || regel.includes(`'${marker}'`)) return; + + const sicherung = `${DB_PFAD}.vor-${marker}-${jetztStempel}`; + try { + d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); + console.log("[workspace] Sicherung vor der Umstellung:", sicherung); + } catch (fehler) { + /* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft die neue Rolle + noch nicht, als dass Daten ohne Netz angefasst werden. */ + console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message); + return; + } + + /* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat. + + `IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von + pruef-spicy). Die Tabelle wurde mit `CREATE TABLE IF NOT EXISTS + personen` angelegt, also steht das auch in sqlite_master. Ohne diese + drei Woerter im Muster griff die Ersetzung nicht, der Bauplan blieb + unveraendert, die Schutzabfrage darunter schlug an -- und die + Umstellung waere auf dem echten Server NIE gelaufen. Sie haette es + gesagt (das ist der Wert der Abfrage), aber sie waere nie gelaufen. */ + const neuerPlan = rollenPlan + .replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i, + "CREATE TABLE personen_neu") + .replace(/rolle\s+IN\s*\([^)]*\)/i, + `rolle IN (${rollen.map((r) => `'${r}'`).join(",")})`); + if (!neuerPlan.includes(`'${marker}'`) || !neuerPlan.includes("personen_neu")) { + console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht " + + "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?"); + return; + } + + /* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */ + const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name); + if (!spalten.length) { + console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden."); + return; + } + const liste = spalten.map((n) => `"${n}"`).join(", "); + + /* Fremdschluessel muessen aus sein, weil andere Tabellen auf + personen(id) zeigen -- und das laesst sich nicht innerhalb einer + Transaktion umschalten. */ + d.exec("PRAGMA foreign_keys = OFF"); + try { + const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n; + d.exec("BEGIN"); + d.exec(neuerPlan); + d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`); + const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n; + /* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht, + wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */ + if (nachher !== vorher) { + d.exec("ROLLBACK"); + console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, ` + + `${nachher} nachher. Sicherung: ${sicherung}`); + } else { + d.exec("DROP TABLE personen;"); + d.exec("ALTER TABLE personen_neu RENAME TO personen;"); + 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] Rolle '${marker}' freigeschaltet, ${nachher} Personen, ` + + `${spalten.length} Spalten uebernommen, Verweise geprueft.`); + } + } + } catch (fehler) { + try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } + console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message, + "-- Sicherung:", sicherung); + } finally { + d.exec("PRAGMA foreign_keys = ON"); + } +} + function umstellungen(d) { /* MIT SEKUNDEN, nicht nur mit Minuten (07.09.2026). @@ -458,6 +590,30 @@ function umstellungen(d) { naheliegende Abkuerzung gewesen -- und faellt in dem Moment um, in dem jemand den Titel einer uebernommenen Aufgabe aendert. */ ["aufgaben", "vorlage", "TEXT"], + + /* DER SUCHSCHLUESSEL ZUM CODE (09.09.2026). + + Bisher gab es nur den scrypt-Hash, und der ist mit Absicht + langsam (rund 150 ms). Wer eine Person AM CODE finden will, muss + deshalb alle Kandidaten der Reihe nach durchrechnen -- die + Anmeldung tut genau das, und im Code steht seit langem der + Hinweis, dass das ab etwa 50 Codes je Rolle spuerbar wird. + + Diese Spalte ist die schnelle Antwort auf "zu welcher Person + gehoert dieser Code?": ein HMAC ueber den Code, in Mikrosekunden + berechnet und mit einem Index hinterlegt. + + WARUM DAS UNBEDENKLICH IST -- nachgemessen, nicht angenommen: Ein + Code besteht aus 16 Zeichen eines 32er-Alphabets, das sind 80 Bit + Zufall. Selbst wenn jemand die ganze Datenbank haette, waere + Durchprobieren aussichtslos, egal wie schnell die Rechnung ist. + Der Schluessel steckt zusaetzlich nicht im Code, sondern in den + Einstellungen (siehe kennungSchluessel). + + DER SCRYPT-HASH BLEIBT DIE ENTSCHEIDUNG. Der Suchschluessel sagt + nur, WEN man pruefen soll; ob der Code stimmt, sagt weiterhin + allein scrypt. Ein Treffer hier allein laesst niemanden herein. */ + ["personen", "code_kennung", "TEXT"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -475,6 +631,16 @@ function umstellungen(d) { die ganze Termintabelle. Er steht hier unten und nicht oben im Bauplan, weil die beiden Spalten erst durch die Schleife darüber entstehen -- oben gäbe es sie beim ersten Start noch nicht. */ + try { + /* Ohne Index waere der Suchschluessel sinnlos -- SQLite laese die + ganze Personentabelle, und genau das sollte er ersparen. Steht + aus demselben Grund hier unten wie der Termin-Index: Die Spalte + entsteht erst durch die Schleife darueber. */ + d.exec("CREATE INDEX IF NOT EXISTS idx_personen_kennung ON personen (code_kennung)"); + } catch (fehler) { + console.error("[workspace] Index idx_personen_kennung:", fehler?.message); + } + try { d.exec("CREATE INDEX IF NOT EXISTS idx_termine_serie ON termine (serie_id, serie_tag)"); } catch (fehler) { @@ -980,91 +1146,14 @@ function umstellungen(d) { } } - const rollenPlan = d.prepare( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; - /* GEPRUEFT WIRD DIE REGEL, NICHT DER TEXT (07.09.2026). - - Hier stand `rollenPlan.includes("'spicy'")`. Das ist eine Suche im - ganzen gespeicherten Bauplan -- und SQLite hebt den woertlich auf, - mitsamt allen Kommentaren darin. Ein erklaerender Satz mit dem Wort - 'spicy' genuegte, und die Umstellung hielt die Tabelle fuer schon - umgestellt, obwohl die CHECK-Regel noch die alte war. Beim Bauen - genau so passiert. - - Jetzt wird die Regel selbst herausgeschnitten und NUR darin gesucht. - Findet sich keine, gibt es nichts umzustellen. */ - const regel = rollenPlan.match(/rolle\s+IN\s*\(([^)]*)\)/i)?.[1] || ""; - if (rollenPlan && regel && !regel.includes("'spicy'")) { - const sicherung = `${DB_PFAD}.vor-spicy-${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; - } - - /* Nur die Rollenliste ersetzen -- und pruefen, dass es geklappt hat. */ - const neuerPlan = rollenPlan - /* `IF NOT EXISTS` MUSS MIT INS MUSTER (07.09.2026, gefunden von - pruef-spicy). SQLite hebt den CREATE-Text WOERTLICH auf -- die - Tabelle wurde mit `CREATE TABLE IF NOT EXISTS personen` angelegt, - also steht das auch in sqlite_master. Ohne diese drei Woerter im - Muster griff die Ersetzung nicht, der Bauplan blieb unveraendert, - die Schutzabfrage darunter schlug an -- und die Umstellung waere - auf dem echten Server NIE gelaufen. Sie haette es gesagt (das ist - der Wert der Abfrage), aber sie waere nie gelaufen. */ - .replace(/CREATE TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?personen["'`]?/i, - "CREATE TABLE personen_neu") - .replace(/rolle\s+IN\s*\([^)]*\)/i, "rolle IN ('spicy','admin','manager','scout','creator')"); - if (!neuerPlan.includes("'spicy'") || !neuerPlan.includes("personen_neu")) { - console.error("[workspace] Umstellung abgebrochen: Der Bauplan liess sich nicht " - + "umschreiben. Steht die CHECK-Regel noch so da wie erwartet?"); - return; - } - - /* Die Spaltenliste kommt aus der Tabelle, nicht aus dem Gedaechtnis. */ - const spalten = d.prepare("PRAGMA table_info(personen)").all().map((z) => z.name); - if (!spalten.length) { - console.error("[workspace] Umstellung abgebrochen: keine Spalten gefunden."); - return; - } - const liste = spalten.map((n) => `"${n}"`).join(", "); - - d.exec("PRAGMA foreign_keys = OFF"); - try { - const vorher = d.prepare("SELECT COUNT(*) AS n FROM personen").get().n; - d.exec("BEGIN"); - d.exec(neuerPlan); - d.exec(`INSERT INTO personen_neu (${liste}) SELECT ${liste} FROM personen;`); - const nachher = d.prepare("SELECT COUNT(*) AS n FROM personen_neu").get().n; - /* Die Zaehlung steht INNERHALB der Transaktion -- stimmt sie nicht, - wird zurueckgerollt und die alte Tabelle bleibt unberuehrt. */ - if (nachher !== vorher) { - d.exec("ROLLBACK"); - console.error(`[workspace] Umstellung abgebrochen: ${vorher} Personen vorher, ` - + `${nachher} nachher. Sicherung: ${sicherung}`); - } else { - d.exec("DROP TABLE personen;"); - d.exec("ALTER TABLE personen_neu RENAME TO personen;"); - 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] Rolle 'spicy' freigeschaltet, ${nachher} Personen, ` - + `${spalten.length} Spalten uebernommen, Verweise geprueft.`); - } - } - } catch (fehler) { - try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } - console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message, - "-- Sicherung:", sicherung); - } finally { - d.exec("PRAGMA foreign_keys = ON"); - } - } + /* Welche Rollen die Datenbank zulaesst. Der Ablauf steht in + rollenRegelUmstellen() -- einmal, nicht je Rolle abgeschrieben. + Beide Aufrufe stehen hier: Auf einer Datenbank, die 'spicy' schon + kennt, tut der erste nichts und nur der zweite laeuft. */ + rollenRegelUmstellen(d, "spicy", + ["spicy", "admin", "manager", "scout", "creator"], jetztStempel); + rollenRegelUmstellen(d, "modi", + ["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel); } export function db() { @@ -1094,7 +1183,7 @@ export function db() { CREATE TABLE IF NOT EXISTS personen ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator')), + rolle TEXT NOT NULL CHECK (rolle IN ('spicy','admin','manager','scout','creator','modi')), code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL, @@ -1838,6 +1927,62 @@ function hashe(code, salt, N) { { N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex"); } +/* ===================================================================== + DER SUCHSCHLUESSEL ZU EINEM CODE (09.09.2026) + + Beantwortet in Mikrosekunden, WEN man pruefen soll -- nicht, ob der + Code stimmt. Das bleibt Sache von scrypt darunter. + + WARUM EIN HMAC UND KEIN EINFACHER HASH: Ein einfacher Hash ueber den + Code waere ueberall gleich. Mit einem Schluessel, der nur in dieser + einen Datenbank steht, ist der Wert ausserhalb wertlos -- er laesst + sich nirgends nachschlagen und nirgends wiedererkennen. + + WARUM DER SCHLUESSEL IN DIE DATENBANK GEHOERT UND NICHT IN DIE + UMGEBUNG: Eine neue Pflichtangabe in server/.env ist eine Angabe, die + beim naechsten Aufsetzen fehlen kann. Sie wuerde nicht laut scheitern, + sondern leise: Der stille Zugang fuende niemanden mehr, und die + Anmeldung saehe aus wie ein falscher Code. Aus der Datenbank kann sie + nicht verschwinden -- sie wird mitgesichert wie alles andere. + + OHNE SCHLUESSEL GIBT ES KEINE ANTWORT, nicht etwa eine schlechtere: + `null` heisst "konnte nicht nachsehen", und die Anmeldung behandelt + das wie "nicht gefunden". Ein Rueckfall auf einen ungeschluesselten + Wert waere die stille Verschlechterung, die niemandem auffaellt. */ +let _kennungSchluessel = null; +function kennungSchluessel() { + if (_kennungSchluessel) return _kennungSchluessel; + try { + const d = db(); + const lies = () => d.prepare( + "SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get()?.wert; + let wert = lies(); + if (!wert) { + /* Bewusst NICHT ueber einstellungSetzen(): Das schriebe einen + Eintrag ins Protokoll, und ein Protokolleintrag ist genau die + Spur, die es hier nicht geben soll. */ + d.prepare(`INSERT INTO einstellungen (schluessel, wert, geaendert, von) + VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING`) + .run("code_kennung_schluessel", randomBytes(32).toString("hex"), + new Date().toISOString()); + /* Danach nochmal LESEN statt den erzeugten Wert zu benutzen: + Legen zwei Vorgaenge ihn im selben Augenblick an, gewinnt einer + -- und beide muessen mit demselben weiterrechnen. */ + wert = lies(); + } + _kennungSchluessel = wert || null; + return _kennungSchluessel; + } catch { + return null; + } +} + +export function codeKennung(code) { + const schluessel = kennungSchluessel(); + if (!schluessel || !code) return null; + return createHmac("sha256", schluessel).update(String(code)).digest("hex"); +} + /* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich ein Geheimnis Zeichen für Zeichen erraten. */ @@ -2075,6 +2220,33 @@ workspaceRouter.use((req, res, next) => { return next(); }); +/** + * Gehoert dieser Code zu einem verborgenen Zugang? + * + * Gibt die Person zurueck oder null. `null` heisst hier ausdruecklich + * "nein oder nicht feststellbar" -- ohne Suchschluessel (etwa weil die + * Einstellung fehlt) gibt codeKennung() null zurueck, und dann wird + * niemand angemeldet. Das ist die richtige Richtung: Im Zweifel kommt + * niemand herein, statt dass im Zweifel jemand hereinkommt. + */ +function stillerZugang(code) { + try { + const kennung = codeKennung(code); + if (!kennung) return null; + const k = db().prepare( + "SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen " + + "WHERE code_kennung = ? AND rolle = 'modi' AND aktiv = 1").get(kennung); + if (!k) return null; + /* Trotz Treffer wird geprueft. Der Suchschluessel ist ein + Wegweiser, kein Ausweis -- und ein Wegweiser, dem man blind + folgt, ist eine Hintertuer. */ + return gleich(hashe(code, k.code_salt, k.code_n), k.code_hash) ? k : null; + } catch (fehler) { + console.error("[workspace] Stiller Zugang nicht pruefbar:", fehler?.message); + return null; + } +} + workspaceRouter.post("/workspace/api/anmelden", (req, res) => { const ip = echteIp(req); try { @@ -2085,7 +2257,56 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => { const rolle = String(req.body?.rolle || ""); const code = String(req.body?.code || ""); - if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) { + const codeBrauchbar = code.length >= 4 && code.length <= 200; + + /* ================================================================= + DER STILLE ZUGANG (09.09.2026) + + Wunsch Filipe: *"dass die keine neue eingangs kachel bekommen wie + spicy dogfather und so sondern einfach einen code. damit die von + der workspace auch nicht mal sehen dass die modis von mir einen + eigenen zugang haben."* + + Ein Modi oeffnet dieselbe Seite, tippt auf IRGENDEINE vorhandene + Kachel und gibt seinen Code ein. Welche Kachel, ist gleichgueltig + -- der Code allein entscheidet. Von aussen sieht das aus wie jede + andere Anmeldung: dieselbe Seite, dieselbe Anfrage, dieselben + Felder. + + ZWEI NAHELIEGENDE WEGE, DIE ABSICHTLICH NICHT GEWAEHLT WURDEN: + + * Ein Merkmal im Code ("M-..."): waere ein sichtbares Kennzeichen + auf dem Zettel des Modis. Wer den Code sieht, sieht die Sorte. + * Eine eigene Adresse (/modi.html): waere eine Seite, die man + finden kann -- und die Seitenpruefung zaehlt Seiten. + + WARUM DAS VOR DEM GEWOHNTEN WEG STEHT UND NICHT DAHINTER: Ein + Rueckfall NACH einem Fehlversuch haette den gewohnten Weg + verlaengert -- und zwar nur dann, wenn er scheitert. Genau daran + waere es zu erkennen gewesen: Fehlversuche dauern ploetzlich + laenger als frueher. Der Suchschluessel kostet Mikrosekunden und + trifft in aller Regel nichts; fuer alle anderen bleibt der Ablauf + unveraendert, auch in der Zeit. + + DER SUCHSCHLUESSEL ALLEIN LAESST NIEMANDEN HEREIN. Er sagt nur, + WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */ + const still = codeBrauchbar ? stillerZugang(code) : null; + if (still) { + db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip); + db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id); + sitzungSetzen(res, still, req); + protokolliere("anmeldung", { + personId: still.id, rolle: still.rolle, ip, detail: still.name, + }); + return res.json({ + weiter: "/workspace/start.html", name: still.name, rolle: still.rolle, + }); + } + + /* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine + Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine + erfundene. */ + if (!ROLLEN_KACHEL.has(rolle) || !codeBrauchbar) { db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); return res.status(401).json({ fehler: "ungueltig" }); } @@ -2452,14 +2673,51 @@ function sichtbareCreatorIdsRoh(person) { ===================================================================== */ export function verborgeneIds(person) { try { - const admins = db().prepare( + const d = db(); + + /* ERSTER TEIL: die verborgenen Admin-Zugaenge (Regel vom 09.09.2026). + Der Admin mit der kleinsten Nummer ist DogFather, jeder weitere + Admin-Zugang ist verborgen. */ + const admins = d.prepare( "SELECT id FROM personen WHERE rolle = 'admin' ORDER BY id").all().map((z) => z.id); - if (admins.length < 2) return []; - const versteckt = admins.slice(1); - if (!person) return versteckt; - if (person.id === admins[0]) return []; /* DogFather sieht alle */ - if (versteckt.includes(person.id)) return []; /* sich selbst sieht man */ - return versteckt; + const versteckteAdmins = admins.length >= 2 ? admins.slice(1) : []; + + /* ZWEITER TEIL: das Modi-Team. + + Hier haengt die Regel an der ROLLE und nicht an einer Nummer -- + und das ist der Unterschied, auf den es ankommt. Beim ersten Teil + steht die Schwachstelle im Kommentar oben ausdruecklich da: Wuerde + Zugang 1 geloescht, rueckte der naechste nach und waere ploetzlich + sichtbar. Eine Rolle kann das nicht passieren. Wer einen Modi + verbergen will, muss ihm nichts anhaengen und nichts nachhalten -- + er IST verborgen, solange er ein Modi ist. + + WER DARF SIE SEHEN, und nur diese zwei: + * die DogFather-Rolle (admin) -- also auch die rechte Hand, + denn sie traegt dieselbe Rolle. So gewollt (Kapitel 3 des + Anforderungsdokuments: gleicher Ueberblick). + * ein Modi selbst -- sie sind untereinander ein Team. + + Manager, Scouts, Creator und Spicy Media sehen sie nicht. Nicht + "sehen sie ohne Einzelheiten", sondern gar nicht: kein Name, kein + Eintrag, keine Zahl, die sie mitzaehlt. */ + const modis = d.prepare( + "SELECT id FROM personen WHERE rolle = 'modi'").all().map((z) => z.id); + + /* Ohne bekannte Person wird BEIDES verborgen. Das ist die strengere + Antwort, und bei einer Sichtbarkeitsregel ist die strengere die + richtige: Wer nicht weiss, wer fragt, zeigt nichts. */ + const darfAdminsSehen = !!person + && (person.id === admins[0] || versteckteAdmins.includes(person.id)); + const darfModisSehen = !!person + && (person.rolle === "admin" || person.rolle === "modi"); + + const weg = []; + if (!darfAdminsSehen) weg.push(...versteckteAdmins); + if (!darfModisSehen) weg.push(...modis); + /* Doppelte heraus: Ein Modi-Zugang koennte theoretisch auch in der + ersten Liste stehen, wenn jemand die Rollen umhaengt. */ + return [...new Set(weg)]; } catch { /* Ohne Datenbank lieber nichts verbergen als abstuerzen -- diese Funktion darf keine Seite lahmlegen. Sie laeuft in jedem @@ -2510,7 +2768,27 @@ function sichtbarePersonenIdsRoh(person) { for (const z of db().prepare( "SELECT id FROM personen WHERE rolle = 'admin' AND aktiv = 1").all()) dogi.push(z.id); } catch { /* ohne Datenbank bleibt die Liste, wie sie war */ } - return [...new Set([person.id, ...creator, ...scouts, ...dogi])]; + + /* DAS MODI-TEAM SIEHT SICH UNTEREINANDER (Entscheidung Filipe, + 09.09.2026: "ja, sie sind untereinander ein Team"). + + Ohne diese Zeilen waere jeder Modi allein: kein Team-Gruppenchat, + keine Schicht, die man tauschen kann, keine Eskalation, die man + weiterreicht, ohne dass DogFather sie einzeln durchstellt. + + Es ist KEIN Loch in der Verborgenheit, sondern ihre andere Seite. + Was diese Liste zusaetzlich hergibt, wird gleich danach durch + ohneVerborgene() wieder eingeschraenkt -- und dort steht, dass ein + Modi nur fuer die DogFather-Rolle und fuer Modis sichtbar ist. Wer + kein Modi ist, bekommt diese Nummern hier gar nicht erst. */ + const modis = []; + if (person.rolle === "modi") { + try { + for (const z of db().prepare( + "SELECT id FROM personen WHERE rolle = 'modi' AND aktiv = 1").all()) modis.push(z.id); + } catch { /* ohne Datenbank bleibt die Liste, wie sie war */ } + } + return [...new Set([person.id, ...creator, ...scouts, ...dogi, ...modis])]; } /** WER BETREUT MICH -- die Blickrichtung nach oben. @@ -2975,8 +3253,9 @@ export function personAnlegen(name, rolle, akteur = null) { const salt = randomBytes(16).toString("hex"); const hash = hashe(code, salt, SCRYPT.N); const { lastInsertRowid } = db().prepare( - "INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)" - ).run(name, rolle, hash, salt, SCRYPT.N, jetzt()); + "INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)" + ).run(name, rolle, hash, salt, SCRYPT.N, codeKennung(code), jetzt()); protokolliere("person_angelegt", { personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, detail: `${name} (${rolle})`, @@ -3000,8 +3279,9 @@ export function codeNeu(id, akteur = null, behalteToken = null) { if (!person) throw new Error(`Keine Person mit Nummer ${id}`); const code = codeErzeugen(); const salt = randomBytes(16).toString("hex"); - db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?") - .run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id); + db().prepare( + "UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ?, code_kennung = ? WHERE id = ?") + .run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, codeKennung(code), id); /* Offene Sitzungen beenden -- ein neuer Code soll den alten Zugang wirklich beenden, nicht nur die nächste Anmeldung betreffen. diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 5ae2f9cc..a40f1d1c 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609092156", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609100010", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609092156", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609100010", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609092156", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100010", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609092156", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100010", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/js/personen.js b/workspace/assets/js/personen.js index a5e428d6..e5605479 100644 --- a/workspace/assets/js/personen.js +++ b/workspace/assets/js/personen.js @@ -65,34 +65,66 @@ : ['creator']; for (const r of ROLLEN.filter((x) => darf.includes(x.wert))) { if ((r.wert === 'admin' || r.wert === 'manager') && ich.rolle !== 'admin') continue; - const b = el('button', 'rollenwahl__knopf'); - b.type = 'button'; - b.dataset.rolle = r.wert; - b.setAttribute('role', 'radio'); - b.setAttribute('aria-checked', r.wert === 'creator' ? 'true' : 'false'); - if (r.wert === 'creator') b.dataset.an = 'ja'; + ziel.append(rollenKnopf(ziel, r)); + } + } - const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - s.setAttribute('viewBox', '0 0 24 24'); - s.setAttribute('class', 'rollenwahl__symbol'); - s.setAttribute('aria-hidden', 'true'); - const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); - u.setAttribute('href', '#r-' + r.symbol); - s.append(u); + /* EIN KNOPF DER ROLLENWAHL. - const txt = el('span', 'rollenwahl__text'); - txt.append(el('span', 'rollenwahl__name', r.name)); - txt.append(el('span', 'rollenwahl__erklaerung', r.text)); - b.append(s, txt); - b.addEventListener('click', () => { - for (const x of ziel.querySelectorAll('.rollenwahl__knopf')) { - delete x.dataset.an; - x.setAttribute('aria-checked', 'false'); - } - b.dataset.an = 'ja'; - b.setAttribute('aria-checked', 'true'); - }); - ziel.append(b); + Steht als eigene Funktion da, seit die Auswahl aus ZWEI Quellen + kommt: der Liste oben (fuer alle gleich) und dem, was der Server + zusaetzlich schickt (siehe rollenwahlErgaenzen). Zwei Abschriften + desselben Knopfes waeren zwei Gelegenheiten, dass der eine ein + aria-checked bekommt und der andere nicht. */ + function rollenKnopf(ziel, r) { + const b = el('button', 'rollenwahl__knopf'); + b.type = 'button'; + b.dataset.rolle = r.wert; + b.setAttribute('role', 'radio'); + b.setAttribute('aria-checked', r.wert === 'creator' ? 'true' : 'false'); + if (r.wert === 'creator') b.dataset.an = 'ja'; + + const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + s.setAttribute('viewBox', '0 0 24 24'); + s.setAttribute('class', 'rollenwahl__symbol'); + s.setAttribute('aria-hidden', 'true'); + const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + u.setAttribute('href', '#r-' + r.symbol); + s.append(u); + + const txt = el('span', 'rollenwahl__text'); + txt.append(el('span', 'rollenwahl__name', r.name)); + txt.append(el('span', 'rollenwahl__erklaerung', r.text)); + b.append(s, txt); + b.addEventListener('click', () => { + for (const x of ziel.querySelectorAll('.rollenwahl__knopf')) { + delete x.dataset.an; + x.setAttribute('aria-checked', 'false'); + } + b.dataset.an = 'ja'; + b.setAttribute('aria-checked', 'true'); + }); + return b; + } + + /* ROLLEN, DIE ERST DER SERVER NENNT (09.09.2026). + + Die Liste ROLLEN oben steht in dieser Datei -- und diese Datei + bekommt jeder ausgeliefert, der die Seite oeffnet. Eine Rolle, von + der ausser der DogFather-Rolle niemand wissen soll, darf deshalb + nicht darin stehen; sie kommt aus /workspace/api/verwaltung/personen + und nur an die, die sie vergeben duerfen. + + WARUM NACHTRAEGLICH UND NICHT GLEICH: baueRollenwahl() laeuft, bevor + die Personen geladen sind -- das Formular soll sofort dastehen und + nicht auf das Netz warten. Die Zusatzrolle kommt also eine Runde + spaeter dazu. Wer nichts bekommt, sieht die Auswahl wie bisher. */ + function rollenwahlErgaenzen(zusatz) { + const ziel = $('f-rolle'); + if (!ziel || !Array.isArray(zusatz) || !zusatz.length) return; + for (const r of zusatz) { + if (!r || !r.wert || ziel.querySelector(`[data-rolle="${r.wert}"]`)) continue; + ziel.append(rollenKnopf(ziel, r)); } } @@ -754,8 +786,9 @@ try { const a = await hole('/workspace/api/verwaltung/personen'); if (!a.ok) { melde('Personen konnten nicht geladen werden.'); return; } - const { personen, betreuer } = await a.json(); + const { personen, betreuer, zusatzrollen } = await a.json(); betreuerListe = betreuer || []; + rollenwahlErgaenzen(zusatzrollen); alle = personen; ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index d5f8aa4c..acf9b636 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -666,9 +666,24 @@ Bei der eigenen Sicht wie bisher die eigene Rolle; bei fremder Sicht der Name davor -- sonst liest man "Creator, dein eigener Bereich" und haelt es fuer die eigene Lage. */ + /* DER NAME DER EIGENEN ROLLE KOMMT NOTFALLS VOM SERVER (09.09.2026). + + ROLLENTEXT steht in einer Datei, die JEDER bekommt, der die Seite + oeffnet -- auch jeder Manager, jeder Scout, jeder Creator. Eine + Rolle, von der ausser DogFather niemand wissen soll, darf deshalb + nicht darin stehen: Ein Blick in den Quelltext wuerde genuegen. + + `rolle_name` kommt dagegen aus /workspace/api/ich und beschreibt + immer nur EINEN Menschen -- den, der gerade angemeldet ist. Wer + Manager ist, liest dort "Manager" und sonst nichts. + + Die Reihenfolge ist Absicht: erst das ausfuehrliche Wort aus der + Liste (mit dem erklaerenden Zusatz dahinter), dann der Name vom + Server, zuletzt der nackte Schluessel. So aendert sich fuer die + fuenf bekannten Rollen nichts. */ $('rollentext').textContent = ich.sicht ? `Arbeitsplatz von ${ich.sicht.name} · ${ROLLENTEXT[zeigt.rolle] || zeigt.rolle}` - : (ROLLENTEXT[ich.rolle] || ich.rolle); + : (ROLLENTEXT[ich.rolle] || ich.rolle_name || ich.rolle); /* Datum und Rolle in der Anrede -- wie im Vorbild, wo unter "Noch wach, Dogi" das Datum steht und daneben "ADMIN & PARTNER". @@ -687,7 +702,14 @@ } const rolleEl = $('zgruss-rolle'); if (rolleEl) { - rolleEl.textContent = (ROLLEN_KURZ[zeigt.rolle] || zeigt.rolle || '').toUpperCase(); + /* Aus demselben Grund wie oben: Steht die Rolle nicht in der + Liste, fragt die Plakette den Server -- aber nur fuer die + EIGENE Sicht. In einer fremden Sicht gibt es kein rolle_name + zu dieser Person, und etwas anderes hinzuschreiben waere + geraten. */ + const eigene = !ich.sicht; + rolleEl.textContent = (ROLLEN_KURZ[zeigt.rolle] + || (eigene ? ich.rolle_name : '') || zeigt.rolle || '').toUpperCase(); rolleEl.hidden = false; } } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 832d2a23..6f49a90e 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,12 +12,12 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + @@ -231,10 +231,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 66afee05..82ab43da 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -140,10 +140,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 93b8cfc4..0d518758 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -149,14 +149,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 584cd78a..4a93bafc 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -82,11 +82,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 397a20a6..7af3bc97 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + @@ -247,10 +247,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 736df125..3d142571 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -172,12 +172,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index ec7bea46..72df973d 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -101,10 +101,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 9fa1ca26..36499d0c 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 75b5e022..68f7b4d4 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -370,11 +370,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index ce74ff51..1314e080 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -217,10 +217,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 6ca8f004..26587650 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -335,10 +335,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 570fd896..fc2dcc9f 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -207,11 +207,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 9a49ccf2..6239f145 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -99,11 +99,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index f2d76225..d38bfd2d 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -132,10 +132,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 5d44a620..eda1e546 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,14 +12,14 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + @@ -667,10 +667,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index a226b18d..56169443 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -78,11 +78,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 3d4f36eb..679eb149 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -119,10 +119,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 46ba86cc..2647d762 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,13 +7,13 @@ - - - - - - - + + + + + + + @@ -103,10 +103,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index cb6167b5..556571e5 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,15 +12,15 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + @@ -142,11 +142,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 689f4643..2bf61a88 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,13 +12,13 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + @@ -183,10 +183,10 @@ - - - - - + + + + +