From a9d920e7d054edf7c50e6829e669d1701f7eaf78 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 23 Sep 2026 14:29:32 +0200 Subject: [PATCH] Vier stillgelegte Pruefungen, zwei kaputte Zeichen -- und die Wache dagegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gefunden beim Vorbereiten der KLIPY-Anbindung, nicht gesucht. DER VERLORENE BACKSLASH In fuenf Dateien stand in einem Suchmuster das BYTE 0x08 statt der zwei Zeichen \ und b. Gemeint war die Wortgrenze; 0x08 ist das Rueckschritt-Zeichen und kommt in keinem Text vor. Gemessen: /<0x08>modis?<0x08>/i.test("Die Modis sind da") -> false /\bmodis?\b/i .test("Die Modis sind da") -> true Folgen, je Stelle: pruef-nachwuchs Das Muster stand in einer VERNEINUNG. Die Zeile lautete damit ok(!false) -- dauerhaft gruen, ohne etwas zu messen. Ausgerechnet die Zeile, die das Durchsickern des Rollennamens verhindern soll. pruef-personen-formular dasselbe Muster, dieselbe Verneinung pruef-nachfrage hatDialogMitFeld war immer falsch. Seiten mit einem Formular-Dialog galten als "totes Gewicht" -- der Kommentar direkt darueber warnt woertlich vor genau diesem Schaden. pruef-entwicklung zwei von drei Alternativen tot workspace-treff.js nur ein Kommentar, aber unlesbar ZWEI KAPUTTE ZEICHEN, BEIDE SICHTBAR content: "<0x15>C9<0x00>A0" in chat.css. Gemeint war U+25C9 und ein geschuetztes Leerzeichen. Live stand woertlich "C9 A0" vor jedem hervorgehobenen @rudel -- am echten Server nachgemessen. content: "¹3" in chat.css UND entwicklung.css. Gemeint war ein Haken. Auf der gewaehlten Kachel stand "¹3"; zu sehen in Filipes Bildschirmfoto. Repariert wird mit der Escape-Schreibweise ("\2713"), nicht mit dem Zeichen selbst: Sie besteht nur aus ASCII und ueberlebt jede Kodierung. WAS DIESE FEHLERART BESONDERS MACHT Niemand sieht sie beim Lesen. Der erste war committet, ausgeliefert und lag live -- und als ich in der Datei danach suchte, meldete grep "Binary file matches" und zeigte die Zeile gar nicht erst an. Wer den Unterschied durchsieht, liest content: "C9 A0" und denkt sich nichts. Deshalb neu: server/pruef-zeichen.mjs (7 Pruefungen). Sie sucht rohe Steuerzeichen in allen ausgelieferten und allen Serverdateien, dazu Ersatzzeichen und Kodierungstruemmer in CSS-content-Angaben. Mit Gegenprobe in beide Richtungen -- sie weist an einer selbst gebauten Datei nach, dass sie anschlaegt, UND dass sie bei einer sauberen schweigt. Die erste Fassung der Regel war zu weit: Sie meldete acht KORREKTE Zeichen (✓, ↯, ↻, „, ⚠). Eine Warnung, die immer kommt, ist keine Warnung mehr. Die engere Regel kommt aus dem Befund selbst -- der Latin-1-Block U+0080..U+00BF, in dem echte Typografie nie steht. Nachgemessen ueber alle 203 content-Angaben im Haus: keine einzige benutzt ihn. GEHEIMNISSE STEHEN NICHT MEHR IM PROTOKOLL einstellungSetzen() schrieb immer Name UND Wert. Nachgemessen in der echten Datenbank: kopie_schluessel steht vollstaendig drin, vapid_paar wurde bei 120 Zeichen abgeschnitten -- kurz VOR dem privaten Teil. Dass der heil blieb, lag an einer Laengengrenze, nicht an einer Absicht. Keine offene Tuer (das Protokoll liegt hinter nurAdmin), aber der Wert wandert in jede Sicherung. Es ist eine REGEL und keine Liste: Wer eine Einstellung so benennt, meint ein Geheimnis. Stehen bleibt, DASS sich etwas geaendert hat, wann und durch wen -- nur der Wert fehlt. EINE VERALTETE PRUEFUNG, UMGEDREHT STATT GELOESCHT pruef-nachwuchs verlangte, dass die rechte Hand keine Zugaenge anlegen kann. Das war bis zum 22.09. richtig; dann hat Filipe es umgedreht ("damit sie das auch machen kann wenn er live ist"). Die Pruefung war seither rot, ohne dass es auffiel -- sie lief nicht. Jetzt prueft sie beides: dass die rechte Hand es darf UND dass die Grenzen stehen (sperren 404, Protokoll 404). Eine Erlaubnis ohne Grenze ist keine Entscheidung, sondern ein Loch. Und tools/nachtlauf.mjs wird nachgetragen -- bisher unversioniert. Zahlen: pruef-nachwuchs 260, pruef-entwicklung 48, pruef-personen-formular 43, pruef-zeichen 7 (neu), pruef-ports 8. Co-Authored-By: Claude Opus 5 --- server/pruef-entwicklung.mjs | 2 +- server/pruef-haerte.mjs | Bin 19223 -> 19248 bytes server/pruef-nachfrage.mjs | 2 +- server/pruef-nachwuchs.mjs | 42 +++- server/pruef-personen-formular.mjs | 2 +- server/pruef-zeichen.mjs | 274 ++++++++++++++++++++++ server/workspace-treff.js | 2 +- server/workspace.js | 40 +++- tools/nachtlauf.mjs | 327 +++++++++++++++++++++++++++ workspace/assets/css/entwicklung.css | 2 +- 10 files changed, 683 insertions(+), 10 deletions(-) create mode 100644 server/pruef-zeichen.mjs create mode 100644 tools/nachtlauf.mjs diff --git a/server/pruef-entwicklung.mjs b/server/pruef-entwicklung.mjs index e013565d..2120f33e 100644 --- a/server/pruef-entwicklung.mjs +++ b/server/pruef-entwicklung.mjs @@ -327,7 +327,7 @@ console.log("=== Die Kacheln ==="); Untertitel. Wer morgen eine Kachel "Dein Kopf" nennt und dasselbe verspricht, ist damit mitgeprueft; die Namensliste haette ihn durchgelassen. */ - const verspricht = (k) => /nur du|nur dich|siehst nur du/i.test(k.unter || ""); + const verspricht = (k) => /\bnur du\b|\bnur dich\b|siehst nur du/i.test(k.unter || ""); const heikel = alleX.filter((k) => verspricht(k) && k.ziel === "entwicklung.html"); ok(alleX.some(verspricht) && heikel.length === 0, `${wer}: keine Kachel verspricht Vertraulichkeit und fuehrt doch auf die` diff --git a/server/pruef-haerte.mjs b/server/pruef-haerte.mjs index d38d56d0cf6d01c980ae9a2d05a1a9d84dfed3b4..2d6f866d902816efe9c24597bacf8fd0efdf2825 100644 GIT binary patch delta 50 qcmbO}jd24IeGy}eDK!9s$zR0+B*08VFlhuP%|WDL(q>QbbH)I]*>[\s\S]*?<\/dialog>/g)] - .some((m) => /<(input|textarea|select)/.test(m[0])); + .some((m) => /<(input|textarea|select)\b/.test(m[0])); const kannFragen = geladen.some((g) => nutzer.has(g)) || hatDialogMitFeld; const hatDatei = html.includes("assets/js/nachfrage.js"); if (kannFragen) { mitNutzung++; fragendeSeiten.push(s); if (!hatDatei) ohneDatei.push(s); } diff --git a/server/pruef-nachwuchs.mjs b/server/pruef-nachwuchs.mjs index 99a3f192..b939a284 100644 --- a/server/pruef-nachwuchs.mjs +++ b/server/pruef-nachwuchs.mjs @@ -614,13 +614,47 @@ melde("=== 11. Probe → Zugang ==="); ok(!/personAnlegen|INSERT INTO personen/.test(eigen), "und der Talente-Server legt selbst keinen einzigen Zugang an"); - /* DIE RECHTE HAND SIEHT DEN TRICHTER, VERGIBT ABER KEINE ZUGAENGE. */ + /* DIE RECHTE HAND LEGT ZUGAENGE AN -- SEIT DEM 22.09.2026. + + Hier stand bis zum 23.09. das Gegenteil: "vergibt aber keine + Zugaenge", geprueft mit 404. Das war bis zum 22.09. richtig. Dann + hat Filipe es ausdruecklich umgedreht -- "damit sie das auch + machen kann wenn er live ist" -- und die Route in + workspace-personen.js oeffnet seither genau zwei Wege: anlegen + und einen Code neu erzeugen. + + DIE PRUEFUNG WAR ALSO SEIT EINEM TAG ROT, ohne dass es jemandem + aufgefallen waere -- sie lief in der Zwischenzeit nicht. Das ist + der Normalfall und nicht die Ausnahme: Man startet die + Pruefungen, an denen man gerade baut. + + UMGEDREHT WIRD SIE, NICHT GELOESCHT. Eine Erlaubnis ohne Grenze + ist keine Entscheidung, sondern ein Loch. Geprueft wird deshalb + ab jetzt beides: dass die rechte Hand es DARF -- und dass die + Grenzen daneben stehen bleiben. */ const hand = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kHand }, { name: "Heimlich", rolle: "modi" }); - ok(hand.code === 404, `die rechte Hand kommt am Anlegen nicht vorbei (${hand.code})`); + ok(hand.code === 201, `die rechte Hand darf einen Zugang anlegen (${hand.code})`); + + /* ABER NICHT ALLES. Sperren gehoert nicht dazu -- das ist die + Grenze, die "anlegen" von "verwalten" trennt. Ohne diese Zeile + waere die Aenderung oben eine Erlaubnis ohne Rand. */ + const handSperrt = await roh(`/workspace/api/verwaltung/personen/${json(hand).id}/aktiv`, + CREW, { cookie: kHand }, { aktiv: 0 }, "PUT"); + ok(handSperrt.code === 404 || handSperrt.code === 403, + `aber sperren darf sie niemanden (${handSperrt.code})`); + + /* UND DAS PROTOKOLL BLEIBT BEI DOGFATHER. Dort stehen die + Einstellungsaenderungen des ganzen Hauses. */ + const handProtokoll = await roh("/workspace/api/verwaltung/protokoll", CREW, { cookie: kHand }); + ok(handProtokoll.code === 404 || handProtokoll.code === 403, + `und ins Protokoll sieht sie nicht (${handProtokoll.code})`); + + /* EIN MODI DARF WEITERHIN GAR NICHTS DAVON. Das ist die eigentliche + Aussage dieses Blocks -- und sie ist unveraendert. */ const einModi = await roh("/workspace/api/verwaltung/personen", CREW, { cookie: kModi }, { name: "Heimlich", rolle: "modi" }); - ok(einModi.code === 404, `ein Modi erst recht nicht (${einModi.code})`); + ok(einModi.code === 404, `ein Modi kommt am Anlegen nicht vorbei (${einModi.code})`); /* GEGENPROBE: DogFather kann es -- sonst waere "404" oben auch dann gruen, wenn der Weg gar nicht existiert. */ @@ -1477,7 +1511,7 @@ melde("=== 17. Der Anschluss im Browser ==="); const neueTexte = await seite.evaluate(() => [...document.querySelectorAll(".t-start, .t-person")] .map((e) => e.textContent || "").join(" ")); - ok(!/modis?/i.test(neueTexte), + ok(!/\bmodis?\b/i.test(neueTexte), `die neuen Bausteine nennen die Rolle nicht (${neueTexte.length} Zeichen geprueft)`); /* GEGENPROBE zur Voraussetzung: Es wurde ueberhaupt etwas angesehen. Ohne Text waere die Zeile darueber auch dann gruen, diff --git a/server/pruef-personen-formular.mjs b/server/pruef-personen-formular.mjs index cd931682..b26bc23e 100644 --- a/server/pruef-personen-formular.mjs +++ b/server/pruef-personen-formular.mjs @@ -235,7 +235,7 @@ ok(mass.kartenZeichen.modi && mass.kartenZeichen.modi !== mass.kartenZeichen.adm Auf dem Bildschirmfoto stand "Sichtbar nur fuer die DogFather-Rolle" -- in einem Text, den jemand liest, sieht das aus wie ein Fehler, weil es einer ist. */ -ok(mass.kartenTexte.modi && !/(fuer|ueber|waehrend|koennen|moeglich)/ +ok(mass.kartenTexte.modi && !/\b(fuer|ueber|waehrend|koennen|moeglich)\b/ .test(mass.kartenTexte.modi), `und ihre Beschreibung hat echte Umlaute ("${mass.kartenTexte.modi.slice(0, 60)}…")`); ok(mass.wahlUnten <= mass.formUnten, diff --git a/server/pruef-zeichen.mjs b/server/pruef-zeichen.mjs new file mode 100644 index 00000000..0b088d5c --- /dev/null +++ b/server/pruef-zeichen.mjs @@ -0,0 +1,274 @@ +/* ===================================================================== + STEHT IN EINER AUSGELIEFERTEN DATEI EIN ZERSTOERTES ZEICHEN? + + WARUM ES DIESE PRUEFUNG GIBT: Weil genau das zweimal live stand, + und zwar am selben Tag (23.09.2026), in zwei Dateien. + + 1. `content: "<0x15>C9<0x00>A0"` in chat.css. Gemeint war U+25C9 + (dasselbe Zeichen, das chat.js fuer das Rudel benutzt) und ein + geschuetztes Leerzeichen. Im Quelltext standen stattdessen ZWEI + ROHE STEUERZEICHEN. Auf dem Bildschirm las man deshalb woertlich + "C9 A0" vor jedem hervorgehobenen @rudel -- die Steuerzeichen + selbst sind unsichtbar. + + 2. `content: "¹3"` in chat.css UND entwicklung.css. Gemeint + war ein Haken. Auf der gewaehlten Kachel stand "¹3". + + DAS BESONDERE AN DIESER FEHLERART: Niemand sieht sie beim Lesen. + Punkt 1 war sogar committet, ausgeliefert und lag live -- und als + ich in der Datei danach suchte, meldete grep "Binary file matches" + und zeigte die Zeile gar nicht erst an. Ein Mensch, der den + Unterschied durchsieht, liest `content: "C9 A0"` und denkt sich + nichts dabei. + + Gefunden hat es am Ende Filipes Bildschirmfoto, nicht der Quelltext. + Das ist zu spaet und zu zufaellig. Also eine Pruefung. + + --------------------------------------------------------------------- + WAS GEPRUEFT WIRD, UND WARUM DIE GRENZE DA LIEGT + + AUSGELIEFERTE DATEIEN (workspace/, assets/, *.html) -- dort ist + beides ein Fehler: ein Steuerzeichen UND ein Ersatzzeichen (U+FFFD). + Was dort steht, sieht ein Mensch. + + SERVERDATEIEN -- dort nur Steuerzeichen. Ein U+FFFD kann in einer + Pruefung absichtlich stehen: pruef-ics.mjs sucht genau danach in + Kalenderdateien. Eine Ausnahmeliste mit Dateinamen waere die + naheliegende Loesung und die schlechtere -- sie altert. Die Grenze + "wird es ausgeliefert?" altert nicht. + + TABULATOR, ZEILENVORSCHUB UND WAGENRUECKLAUF sind erlaubt. Alles + andere unter 0x20 ist in Quelltext nie Absicht. + ===================================================================== */ +import { readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs"; +import { join, dirname, extname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* NOTBREMSE. Auch eine Pruefung ohne Netzzugriff bekommt eine -- ein + Lauf, der haengt, ist schlimmer als einer, der scheitert (06.09.2026, + damals drei Stunden). */ +const notbremse = setTimeout(() => { + console.log(" FEHL Notbremse: die Pruefung haengt."); + process.exit(1); +}, 60_000); + +const ENDUNGEN = new Set([".js", ".mjs", ".html", ".css", ".json", ".svg", + ".txt", ".webmanifest"]); + +/* Ordner, die uns nicht gehoeren oder erzeugt sind. */ +const AUSSEN = new Set([".git", "node_modules", "sicherungen", ".tmp"]); + +/** Alle Dateien unterhalb eines Ordners, die uns angehen. */ +function dateien(ordner) { + const raus = []; + const gehe = (o) => { + let eintraege; + try { eintraege = readdirSync(o, { withFileTypes: true }); } catch { return; } + for (const e of eintraege) { + if (e.name.startsWith(".tmp-")) continue; + const p = join(o, e.name); + if (e.isDirectory()) { if (!AUSSEN.has(e.name)) gehe(p); continue; } + if (ENDUNGEN.has(extname(e.name).toLowerCase())) raus.push(p); + } + }; + gehe(ordner); + return raus; +} + +/* --------------------------------------------------------------------- + Die beiden Sucher. Getrennt, damit die Gegenprobe jeden einzeln + nachweisen kann. + --------------------------------------------------------------------- */ + +/** Rohe Steuerzeichen -- alles unter 0x20 ausser \t \n \r. */ +function steuerzeichenIn(roh) { + const treffer = []; + for (let i = 0; i < roh.length; i++) { + const b = roh[i]; + if (b >= 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) continue; + /* Die Zeile mitgeben -- eine Bytenummer allein sucht man lange. */ + const zeile = roh.subarray(0, i).toString("utf8").split("\n").length; + treffer.push({ zeile, byte: b }); + } + return treffer; +} + +/** Ersatzzeichen U+FFFD -- in UTF-8 die drei Bytes ef bf bd. */ +function ersatzzeichenIn(roh) { + const treffer = []; + let i = -1; + while ((i = roh.indexOf(Buffer.from([0xef, 0xbf, 0xbd]), i + 1)) !== -1) { + treffer.push({ zeile: roh.subarray(0, i).toString("utf8").split("\n").length }); + } + return treffer; +} + +/* ===================================================================== + 1. GEGENPROBE ZUERST + + Eine Pruefung, die immer bestaetigt, bestaetigt nichts. Bevor sie + ueber echte Dateien redet, weist sie nach, dass sie ueberhaupt + anschlagen KANN -- an einer Datei, die sie sich selbst baut. Ohne + das waere ein gruener Lauf hier auch dann gruen, wenn beide Sucher + ins Leere greifen (der Fehler vom 01.09.2026, zweimal an einem Tag). + ===================================================================== */ +console.log("=== 0. Kann die Pruefung ueberhaupt Alarm schlagen? ==="); +{ + const probe = join(tmpdir(), "pruef-zeichen-probe.css"); + try { + /* Genau die beiden Faelle, die live standen. */ + writeFileSync(probe, Buffer.concat([ + Buffer.from('a { content: "'), + Buffer.from([0x15]), Buffer.from("C9"), + Buffer.from([0x00]), Buffer.from('A0"; }\n'), + Buffer.from('b::after { content: "'), Buffer.from([0xef, 0xbf, 0xbd]), Buffer.from('"; }\n'), + ])); + const roh = readFileSync(probe); + const st = steuerzeichenIn(roh); + const er = ersatzzeichenIn(roh); + ok(st.length === 2, `sie findet rohe Steuerzeichen (${st.length}, erwartet 2)`); + ok(er.length === 1, `sie findet Ersatzzeichen (${er.length}, erwartet 1)`); + + /* Und die andere Richtung: saubere Datei, kein Alarm. Eine + Pruefung, die das richtige Verhalten bestraft, wird abgeschafft + (03.09.2026). */ + writeFileSync(probe, 'a { content: "\\25C9\\A0"; }\n\tb { color: red; }\r\n'); + const sauber = readFileSync(probe); + ok(steuerzeichenIn(sauber).length === 0 && ersatzzeichenIn(sauber).length === 0, + "und sie schweigt bei einer sauberen Datei -- auch mit Tabulator und CRLF"); + } finally { + try { unlinkSync(probe); } catch { /* beim Aufraeumen ist Scheitern egal */ } + } +} + +/* ===================================================================== + 2. DIE AUSGELIEFERTEN DATEIEN + ===================================================================== */ +console.log(""); +console.log("=== 1. Was der Browser bekommt ==="); + +const geliefert = [ + ...dateien(join(WURZEL, "workspace")), + ...dateien(join(WURZEL, "assets")), + ...dateien(WURZEL).filter((p) => dirname(p) === WURZEL && extname(p) === ".html"), +]; + +{ + const schlimm = []; + for (const p of geliefert) { + const roh = readFileSync(p); + for (const t of steuerzeichenIn(roh)) { + schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Byte 0x${t.byte.toString(16).padStart(2, "0")})`); + } + for (const t of ersatzzeichenIn(roh)) { + schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Ersatzzeichen)`); + } + } + /* DIE ANZAHL STEHT IN DER BEDINGUNG, nicht nur im Text. Waere der + Ordner leer oder der Pfad falsch, meldete "keine Fundstellen" + sonst Erfolg -- gruen, ohne eine einzige Datei angesehen zu + haben. */ + ok(geliefert.length > 50 && schlimm.length === 0, + `${geliefert.length} ausgelieferte Dateien, ${schlimm.length} mit zerstoerten Zeichen`); + for (const s of schlimm.slice(0, 12)) console.log(" " + s); + if (schlimm.length > 12) console.log(` ... und ${schlimm.length - 12} weitere`); +} + +/* ===================================================================== + 3. DIE SERVERDATEIEN -- nur Steuerzeichen + ===================================================================== */ +console.log(""); +console.log("=== 2. Der Server ==="); + +{ + const schlimm = []; + const serverDateien = [...dateien(join(WURZEL, "server")), ...dateien(join(WURZEL, "tools"))]; + for (const p of serverDateien) { + const roh = readFileSync(p); + for (const t of steuerzeichenIn(roh)) { + schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Byte 0x${t.byte.toString(16).padStart(2, "0")})`); + } + } + ok(serverDateien.length > 50 && schlimm.length === 0, + `${serverDateien.length} Serverdateien, ${schlimm.length} mit Steuerzeichen`); + for (const s of schlimm.slice(0, 12)) console.log(" " + s); +} + +/* ===================================================================== + 4. DIE STELLE, AN DER ES ZWEIMAL PASSIERT IST + + `content:` in CSS ist der eine Ort, an dem ein Zeichen SICHTBAR + wird, ohne dass es im Text steht. Beide Fehler sassen dort. + + --------------------------------------------------------------------- + DIE REGEL WAR IM ERSTEN ANLAUF ZU WEIT -- und das ist hier notiert, + weil es der wichtigere Teil ist. + + Zuerst stand hier "jedes Zeichen ueber 0x7f ist verdaechtig, schreib + es als Escape". Das meldete auf Anhieb ACHT Stellen: ✓ in vier + Dateien, ↯, ↻, „ und ⚠. Alle acht sind richtig, absichtlich und + seit Monaten in Ordnung. Eine Pruefung, die beim ersten Lauf acht + korrekte Stellen anmeckert, wird beim neunten Mal weggeklickt -- und + dann faengt sie auch den echten Fall nicht mehr (03.09.2026). + + DIE ENGERE REGEL KOMMT AUS DEM BEFUND SELBST. Das kaputte Zeichen + war U+00B9 (hochgestellte Eins) -- ein Rest aus dem Latin-1-Block. + Genau dort, zwischen U+0080 und U+00BF, liegen die typischen + Truemmer einer verungluekten Kodierung: ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ ® + ° ± ² ³ ´ µ ¶ · ¸ ¹ º » ¼ ½ ¾. + + Nachgemessen ueber alle 203 content-Angaben im Haus: KEINE + einzige benutzt ein Zeichen aus diesem Block. Alle echten liegen + darueber (✓ = U+2713, ⚠ = U+26A0, „ = U+201E). Die Regel ist damit + heute gruen, haette den Fehler aber gefunden -- und sie ist nicht + geraten, sondern an den vorhandenen Daten geprueft. + ===================================================================== */ +console.log(""); +console.log("=== 3. Zeichen, die CSS auf den Bildschirm schreibt ==="); + +/** Truemmerverdacht: Latin-1-Block, wo echte Typografie nie hingehoert. */ +const istTruemmer = (c) => c >= 0x80 && c <= 0xbf; + +{ + const verdaechtig = []; + let stellen = 0; + for (const p of geliefert.filter((x) => extname(x) === ".css")) { + const text = readFileSync(p, "utf8"); + for (const m of text.matchAll(/content:\s*"([^"]*)"/g)) { + stellen++; + for (const z of m[1]) { + const c = z.codePointAt(0); + if (istTruemmer(c)) { + verdaechtig.push(`${relative(WURZEL, p)}: content "${m[1]}" enthaelt U+${c.toString(16).toUpperCase().padStart(4, "0")}`); + } + } + } + } + ok(stellen > 50 && verdaechtig.length === 0, + `${stellen} content-Angaben in CSS, ${verdaechtig.length} mit Kodierungstruemmern`); + for (const s of verdaechtig.slice(0, 12)) console.log(" " + s); + + /* GEGENPROBE: Wuerde sie den echten Fall sehen? Nicht theoretisch -- + an genau der Zeichenfolge, die in chat.css stand. */ + const kaputt = [...'content: "¹3"'.matchAll(/content:\s*"([^"]*)"/g)] + .flatMap((m) => [...m[1]]).some((z) => istTruemmer(z.codePointAt(0))); + const heil = [...'content: "✓"'.matchAll(/content:\s*"([^"]*)"/g)] + .flatMap((m) => [...m[1]]).some((z) => istTruemmer(z.codePointAt(0))); + ok(kaputt && !heil, + 'sie erkennt das echte "¹3" und laesst den richtigen Haken in Ruhe'); +} + +clearTimeout(notbremse); +console.log(""); +console.log(fehler === 0 + ? `Alles in Ordnung — ${geprueft} Prüfungen` + : `${fehler} FEHLER — ${geprueft} Prüfungen`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-treff.js b/server/workspace-treff.js index 82dbf8ac..7a27c1c9 100644 --- a/server/workspace-treff.js +++ b/server/workspace-treff.js @@ -452,7 +452,7 @@ treffRouter.get("/workspace/api/treff/regeln", (req, res) => { Skript. Jetzt kommt er von hier. UND DER SCHLUESSEL HIESS SELBST SO: `modi_frist_tage`. Das - Wortleck-Muster ist `modi` und trifft das nicht, weil danach + Wortleck-Muster ist `\bmodi\b` und trifft das nicht, weil danach ein Unterstrich folgt -- ein Leck durch eine Luecke im Muster, nicht durch eine Entscheidung. Deshalb heisst das Feld jetzt nach dem, was es bedeutet. */ diff --git a/server/workspace.js b/server/workspace.js index a0a81c19..121707c4 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -7220,15 +7220,53 @@ export function einstellung(schluessel, vorgabe = null) { } } +/* WELCHE EINSTELLUNGEN NICHT IM KLARTEXT INS PROTOKOLL GEHOEREN + --------------------------------------------------------------------- + Gefunden am 23.09.2026, beim Vorbereiten des KLIPY-Zugangs. + + einstellungSetzen() schrieb bis dahin IMMER Name UND Wert ins + Protokoll. Fuer "nachtruhe_an = 1" ist das genau richtig -- man + will sehen, wer wann was umgestellt hat. Fuer einen Schluessel + ist es das Gegenteil. + + NACHGEMESSEN in der echten Datenbank, nicht vermutet: + + kopie_schluessel (31.08.) steht VOLLSTAENDIG im Protokoll + vapid_paar (05.09.) wurde bei 120 Zeichen abgeschnitten, + kurz VOR dem privaten Teil + + Dass der zweite heil blieb, lag an einer Laengengrenze, nicht an + einer Absicht. Das ist keine offene Tuer -- das Protokoll liegt + hinter nurAdmin --, aber der Wert wandert in JEDE Sicherung und in + jeden Export. Und der naechste Schluessel waere denselben Weg + gegangen. + + ES IST EINE REGEL UND KEINE LISTE. Eine Liste + ["kopie_schluessel","vapid_paar"] waere beim naechsten Geheimnis + still unvollstaendig -- derselbe Fehler wie bei den abgeschriebenen + Spaltennamen am 11.09. Wer eine Einstellung so BENENNT, meint auch + ein Geheimnis. Faellt ein harmloser Name versehentlich darunter, + ist der Schaden, dass im Protokoll etwas weniger Text steht. + + WAS STEHEN BLEIBT: dass sich die Einstellung geaendert hat, wann, + und durch wen. Nur der Wert fehlt. Ein Protokolleintrag ohne + Eintrag waere die schlechtere Loesung -- dann saehe niemand mehr, + dass jemand den Schluessel ausgetauscht hat. */ +const GEHEIME_EINSTELLUNG = /(schluessel|schlüssel|geheim|token|passwort|kennwort|secret|paar|(^|[_-])key([_-]|$))/i; + export function einstellungSetzen(schluessel, wert, akteur = null) { db().prepare(` INSERT INTO einstellungen (schluessel, wert, geaendert, von) VALUES (?,?,?,?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert, von = excluded.von`).run( schluessel, String(wert), new Date().toISOString(), akteur?.id ?? null); + const roh = String(wert ?? ""); + const detail = GEHEIME_EINSTELLUNG.test(String(schluessel)) + ? `${schluessel} = ${roh ? "(gesetzt, " + roh.length + " Zeichen)" : "(geleert)"}` + : `${schluessel} = ${wert}`; protokolliere("einstellung_geaendert", { personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, - detail: `${schluessel} = ${wert}`.slice(0, 120), + detail: detail.slice(0, 120), }); } diff --git a/tools/nachtlauf.mjs b/tools/nachtlauf.mjs new file mode 100644 index 00000000..3c89b18f --- /dev/null +++ b/tools/nachtlauf.mjs @@ -0,0 +1,327 @@ +/* ===================================================================== + nachtlauf.mjs — einmal in der Nacht alles prüfen und es AUFSCHREIBEN + + Filipe am 23.09.2026 auf die Frage, ob ein nächtlicher Lauf die + Prüfungen selbst anstoßen und morgens sagen soll, welche rot sind: + „ja". + + --------------------------------------------------------------------- + WARUM ES DAS BRAUCHT — drei Funde an EINEM Tag + + Am 23.09.2026 sind mir drei Prüfungen aufgefallen, die rot waren, + ohne dass es jemandem aufgefallen wäre: + + pruef-modi-wortleck seit dem 22.09. — der verborgene Rollenname + stand offen im Netz + pruef-zuteilung stürzte ab und prüfte danach gar nichts mehr + pruef-push-ziel meldete einen Befund, der in Wahrheit eine + erfüllte Bestellung war + + Alle drei habe ich durch ZUFALL gefunden — weil sie neben dem lagen, + woran ich gerade gebaut habe. Prüfungen laufen nur, wenn jemand sie + startet, und man startet die, an denen man arbeitet. + + --------------------------------------------------------------------- + WOHIN DAS ERGEBNIS GEHT — und wohin ausdrücklich NICHT + + In eine Notiz im Vault. NICHT in die laufende Anwendung. + + Das ist keine Bequemlichkeit, sondern die Lehre vom 04.09.2026: Ein + Prüflauf ist kein harmloser Lesevorgang, sein Ergebnis wird + VERÖFFENTLICHT. Damals hat ein Netzproblem bei npm einen nächtlichen + Selbsttest rot gemacht, und ab da stand VanVan in roter Schrift auf + dem Startbildschirm, die Buchführung sei womöglich verletzt. An der + Buchführung war nichts. + + Wer einen Prüflauf anstößt, muss vorher überlegen, was der + SCHLECHTESTE Ausgang auf dem Bildschirm einer Nutzerin anrichtet. + Hier richtet er nichts an: Er schreibt eine Notiz, die Filipe und ich + lesen. Die Community sieht davon nichts. + + --------------------------------------------------------------------- + DREI AUSGÄNGE, NICHT ZWEI + + in Ordnung alles grün + nicht in Ordnung mindestens eine Datei rot + KONNTE NICHT NACHSEHEN der Lauf hat gar nicht stattgefunden + + Der dritte ist der wichtige. Wenn der Rechner nachts aus war, gibt es + kein Ergebnis — und ein FEHLENDES Ergebnis sieht sonst genauso aus + wie ein altes gutes. Die Notiz sagt deshalb immer, WANN zuletzt + wirklich gemessen wurde, und ab zwei Tagen ohne Lauf steht das ganz + oben. + + --------------------------------------------------------------------- + DIE ZAHL WIRD MIT GESTERN VERGLICHEN + + Ein grüner Lauf ist kein Beweis, solange nicht auch die ANZAHL + stimmt (28.08.2026, RunOne: 789 statt 804 Prüfungen, weil acht je + Gerät stillschweigend weggefallen waren). Diese Datei merkt sich + deshalb den letzten Stand und meldet jeden Rückgang — auch wenn + alles grün ist. + + AUFRUF: + node tools/nachtlauf.mjs normal + node tools/nachtlauf.mjs --jetzt auch wenn gerade gearbeitet wird + ===================================================================== */ + +import { spawn } from "node:child_process"; +import { + writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, statSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); + +/* Der Vault -- dort liest Filipe. Absichtlich fest und nicht geraten: + Wenn der Ordner umzieht, soll das auffallen und nicht still in einen + neuen Ordner schreiben, den niemand aufmacht. */ +const VAULT = "C:/Users/qciga/Documents/Obelix/DogFather"; +const NOTIZ = join(VAULT, "02 Projekte", "Pruefstand.md"); +const ZUSTAND = join(WURZEL, "tools", ".nachtlauf-stand.json"); +const SCHLOSS = join(WURZEL, "tools", ".nachtlauf-laeuft"); + +/* ZWEI LÄUFE GLEICHZEITIG SIND KEIN LAUF, SONDERN ZWEI KAPUTTE + (06.09.2026, RunOne). Beide belegen dieselben Ports und legen + dieselben Testdaten an; heraus kommen Fehler, die es nicht gibt -- + und wer sie für echt hält, sucht stundenlang an Stellen, an denen + nichts kaputt ist. + + DREI STUNDEN, dann gilt das Schloss als vergessen. Ein Schloss, das + nie verfällt, blockiert beim nächsten Stromausfall für immer -- und + was blockiert, wird abgeschafft. */ +const SCHLOSS_FRIST_MS = 3 * 60 * 60 * 1000; + +const zweiZiffern = (n) => String(n).padStart(2, "0"); +function zeitWort(d = new Date()) { + return `${zweiZiffern(d.getDate())}.${zweiZiffern(d.getMonth() + 1)}.${d.getFullYear()}` + + ` um ${zweiZiffern(d.getHours())}:${zweiZiffern(d.getMinutes())}`; +} + +function schlossNehmen() { + if (existsSync(SCHLOSS)) { + let alter = Infinity; + try { alter = Date.now() - statSync(SCHLOSS).mtimeMs; } catch { /* egal */ } + if (alter < SCHLOSS_FRIST_MS) { + let wer = ""; + try { wer = readFileSync(SCHLOSS, "utf8").trim(); } catch { /* egal */ } + return { ok: false, grund: `Es läuft schon einer (seit ${Math.round(alter / 60000)} min, ${wer}).` }; + } + /* Älter als die Frist: Der gehört niemandem mehr. */ + try { unlinkSync(SCHLOSS); } catch { /* egal */ } + } + try { + writeFileSync(SCHLOSS, `PID ${process.pid}, gestartet ${zeitWort()}\n`, { flag: "wx" }); + return { ok: true }; + } catch { + return { ok: false, grund: "Das Schloss ließ sich nicht setzen." }; + } +} +function schlossAbgeben() { try { unlinkSync(SCHLOSS); } catch { /* egal */ } } + +function standLesen() { + try { return JSON.parse(readFileSync(ZUSTAND, "utf8")); } catch { return null; } +} + +/* --------------------------------------------------------------------- + Der Lauf selbst -- alles-pruefen.mjs macht die eigentliche Arbeit. + + KEIN ZWEITER LÄUFER: Dort stecken die Frist je Datei, das Zählen der + Einzelpunkte und die Regel "null Prüfungen ist ein Fehler". Das hier + noch einmal zu bauen hiesse, zwei Rechnungen über dieselbe Frage zu + haben -- und eine davon wäre irgendwann anders. + --------------------------------------------------------------------- */ +function laufen() { + return new Promise((fertig) => { + const start = Date.now(); + const kind = spawn(process.execPath, [join("tools", "alles-pruefen.mjs")], + { cwd: WURZEL, stdio: ["ignore", "pipe", "pipe"] }); + let aus = ""; + kind.stdout.on("data", (d) => { aus += d; }); + kind.stderr.on("data", (d) => { aus += d; }); + kind.on("error", (f) => fertig({ ging: false, grund: String(f?.message || f), aus })); + kind.on("close", (code) => fertig({ + ging: true, code, aus, + minuten: Math.round((Date.now() - start) / 60000), + })); + }); +} + +/** Aus der Ausgabe die Zahlen holen -- gelesen, nicht geschätzt. */ +function auswerten(aus) { + const dateien = aus.match(/Dateien:\s+(\d+) von (\d+)/); + const punkte = aus.match(/Einzelpunkte:\s+(\d+) von (\d+)/); + const rote = []; + for (const treffer of aus.matchAll(/^### (pruef-[\w-]+\.mjs) \(Exitcode (-?\d+)\)$/gm)) { + /* Die erste FEHL-Zeile darunter ist die Auskunft, die man braucht -- + der Rest ist Zusammenhang. */ + const ab = aus.indexOf(treffer[0]) + treffer[0].length; + const stueck = aus.slice(ab, ab + 900); + const erste = (stueck.split("\n").find((z) => /FEHL|Error|error:/.test(z)) || "").trim(); + rote.push({ datei: treffer[1], code: Number(treffer[2]), erste: erste.slice(0, 160) }); + } + return { + dateienOk: dateien ? Number(dateien[1]) : null, + dateien: dateien ? Number(dateien[2]) : null, + punkteOk: punkte ? Number(punkte[1]) : null, + punkte: punkte ? Number(punkte[2]) : null, + rote, + }; +} + +/* --------------------------------------------------------------------- + Die Notiz + --------------------------------------------------------------------- */ +function notizSchreiben({ lage, vorher, minuten, konnteNicht }) { + const jetzt = new Date(); + const zeilen = []; + zeilen.push("# Prüfstand Team Dogi"); + zeilen.push(""); + zeilen.push("*Diese Notiz schreibt `tools/nachtlauf.mjs` — von Hand ändern bringt nichts,"); + zeilen.push("beim nächsten Lauf ist es wieder überschrieben.*"); + zeilen.push(""); + + if (konnteNicht) { + zeilen.push("## ⚠ Konnte nicht nachsehen"); + zeilen.push(""); + zeilen.push(konnteNicht); + zeilen.push(""); + if (vorher?.wann) { + const tage = Math.floor((Date.now() - vorher.zeit) / 86400000); + zeilen.push(`Zuletzt wirklich gemessen: **${vorher.wann}**` + + (tage >= 1 ? ` — das ist **${tage} Tag${tage === 1 ? "" : "e"} her**.` : ".")); + } else { + zeilen.push("Es gibt noch kein früheres Ergebnis."); + } + return zeilen.join("\n") + "\n"; + } + + const rot = lage.rote.length; + zeilen.push(rot + ? `## ${rot} Prüfung${rot === 1 ? "" : "en"} ${rot === 1 ? "ist" : "sind"} rot` + : "## Alles in Ordnung"); + zeilen.push(""); + zeilen.push(`Gemessen am **${zeitWort(jetzt)}**, Dauer ${minuten} Minuten.`); + zeilen.push(""); + zeilen.push("| | jetzt | vorher |"); + zeilen.push("|---|---|---|"); + zeilen.push(`| Dateien in Ordnung | ${lage.dateienOk} von ${lage.dateien} | ` + + `${vorher ? `${vorher.dateienOk} von ${vorher.dateien}` : "—"} |`); + zeilen.push(`| Einzelprüfungen bestanden | ${lage.punkteOk} von ${lage.punkte} | ` + + `${vorher ? `${vorher.punkteOk} von ${vorher.punkte}` : "—"} |`); + zeilen.push(""); + + /* EIN RÜCKGANG DER ANZAHL IST EIN BEFUND, AUCH WENN ALLES GRÜN IST. + Am 28.08.2026 meldete ein Lauf 789 statt 804 Prüfungen und war + dabei grün: Acht Prüfungen je Gerät waren stillschweigend + weggefallen -- ausgerechnet die, die die wichtigste Funktion + absichern. Wer nur "bestanden" liest, hätte sie ab da dauerhaft + ungeprüft gelassen. */ + if (vorher && lage.punkte != null && vorher.punkte != null + && lage.punkte < vorher.punkte) { + zeilen.push(`> **Es werden ${vorher.punkte - lage.punkte} Prüfungen WENIGER gemacht als beim letzten Lauf.**`); + zeilen.push("> Das ist auch dann ein Befund, wenn alles grün ist: Eine Prüfung, die"); + zeilen.push("> still nicht mehr läuft, beweist nichts — und niemandem fällt es auf."); + zeilen.push(""); + } + if (vorher && lage.dateien != null && vorher.dateien != null + && lage.dateien < vorher.dateien) { + zeilen.push(`> **Es gibt ${vorher.dateien - lage.dateien} Prüfdatei(en) weniger als beim letzten Lauf.**`); + zeilen.push(""); + } + + if (rot) { + zeilen.push("## Was rot ist"); + zeilen.push(""); + for (const r of lage.rote) { + zeilen.push(`### \`${r.datei}\``); + zeilen.push(""); + zeilen.push(r.erste ? `\`\`\`\n${r.erste}\n\`\`\`` : "*(keine Fehlerzeile gefunden)*"); + zeilen.push(""); + zeilen.push(`Einzeln nachsehen: \`node server/${r.datei}\``); + zeilen.push(""); + } + zeilen.push("**Rot heißt nicht automatisch kaputt.** Dreimal am 23.09.2026 war"); + zeilen.push("die Prüfung veraltet und das Programm richtig — einmal war es"); + zeilen.push("umgekehrt und ein Rollenname stand offen im Netz. Erst nachsehen,"); + zeilen.push("dann entscheiden, wer recht hat."); + } else { + zeilen.push("Keine Datei ist rot. Der nächste Lauf kommt in der nächsten Nacht."); + } + zeilen.push(""); + return zeilen.join("\n") + "\n"; +} + +/* --------------------------------------------------------------------- + Los + --------------------------------------------------------------------- */ +const vorher = standLesen(); +const erzwingen = process.argv.includes("--jetzt"); + +const schloss = erzwingen ? { ok: true } : schlossNehmen(); +if (!schloss.ok) { + /* DER DRITTE AUSGANG. Kein Fehler -- nur: hier war nichts zu messen. */ + mkdirSync(dirname(NOTIZ), { recursive: true }); + writeFileSync(NOTIZ, notizSchreiben({ vorher, konnteNicht: schloss.grund }), "utf8"); + console.log("Konnte nicht nachsehen: " + schloss.grund); + process.exit(0); +} + +let ergebnis; +try { + console.log("Nachtlauf gestartet — das dauert. " + zeitWort()); + ergebnis = await laufen(); +} finally { + if (!erzwingen) schlossAbgeben(); +} + +if (!ergebnis.ging) { + mkdirSync(dirname(NOTIZ), { recursive: true }); + writeFileSync(NOTIZ, notizSchreiben({ + vorher, konnteNicht: `Der Läufer ließ sich nicht starten: ${ergebnis.grund}`, + }), "utf8"); + console.log("Konnte nicht nachsehen: " + ergebnis.grund); + process.exit(0); +} + +const lage = auswerten(ergebnis.aus); + +/* KONNTE DER LAUF ÜBERHAUPT ETWAS MESSEN? Steht keine Zahl in der + Ausgabe, ist etwas anderes schiefgegangen als eine rote Prüfung -- + und "0 von 0 in Ordnung" wäre die beruhigendste Lüge von allen. */ +if (lage.dateien == null || lage.punkte == null || lage.punkte === 0) { + mkdirSync(dirname(NOTIZ), { recursive: true }); + writeFileSync(NOTIZ, notizSchreiben({ + vorher, + konnteNicht: "Der Lauf hat keine einzige Zahl geliefert — vermutlich ist er" + + " abgebrochen, bevor er anfangen konnte.\n\nLetzte Ausgabe:\n\n```\n" + + ergebnis.aus.slice(-700) + "\n```", + }), "utf8"); + console.log("Konnte nicht nachsehen: keine Zahlen in der Ausgabe."); + process.exit(0); +} + +mkdirSync(dirname(NOTIZ), { recursive: true }); +writeFileSync(NOTIZ, notizSchreiben({ lage, vorher, minuten: ergebnis.minuten }), "utf8"); + +/* Der Stand für den nächsten Vergleich -- erst NACH der Notiz, damit + ein Absturz beim Schreiben nicht den alten Vergleichswert wegnimmt. */ +writeFileSync(ZUSTAND, JSON.stringify({ + wann: zeitWort(), zeit: Date.now(), + dateien: lage.dateien, dateienOk: lage.dateienOk, + punkte: lage.punkte, punkteOk: lage.punkteOk, + rote: lage.rote.map((r) => r.datei), +}, null, 1), "utf8"); + +/* Die ganze Ausgabe daneben -- für den Fall, dass die Notiz nicht + reicht. Sie wird jedes Mal überschrieben; zwei Läufe zu vergleichen + ist Sache der Zahlen oben, nicht eines wachsenden Protokolls. */ +writeFileSync(join(WURZEL, "tools", ".nachtlauf-letzte-ausgabe.txt"), ergebnis.aus, "utf8"); + +console.log(`Fertig nach ${ergebnis.minuten} min: ` + + `${lage.dateienOk} von ${lage.dateien} Dateien, ` + + `${lage.punkteOk} von ${lage.punkte} Einzelprüfungen.`); +console.log(`Ergebnis steht in: ${NOTIZ}`); +process.exit(lage.rote.length ? 1 : 0); diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css index 4c59bcbc..776e8b51 100644 --- a/workspace/assets/css/entwicklung.css +++ b/workspace/assets/css/entwicklung.css @@ -1533,7 +1533,7 @@ /* Ein Haken, kein Aufzählungspunkt: Was trägt, ist etwas Erledigtes, und das sieht man an der Form schneller als am Wort. */ .b-fazit__punkt::before { - content: "¹3"; + content: "\2713"; position: absolute; left: 0; top: 0; color: var(--gut, #5fc08a); font-weight: 700;