diff --git a/server/pruef-rollstelle.mjs b/server/pruef-rollstelle.mjs new file mode 100644 index 00000000..0124eacd --- /dev/null +++ b/server/pruef-rollstelle.mjs @@ -0,0 +1,179 @@ +/* ===================================================================== + pruef-rollstelle.mjs — Beim Zurückgehen dort bleiben, wo man war + + Filipe, zum dritten Mal und in Großbuchstaben (20.09.2026): + „WENN ICH IN EINE KATEGORIE REIN GEHE UND DAN ZURÜCK, GEHT DIE + HAUPTSEITE IMMER WIEDER GANZ HOCH; DAS NERVT ... SO DASS WENN MAN + ZURÜCK GEHT MAN DAN GENAU DA IST UND BLEIBT WO MAN VORHER WAR." + + --------------------------------------------------------------------- + WAS GEMESSEN WURDE, BEVOR ETWAS GEBAUT WURDE + + Scrollen auf 900, eine Kachel anklicken, zurück: + + gemerkt: 900 + gelandet: 1054 <- 154 px daneben, zuverlässig + + Die Wiederherstellung LIEF also — sie traf nur nicht. Die Erklärung + ist Chromes Scroll-Verankerung: Wächst Inhalt OBERHALB der Stelle, + verschiebt der Browser den Bildlauf mit, damit das Sichtbare stehen + bleibt. Im Alltag richtig, beim Wiederherstellen das Gegenteil: Wir + setzen 900, danach lädt der Inhalt darüber nach, und aus 900 wird + 1054. + + --------------------------------------------------------------------- + ZWEI FALLEN BEIM MESSEN, IN BEIDE BIN ICH GELAUFEN + + 1. `scrollIntoViewIfNeeded()` vor dem Klick scrollt SELBST. Gemerkt + wurde dann die neue Stelle, und die Prüfung meldete einen Fehler, + den es nicht gab. Deshalb wird hier ein Link genommen, der schon + im Bild steht. + + 2. Playwright schaltet den Vor-/Zurück-Speicher des Browsers ab. + Diese Prüfung misst deshalb ausdrücklich den Weg OHNE ihn — also + den schlechtesten Fall. Ist er im echten Browser aktiv, ist es + ohnehin sofort und exakt. + + AUFRUF: node server/pruef-rollstelle.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { notbremse } from "./helfer-notbremse.mjs"; + +const PORT = await portMussFreiSein(4432, "pruef-rollstelle"); +const ordner = mkdtempSync(join(tmpdir(), "ws-rollstelle-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(240_000, "pruef-rollstelle"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +console.log("\n=== Beim Zurueckgehen dort bleiben, wo man war ===\n"); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync("CODE-DOGI-0001", salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run("Filipe", "admin", hash, salt, 32768, new Date().toISOString()); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +try { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const s = await ctx.newPage(); + await s.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await s.click('.rolle[data-rolle="admin"]'); + await s.fill("#code", "CODE-DOGI-0001"); + await s.click("button[type=submit]"); + await s.waitForURL("**/start.html", { timeout: 25000 }); + await s.waitForTimeout(1800); + + const hoch = await s.evaluate(() => document.documentElement.scrollHeight); + ok(hoch > 1600, `die Startseite ist hoch genug zum Messen (${hoch} px)`); + + const ZIEL = 900; + await s.evaluate((y) => window.scrollTo(0, y), ZIEL); + await s.waitForTimeout(400); + const vorKlick = await s.evaluate(() => Math.round(window.scrollY)); + ok(vorKlick === ZIEL, `gescrollt auf ${vorKlick}`); + + /* EINEN LINK NEHMEN, DER SCHON IM BILD STEHT -- siehe Falle 1 oben. */ + const ziel = await s.evaluate(() => { + const mitte = window.scrollY + window.innerHeight / 2; + let best = null, dist = 1e9; + for (const a of document.querySelectorAll("a.kachel__link")) { + const r = a.getBoundingClientRect(); + if (r.top < 0 || r.bottom > window.innerHeight) continue; + const y = window.scrollY + r.top + r.height / 2; + if (Math.abs(y - mitte) < dist) { dist = Math.abs(y - mitte); best = a; } + } + if (!best) return null; + best.id = "pruef-ziel"; + return best.getAttribute("href"); + }); + ok(!!ziel, `eine Kachel im Bild gefunden (${ziel})`); + + await s.click("#pruef-ziel"); + await s.waitForLoadState("networkidle"); + await s.waitForTimeout(1200); + ok((await s.url()).includes(String(ziel).split("?")[0]), + "die Kachel hat wirklich geöffnet"); + + /* MITSCHREIBEN, WAS NACH DEM ZURUECKGEHEN PASSIERT. Ein Endwert sagt + nicht, ob die Seite erst richtig stand und dann verschoben wurde. */ + await s.addInitScript(() => { + window.__spur = []; + setInterval(() => window.__spur.push(Math.round(window.scrollY)), 120); + }); + await s.click("#zurueck"); + await s.waitForLoadState("networkidle"); + await s.waitForTimeout(2600); + + const nachher = await s.evaluate(() => Math.round(window.scrollY)); + const spur = await s.evaluate(() => (window.__spur || []).slice(0, 25)); + ok(Math.abs(nachher - ZIEL) <= 8, + `nach dem Zurückgehen wieder bei ${nachher} (war ${ZIEL})`); + + /* UND SIE BLEIBT DORT. Der erste Anlauf traf 900 und wurde danach von + der Scroll-Verankerung auf 1054 geschoben -- ein Endwert allein + hätte das nicht gezeigt. */ + const spaet = spur.slice(-8); + ok(spaet.length >= 4 && spaet.every((y) => Math.abs(y - ZIEL) <= 8), + `und bleibt dort (letzte Messwerte: ${spaet.join(", ")})`); + + /* ---- GEGENPROBE 1: ohne gemerkte Stelle darf nichts springen ---- + + OHNE KLICK. Zweimal bin ich in dieselbe Falle gelaufen: Playwright + scrollt von selbst zu einem Element ausserhalb des Bildes, die + Seite merkt sich diese Stelle, und die Gegenprobe meldet einen + Fehler, den es nicht gibt. Hier wird deshalb direkt navigiert. + + ganz oben (unter 80 px) LOESCHT die gemerkte Stelle sogar -- so + steht es in kopf.js: Wer zehn Pixel gescrollt hat, merkt vom + Wiederherstellen nichts. Genau das wird hier ausgenutzt. */ + await s.evaluate(() => window.scrollTo(0, 0)); + await s.waitForTimeout(300); + await s.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" }); + await s.waitForTimeout(700); + await s.goBack({ waitUntil: "networkidle" }); + await s.waitForTimeout(2000); + const ohne = await s.evaluate(() => Math.round(window.scrollY)); + ok(ohne < 80, + `Gegenprobe: ohne gemerkte Stelle beginnt die Seite oben (${ohne})`); + + /* ---- GEGENPROBE 2: die Verankerung ist danach wieder an -------- + Sie wird nur waehrend des Wiederherstellens abgeschaltet. Bliebe + sie aus, spraenge einem im Chat der Text unter dem Finger weg -- + eine Reparatur, die woanders etwas kaputt macht. */ + const anker = await s.evaluate(() => + getComputedStyle(document.documentElement).overflowAnchor); + ok(anker !== "none", + `die Scroll-Verankerung ist danach wieder an (${anker})`); + + await ctx.close(); +} finally { + await browser.close(); +} + +console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-schranke.mjs b/server/pruef-schranke.mjs index 981b53e4..8b9bcf25 100644 --- a/server/pruef-schranke.mjs +++ b/server/pruef-schranke.mjs @@ -108,15 +108,25 @@ const hol = (pfad, keks) => fetch(BASIS + pfad, { ======================================================================= */ console.log("\n=== Ohne Anmeldung ==="); -/* Die Liste aus workspace.js, damit hier keine zweite entsteht, die - irgendwann abweicht. */ -const { readFileSync } = await import("node:fs"); -const quelle = readFileSync(new URL("./workspace.js", import.meta.url), "utf8"); -const block = quelle.slice(quelle.indexOf("const GESCHUETZT = {")); -const SEITEN = [...block.slice(0, block.indexOf("\n};")).matchAll(/"(\/workspace\/[a-z]+\.html)"/g)] - .map((m) => m[1]); +/* DIE LISTE WIRD IMPORTIERT, NICHT AUS DEM QUELLTEXT GEKRATZT + (berichtigt 20.09.2026). -ok(SEITEN.length >= 15, `${SEITEN.length} geschuetzte Seiten aus workspace.js gelesen`); + Hier stand ein Textmuster auf "const GESCHUETZT = {" in + workspace.js. Diese Tabelle ist am 11.09.2026 nach rechte.js + UMGEZOGEN -- und seitdem fand das Muster nichts. Die Pruefung lief + weiter, meldete "0 geschuetzte Seiten" und pruefte damit neun Tage + lang NICHTS: keine Umleitung, keine Schreibweise, keine Schranke. + Drei rote Zeilen, die niemand las, weil sie nach einem kleinen + Zaehlfehler aussahen. + + Gefunden beim Umbau der Anmeldedauer -- also zufaellig. Ein + Textmuster auf fremden Quelltext ist genau die Art Verbindung, die + beim naechsten Umzug still reisst. Ein import reisst laut. */ +const { SEITEN: RECHTE_SEITEN } = await import("./rechte.js"); +const SEITEN = Object.keys(RECHTE_SEITEN) + .filter((x) => /workspace/.test(x) && x.endsWith(".html")); + +ok(SEITEN.length >= 15, `${SEITEN.length} geschuetzte Seiten aus rechte.js gelesen`); let geprueft = 0; for (const seite of SEITEN) { diff --git a/server/workspace.js b/server/workspace.js index 17d7528c..89b67e61 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -65,7 +65,37 @@ export const DB_PFAD = process.env.WORKSPACE_DB export const DATEN_ORDNER = dirname(DB_PFAD); const COOKIE = "dfw_sitzung"; -const SITZUNG_STUNDEN = 12; +/* ===== WIE LANGE GILT EINE ANMELDUNG? (20.09.2026) ================== + + Filipe: "kannst du bitte machen dass die leute sich nur einmal + anmelden muessen und dan nur noch abgemeldet werden wenn sie sich + selbst abmelden. damit sie auch die benarichtigungen sofort kriegen + ... und auch sofort die anrufe annehmen koennen." + + VORHER: zwoelf Stunden, feste Frist ab dem Anmelden. Wer morgens um + acht anfing, flog abends um acht raus -- mitten im Betrieb. Und wer + abgemeldet ist, hat die Seite nicht offen; ein Anruf erreicht ihn + dann nur noch ueber die Benachrichtigung, und bis er sich wieder + angemeldet hat, ist das Klingeln vorbei. + + JETZT: ein GLEITENDES Fenster. Wer die Seite benutzt, bleibt + angemeldet -- ohne Ende, ohne Nachfrage. Abgemeldet wird nur, wer + sich selbst abmeldet. + + WARUM NICHT "unendlich": Ein Zugang, der nie ablaeuft, ist auf einem + verlorenen Handy fuer immer offen -- und in diesem Haus liegen + vertrauliche Meldungen ueber Menschen. Wer ein halbes Jahr nicht da + war, meldet sich neu an; das ist niemandem zuzumuten zu viel und + schliesst das verlorene Geraet. + + WARUM NICHT BEI JEDEM AUFRUF NEU SCHREIBEN: Das waere ein + Schreibzugriff auf die Datenbank bei jedem Bild, jeder Abfrage, + jedem Herzschlag des Ereignisstroms. Verlaengert wird erst, wenn + weniger als die Haelfte des Fensters uebrig ist -- also hoechstens + einmal alle drei Monate je Geraet. + ==================================================================== */ +const SITZUNG_TAGE = 180; +const SITZUNG_MS = SITZUNG_TAGE * 86400_000; const VERSUCHE_MAX = 8; // pro IP const VERSUCHE_FENSTER_MIN = 10; const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"]); @@ -4448,6 +4478,38 @@ export function sitzungLesen(req) { db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token)); return null; } + + /* ==== DAS FENSTER GLEITET MIT (20.09.2026) ======================= + + Wer die Seite benutzt, bleibt angemeldet. Verlaengert wird erst, + wenn weniger als die HAELFTE des Fensters uebrig ist -- sonst + waere hier ein Schreibzugriff bei jedem einzelnen Aufruf, auch + bei jedem Herzschlag des Ereignisstroms. + + DER KEKS MUSS MIT. Ohne ihn liefe die Zeile in der Datenbank + weiter und der Browser wuerfe den Keks trotzdem weg -- die + Person waere abgemeldet, obwohl der Server sie noch kennt. Das + ist die Sorte Fehler, die niemand findet, weil beide Seiten fuer + sich richtig aussehen. + + `req.res` STATT EINES ZWEITEN ARGUMENTS: Diese Funktion wird an + 26 Stellen aufgerufen -- in jedem Fachmodul einmal. Einen + Parameter zu ergaenzen hiesse, 26 Aufrufe zu aendern und beim + 27. Modul daran zu denken. Express haengt die Antwort ohnehin an + die Anfrage; wo sie fehlt (etwa in einer Pruefung), wird eben + nur die Datenbank verlaengert. */ + const restMs = new Date(reihe.gueltig_bis).getTime() - Date.now(); + if (restMs < SITZUNG_MS / 2) { + const neuBis = new Date(Date.now() + SITZUNG_MS).toISOString(); + db().prepare("UPDATE sitzungen SET gueltig_bis = ? WHERE token_hash = ?") + .run(neuBis, tokenHash(token)); + try { + req.res?.cookie?.(COOKIE, token, { + httpOnly: true, secure: !!req.secure, sameSite: "lax", + path: "/workspace", maxAge: SITZUNG_MS, + }); + } catch { /* kein res (z. B. in einer Pruefung) -- die Zeile reicht */ } + } /* DIE ADRESSE ENTSCHEIDET MIT (10.09.2026). Seit die Modi-App unter crew.dogfather-universe.com laeuft, gehoert @@ -4527,7 +4589,7 @@ export function sitzungLesen(req) { function sitzungSetzen(res, person, req) { const token = randomBytes(32).toString("hex"); - const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString(); + const bis = new Date(Date.now() + SITZUNG_MS).toISOString(); db().prepare( "INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)" ).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req), @@ -4542,7 +4604,7 @@ function sitzungSetzen(res, person, req) { secure: req.secure, sameSite: "lax", // schützt vor fremden Formularen (CSRF) path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain - maxAge: SITZUNG_STUNDEN * 3600_000, + maxAge: SITZUNG_MS, }); } diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index e5c48b15..16513e64 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +