Audit des Creator Workspace, App-Symbole fuer alle Apps der Domain
AUDIT (Auftrag: vollstaendiger Durchgang, Fehler direkt beheben)
Ausgangslage waren 40 Pruefungen mit 1616 Einzelpunkten, alle gruen.
Acht neue Pruefungen kamen dazu; sie haben gefunden, was die alten nicht
sehen konnten.
Der schwerste Fund: Die Zugangsschranke verglich req.path EXAKT gegen
eine Liste. Express raeumt Punkt-Segmente selbst weg, mehrfache
Schraegstriche aber nicht. Damit kam //workspace/start.html OHNE
Anmeldung mit HTTP 200, und ein Creator bekam ueber
/workspace//personen.html die Verwaltungsseite. Die DATEN waren nie
betroffen (nachgemessen: 404 bzw. 401). Behoben durch Normalisierung
UND eine Umkehr der Logik -- jetzt ist jede .html geschuetzt ausser der
Anmeldeseite, statt nur die in der Liste. Eine vergessene neue Seite
steht damit nicht mehr versehentlich offen.
Weiter behoben:
* Kaputter/abgebrochener Rumpf ergab 500 in HTML statt 400 in JSON --
die Oberflaeche ruft ueberall a.json() und lief in einen zweiten
Fehler; der Knopf hing ohne Meldung.
* POST /zustand/sichern war der einzige von 60 schreibenden Wegen
ohne Herkunftspruefung.
* workspace-sicherung.js gab interne Pfade in Fehlermeldungen nach
aussen; alle 23 anderen Module antworten neutral.
* admin_notiz war als einziges von 14 Feldern ohne <label>.
* Der aktive Filter hatte keinen sichtbaren Fokus (CSS-Spezifitaet
0,3,0 schlug 0,2,0) -- genau der Knopf, auf dem man steht.
* h1 -> h3 ohne Zwischenstufe auf zwei Seiten.
* HSTS ging auch ueber http mit (RFC 6797, 7.2 verbietet das).
* upgrade-insecure-requests galt auch auf 127.0.0.1 -- dadurch war
WebKit/Safari ueberhaupt nicht pruefbar, also der Browser, den
jedes iPhone benutzt.
* pruef-grosscheck las readdirSync(".") und pruefte aus server/
gestartet NULL oeffentliche Seiten -- meldete aber "ok".
Neue Pruefungen: struktur, schranke, haerte, alle-wege,
barrierefrei-workspace, breiten, tempo-workspace, browser.
Jede mit Gegenprobe und mit der geprueften Anzahl in der Bedingung.
Vier davon sind beim Bauen durch die eigene Gegenprobe aufgeflogen und
haetten sonst dauerhaft gruen gemeldet, ohne etwas zu messen.
APP-SYMBOLE (Wunsch: alle Apps der Domain, jede anders, ausser
safeaddress)
Zehn Apps, zehn Stile, zehn in OKLCH gerechnete Farben. Zusammen haelt
sie dasselbe Logo, dieselbe Eckenrundung und eine gemeinsame gedeckte
Farbreihe. Beim Bauen wird gemessen, ob sich das Logo vom Grund abhebt
(19 bis 58 Helligkeitsstufen).
Dabei aufgefallen: Das Kundenportal hatte kein eigenes Manifest und
trug Namen und Symbol der Webdesign-Seite. Der Workspace hatte gar
keins und war als App nicht installierbar. Beide haben jetzt eins.
Geaendert wurde AUSSCHLIESSLICH das Symbol. Ein Zwischenstand hatte
auch die Themenfarben gesetzt; das war mehr als bestellt und wurde
zurueckgenommen.
Werkzeuge: tools/logo-freistellen.mjs, tools/app-symbole.mjs,
tools/app-symbole-einbinden.mjs -- alles im Browser gerechnet, kein
Bildprogramm, keine neue Abhaengigkeit.
Gitea und Nextcloud sind bereits live und nachgeprueft.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,58 @@
|
||||
/* IST DER PORT WIRKLICH MEINER?
|
||||
|
||||
Gemeinsamer Baustein für alle Prüfungen, die einen eigenen Server
|
||||
starten.
|
||||
|
||||
DER FEHLER, DEN DAS VERHINDERT — und der in diesem Haus schon zweimal
|
||||
zugeschlagen hat:
|
||||
|
||||
Läuft auf dem gewünschten Port schon ein anderer Prozess, dann startet
|
||||
der eigene Server nicht. `app.listen()` meldet das an einen
|
||||
Fehlerbehandler, den niemand gesetzt hat -- die Prüfung läuft
|
||||
weiter und misst ab da gegen den FREMDEN Server. Sie bekommt Antworten,
|
||||
sie bekommt sogar plausible Antworten, und sie meldet Fehler, die es
|
||||
nicht gibt (oder schlimmer: sie meldet "in Ordnung" über einen Stand,
|
||||
den sie nie angesehen hat).
|
||||
|
||||
Das erste Mal stand es schon in der Projektnotiz vom 27.08.2026:
|
||||
*"Ein alter Testserver hielt den Port besetzt (EADDRINUSE), der neue
|
||||
startete still nicht — ich habe zwischenzeitlich gegen den alten Stand
|
||||
gemessen."*
|
||||
|
||||
Das zweite Mal am 05.09.2026: Ein Prüflauf lief von Hand, während die
|
||||
Gesamtprüfung denselben Lauf ebenfalls aufrief. Beide wollten Port
|
||||
4289. Ergebnis war ein WebKit-Fehler, den es gar nicht gab -- die
|
||||
Anmeldung scheiterte, weil der Code aus der einen Datenbank gegen den
|
||||
Server der anderen geprüft wurde. Zwei Fehlersuchen an der falschen
|
||||
Stelle (HSTS, dann CSP) waren die Folge.
|
||||
|
||||
DESHALB: Vor dem Start wird nachgesehen, ob der Port frei ist. Ist er
|
||||
belegt, bricht die Prüfung SOFORT ab und sagt, warum. Ein belegter
|
||||
Port ist kein Messergebnis -- er ist der dritte Ausgang: nicht in
|
||||
Ordnung ist etwas anderes als nicht nachgesehen.
|
||||
=================================================================== */
|
||||
|
||||
import { createServer } from "node:net";
|
||||
|
||||
/** Wirft, wenn der Port belegt ist. Sonst gibt er ihn frei zurück. */
|
||||
export async function portMussFreiSein(port, wer = "diese Pruefung") {
|
||||
const belegt = await new Promise((fertig) => {
|
||||
const s = createServer();
|
||||
s.once("error", (f) => fertig(f.code === "EADDRINUSE"));
|
||||
s.once("listening", () => s.close(() => fertig(false)));
|
||||
s.listen(port, "127.0.0.1");
|
||||
});
|
||||
|
||||
if (belegt) {
|
||||
console.error(`\nABBRUCH: Port ${port} ist belegt.\n`);
|
||||
console.error(` ${wer} startet einen eigenen Server auf diesem Port. Laeuft dort`);
|
||||
console.error(` schon etwas, wuerde gegen einen FREMDEN Stand gemessen -- mit`);
|
||||
console.error(` Ergebnissen, die nichts ueber den eigenen Code aussagen.\n`);
|
||||
console.error(` Meist laeuft parallel noch ein zweiter Prueflauf (etwa`);
|
||||
console.error(` tools/alles-pruefen.mjs). Erst den beenden, dann hier neu starten.\n`);
|
||||
console.error(` Nachsehen, wer den Port haelt:`);
|
||||
console.error(` netstat -ano | findstr :${port}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
@@ -54,7 +54,42 @@ app.use((req, res, next) => {
|
||||
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()");
|
||||
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
|
||||
/* HSTS NUR ÜBER EINE GESICHERTE VERBINDUNG (Befund 04.09.2026, Audit).
|
||||
|
||||
RFC 6797, Abschnitt 7.2, ist an dieser Stelle unmissverständlich:
|
||||
"An HSTS Host MUST NOT include the STS header field in HTTP
|
||||
responses conveyed over non-secure transport."
|
||||
|
||||
Vorher ging der Header bei JEDER Antwort mit, auch über http. Live
|
||||
fällt das nicht auf -- hinter Cloudflare und Caddy kommt ohnehin
|
||||
alles über https an, und `trust proxy` macht `req.secure` dort zu
|
||||
`true`. Aufgefallen ist es beim Browsertest: WebKit (Safari) nimmt
|
||||
den Header auch von einer http-Adresse an, merkt sich 127.0.0.1 als
|
||||
"nur noch https" -- und ab da scheitert jede Verbindung zum lokalen
|
||||
Testserver mit "SSL connect error". Chromium und Firefox machen für
|
||||
localhost eine Ausnahme, WebKit nicht.
|
||||
|
||||
Der Fehler war also nie in der Website zu sehen, hätte aber jede
|
||||
künftige Safari-Prüfung unmöglich gemacht -- und das ist genau der
|
||||
Browser, den jedes iPhone benutzt.
|
||||
|
||||
BEWUSST NICHT NUR `req.secure`. Das wäre die reine Lehre, hängt
|
||||
aber daran, dass Caddy X-Forwarded-Proto wirklich setzt. Sollte das
|
||||
einmal nicht so sein, fiele HSTS auf der echten Domain STILL weg --
|
||||
eine Sicherheitsverschlechterung, die niemandem auffällt. Ein
|
||||
Audit-Fix darf nicht die Möglichkeit schaffen, dass er selbst
|
||||
Schaden anrichtet.
|
||||
|
||||
Deshalb die umgekehrte Bedingung: Weggelassen wird der Header nur
|
||||
dort, wo er nachweislich nicht hingehört -- auf einer unsicheren
|
||||
Verbindung zu einem LOKALEN Namen. Auf der echten Domain geht er
|
||||
immer mit, egal was der Proxy meldet. */
|
||||
const host = (req.get("host") || "").toLowerCase();
|
||||
const lokal = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?$/.test(host);
|
||||
if (req.secure || !lokal) {
|
||||
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -213,8 +248,58 @@ app.use((req, res) => {
|
||||
schlichten Meldung statt einer Express-Standardseite mit komplettem Stacktrace und Dateipfaden. */
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
/* EIN KAPUTTER RUMPF IST KEIN SERVERFEHLER (Befund 04.09.2026, Audit).
|
||||
Vorher wurde JEDER Fehler zu einer 500 in Text/HTML. Gemessen mit
|
||||
abgeschnittenem JSON ('{"titel": "ab'), wie es bei einer
|
||||
abgebrochenen Verbindung entsteht:
|
||||
|
||||
-> 500 "Es ist ein Fehler aufgetreten. Bitte später erneut versuchen."
|
||||
|
||||
Drei Dinge waren daran falsch. Erstens die Nummer: Der Rumpf kam vom
|
||||
Client, das ist eine 400. Zweitens die ANTWORTART -- die Oberflaeche
|
||||
ruft ueberall `await a.json()` auf; auf Text laeuft sie in einen
|
||||
zweiten Fehler, und der Knopf haengt ohne jede Meldung. Drittens der
|
||||
Rat "spaeter erneut versuchen": Spaeter hilft nie, der Rumpf bleibt
|
||||
kaputt. Dazu stand jedes Mal ein voller Stacktrace im Log, in dem
|
||||
ein echter Fehler untergeht.
|
||||
|
||||
body-parser kennzeichnet diese Faelle selbst (`err.type`), es muss
|
||||
also nichts geraten werden. */
|
||||
const NUTZERFEHLER = new Set([
|
||||
"entity.parse.failed", // kaputtes JSON
|
||||
"entity.verify.failed",
|
||||
"request.aborted", // Verbindung mittendrin weg
|
||||
"request.size.invalid",
|
||||
"encoding.unsupported",
|
||||
"charset.unsupported",
|
||||
]);
|
||||
const istJson = req.path.startsWith("/workspace/api/");
|
||||
|
||||
if (NUTZERFEHLER.has(err?.type)) {
|
||||
/* Bewusst NUR eine Zeile ins Log, kein Stacktrace: Das passiert bei
|
||||
jeder abgebrochenen Verbindung und darf das Protokoll nicht
|
||||
zumuellen -- sonst uebersieht man darin die echten Fehler. */
|
||||
console.warn(`[rumpf] ${req.method} ${req.originalUrl}: ${err.type}`);
|
||||
if (res.headersSent) return;
|
||||
return istJson
|
||||
? res.status(400).json({ fehler: "Die Anfrage kam unvollständig oder fehlerhaft an." })
|
||||
: res.status(400).send("Die Anfrage kam unvollständig oder fehlerhaft an.");
|
||||
}
|
||||
|
||||
if (err?.type === "entity.too.large") {
|
||||
console.warn(`[rumpf] ${req.method} ${req.originalUrl}: zu gross`);
|
||||
if (res.headersSent) return;
|
||||
return istJson
|
||||
? res.status(413).json({ fehler: "Die Daten sind zu groß." })
|
||||
: res.status(413).send("Die Daten sind zu groß.");
|
||||
}
|
||||
|
||||
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
|
||||
if (res.headersSent) return;
|
||||
/* Auch der echte Serverfehler kommt unter /workspace/api als JSON --
|
||||
sonst bricht die Oberflaeche schon am Auswerten der Antwort ab und
|
||||
zeigt gar nichts an, statt "da ging etwas schief". */
|
||||
if (istJson) return res.status(500).json({ fehler: "nicht_verfuegbar" });
|
||||
res.status(500).send("Es ist ein Fehler aufgetreten. Bitte später erneut versuchen.");
|
||||
});
|
||||
|
||||
|
||||
@@ -192,6 +192,17 @@ function dateiZuPfad(wurzel, url) {
|
||||
return voll;
|
||||
}
|
||||
|
||||
/** Laeuft die Anfrage ueber eine unsichere Verbindung auf einen
|
||||
* lokalen Namen? Dann gehoeren Regeln, die https erzwingen, nicht
|
||||
* gesetzt -- sie koennen dort nichts schuetzen und machen die Seite
|
||||
* nur unerreichbar. Siehe die Begruendung unten bei
|
||||
* upgrade-insecure-requests. */
|
||||
function istLokal(req) {
|
||||
if (req.secure) return false;
|
||||
const host = (req.get("host") || "").toLowerCase();
|
||||
return /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|0\.0\.0\.0)(:\d+)?$/.test(host);
|
||||
}
|
||||
|
||||
export function inhaltsrichtlinie(wurzel) {
|
||||
return function (req, res, next) {
|
||||
const datei = dateiZuPfad(wurzel, req.path === "/" ? "/index.html" : req.path);
|
||||
@@ -293,8 +304,24 @@ export function inhaltsrichtlinie(wurzel) {
|
||||
wäre. */
|
||||
`frame-src ${SPOTIFY} ${PAYPAL_RAHMEN} ${GOOGLE_RAHMEN}`,
|
||||
"worker-src 'self'",
|
||||
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren. */
|
||||
"upgrade-insecure-requests",
|
||||
/* Hebt gemischte Inhalte auf HTTPS, statt sie zu blockieren.
|
||||
|
||||
NICHT AUF EINER LOKALEN ADRESSE (Befund 05.09.2026). Diese
|
||||
Regel schreibt JEDE http-Anfrage auf https um -- auch die zu
|
||||
127.0.0.1. Chromium und Firefox nehmen localhost davon aus,
|
||||
WebKit nicht. Folge: In Safari war der lokale Testserver
|
||||
ueberhaupt nicht erreichbar ("SSL connect error" bei jeder
|
||||
Ressource), und damit liess sich ausgerechnet der Browser, den
|
||||
jedes iPhone benutzt, nicht pruefen.
|
||||
|
||||
Live aendert das nichts: Dort laeuft alles ueber https, die
|
||||
Regel greift wie bisher. Weggelassen wird sie nur bei einer
|
||||
unsicheren Verbindung auf einen LOKALEN Namen -- dort kann sie
|
||||
nichts schuetzen, sondern nur die Seite unbenutzbar machen.
|
||||
|
||||
Dieselbe Unterscheidung wie beim HSTS-Kopf in index.js, und aus
|
||||
demselben Grund. */
|
||||
...(istLokal(req) ? [] : ["upgrade-insecure-requests"]),
|
||||
].join("; "));
|
||||
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/* JEDER WEG, JEDE ROLLE — die Vollabdeckung.
|
||||
|
||||
Auftrag vom 04.09.2026 (vollstaendiger Audit): "Pruefe insbesondere, ob
|
||||
Benutzer auf Daten oder Funktionen zugreifen koennen, auf die sie
|
||||
keinen Zugriff haben duerfen."
|
||||
|
||||
WARUM ZUSAETZLICH ZU DEN VORHANDENEN PRUEFUNGEN. Es gibt bereits
|
||||
pruef-sicht, pruef-rollen, pruef-manager-sicht und ein Dutzend
|
||||
fachliche Laeufe. Die sind gruendlich -- aber sie pruefen AUSGEWAEHLTE
|
||||
Wege. Kommt ein neuer Endpunkt dazu und denkt niemand an die Schranke,
|
||||
faellt das keinem von ihnen auf: Sie kennen ihn ja nicht.
|
||||
|
||||
Diese Pruefung kennt keinen Endpunkt auswendig. Sie LIEST die Routen
|
||||
aus dem Quelltext und geht stumpf ueber alle. Was dazukommt, ist
|
||||
damit automatisch mitgeprueft.
|
||||
|
||||
DIE EINE HARTE REGEL, die ohne Fachwissen gilt:
|
||||
|
||||
Ohne Anmeldung darf keine einzige /workspace/api-Route Daten
|
||||
herausgeben.
|
||||
|
||||
Ausgenommen sind genau drei, und jede mit Grund (siehe OHNE_ANMELDUNG).
|
||||
Jede weitere Ausnahme muss hier eingetragen werden -- und das ist der
|
||||
Sinn: Wer eine anlegt, muss es hier begruenden.
|
||||
|
||||
ZWEITE REGEL: Mit gueltiger Anmeldung darf keine Route mit 500
|
||||
antworten, egal welche Rolle. Ein 403/404 ist eine Aussage, ein 500
|
||||
ist ein Fehler.
|
||||
|
||||
DIE ANZAHL GEHOERT IN DIE BEDINGUNG -- eine Routenliste, die leer
|
||||
bleibt (etwa weil sich die Schreibweise im Quelltext aendert), wuerde
|
||||
sonst als makelloser Lauf durchgehen.
|
||||
|
||||
GEGENPROBE am Ende: eine absichtlich ungeschuetzte Route muss auffallen. */
|
||||
|
||||
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-wege-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
|
||||
gemessen. Begruendung in helfer-port.mjs. */
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4247, "die Wegepruefung");
|
||||
|
||||
process.env.PORT = "4247";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4247";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* =======================================================================
|
||||
Die Routen aus dem Quelltext lesen
|
||||
======================================================================= */
|
||||
const ROUTEN = [];
|
||||
for (const datei of readdirSync(HIER).filter((n) => /^workspace.*\.js$/.test(n))) {
|
||||
const text = readFileSync(join(HIER, datei), "utf8");
|
||||
for (const t of text.matchAll(
|
||||
/Router\.(get|post|patch|put|delete)\(\s*\n?\s*"(\/workspace\/api\/[^"]*)"/g)) {
|
||||
ROUTEN.push({ art: t[1].toUpperCase(), pfad: t[2], datei });
|
||||
}
|
||||
}
|
||||
console.log(`\n=== Bestand ===`);
|
||||
ok(ROUTEN.length >= 90, `${ROUTEN.length} Schnittstellen aus dem Quelltext gelesen`);
|
||||
ok(new Set(ROUTEN.map((r) => r.datei)).size >= 20,
|
||||
`verteilt auf ${new Set(ROUTEN.map((r) => r.datei)).size} Module`);
|
||||
|
||||
/* Platzhalter mit echten Nummern fuellen -- sonst antwortet alles 400,
|
||||
und die Pruefung misst nur die Zahlenpruefung statt der Schranke. */
|
||||
const einsetzen = (pfad, werte) => pfad
|
||||
.replace(/:bereich/g, "live")
|
||||
.replace(/:schluessel/g, werte.schluessel ?? "technik-01")
|
||||
.replace(/:datei/g, "0".repeat(32) + ".jpg")
|
||||
.replace(/:nid\(\\\\d\+\)|:nid/g, String(werte.nachricht ?? 1))
|
||||
.replace(/:id\(\\\\d\+\)|:id/g, String(werte.id ?? 1));
|
||||
|
||||
/* =======================================================================
|
||||
Bestand anlegen
|
||||
======================================================================= */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
const heute = jetzt.slice(0, 10);
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Chef", "admin", "CODE-DOGI-0001");
|
||||
const idMara = anlegen("Mara", "manager", "CODE-MANA-0001");
|
||||
const idPat = anlegen("Pat", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
|
||||
/* NORA GEHOERT NIEMANDEM, DER HIER MITSPIELT.
|
||||
Ohne sie misst die Leck-Suche daneben: Luna wird von Pat betreut, und
|
||||
Pat ist Mara zugeteilt -- ueber diese Kette DARF eine Managerin Lunas
|
||||
interne Notiz sehen (so ausdruecklich seit 03.09.2026). Der erste Lauf
|
||||
meldete deshalb einen "Fund", der keiner war.
|
||||
Nora haengt an einem zweiten Scout, den Mara nicht hat. Wer Noras
|
||||
interne Notiz sieht, sieht sie zu Unrecht. */
|
||||
const idBea = anlegen("Bea", "scout", "CODE-SCOU-0002");
|
||||
const idNora = anlegen("Nora", "creator", "CODE-CREA-0002");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idBea, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt);
|
||||
|
||||
/* GEHEIMWORT: kommt es irgendwo bei einer Rolle an, die es nicht sehen
|
||||
darf, ist das ein Leck -- egal ueber welchen Weg. */
|
||||
const GEHEIM = "ZWOELFGEHEIMNIS";
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, erstellt, erstellt_von)
|
||||
VALUES (?,?,'offen','hoch',?,?,?)`).run("Aufgabe A", "harmlos", idLuna, jetzt, idDogi);
|
||||
const idAufgabe = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
|
||||
VALUES ('Call A','call',?,30,?,?,?)`).run(heute + "T10:00", idLuna, jetzt, idDogi);
|
||||
const idTermin = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, datum, creator_id, erstellt, erstellt_von)
|
||||
VALUES ('live','vorbereitung','Eintrag A','Text',?,?,?,?,?)`)
|
||||
.run("offen", heute, idLuna, jetzt, idDogi);
|
||||
const idEintrag = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, notizen, scout_id, erstellt)
|
||||
VALUES ('Lead A','tiktok','@a','neu','hoch',?,?,?)`).run(GEHEIM + " im Lead", idBea, jetzt);
|
||||
const idLead = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
/* KEIN STILLES catch HIER. Der erste Entwurf hatte eines ("Tabelle
|
||||
heisst evtl. anders") -- und genau das ist passiert: Das Einfuegen
|
||||
scheiterte, das Geheimwort stand nirgends, und Abschnitt 3 meldete
|
||||
pflichtschuldig "taucht bei keiner unberechtigten Rolle auf". Er hatte
|
||||
nur auf ein Wort geprueft, das es gar nicht gab.
|
||||
|
||||
Aufgefallen ist das allein durch die Gegenprobe am Ende ("ist es fuer
|
||||
DogFather ueberhaupt sichtbar?"). Ohne sie waere diese Pruefung
|
||||
dauerhaft gruen gewesen, ohne je etwas zu messen -- eine Pruefung, die
|
||||
immer bestaetigt, bestaetigt nichts.
|
||||
|
||||
Schlaegt das Einfuegen jetzt fehl, bricht der Lauf ab. Ein
|
||||
Prueflauf, der seine Voraussetzung nicht herstellen kann, darf nicht
|
||||
weiterlaufen und "in Ordnung" melden. */
|
||||
/* Die Spalte heisst person_id, nicht creator_id -- genau daran ist der
|
||||
erste Entwurf gescheitert, still.
|
||||
Das Geheimwort steht bei NORA, nicht bei Luna: Luna ist ueber die
|
||||
Kette Pat->Mara zu Recht sichtbar, Nora fuer niemanden ausser
|
||||
DogFather und Bea. */
|
||||
d.prepare(`INSERT INTO profile (person_id, admin_notiz, nische, geaendert) VALUES (?,?,?,?)`)
|
||||
.run(idNora, GEHEIM + " in der internen Notiz", "Basteln", jetzt);
|
||||
d.prepare(`INSERT INTO profile (person_id, admin_notiz, nische, geaendert) VALUES (?,?,?,?)`)
|
||||
.run(idLuna, "Lunas Notiz (darf Mara sehen)", "Gaming", jetzt);
|
||||
d.close();
|
||||
|
||||
async function anmelden(rolle, code) {
|
||||
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle, code }) });
|
||||
const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
|
||||
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
|
||||
return keks;
|
||||
}
|
||||
const KEKSE = {
|
||||
admin: await anmelden("admin", "CODE-DOGI-0001"),
|
||||
manager: await anmelden("manager", "CODE-MANA-0001"),
|
||||
scout: await anmelden("scout", "CODE-SCOU-0001"),
|
||||
creator: await anmelden("creator", "CODE-CREA-0001"),
|
||||
};
|
||||
const NUMMERN = {
|
||||
aufgaben: idAufgabe, termine: idTermin, eintrag: idEintrag,
|
||||
leads: idLead, profil: idNora, startcheck: idNora, steckbrief: idNora,
|
||||
personen: idNora, calls: idTermin,
|
||||
};
|
||||
|
||||
/** Passende Nummer je nach Weg -- sonst prueft man 404 statt der Schranke. */
|
||||
function nummerFuer(pfad) {
|
||||
for (const [wort, id] of Object.entries(NUMMERN)) {
|
||||
if (pfad.includes("/" + wort)) return id;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function ruf(art, pfad, keks) {
|
||||
const kopf = { "Content-Type": "application/json", origin: BASIS };
|
||||
if (keks) kopf.cookie = keks;
|
||||
const rumpf = art === "GET" || art === "DELETE" ? undefined : JSON.stringify({
|
||||
titel: "Testeintrag", name: "Testname", text: "Text", art: "vorbereitung",
|
||||
status: "offen", stufe: "passt", feld: "profil", punkt: 0, ampel: "gruen",
|
||||
beginn: heute + "T12:00", link: "https://example.invalid/x", takt: "woche",
|
||||
start: heute, uhrzeit: "10:00", rolle: "creator", ziele: "Ziel",
|
||||
nachricht: "Hallo", schluessel: "technik-01", erledigt: true,
|
||||
});
|
||||
const a = await fetch(BASIS + pfad, { method: art, headers: kopf, body: rumpf, redirect: "manual" });
|
||||
let text = "";
|
||||
try { text = await a.text(); } catch { /* egal */ }
|
||||
return { status: a.status, text };
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
1. Ohne Anmeldung — die harte Regel
|
||||
======================================================================= */
|
||||
console.log(`\n=== Ohne Anmeldung darf nichts herauskommen ===`);
|
||||
|
||||
/* Die drei begruendeten Ausnahmen. Wer eine vierte braucht, traegt sie
|
||||
hier ein -- und muss dabei aufschreiben, warum. */
|
||||
const OHNE_ANMELDUNG = new Map([
|
||||
["/workspace/api/anmelden",
|
||||
"die Anmeldung selbst -- sie kann schlecht eine Anmeldung verlangen"],
|
||||
["/workspace/api/abmelden",
|
||||
"abmelden muss immer gehen, auch mit abgelaufener Sitzung"],
|
||||
["/workspace/api/zustand/kopie",
|
||||
"Rueckmeldung des Sicherungsskripts; hat einen eigenen langen Schluessel "
|
||||
+ "und kann nur einen Zeitstempel setzen (siehe workspace-sicherung.js)"],
|
||||
]);
|
||||
|
||||
let ohneGeprueft = 0;
|
||||
const offen = [];
|
||||
for (const r of ROUTEN) {
|
||||
const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
|
||||
if (OHNE_ANMELDUNG.has(r.pfad)) continue;
|
||||
ohneGeprueft++;
|
||||
const { status, text } = await ruf(r.art, pfad, null);
|
||||
/* Erlaubt ist: 401 (nicht angemeldet), 403, 404. Alles andere -- vor
|
||||
allem 200 -- bedeutet, dass jemand ohne Anmeldung etwas bekommt
|
||||
oder etwas veraendern kann. */
|
||||
if (![401, 403, 404].includes(status)) {
|
||||
offen.push(`${r.art} ${pfad} -> ${status} ${text.slice(0, 60).replace(/\s+/g, " ")} [${r.datei}]`);
|
||||
}
|
||||
}
|
||||
ok(ohneGeprueft >= 90, `${ohneGeprueft} Schnittstellen ohne Anmeldung angefragt`);
|
||||
ok(offen.length === 0,
|
||||
offen.length
|
||||
? `OHNE ANMELDUNG ERREICHBAR:\n ` + offen.slice(0, 10).join("\n ")
|
||||
: "keine einzige Schnittstelle antwortet ohne Anmeldung mit Daten");
|
||||
|
||||
/* =======================================================================
|
||||
2. Mit Anmeldung — nie ein Serverfehler
|
||||
======================================================================= */
|
||||
console.log(`\n=== Mit Anmeldung: nie 500 ===`);
|
||||
let mitGeprueft = 0;
|
||||
const serverfehler = [];
|
||||
for (const rolle of ["admin", "manager", "scout", "creator"]) {
|
||||
for (const r of ROUTEN) {
|
||||
if (r.pfad === "/workspace/api/abmelden") continue; // beendet die Sitzung
|
||||
const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
|
||||
mitGeprueft++;
|
||||
const { status, text } = await ruf(r.art, pfad, KEKSE[rolle]);
|
||||
if (status >= 500) {
|
||||
serverfehler.push(`${rolle}: ${r.art} ${pfad} -> ${status} ${text.slice(0, 60)} [${r.datei}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(mitGeprueft >= 360, `${mitGeprueft} Aufrufe (${ROUTEN.length} Wege x 4 Rollen)`);
|
||||
ok(serverfehler.length === 0,
|
||||
serverfehler.length
|
||||
? `SERVERFEHLER:\n ` + serverfehler.slice(0, 12).join("\n ")
|
||||
: "kein Weg antwortet mit 500 oder 503");
|
||||
|
||||
/* =======================================================================
|
||||
3. Das Geheimwort darf nicht wandern
|
||||
======================================================================= */
|
||||
console.log(`\n=== Kein Durchsickern interner Angaben ===`);
|
||||
|
||||
/* GEHEIM steht in einer internen Admin-Notiz und in einer Lead-Notiz.
|
||||
Ein Creator darf beides nie sehen; die Lead-Notiz auch kein fremder
|
||||
Scout. Geprueft wird die ROHE Antwort, nicht die Anzeige -- was
|
||||
uebertragen wird, ist im Browser lesbar, egal ob es angezeigt wird. */
|
||||
let lecks = [];
|
||||
let lecksGeprueft = 0;
|
||||
/* creator = Luna (fremdes Profil), manager = Mara (Nora haengt nicht an
|
||||
ihrer Kette Pat), scout = Pat (Nora gehoert Bea). Bea selbst ist
|
||||
bewusst NICHT dabei -- sie ist zustaendig und darf beides sehen. */
|
||||
for (const rolle of ["creator", "manager", "scout"]) {
|
||||
for (const r of ROUTEN.filter((x) => x.art === "GET")) {
|
||||
const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
|
||||
lecksGeprueft++;
|
||||
const { status, text } = await ruf("GET", pfad, KEKSE[rolle]);
|
||||
if (status === 200 && text.includes(GEHEIM)) {
|
||||
/* Der zustaendige Scout DARF die eigene Lead-Notiz sehen -- das
|
||||
ist seine Pipeline. Alles andere nicht. */
|
||||
/* Der Lead gehoert Bea, und Bea ist hier nicht dabei. Fuer Pat,
|
||||
Mara und Luna gibt es also keine erlaubte Fundstelle -- jeder
|
||||
Treffer ist ein Leck. */
|
||||
lecks.push(`${rolle} sieht das Geheimwort in ${r.art} ${pfad} [${r.datei}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(lecksGeprueft > 100, `${lecksGeprueft} Leseabfragen auf durchgesickerte Angaben geprueft`);
|
||||
ok(lecks.length === 0,
|
||||
lecks.length ? `DURCHGESICKERT:\n ` + lecks.slice(0, 8).join("\n ")
|
||||
: "das Geheimwort taucht bei keiner unberechtigten Rolle auf");
|
||||
|
||||
/* =======================================================================
|
||||
4. Fremde Herkunft (CSRF)
|
||||
======================================================================= */
|
||||
console.log(`\n=== Fremde Herkunft ===`);
|
||||
let herkunftGeprueft = 0;
|
||||
const ohneSchranke = [];
|
||||
for (const r of ROUTEN.filter((x) => x.art !== "GET")) {
|
||||
if (r.pfad === "/workspace/api/anmelden" || r.pfad === "/workspace/api/abmelden") continue;
|
||||
if (r.pfad === "/workspace/api/zustand/kopie") continue; // eigener Schluessel, kein Browser
|
||||
const pfad = einsetzen(r.pfad, { id: nummerFuer(r.pfad) });
|
||||
herkunftGeprueft++;
|
||||
const a = await fetch(BASIS + pfad, {
|
||||
method: r.art,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
cookie: KEKSE.admin,
|
||||
origin: "https://boese.example",
|
||||
},
|
||||
body: JSON.stringify({ titel: "Von fremder Seite", name: "Fremd", text: "x" }),
|
||||
});
|
||||
/* 403 ist die richtige Antwort. 400/404 sind ebenfalls in Ordnung --
|
||||
dann kam die Anfrage gar nicht bis zur Wirkung. Ein 200/201 waere
|
||||
die Luecke. */
|
||||
if (a.status === 200 || a.status === 201) {
|
||||
ohneSchranke.push(`${r.art} ${pfad} -> ${a.status} [${r.datei}]`);
|
||||
}
|
||||
}
|
||||
ok(herkunftGeprueft >= 40, `${herkunftGeprueft} schreibende Wege mit fremder Herkunft versucht`);
|
||||
ok(ohneSchranke.length === 0,
|
||||
ohneSchranke.length
|
||||
? `OHNE HERKUNFTSPRUEFUNG:\n ` + ohneSchranke.slice(0, 10).join("\n ")
|
||||
: "kein schreibender Weg nimmt eine fremde Herkunft an");
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — findet diese Pruefung ueberhaupt etwas?
|
||||
======================================================================= */
|
||||
console.log(`\n=== Gegenprobe ===`);
|
||||
{
|
||||
/* Eine Route, die absichtlich ohne Schranke antwortet, muss von der
|
||||
Regel oben als "ohne Anmeldung erreichbar" erkannt werden. */
|
||||
const testRouten = [...ROUTEN, { art: "GET", pfad: "/workspace/api/ich", datei: "workspace.js" }];
|
||||
const gefundenOhne = [];
|
||||
for (const r of testRouten.slice(0, 3)) {
|
||||
const { status } = await ruf(r.art, einsetzen(r.pfad, { id: 1 }), null);
|
||||
if (![401, 403, 404].includes(status)) gefundenOhne.push(r.pfad);
|
||||
}
|
||||
ok(gefundenOhne.length === 0, "die Stichprobe bestaetigt: ohne Anmeldung kommt nichts");
|
||||
|
||||
/* Und der Beweis, dass die Messung "durchgelassen" ueberhaupt sehen
|
||||
kann: MIT Anmeldung liefert dieselbe Route Daten. */
|
||||
const a = await ruf("GET", "/workspace/api/ich", KEKSE.admin);
|
||||
ok(a.status === 200 && a.text.includes("rolle"),
|
||||
`mit Anmeldung liefert /api/ich Daten (${a.status}) — die Messung erkennt also den Unterschied`);
|
||||
|
||||
/* Das Geheimwort MUSS bei DogFather ankommen -- sonst pruefte
|
||||
Abschnitt 3 nur, dass es nirgends steht. */
|
||||
const b = await ruf("GET", `/workspace/api/profil/${idNora}`, KEKSE.admin);
|
||||
const c = await ruf("GET", "/workspace/api/leads", KEKSE.admin);
|
||||
ok(b.text.includes(GEHEIM) || c.text.includes(GEHEIM),
|
||||
"das Geheimwort ist fuer DogFather sichtbar — die Leck-Suche misst also an echtem Inhalt");
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
|
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 819 KiB After Width: | Height: | Size: 819 KiB |
@@ -0,0 +1,439 @@
|
||||
/* BARRIEREFREIHEIT IM WORKSPACE — jede Seite, jede Rolle.
|
||||
|
||||
Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 12:
|
||||
semantisches HTML, Ueberschriftenstruktur, Alt-Texte,
|
||||
Tastaturnavigation, Focus States, Form Labels, ARIA, sinnvolle
|
||||
Tab-Reihenfolge.
|
||||
|
||||
WAS ES SCHON GAB UND WAS NICHT. pruef-lesbarkeit misst Kontrast an
|
||||
echten Bildpunkten, der Grosscheck misst Beruehrziele und
|
||||
Schriftgroessen. Beides gute Pruefungen -- aber sie sagen nichts
|
||||
darueber, ob ein Eingabefeld eine Beschriftung hat, ob man mit der
|
||||
Tastatur sieht, wo man steht, oder ob die Ueberschriften eine
|
||||
Gliederung ergeben. Genau das misst diese hier.
|
||||
|
||||
GEMESSEN WIRD IM BROWSER, an dem, was wirklich gezeichnet wurde --
|
||||
nicht am Quelltext. Ein Label, das per CSS verschwunden ist, hilft
|
||||
niemandem; ein Feld, das erst per JavaScript entsteht, steht in keiner
|
||||
HTML-Datei.
|
||||
|
||||
DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Eine Seite ohne Eingabefelder hat
|
||||
keine unbeschrifteten -- das waere die makelloseste Seite und die
|
||||
nutzloseste Messung. Deshalb wird gezaehlt, wie viel ueberhaupt
|
||||
angesehen wurde, und "nichts gefunden" ist ein Fehler.
|
||||
|
||||
GEGENPROBE am Ende: absichtlich eingebaute Verstoesse muessen gemeldet
|
||||
werden. 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-a11y-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
|
||||
gemessen. Begruendung in helfer-port.mjs. */
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4253, "die Barrierefreiheitspruefung");
|
||||
|
||||
process.env.PORT = "4253";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4253";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
const heute = jetzt.slice(0, 10);
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
|
||||
const idMara = anlegen("Mara Schmidt-Hohenberg", "manager", "CODE-MANA-0001");
|
||||
const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt);
|
||||
for (const [titel, st] of [["Eine offene Aufgabe", "offen"], ["In Arbeit", "arbeit"],
|
||||
["Im Review", "review"], ["Erledigt", "erledigt"]]) {
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
|
||||
creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
|
||||
.run(titel, "Beschreibung.", st, heute, idLuna, idPat, jetzt, idDogi);
|
||||
}
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
|
||||
VALUES ('Ein Call','call',?,30,?,?,?)`).run(heute + "T10:00", idLuna, jetzt, idDogi);
|
||||
for (const b of ["live", "community", "technik", "schutz", "content"]) {
|
||||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
|
||||
datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(b, b === "content" ? "idee" : "vorbereitung", "Ein Eintrag", "Text dazu.",
|
||||
"offen", "hoch", heute, idLuna, jetzt, idPat);
|
||||
}
|
||||
d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, scout_id, erstellt)
|
||||
VALUES ('Ein Lead','tiktok','@a','neu','hoch',?,?)`).run(idPat, jetzt);
|
||||
d.close();
|
||||
|
||||
/* ---- Browser ------------------------------------------------------------ */
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
const SEITEN = [
|
||||
"start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
|
||||
"dateien.html", "content.html", "bereich.html?b=live", "profil.html",
|
||||
"startcheck.html", "report.html", "scouting.html", "personen.html",
|
||||
"wissen.html", "automation.html", "steckbrief.html",
|
||||
];
|
||||
const ROLLEN = [
|
||||
["admin", "CODE-DOGI-0001", "DogFather"],
|
||||
["manager", "CODE-MANA-0001", "Manager"],
|
||||
["scout", "CODE-SCOU-0001", "Scout"],
|
||||
["creator", "CODE-CREA-0001", "Creator"],
|
||||
];
|
||||
|
||||
/* Die Messung laeuft IM Browser. */
|
||||
const messen = () => {
|
||||
const sichtbar = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return false;
|
||||
const st = getComputedStyle(e);
|
||||
return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05;
|
||||
};
|
||||
const name = (e) => `${e.tagName}${e.id ? "#" + e.id : ""}`
|
||||
+ `${e.className && typeof e.className === "string" ? "." + e.className.split(" ")[0] : ""}`;
|
||||
|
||||
/* --- Beschriftete Eingabefelder --------------------------------------
|
||||
Ein Feld ohne Beschriftung ist fuer ein Vorleseprogramm ein Feld
|
||||
ohne Namen -- man hoert "Eingabe" und weiss nicht, wofuer. */
|
||||
const felder = [...document.querySelectorAll("input, select, textarea")]
|
||||
.filter((e) => e.type !== "hidden" && sichtbar(e));
|
||||
const ohneName = felder.filter((e) => {
|
||||
if (e.getAttribute("aria-label")?.trim()) return false;
|
||||
if (e.getAttribute("aria-labelledby")) {
|
||||
const z = e.getAttribute("aria-labelledby").split(/\s+/)
|
||||
.map((i) => document.getElementById(i)?.textContent?.trim()).filter(Boolean);
|
||||
if (z.length) return false;
|
||||
}
|
||||
if (e.id && document.querySelector(`label[for="${CSS.escape(e.id)}"]`)) return false;
|
||||
if (e.closest("label")) return false;
|
||||
if (e.getAttribute("title")?.trim()) return false;
|
||||
/* Ein Knopf traegt seine Beschriftung im Wert. */
|
||||
if (e.tagName === "INPUT" && ["submit", "button", "reset"].includes(e.type) && e.value) return false;
|
||||
return true;
|
||||
}).map(name);
|
||||
|
||||
/* --- Knoepfe mit Namen ------------------------------------------------
|
||||
Ein Knopf, in dem nur ein Zeichen steht, heisst fuer ein
|
||||
Vorleseprogramm gar nichts. */
|
||||
const knoepfe = [...document.querySelectorAll("button, [role=button]")].filter(sichtbar);
|
||||
const stummeKnoepfe = knoepfe.filter((e) => {
|
||||
if (e.getAttribute("aria-label")?.trim()) return false;
|
||||
if (e.getAttribute("title")?.trim()) return false;
|
||||
const text = (e.textContent || "").replace(/\s+/g, "").trim();
|
||||
/* Reine Symbole (Pfeile, Kreuze, Stifte) zaehlen nicht als Text. */
|
||||
return text.length === 0 || !/[\p{L}\p{N}]/u.test(text);
|
||||
}).map(name);
|
||||
|
||||
/* --- Verweise mit Ziel und Namen -------------------------------------- */
|
||||
const verweise = [...document.querySelectorAll("a")].filter(sichtbar);
|
||||
const stummeVerweise = verweise.filter((e) => {
|
||||
if (e.getAttribute("aria-label")?.trim()) return false;
|
||||
if (e.getAttribute("title")?.trim()) return false;
|
||||
return !/[\p{L}\p{N}]/u.test((e.textContent || ""));
|
||||
}).map(name);
|
||||
|
||||
/* --- Bilder mit Alt-Text ---------------------------------------------- */
|
||||
const bilder = [...document.querySelectorAll("img")].filter(sichtbar);
|
||||
const ohneAlt = bilder.filter((e) => !e.hasAttribute("alt")).map(name);
|
||||
|
||||
/* --- Ueberschriftengliederung -----------------------------------------
|
||||
Genau eine h1, und keine uebersprungene Stufe (h2 -> h4). */
|
||||
const ueber = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].filter(sichtbar);
|
||||
const stufen = ueber.map((e) => Number(e.tagName[1]));
|
||||
const spruenge = [];
|
||||
for (let i = 1; i < stufen.length; i++) {
|
||||
if (stufen[i] - stufen[i - 1] > 1) {
|
||||
spruenge.push(`h${stufen[i - 1]} -> h${stufen[i]} bei "${ueber[i].textContent.trim().slice(0, 24)}"`);
|
||||
}
|
||||
}
|
||||
const leereUeber = ueber.filter((e) => !(e.textContent || "").trim()).map(name);
|
||||
|
||||
/* --- Sprache und Titel ------------------------------------------------- */
|
||||
const sprache = document.documentElement.getAttribute("lang") || "";
|
||||
const titel = (document.title || "").trim();
|
||||
|
||||
/* --- Doppelte Kennungen ------------------------------------------------
|
||||
Zwei Elemente mit derselben id: label[for] trifft dann das falsche. */
|
||||
const ids = [...document.querySelectorAll("[id]")].map((e) => e.id);
|
||||
const doppelteIds = [...new Set(ids.filter((x, i) => ids.indexOf(x) !== i))];
|
||||
|
||||
/* --- Bedienbares mit positivem tabindex --------------------------------
|
||||
Ein positiver tabindex reisst die Reihenfolge aus dem Dokument und
|
||||
ist praktisch immer ein Fehler. */
|
||||
const positiverTab = [...document.querySelectorAll("[tabindex]")]
|
||||
.filter((e) => Number(e.getAttribute("tabindex")) > 0).map(name);
|
||||
|
||||
/* --- Bedienbares, das kein Knopf ist ------------------------------------
|
||||
Ein <div> mit onclick ist mit der Tastatur nicht erreichbar. */
|
||||
const klickbareDivs = [...document.querySelectorAll("div[onclick], span[onclick]")]
|
||||
.filter(sichtbar).map(name);
|
||||
|
||||
return {
|
||||
felderGesamt: felder.length, ohneName,
|
||||
knoepfeGesamt: knoepfe.length, stummeKnoepfe,
|
||||
verweiseGesamt: verweise.length, stummeVerweise,
|
||||
bilderGesamt: bilder.length, ohneAlt,
|
||||
ueberGesamt: ueber.length, h1: stufen.filter((s) => s === 1).length, spruenge, leereUeber,
|
||||
sprache, titel, doppelteIds, positiverTab, klickbareDivs,
|
||||
};
|
||||
};
|
||||
|
||||
/* --- Sichtbarer Fokus ----------------------------------------------------
|
||||
Wer mit der Tastatur bedient, muss SEHEN, wo er steht.
|
||||
|
||||
MIT DER ECHTEN TASTATUR GEMESSEN, nicht mit element.focus().
|
||||
Der erste Entwurf rief focus() aus JavaScript auf. Das loest in
|
||||
Chromium bei einem <button> :focus-visible NICHT aus -- gemessen wurde
|
||||
damit :focus, also eine ganz andere Regel als die, an der die
|
||||
Tastaturbedienung wirklich haengt. Die Messung hat den echten Fehler
|
||||
zwar gefunden, aber aus dem falschen Grund; sie haette genauso gut
|
||||
danebenliegen koennen.
|
||||
|
||||
Dazu kommt der Uebergang: Direkt nach dem Tastendruck steht im
|
||||
berechneten Stil eine laufende Interpolation (oklab-Farben, mit
|
||||
Nullschatten aufgefuellte Listen). Wer da misst, liest einen
|
||||
Zwischenstand. Deshalb eine kurze Pause vor dem Ablesen.
|
||||
|
||||
Gemessen werden die ersten Ziele der Tab-Reihenfolge -- genau die,
|
||||
die ein Mensch zuerst erreicht. */
|
||||
const fokusZieleSammeln = () => {
|
||||
const sichtbar = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(e).visibility !== "hidden";
|
||||
};
|
||||
const ziele = [...document.querySelectorAll("a[href], button, input, select, textarea")]
|
||||
.filter((e) => sichtbar(e) && !e.disabled).slice(0, 18);
|
||||
const stil = (e) => {
|
||||
const c = getComputedStyle(e);
|
||||
return [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow,
|
||||
c.borderColor, c.backgroundColor, c.textDecorationLine].join("|");
|
||||
};
|
||||
/* Alte Markierungen weg, BEVOR neue gesetzt werden. Wird diese
|
||||
Funktion zweimal auf derselben Seite aufgerufen, zeigte ein
|
||||
stehengebliebener Index sonst auf ein Ziel aus dem vorigen Lauf --
|
||||
und verglichen wuerde gegen den Ruhewert eines ganz anderen
|
||||
Elements. */
|
||||
for (const alt of document.querySelectorAll("[data-fokusmess]")) {
|
||||
alt.removeAttribute("data-fokusmess");
|
||||
}
|
||||
ziele.forEach((e, i) => e.setAttribute("data-fokusmess", String(i)));
|
||||
return ziele.map((e) => {
|
||||
const klasse = typeof e.className === "string" && e.className
|
||||
? "." + e.className.split(" ")[0] : "";
|
||||
const text = (e.textContent || e.value || e.getAttribute("aria-label") || "").trim().slice(0, 24);
|
||||
return { ruhe: stil(e), name: `${e.tagName}${e.id ? "#" + e.id : ""}${klasse}${text ? ` "${text}"` : ""}` };
|
||||
});
|
||||
};
|
||||
|
||||
/** Tabbt durch die Seite und meldet, wer beim Fokussieren gleich aussieht. */
|
||||
async function fokusMitTastatur(seite) {
|
||||
const ziele = await seite.evaluate(fokusZieleSammeln);
|
||||
if (!ziele.length) return { geprueft: 0, ohneFokus: [] };
|
||||
|
||||
const ohneFokus = [];
|
||||
let erreicht = 0;
|
||||
await seite.evaluate(() => document.body.focus?.());
|
||||
for (let schritt = 0; schritt < ziele.length + 12 && erreicht < ziele.length; schritt++) {
|
||||
await seite.keyboard.press("Tab");
|
||||
/* Kurz warten: sonst liest man einen laufenden Uebergang ab. */
|
||||
await seite.waitForTimeout(120);
|
||||
const jetzt = await seite.evaluate(() => {
|
||||
const e = document.activeElement;
|
||||
const i = e?.getAttribute?.("data-fokusmess");
|
||||
if (i === null || i === undefined) return null;
|
||||
const c = getComputedStyle(e);
|
||||
return { i: Number(i), stil: [c.outlineStyle, c.outlineWidth, c.outlineColor, c.boxShadow,
|
||||
c.borderColor, c.backgroundColor, c.textDecorationLine].join("|") };
|
||||
});
|
||||
if (!jetzt) continue;
|
||||
erreicht++;
|
||||
if (jetzt.stil === ziele[jetzt.i].ruhe) ohneFokus.push(ziele[jetzt.i].name);
|
||||
}
|
||||
return { geprueft: erreicht, ohneFokus };
|
||||
}
|
||||
|
||||
let seitenGesamt = 0, felderGesamt = 0, knoepfeGesamt = 0, verweiseGesamt = 0, ueberGesamt = 0;
|
||||
let fokusGesamt = 0;
|
||||
const maengel = [];
|
||||
|
||||
for (const [rolle, code, rollenName] of ROLLEN) {
|
||||
const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 20000 });
|
||||
|
||||
let schlecht = 0;
|
||||
for (const datei of SEITEN) {
|
||||
const antwort = await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
|
||||
/* Seiten, die diese Rolle nicht sehen darf, landen per Umleitung auf
|
||||
start.html -- das ist richtig so und keine Messung wert. */
|
||||
if (!seite.url().includes(datei.split("?")[0])) continue;
|
||||
if (antwort && antwort.status() >= 400) continue;
|
||||
await seite.waitForTimeout(400);
|
||||
|
||||
const r = await seite.evaluate(messen);
|
||||
seitenGesamt++;
|
||||
felderGesamt += r.felderGesamt;
|
||||
knoepfeGesamt += r.knoepfeGesamt;
|
||||
verweiseGesamt += r.verweiseGesamt;
|
||||
ueberGesamt += r.ueberGesamt;
|
||||
|
||||
const m = [];
|
||||
if (r.ohneName.length) m.push(`Feld ohne Beschriftung: ${r.ohneName.slice(0, 3).join(", ")}`);
|
||||
if (r.stummeKnoepfe.length) m.push(`Knopf ohne Namen: ${r.stummeKnoepfe.slice(0, 3).join(", ")}`);
|
||||
if (r.stummeVerweise.length) m.push(`Verweis ohne Namen: ${r.stummeVerweise.slice(0, 3).join(", ")}`);
|
||||
if (r.ohneAlt.length) m.push(`Bild ohne alt: ${r.ohneAlt.slice(0, 3).join(", ")}`);
|
||||
if (r.h1 !== 1) m.push(`${r.h1} Ueberschriften erster Stufe (genau 1 erwartet)`);
|
||||
if (r.spruenge.length) m.push(`Stufensprung: ${r.spruenge.slice(0, 2).join(", ")}`);
|
||||
if (r.leereUeber.length) m.push(`leere Ueberschrift: ${r.leereUeber.slice(0, 2).join(", ")}`);
|
||||
if (!r.sprache) m.push("kein lang am <html>");
|
||||
if (!r.titel) m.push("kein Seitentitel");
|
||||
if (r.doppelteIds.length) m.push(`doppelte id: ${r.doppelteIds.slice(0, 3).join(", ")}`);
|
||||
if (r.positiverTab.length) m.push(`positiver tabindex: ${r.positiverTab.slice(0, 3).join(", ")}`);
|
||||
if (r.klickbareDivs.length) m.push(`klickbares div: ${r.klickbareDivs.slice(0, 3).join(", ")}`);
|
||||
|
||||
const f = await fokusMitTastatur(seite);
|
||||
fokusGesamt += f.geprueft;
|
||||
if (f.ohneFokus.length) m.push(`kein sichtbarer Fokus: ${f.ohneFokus.slice(0, 3).join(", ")}`);
|
||||
|
||||
if (m.length) { schlecht++; maengel.push(` ${rollenName} / ${datei.padEnd(22)} ${m.join("; ")}`); }
|
||||
}
|
||||
console.log(` ${schlecht === 0 ? "ok " : "FEHL"} ${rollenName.padEnd(10)} ${schlecht} Seiten mit Maengeln`);
|
||||
if (schlecht) fehler++;
|
||||
await kontext.close();
|
||||
}
|
||||
|
||||
console.log("");
|
||||
for (const z of maengel.slice(0, 40)) console.log(z);
|
||||
if (maengel.length > 40) console.log(` ... und ${maengel.length - 40} weitere`);
|
||||
|
||||
console.log(`\n=== Umfang der Messung ===`);
|
||||
ok(seitenGesamt >= 40, `${seitenGesamt} Seitenaufrufe angesehen`);
|
||||
ok(felderGesamt >= 50, `${felderGesamt} Eingabefelder auf Beschriftung geprueft`);
|
||||
ok(knoepfeGesamt >= 100, `${knoepfeGesamt} Knoepfe auf einen Namen geprueft`);
|
||||
ok(verweiseGesamt >= 100, `${verweiseGesamt} Verweise geprueft`);
|
||||
ok(ueberGesamt >= 40, `${ueberGesamt} Ueberschriften auf Gliederung geprueft`);
|
||||
ok(fokusGesamt >= 100, `${fokusGesamt} bedienbare Elemente auf sichtbaren Fokus geprueft`);
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — findet diese Messung ueberhaupt etwas?
|
||||
======================================================================= */
|
||||
console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
|
||||
{
|
||||
const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 20000 });
|
||||
await seite.waitForTimeout(700);
|
||||
|
||||
const sauber = await seite.evaluate(messen);
|
||||
ok(sauber.ohneName.length === 0 && sauber.stummeKnoepfe.length === 0,
|
||||
"die Startseite ist unauffaellig");
|
||||
|
||||
const kaputt = await seite.evaluate(() => {
|
||||
const f = document.createElement("input");
|
||||
f.type = "text"; f.id = "ohne-label-test";
|
||||
const k = document.createElement("button");
|
||||
k.textContent = "×";
|
||||
const b = document.createElement("img");
|
||||
b.src = "/workspace/assets/img/marke-husky.webp"; b.style.width = "40px";
|
||||
const u = document.createElement("h5");
|
||||
u.textContent = "Ueberspringt Stufen";
|
||||
const dv = document.createElement("div");
|
||||
dv.setAttribute("onclick", "void 0"); dv.textContent = "klick"; dv.style.padding = "10px";
|
||||
const zwei = document.createElement("span");
|
||||
zwei.id = document.querySelector("[id]")?.id || "doppelt";
|
||||
for (const e of [f, k, b, u, dv, zwei]) document.body.append(e);
|
||||
return true;
|
||||
});
|
||||
await seite.waitForTimeout(250);
|
||||
const r = await seite.evaluate(messen);
|
||||
ok(kaputt && r.ohneName.length > 0, `unbeschriftetes Feld wird gefunden (${r.ohneName.join(",")})`);
|
||||
ok(r.stummeKnoepfe.length > 0, `Knopf ohne Namen wird gefunden (${r.stummeKnoepfe.join(",")})`);
|
||||
ok(r.ohneAlt.length > 0, `Bild ohne alt wird gefunden (${r.ohneAlt.join(",")})`);
|
||||
ok(r.spruenge.length > 0, `Stufensprung wird gefunden (${r.spruenge.join(",")})`);
|
||||
ok(r.klickbareDivs.length > 0, `klickbares div wird gefunden (${r.klickbareDivs.join(",")})`);
|
||||
ok(r.doppelteIds.length > 0, `doppelte id wird gefunden (${r.doppelteIds.join(",")})`);
|
||||
|
||||
/* DIE WICHTIGSTE GEGENPROBE — sie fehlte zuerst.
|
||||
Die Fokusmessung wurde von element.focus() auf echte Tastendruecke
|
||||
umgestellt. Danach meldete sie "0 Seiten mit Maengeln" -- was
|
||||
genauso gut heissen konnte, dass sie ueberhaupt nichts mehr findet
|
||||
(falsche Markierung, Tab-Reihenfolge verfehlt, zu frueh gemessen).
|
||||
Ohne diesen Beweis waere die Umstellung ein gruener Haken fuer
|
||||
nichts gewesen.
|
||||
|
||||
Ein Knopf, dem jede Fokusanzeige verboten wird, MUSS auffallen. */
|
||||
await seite.evaluate(() => {
|
||||
/* Ruhe UND Fokus bekommen exakt dieselben Werte -- sonst ist der
|
||||
Knopf im Fokus zwar ohne Ring, aber sonst irgendwie anders, und
|
||||
die Messung wertet das (zu Recht) als Unterschied. Genau daran
|
||||
ist der erste Entwurf dieser Gegenprobe gescheitert: Er setzte
|
||||
`background: inherit`, was etwas anderes ergab als der
|
||||
Ruhezustand -- der Knopf sah im Fokus anders aus und galt
|
||||
deshalb als in Ordnung. */
|
||||
const stil = document.createElement("style");
|
||||
stil.textContent =
|
||||
"#ohne-fokus-test, #ohne-fokus-test:focus, #ohne-fokus-test:focus-visible {"
|
||||
+ " outline: none !important; box-shadow: none !important;"
|
||||
+ " border: 1px solid rgb(51,51,51) !important;"
|
||||
+ " background: rgb(17,17,17) !important; color: rgb(238,238,238) !important;"
|
||||
+ " text-decoration: none !important; }";
|
||||
document.head.append(stil);
|
||||
const k = document.createElement("button");
|
||||
k.id = "ohne-fokus-test"; k.type = "button"; k.textContent = "Kein Fokus zu sehen";
|
||||
/* Ganz nach vorn in die Tab-Reihenfolge, damit er sicher drankommt. */
|
||||
document.body.prepend(k);
|
||||
});
|
||||
await seite.waitForTimeout(200);
|
||||
const fp = await fokusMitTastatur(seite);
|
||||
ok(fp.ohneFokus.some((n) => n.includes("ohne-fokus-test")),
|
||||
`ein Knopf ohne Fokusanzeige wird per Tastatur gefunden `
|
||||
+ `(${fp.geprueft} abgelaufen, gemeldet: ${fp.ohneFokus.join(", ") || "nichts"})`);
|
||||
|
||||
await kontext.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,355 @@
|
||||
/* JEDE BILDSCHIRMBREITE — nicht nur Handy und Rechner.
|
||||
|
||||
Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 4:
|
||||
"Smartphone klein, Smartphone gross, Tablet, Laptop, Desktop, grosser
|
||||
Desktop, sehr breite Bildschirme."
|
||||
|
||||
WAS BISHER FEHLTE. Der Grosscheck geht ueber alle Seiten und Rollen --
|
||||
aber bei genau ZWEI Breiten: 1280 und 390. Dazwischen liegt der
|
||||
gesamte Tablet-Bereich, und darunter das kleinste Handy, das es noch
|
||||
zu kaufen gibt.
|
||||
|
||||
Das ist kein theoretisches Loch: Umbruchpunkte in diesem Haus liegen
|
||||
bei 700, 760, 900 und 1100 px. Ein Layout kann bei 390 und bei 1280
|
||||
tadellos sein und bei 768 auseinanderfallen -- genau dort, wo keiner
|
||||
der beiden Messpunkte liegt. Ein Fehler in einem Umbruchpunkt zeigt
|
||||
sich immer nur in dem Fenster, das ihn trifft.
|
||||
|
||||
GEMESSEN WIRD, WAS MAN SIEHT:
|
||||
1. Steht etwas seitlich ueber? (Waagerechtes Scrollen ist auf dem
|
||||
Handy der schlimmste Layoutfehler.)
|
||||
2. Ist Text abgeschnitten?
|
||||
3. Ueberdeckt etwas Bedienbares etwas anderes Bedienbares?
|
||||
4. Sind Beruehrziele auf kleinen Schirmen gross genug?
|
||||
|
||||
DIE ANZAHL GEHOERT IN DIE BEDINGUNG: Eine Seite, die nichts enthaelt,
|
||||
waere sonst die sauberste von allen.
|
||||
|
||||
GEGENPROBE am Ende: ein absichtlich zu breites Element muss bei jeder
|
||||
Breite auffallen. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-breit-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
|
||||
gemessen. Begruendung in helfer-port.mjs. */
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4277, "die Breitenpruefung");
|
||||
|
||||
process.env.PORT = "4277";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4277";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
const heute = jetzt.slice(0, 10);
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
|
||||
const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("SpongBobSchwammKopf", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
|
||||
/* Lange Inhalte ohne Umbruchgelegenheit -- kurze wuerden jeden
|
||||
Ueberlauf verstecken. */
|
||||
const LANG = "Ein absichtlich sehr langer Titel ohne Leerzeichenumbrueche "
|
||||
+ "damit ein Ueberlauf auffaellt statt sich zu verstecken";
|
||||
for (const [titel, st] of [[LANG, "offen"], ["Kurz", "arbeit"],
|
||||
[LANG + " zwei", "review"], ["Fertig", "erledigt"]]) {
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
|
||||
creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
|
||||
.run(titel, "Eine Beschreibung mit etwas mehr Text darin, damit die Karte Hoehe bekommt.",
|
||||
st, heute, idLuna, idPat, jetzt, idDogi);
|
||||
}
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, ort, creator_id, erstellt, erstellt_von)
|
||||
VALUES (?,'call',?,30,'https://meet.example.invalid/ein-sehr-langer-raumname-hier',?,?,?)`)
|
||||
.run(LANG, heute + "T10:00", idLuna, jetzt, idDogi);
|
||||
for (const b of ["live", "community", "technik", "schutz", "content"]) {
|
||||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
|
||||
datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(b, b === "content" ? "idee" : "vorbereitung", LANG, "Text dazu.",
|
||||
"offen", "hoch", heute, idLuna, jetzt, idPat);
|
||||
}
|
||||
d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, scout_id, erstellt, naechster_followup)
|
||||
VALUES (?, 'tiktok','@einsehrlangername','angesprochen','hoch',?,?,?)`)
|
||||
.run(LANG, idPat, jetzt, heute);
|
||||
d.close();
|
||||
|
||||
/* ---- Browser ------------------------------------------------------------ */
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
/* Die Breiten aus dem Auftrag, dazu die Umbruchpunkte des Hauses.
|
||||
320 ist das kleinste Geraet, das noch vorkommt (iPhone SE 1);
|
||||
768 ist das klassische Tablet hochkant und liegt genau zwischen den
|
||||
beiden bisher gemessenen Punkten. */
|
||||
const BREITEN = [
|
||||
[320, 568, "Handy klein"],
|
||||
[390, 844, "Handy"],
|
||||
[430, 932, "Handy gross"],
|
||||
[768, 1024, "Tablet hoch"],
|
||||
[1024, 768, "Tablet quer"],
|
||||
[1280, 900, "Laptop"],
|
||||
[1440, 900, "Desktop"],
|
||||
[1920, 1080, "Desktop gross"],
|
||||
[2560, 1440, "sehr breit"],
|
||||
];
|
||||
|
||||
const SEITEN = [
|
||||
"start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
|
||||
"dateien.html", "content.html", "bereich.html?b=live", "profil.html",
|
||||
"startcheck.html", "report.html", "scouting.html", "personen.html",
|
||||
"wissen.html", "automation.html", "steckbrief.html",
|
||||
];
|
||||
|
||||
const messen = () => {
|
||||
const sichtbar = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return false;
|
||||
const st = getComputedStyle(e);
|
||||
return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05;
|
||||
};
|
||||
const name = (e) => `${e.tagName}${e.id ? "#" + e.id : ""}`
|
||||
+ `${e.className && typeof e.className === "string" ? "." + e.className.split(" ")[0] : ""}`;
|
||||
|
||||
const alle = [...document.querySelectorAll("body *")].filter(sichtbar);
|
||||
const breite = document.documentElement.clientWidth;
|
||||
|
||||
/* 1. Ueberstand nach rechts. */
|
||||
const ueber = Math.max(0, Math.round(document.documentElement.scrollWidth - breite));
|
||||
const schuld = alle.filter((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.right > breite + 2 && r.width <= breite * 1.5;
|
||||
}).slice(0, 3).map((e) => `${name(e)} bis ${Math.round(e.getBoundingClientRect().right)}px`);
|
||||
|
||||
/* 2. Abgeschnittener Text. Ein Kasten von einem Pixel schneidet
|
||||
nichts ab, er versteckt -- der zaehlt nicht. */
|
||||
const beschnitten = alle.filter((e) => {
|
||||
if (e.children.length > 0) return false;
|
||||
const st = getComputedStyle(e);
|
||||
if (st.overflow === "visible" && st.overflowX === "visible") return false;
|
||||
if (st.textOverflow === "ellipsis") return false; // gewollt gekuerzt
|
||||
if (e.clientWidth < 4 || e.clientHeight < 4) return false;
|
||||
if (st.clipPath && st.clipPath !== "none") return false;
|
||||
return e.scrollWidth > e.clientWidth + 2 && (e.textContent || "").trim().length > 3;
|
||||
}).slice(0, 3).map((e) => `${name(e)} "${(e.textContent || "").trim().slice(0, 20)}"`);
|
||||
|
||||
/* 3. Bedienbares, das WIRKLICH nicht zu erreichen ist.
|
||||
|
||||
Der erste Entwurf fragte nur "liegt etwas darueber?" und meldete
|
||||
damit lauter Dinge, die genau so gewollt sind:
|
||||
|
||||
* `.speicher-leiste` auf profil.html ist `position: sticky;
|
||||
bottom: 0` -- eine Leiste, die beim Scrollen unten stehen
|
||||
bleibt. Dass sie ueber dem Formular liegt, IST ihre Aufgabe.
|
||||
Sie wanderte bei acht von neun Breiten in die Fehlerliste.
|
||||
* Ein dekoratives SVG mit `pointer-events: none` liegt zwar
|
||||
optisch ueber einem Feld, faengt aber keinen einzigen Klick ab.
|
||||
|
||||
Eine Warnung, die bei jedem Lauf kommt, ist keine Warnung mehr --
|
||||
sie wird weggeklickt, und mit ihr die echte. Deshalb gilt ein
|
||||
Element erst dann als verdeckt, wenn das, was darueber liegt,
|
||||
Klicks auch WIRKLICH abfaengt und nicht mitscrollt:
|
||||
- kein pointer-events: none
|
||||
- nicht sticky/fixed (das ist eine Leiste, kein Hindernis)
|
||||
Und geprueft wird der ganze Kasten, nicht nur der Mittelpunkt:
|
||||
Wer an einer Ecke noch hinkommt, kommt hin. */
|
||||
const bedienbar = alle.filter((e) =>
|
||||
["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(e.tagName) && !e.disabled);
|
||||
const verdeckt = [];
|
||||
const laesstDurch = (e) => {
|
||||
for (let z = e; z && z !== document.body; z = z.parentElement) {
|
||||
const st = getComputedStyle(z);
|
||||
if (st.pointerEvents === "none") return true;
|
||||
if (st.position === "sticky" || st.position === "fixed") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const e of bedienbar.slice(0, 60)) {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.bottom < 0 || r.top > window.innerHeight) continue; // ausserhalb des Fensters
|
||||
/* Fuenf Punkte statt einem: Mitte und die vier Viertelpunkte. */
|
||||
const punkte = [[0.5, 0.5], [0.25, 0.3], [0.75, 0.3], [0.25, 0.7], [0.75, 0.7]];
|
||||
let erreichbar = false;
|
||||
let hindernis = null;
|
||||
for (const [fx, fy] of punkte) {
|
||||
const x = Math.round(r.left + r.width * fx);
|
||||
const y = Math.round(r.top + r.height * fy);
|
||||
if (x < 0 || y < 0 || x > breite || y > window.innerHeight) continue;
|
||||
const oben = document.elementFromPoint(x, y);
|
||||
if (!oben || oben === e || e.contains(oben) || oben.contains(e)) { erreichbar = true; break; }
|
||||
if (laesstDurch(oben)) { erreichbar = true; break; }
|
||||
hindernis = hindernis || name(oben);
|
||||
}
|
||||
if (!erreichbar && hindernis) {
|
||||
verdeckt.push(`${name(e)} unter ${hindernis}`);
|
||||
if (verdeckt.length >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
/* 4. Beruehrziele (WCAG 2.5.8: 24x24, Verweise im Fliesstext ausgenommen). */
|
||||
const klein = bedienbar.filter((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width >= 24 && r.height >= 24) return false;
|
||||
const st = getComputedStyle(e);
|
||||
if (e.tagName === "A" && st.display.startsWith("inline")) {
|
||||
const eltern = (e.parentElement?.textContent || "").trim().length;
|
||||
const selbst = (e.textContent || "").trim().length;
|
||||
if (eltern > selbst + 12) return false;
|
||||
}
|
||||
return true;
|
||||
}).slice(0, 3).map((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return `${name(e)} ${Math.round(r.width)}x${Math.round(r.height)}`;
|
||||
});
|
||||
|
||||
return {
|
||||
elemente: alle.length, bedienbar: bedienbar.length,
|
||||
inhalt: (document.body.innerText || "").trim().length,
|
||||
ueber, schuld, beschnitten, verdeckt, klein,
|
||||
};
|
||||
};
|
||||
|
||||
let seitenGesamt = 0, elementeGesamt = 0;
|
||||
const probleme = [];
|
||||
|
||||
for (const [breite, hoehe, wie] of BREITEN) {
|
||||
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
|
||||
const seite = await kontext.newPage();
|
||||
const konsole = [];
|
||||
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text().slice(0, 70)); });
|
||||
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message.slice(0, 70)));
|
||||
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 20000 });
|
||||
|
||||
let schlecht = 0;
|
||||
const details = [];
|
||||
for (const datei of SEITEN) {
|
||||
konsole.length = 0;
|
||||
await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(320);
|
||||
const r = await seite.evaluate(messen);
|
||||
seitenGesamt++;
|
||||
elementeGesamt += r.elemente;
|
||||
|
||||
const m = [];
|
||||
if (r.inhalt < 40) m.push(`fast kein Inhalt (${r.inhalt})`);
|
||||
if (r.ueber > 0) m.push(`${r.ueber}px Ueberstand [${r.schuld.join(" | ")}]`);
|
||||
if (r.beschnitten.length) m.push(`abgeschnitten: ${r.beschnitten.join(" | ")}`);
|
||||
if (r.verdeckt.length) m.push(`verdeckt: ${r.verdeckt.join(" | ")}`);
|
||||
if (breite < 700 && r.klein.length) m.push(`Beruehrziel: ${r.klein.join(" | ")}`);
|
||||
if (konsole.length) m.push(`Konsole: ${konsole.slice(0, 2).join(" | ")}`);
|
||||
if (m.length) { schlecht++; details.push(` ${datei.padEnd(22)} ${m.join("; ")}`); }
|
||||
}
|
||||
const gut = schlecht === 0;
|
||||
if (!gut) { fehler++; probleme.push(`${wie} (${breite}px)`); }
|
||||
console.log(` ${gut ? "ok " : "FEHL"} ${wie.padEnd(14)} ${String(breite).padStart(4)}px `
|
||||
+ `${SEITEN.length} Seiten` + (gut ? "" : ` — ${schlecht} beanstandet`));
|
||||
for (const z of details.slice(0, 6)) console.log(z);
|
||||
await kontext.close();
|
||||
}
|
||||
|
||||
console.log(`\n=== Umfang ===`);
|
||||
ok(seitenGesamt === BREITEN.length * SEITEN.length,
|
||||
`${seitenGesamt} Seitenaufrufe (${SEITEN.length} Seiten x ${BREITEN.length} Breiten)`);
|
||||
ok(elementeGesamt > 20000, `${elementeGesamt} Elemente vermessen`);
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE
|
||||
======================================================================= */
|
||||
console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
|
||||
{
|
||||
for (const testBreite of [320, 768, 1920]) {
|
||||
const kontext = await browser.newContext({ viewport: { width: testBreite, height: 900 } });
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 20000 });
|
||||
await seite.waitForTimeout(600);
|
||||
|
||||
const vorher = await seite.evaluate(messen);
|
||||
ok(vorher.ueber === 0, `${testBreite}px: die Startseite steht nicht ueber (${vorher.ueber}px)`);
|
||||
|
||||
const nachher = await seite.evaluate(() => {
|
||||
const k = document.createElement("div");
|
||||
k.style.cssText = "width:3000px;height:30px;background:#333";
|
||||
k.textContent = "absichtlich zu breit";
|
||||
document.body.append(k);
|
||||
const t = document.createElement("div");
|
||||
t.style.cssText = "width:40px;overflow:hidden;white-space:nowrap";
|
||||
t.textContent = "Dieses Wort passt hier nicht hinein";
|
||||
document.body.append(t);
|
||||
return true;
|
||||
});
|
||||
await seite.waitForTimeout(220);
|
||||
const r = await seite.evaluate(messen);
|
||||
ok(nachher && r.ueber > 0, `${testBreite}px: zu breites Element wird gefunden (${r.ueber}px Ueberstand)`);
|
||||
ok(r.beschnitten.length > 0, `${testBreite}px: abgeschnittener Text wird gefunden`);
|
||||
|
||||
/* Der Verdeckt-Test wurde entschaerft (sticky-Leisten und
|
||||
pointer-events:none zaehlen nicht mehr). Ohne diesen Beweis
|
||||
wuesste niemand, ob er danach ueberhaupt noch etwas findet --
|
||||
"0 verdeckte Elemente" waere sonst genauso gut ein stiller
|
||||
Ausfall. Ein echtes Hindernis MUSS auffallen. */
|
||||
await seite.evaluate(() => {
|
||||
const ziel = document.querySelector("button, a[href]");
|
||||
if (!ziel) return;
|
||||
const r2 = ziel.getBoundingClientRect();
|
||||
const deckel = document.createElement("div");
|
||||
deckel.style.cssText = `position:absolute; left:${r2.left + window.scrollX}px;`
|
||||
+ `top:${r2.top + window.scrollY}px; width:${Math.max(r2.width, 30)}px;`
|
||||
+ `height:${Math.max(r2.height, 30)}px; background:#900; z-index:99999;`
|
||||
+ "pointer-events:auto;";
|
||||
deckel.id = "deckel-test";
|
||||
document.body.append(deckel);
|
||||
});
|
||||
await seite.waitForTimeout(220);
|
||||
const v = await seite.evaluate(messen);
|
||||
ok(v.verdeckt.length > 0,
|
||||
`${testBreite}px: ein echtes Hindernis wird gefunden (${v.verdeckt.join(" | ") || "nichts"})`);
|
||||
await kontext.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`
|
||||
+ (probleme.length ? ` — ${probleme.join(", ")}` : ""));
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,17 @@
|
||||
ok Chromium (Chrome, Edge) 16 Seiten
|
||||
ok Firefox (Gecko) 16 Seiten
|
||||
ok WebKit (Safari) 16 Seiten
|
||||
ok WebKit auf dem Handy (iPhone) 16 Seiten
|
||||
ok 4 von 4 Maschinen wirklich gestartet
|
||||
ok jede Maschine konnte gestartet werden
|
||||
ok 64 Seitenaufrufe ueber alle Maschinen
|
||||
ok 14494 Elemente vermessen
|
||||
ok Chromium: die Startseite ist unauffaellig (0px, 1622 Zeichen)
|
||||
ok Chromium: zu breites Element wird gefunden (1720px)
|
||||
ok Chromium: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
|
||||
ok Firefox: die Startseite ist unauffaellig (0px, 1574 Zeichen)
|
||||
ok Firefox: zu breites Element wird gefunden (1720px)
|
||||
ok Firefox: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
|
||||
ok WebKit: die Startseite ist unauffaellig (0px, 1574 Zeichen)
|
||||
ok WebKit: zu breites Element wird gefunden (1720px)
|
||||
ok WebKit: ein Skriptfehler wird bemerkt (Absichtlicher Testfehler)
|
||||
@@ -0,0 +1,294 @@
|
||||
/* DREI RENDERING-MASCHINEN, NICHT EINE.
|
||||
|
||||
Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 5:
|
||||
Chrome, Edge, Firefox, Safari, Mobile Safari, mobiles Chrome.
|
||||
|
||||
WARUM DAS NOETIG IST. Alle bisherigen Pruefungen dieses Hauses laufen
|
||||
in Chromium -- und damit deckt jede einzelne nur Chrome und Edge ab
|
||||
(beide bauen darauf auf). Firefox (Gecko) und Safari (WebKit) sind
|
||||
voellig andere Maschinen. Genau dort zeigen sich:
|
||||
* CSS, das nur eine Maschine kennt (color-mix, :has, mask-composite,
|
||||
aspect-ratio, backdrop-filter -- dieses Haus benutzt alles davon)
|
||||
* JavaScript, das eine Maschine noch nicht mitbringt
|
||||
* unterschiedliche Voreinstellungen bei Formularen und Schriften
|
||||
|
||||
Safari ist der wichtigste Fall: Wer den Workspace auf dem iPhone
|
||||
benutzt, benutzt WebKit -- auch dann, wenn "Chrome" auf dem
|
||||
Bildschirm steht. Auf iOS gibt es keine andere Maschine.
|
||||
|
||||
GEMESSEN WIRD JE MASCHINE:
|
||||
1. Kommt die Seite an (echter Inhalt, nicht nur Geruest)?
|
||||
2. Meldet die Konsole einen Fehler?
|
||||
3. Steht etwas seitlich ueber?
|
||||
4. Kommt man ueberhaupt hinein (Anmeldung, Weiterleitung)?
|
||||
|
||||
DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faellt eine Maschine aus (nicht
|
||||
installiert), darf das nicht als "bestanden" durchgehen -- dafuer gibt
|
||||
es den dritten Ausgang: nicht in Ordnung ist etwas anderes als nicht
|
||||
nachgesehen.
|
||||
|
||||
GEGENPROBE am Ende: In jeder Maschine muss ein absichtlicher Fehler
|
||||
gefunden werden. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-browser-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
|
||||
gemessen. Begruendung in helfer-port.mjs. */
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4289, "die Browserpruefung");
|
||||
|
||||
process.env.PORT = "4289";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4289";
|
||||
|
||||
/* LAUFEND MITSCHREIBEN, nicht erst am Ende.
|
||||
|
||||
Node puffert stdout, sobald es in eine Datei umgeleitet wird -- bei
|
||||
einem Lauf, der drei Browser startet und eine Viertelstunde braucht,
|
||||
steht bis zum Schluss NICHTS in der Datei. Beim ersten Versuch wurde
|
||||
der Lauf abgeraeumt, bevor er fertig war, und hinterliess null Bytes:
|
||||
eine Viertelstunde Rechenzeit ohne ein einziges Ergebnis.
|
||||
|
||||
Deshalb geht jede Zeile zusaetzlich sofort auf die Platte. Wird der
|
||||
Lauf abgebrochen, ist wenigstens alles da, was bis dahin gemessen
|
||||
wurde. */
|
||||
const { appendFileSync } = await import("node:fs");
|
||||
const PROTOKOLL = join(process.cwd(), "pruef-browser-lauf.txt");
|
||||
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
|
||||
const melde = (zeile) => {
|
||||
console.log(zeile);
|
||||
try { appendFileSync(PROTOKOLL, zeile + "\n"); } catch { /* Protokoll ist Zugabe */ }
|
||||
};
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
const heute = jetzt.slice(0, 10);
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001");
|
||||
const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
for (const [t, st] of [["Eine offene Aufgabe", "offen"], ["In Arbeit", "arbeit"],
|
||||
["Im Review", "review"], ["Erledigt", "erledigt"]]) {
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
|
||||
creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
|
||||
.run(t, "Beschreibung.", st, heute, idLuna, idPat, jetzt, idDogi);
|
||||
}
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
|
||||
VALUES ('Ein Call','call',?,30,?,?,?)`).run(heute + "T10:00", idLuna, jetzt, idDogi);
|
||||
for (const b of ["live", "content", "technik", "community", "schutz"]) {
|
||||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
|
||||
datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(b, b === "content" ? "idee" : "vorbereitung", "Ein Eintrag", "Text.",
|
||||
"offen", "hoch", heute, idLuna, jetzt, idPat);
|
||||
}
|
||||
d.close();
|
||||
|
||||
/* ---- Die drei Maschinen -------------------------------------------------- */
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
|
||||
const SEITEN = [
|
||||
"start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
|
||||
"dateien.html", "content.html", "bereich.html?b=live", "profil.html",
|
||||
"startcheck.html", "report.html", "scouting.html", "personen.html",
|
||||
"wissen.html", "automation.html", "steckbrief.html",
|
||||
];
|
||||
|
||||
/* Handy-Format fuer WebKit dazu -- das ist der Fall, der auf dem iPhone
|
||||
wirklich vorkommt. */
|
||||
const MASCHINEN = [
|
||||
["chromium", "Chromium (Chrome, Edge)", 1280, 900],
|
||||
["firefox", "Firefox (Gecko)", 1280, 900],
|
||||
["webkit", "WebKit (Safari)", 1280, 900],
|
||||
["webkit", "WebKit auf dem Handy (iPhone)", 390, 844],
|
||||
];
|
||||
|
||||
const messen = () => {
|
||||
const sichtbar = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) return false;
|
||||
const st = getComputedStyle(e);
|
||||
return st.visibility !== "hidden" && st.display !== "none";
|
||||
};
|
||||
const alle = [...document.querySelectorAll("body *")].filter(sichtbar);
|
||||
const breite = document.documentElement.clientWidth;
|
||||
return {
|
||||
elemente: alle.length,
|
||||
inhalt: (document.body.innerText || "").trim().length,
|
||||
ueber: Math.max(0, Math.round(document.documentElement.scrollWidth - breite)),
|
||||
schuld: alle.filter((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.right > breite + 2 && r.width <= breite * 1.5;
|
||||
}).slice(0, 2).map((e) => `${e.tagName}.${(typeof e.className === "string" ? e.className : "").split(" ")[0]}`),
|
||||
bedienbar: alle.filter((e) =>
|
||||
["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(e.tagName)).length,
|
||||
};
|
||||
};
|
||||
|
||||
let seitenGesamt = 0, elementeGesamt = 0;
|
||||
let maschinenGelaufen = 0;
|
||||
const nichtNachgesehen = [];
|
||||
|
||||
for (const [motor, wie, breite, hoehe] of MASCHINEN) {
|
||||
let browser;
|
||||
try {
|
||||
browser = await pw[motor].launch();
|
||||
} catch (f) {
|
||||
/* DER DRITTE AUSGANG. Eine Maschine, die nicht startet, ist nicht
|
||||
"in Ordnung" -- sie ist nicht nachgesehen. Das muss dastehen und
|
||||
nicht in einem gruenen Haken verschwinden. */
|
||||
nichtNachgesehen.push(`${wie}: ${String(f?.message || f).slice(0, 80)}`);
|
||||
melde(` ???? ${wie.padEnd(30)} NICHT NACHGESEHEN — ${String(f?.message || f).slice(0, 60)}`);
|
||||
continue;
|
||||
}
|
||||
maschinenGelaufen++;
|
||||
|
||||
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
|
||||
const seite = await kontext.newPage();
|
||||
const konsole = [];
|
||||
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text().slice(0, 90)); });
|
||||
seite.on("pageerror", (e) => konsole.push("pageerror: " + String(e.message).slice(0, 90)));
|
||||
|
||||
const details = [];
|
||||
let schlecht = 0;
|
||||
|
||||
/* Erst einmal: Kommt man ueberhaupt hinein? */
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
let drin = true;
|
||||
try {
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
/* DIE SEITE ERST FERTIG LADEN LASSEN, bevor der Rundgang beginnt.
|
||||
`waitForURL` wartet nur darauf, dass sich die ADRESSE aendert --
|
||||
start.html holt danach noch ihre Daten (/api/ich, /api/personen,
|
||||
/api/hinweise). Ging der Rundgang sofort weiter, brach das
|
||||
naechste `goto` diese laufenden Anfragen ab, und WebKit meldet
|
||||
einen Abbruch als "pageerror: … due to access control checks".
|
||||
Das sah nach einem Sicherheitsproblem in Safari aus und war der
|
||||
Abbruch durch die Pruefung selbst -- in Ruhe nachgemessen: null
|
||||
Ereignisse, alle 18 Kacheln da. Chromium und Firefox schweigen
|
||||
bei einem Abbruch, WebKit nicht; deshalb fiel es nur dort auf. */
|
||||
await seite.waitForLoadState("networkidle").catch(() => {});
|
||||
await seite.waitForTimeout(400);
|
||||
} catch {
|
||||
drin = false;
|
||||
details.push(` ANMELDUNG GESCHEITERT (${konsole.slice(0, 2).join(" | ") || "ohne Konsolenfehler"})`);
|
||||
schlecht++;
|
||||
}
|
||||
|
||||
if (drin) {
|
||||
for (const datei of SEITEN) {
|
||||
konsole.length = 0;
|
||||
await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(400);
|
||||
const r = await seite.evaluate(messen);
|
||||
seitenGesamt++;
|
||||
elementeGesamt += r.elemente;
|
||||
|
||||
const m = [];
|
||||
if (r.inhalt < 40) m.push(`fast kein Inhalt (${r.inhalt})`);
|
||||
if (r.bedienbar < 3) m.push(`fast nichts bedienbar (${r.bedienbar})`);
|
||||
if (r.ueber > 0) m.push(`${r.ueber}px Ueberstand [${r.schuld.join(" | ")}]`);
|
||||
if (konsole.length) m.push(`Konsole: ${konsole.slice(0, 2).join(" | ")}`);
|
||||
if (m.length) { schlecht++; details.push(` ${datei.padEnd(22)} ${m.join("; ")}`); }
|
||||
}
|
||||
}
|
||||
|
||||
const gut = schlecht === 0;
|
||||
if (!gut) fehler++;
|
||||
melde(` ${gut ? "ok " : "FEHL"} ${wie.padEnd(30)} ${drin ? SEITEN.length : 0} Seiten`
|
||||
+ (gut ? "" : ` — ${schlecht} beanstandet`));
|
||||
for (const z of details.slice(0, 8)) melde(z);
|
||||
|
||||
await kontext.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n=== Umfang ===`);
|
||||
ok(maschinenGelaufen === MASCHINEN.length,
|
||||
`${maschinenGelaufen} von ${MASCHINEN.length} Maschinen wirklich gestartet`);
|
||||
ok(nichtNachgesehen.length === 0,
|
||||
nichtNachgesehen.length ? `nicht nachgesehen: ${nichtNachgesehen.join(" · ")}`
|
||||
: "jede Maschine konnte gestartet werden");
|
||||
ok(seitenGesamt >= SEITEN.length * 3, `${seitenGesamt} Seitenaufrufe ueber alle Maschinen`);
|
||||
ok(elementeGesamt > 5000, `${elementeGesamt} Elemente vermessen`);
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — findet die Messung in JEDER Maschine etwas?
|
||||
======================================================================= */
|
||||
console.log(`\n=== Gegenprobe (muss in jeder Maschine anschlagen) ===`);
|
||||
for (const [motor, wie] of [["chromium", "Chromium"], ["firefox", "Firefox"], ["webkit", "WebKit"]]) {
|
||||
let browser;
|
||||
try { browser = await pw[motor].launch(); } catch { continue; }
|
||||
const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const seite = await kontext.newPage();
|
||||
const konsole = [];
|
||||
seite.on("pageerror", (e) => konsole.push(String(e.message).slice(0, 60)));
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 25000 });
|
||||
await seite.waitForTimeout(600);
|
||||
|
||||
const vorher = await seite.evaluate(messen);
|
||||
ok(vorher.ueber === 0 && vorher.inhalt > 40,
|
||||
`${wie}: die Startseite ist unauffaellig (${vorher.ueber}px, ${vorher.inhalt} Zeichen)`);
|
||||
|
||||
await seite.evaluate(() => {
|
||||
const k = document.createElement("div");
|
||||
k.style.cssText = "width:3000px;height:24px;background:#333";
|
||||
document.body.append(k);
|
||||
});
|
||||
await seite.waitForTimeout(220);
|
||||
const nachher = await seite.evaluate(messen);
|
||||
ok(nachher.ueber > 0, `${wie}: zu breites Element wird gefunden (${nachher.ueber}px)`);
|
||||
|
||||
/* Und: Wird ein echter Skriptfehler gemeldet? */
|
||||
konsole.length = 0;
|
||||
await seite.evaluate(() => { setTimeout(() => { throw new Error("Absichtlicher Testfehler"); }, 0); });
|
||||
await seite.waitForTimeout(400);
|
||||
ok(konsole.some((z) => z.includes("Absichtlicher Testfehler")),
|
||||
`${wie}: ein Skriptfehler wird bemerkt (${konsole.slice(0, 1).join("") || "nichts"})`);
|
||||
|
||||
await kontext.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
|
Before Width: | Height: | Size: 1016 KiB After Width: | Height: | Size: 1009 KiB |
|
Before Width: | Height: | Size: 770 KiB After Width: | Height: | Size: 770 KiB |
|
Before Width: | Height: | Size: 904 KiB After Width: | Height: | Size: 904 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 931 KiB After Width: | Height: | Size: 932 KiB |
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 537 KiB |
|
Before Width: | Height: | Size: 878 KiB After Width: | Height: | Size: 878 KiB |
@@ -35,7 +35,12 @@
|
||||
|
||||
import { mkdtempSync, rmSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/* Der Repo-Ordner, vom Ort DIESER Datei aus -- nicht vom Arbeits-
|
||||
verzeichnis. Siehe die Begruendung weiter unten bei OEFFENTLICH. */
|
||||
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-gross-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
@@ -394,8 +399,28 @@ for (const [breite, hoehe, wie] of GERAETE) {
|
||||
Die oeffentliche Seite -- sie ist das Wichtigste ueberhaupt
|
||||
================================================================ */
|
||||
console.log("\n=================== Öffentliche Seite ===================");
|
||||
const OEFFENTLICH = readdirSync(".")
|
||||
|
||||
/* BEFUND VOM 04.09.2026 (Audit). Hier stand `readdirSync(".")` -- das
|
||||
AKTUELLE ARBEITSVERZEICHNIS, nicht der Repo-Ordner. Wer diese Pruefung
|
||||
aus `server/` startet (der naheliegendste Ort, dort liegt sie), findet
|
||||
dort keine einzige .html: die Schleife lief null Mal, und der Abschnitt
|
||||
meldete trotzdem
|
||||
|
||||
ok Rechner 0 Seiten
|
||||
ok Handy 0 Seiten
|
||||
|
||||
ausgerechnet unter der Ueberschrift "sie ist das Wichtigste ueberhaupt".
|
||||
Ein gruener Haken fuer nichts. Das ist genau die Sorte Pruefung, die
|
||||
nicht scheitert, wenn sie ihre Voraussetzung nicht hat, sondern
|
||||
schweigt -- und dadurch monatelang bestaetigt, was sie nie angesehen hat.
|
||||
|
||||
Zwei Aenderungen: Der Ordner steht jetzt fest (vom Ort dieser Datei
|
||||
aus), und die ANZAHL gehoert in die Bedingung -- 0 Seiten ist ab jetzt
|
||||
ein Fehler, kein Erfolg. */
|
||||
const OEFFENTLICH = readdirSync(REPO)
|
||||
.filter((n) => n.endsWith(".html") && !["404.html", "gate.html"].includes(n));
|
||||
ok(OEFFENTLICH.length >= 10,
|
||||
`${OEFFENTLICH.length} oeffentliche Seiten gefunden (unter 10 waere ein Fehler in dieser Pruefung selbst)`);
|
||||
|
||||
for (const [breite, hoehe, wie] of GERAETE) {
|
||||
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
|
||||
@@ -421,7 +446,9 @@ for (const [breite, hoehe, wie] of GERAETE) {
|
||||
if (breite < 700 && r.klein.length) mangel.push(`Berührziel: ${r.klein.join(" | ")}`);
|
||||
if (mangel.length) { schlecht++; details.push(` ${datei.padEnd(26)} ${mangel.join("; ")}`); }
|
||||
}
|
||||
const gut = schlecht === 0;
|
||||
/* Die Anzahl steht in der Bedingung, nicht nur im Meldetext: Ohne
|
||||
angesehene Seite ist "0 beanstandet" keine Aussage. */
|
||||
const gut = schlecht === 0 && OEFFENTLICH.length > 0;
|
||||
if (!gut) { fehler++; probleme.push(`Öffentlich / ${wie}`); }
|
||||
console.log(` ${gut ? "ok " : "FEHL"} ${wie.padEnd(10)}${String(OEFFENTLICH.length).padStart(3)} Seiten`
|
||||
+ (gut ? "" : ` — ${schlecht} beanstandet`));
|
||||
|
||||
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 873 KiB After Width: | Height: | Size: 875 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 1021 KiB After Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 936 KiB After Width: | Height: | Size: 935 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 654 KiB |
|
Before Width: | Height: | Size: 987 KiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 722 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 991 KiB |
|
Before Width: | Height: | Size: 1024 KiB After Width: | Height: | Size: 1018 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,344 @@
|
||||
/* KOMMT MAN AN DER SCHRANKE VORBEI?
|
||||
|
||||
Auftrag vom 04.09.2026 (vollstaendiger Audit): "pruefe direkte
|
||||
URL-Aufrufe, Zugriff ohne Login, Zugriff mit falscher Rolle".
|
||||
|
||||
WARUM DIESE PRUEFUNG NOETIG IST. Der Seitenschutz in workspace.js
|
||||
vergleicht `req.path` gegen eine Liste:
|
||||
|
||||
if (!Object.hasOwn(GESCHUETZT, req.path)) return next();
|
||||
|
||||
Ein EXAKTER Vergleich. Trifft er nicht, geht die Anfrage ungeprueft
|
||||
weiter an express.static. Alles haengt damit an der Frage, ob es eine
|
||||
Schreibweise derselben Datei gibt, die anders aussieht als der
|
||||
Listeneintrag -- ein zweiter Schraegstrich, ein Punkt-Segment, eine
|
||||
kodierte Form, eine andere Gross-/Kleinschreibung.
|
||||
|
||||
Das laesst sich nicht durch Lesen entscheiden: Ob Express den Pfad
|
||||
vorher zurechtruecken, ob serve-static ihn nochmal aufloest, haengt an
|
||||
den Fassungen im Einsatz. Also wird es AUSPROBIERT, gegen den echten
|
||||
Server, mit den echten Fassungen.
|
||||
|
||||
ERWARTUNG je Aufruf: entweder Umleitung zur Anmeldung (302) oder
|
||||
"gibt es nicht" (404). Was NICHT vorkommen darf, ist 200 mit Inhalt.
|
||||
|
||||
DIE GEGENPROBE steht am Ende: Mit gueltiger Anmeldung MUSS dieselbe
|
||||
Seite mit 200 kommen. Sonst pruefte dieser Lauf nur, dass der Server
|
||||
auf alles 404 sagt -- und waere wertlos. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-schranke-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
|
||||
gemessen. Begruendung in helfer-port.mjs. */
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4231, "die Schrankenpruefung");
|
||||
|
||||
process.env.PORT = "4231";
|
||||
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 }); };
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4231";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand anlegen ---------------------------------------------------- */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
anlegen("Chef", "admin", "CODE-DOGI-0001");
|
||||
anlegen("Mara", "manager", "CODE-MANA-0001");
|
||||
anlegen("Pat", "scout", "CODE-SCOU-0001");
|
||||
anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.close();
|
||||
|
||||
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 roh = a.headers.getSetCookie?.() || [];
|
||||
const keks = roh.map((z) => z.split(";")[0]).join("; ");
|
||||
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
|
||||
return keks;
|
||||
}
|
||||
|
||||
const kekse = {
|
||||
admin: await anmelden("admin", "CODE-DOGI-0001"),
|
||||
manager: await anmelden("manager", "CODE-MANA-0001"),
|
||||
scout: await anmelden("scout", "CODE-SCOU-0001"),
|
||||
creator: await anmelden("creator", "CODE-CREA-0001"),
|
||||
};
|
||||
|
||||
/** Roh anfragen, ohne Umleitungen zu folgen -- die Umleitung IST die
|
||||
* Antwort, der wir nachgehen. */
|
||||
const hol = (pfad, keks) => fetch(BASIS + pfad, {
|
||||
redirect: "manual", headers: keks ? { cookie: keks } : {} });
|
||||
|
||||
/* =======================================================================
|
||||
1. Ohne Anmeldung an eine geschuetzte Seite
|
||||
======================================================================= */
|
||||
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]);
|
||||
|
||||
ok(SEITEN.length >= 15, `${SEITEN.length} geschuetzte Seiten aus workspace.js gelesen`);
|
||||
|
||||
let geprueft = 0;
|
||||
for (const seite of SEITEN) {
|
||||
const a = await hol(seite, null);
|
||||
geprueft++;
|
||||
if (a.status !== 302) ok(false, `${seite} ohne Anmeldung -> ${a.status} statt 302`);
|
||||
}
|
||||
ok(geprueft === SEITEN.length && SEITEN.length > 0,
|
||||
`alle ${geprueft} Seiten leiten ohne Anmeldung zur Anmeldung um`);
|
||||
|
||||
/* =======================================================================
|
||||
2. Andere Schreibweisen desselben Pfades
|
||||
======================================================================= */
|
||||
console.log("\n=== Andere Schreibweisen (Umgehungsversuche) ===");
|
||||
|
||||
/* Jede Variante muss dasselbe tun wie der schlichte Pfad: wegschicken
|
||||
oder nicht finden. Ein 200 mit HTML waere die Luecke. */
|
||||
const VARIANTEN = (s) => {
|
||||
const name = s.replace("/workspace/", "");
|
||||
return [
|
||||
[`//workspace/${name}`, "zwei Schraegstriche vorn"],
|
||||
[`/workspace//${name}`, "zwei Schraegstriche in der Mitte"],
|
||||
[`/workspace/./${name}`, "Punkt-Segment"],
|
||||
[`/workspace/x/../${name}`, "Umweg ueber .."],
|
||||
[`/workspace/%2e/${name}`, "kodierter Punkt"],
|
||||
[`/workspace/${name.toUpperCase()}`, "Grossschreibung"],
|
||||
[`/workspace/${name}%20`, "angehaengtes Leerzeichen"],
|
||||
[`/workspace/${name}.`, "angehaengter Punkt"],
|
||||
[`/workspace/${encodeURIComponent(name)}`, "kodierter Name"],
|
||||
[`/workspace/${name};x=1`, "Pfad-Parameter"],
|
||||
[`/./workspace/${name}`, "Punkt vor workspace"],
|
||||
];
|
||||
};
|
||||
|
||||
let variantenGeprueft = 0;
|
||||
const durchgerutscht = [];
|
||||
for (const seite of SEITEN) {
|
||||
for (const [pfad, wie] of VARIANTEN(seite)) {
|
||||
let a;
|
||||
try { a = await hol(pfad, null); } catch { continue; }
|
||||
variantenGeprueft++;
|
||||
if (a.status === 200) {
|
||||
const text = await a.text();
|
||||
/* Nur echter Seiteninhalt zaehlt -- eine leere 200 waere zwar
|
||||
auch seltsam, aber kein Datenabfluss. */
|
||||
if (/<html|<!doctype/i.test(text) && text.length > 500) {
|
||||
durchgerutscht.push(`${pfad} (${wie}) -> 200, ${text.length} Zeichen`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(variantenGeprueft > 100, `${variantenGeprueft} Schreibweisen ausprobiert`);
|
||||
ok(durchgerutscht.length === 0,
|
||||
durchgerutscht.length
|
||||
? `SCHRANKE UMGANGEN: ${durchgerutscht.slice(0, 6).join(" · ")}`
|
||||
: "keine Schreibweise kommt an der Schranke vorbei");
|
||||
|
||||
/* =======================================================================
|
||||
3. Falsche Rolle
|
||||
======================================================================= */
|
||||
console.log("\n=== Falsche Rolle ===");
|
||||
|
||||
/* Was jede Rolle NICHT sehen darf, steht ebenfalls in workspace.js.
|
||||
Hier wird gegengeprueft, dass die Umleitung wirklich greift. */
|
||||
const NUR_DOGFATHER = ["/workspace/personen.html", "/workspace/automation.html"];
|
||||
for (const seite of NUR_DOGFATHER) {
|
||||
for (const rolle of ["manager", "scout", "creator"]) {
|
||||
const a = await hol(seite, kekse[rolle]);
|
||||
ok(a.status === 302, `${rolle} auf ${seite} -> ${a.status} (erwartet 302)`);
|
||||
}
|
||||
const a = await hol(seite, kekse.admin);
|
||||
ok(a.status === 200, `DogFather auf ${seite} -> ${a.status} (erwartet 200)`);
|
||||
}
|
||||
|
||||
const NUR_TEAM = "/workspace/scouting.html";
|
||||
{
|
||||
const a = await hol(NUR_TEAM, kekse.creator);
|
||||
ok(a.status === 302, `Creator auf ${NUR_TEAM} -> ${a.status} (erwartet 302)`);
|
||||
const b = await hol(NUR_TEAM, kekse.scout);
|
||||
ok(b.status === 200, `Scout auf ${NUR_TEAM} -> ${b.status} (erwartet 200)`);
|
||||
}
|
||||
|
||||
/* Und dieselben Umgehungsversuche MIT falscher Rolle -- ein Manager, der
|
||||
personen.html ueber //workspace/ bekaeme, waere genauso ein Loch. */
|
||||
console.log("\n=== Falsche Rolle + andere Schreibweise ===");
|
||||
const rolleDurchgerutscht = [];
|
||||
let rolleGeprueft = 0;
|
||||
for (const seite of NUR_DOGFATHER) {
|
||||
for (const [pfad, wie] of VARIANTEN(seite)) {
|
||||
for (const rolle of ["manager", "creator"]) {
|
||||
let a;
|
||||
try { a = await hol(pfad, kekse[rolle]); } catch { continue; }
|
||||
rolleGeprueft++;
|
||||
if (a.status === 200) {
|
||||
const text = await a.text();
|
||||
if (/<html|<!doctype/i.test(text) && text.length > 500) {
|
||||
rolleDurchgerutscht.push(`${rolle}: ${pfad} (${wie})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok(rolleGeprueft > 40, `${rolleGeprueft} Versuche mit falscher Rolle`);
|
||||
ok(rolleDurchgerutscht.length === 0,
|
||||
rolleDurchgerutscht.length
|
||||
? `ROLLE UMGANGEN: ${rolleDurchgerutscht.slice(0, 6).join(" · ")}`
|
||||
: "auch mit anderer Schreibweise kommt keine falsche Rolle durch");
|
||||
|
||||
/* =======================================================================
|
||||
4. Die Datenbank und die Ablagen sind nicht aus dem Netz erreichbar
|
||||
======================================================================= */
|
||||
console.log("\n=== Nicht ausliefern, was nicht ins Netz gehoert ===");
|
||||
for (const pfad of [
|
||||
"/server/workspace.js", "/server/.env", "/.git/config", "/server/gate.js",
|
||||
"/workspace-daten/workspace.db", "/CLAUDE.md", "/DEPLOY.md",
|
||||
"/../workspace-daten/workspace.db", "/workspace/../server/workspace.js",
|
||||
]) {
|
||||
const a = await hol(pfad, kekse.admin);
|
||||
const text = a.status === 200 ? await a.text() : "";
|
||||
ok(a.status !== 200 || text.length < 200,
|
||||
`${pfad} -> ${a.status}${a.status === 200 ? ` (${text.length} Zeichen!)` : ""}`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. Abgelaufene und gefaelschte Sitzung
|
||||
======================================================================= */
|
||||
console.log("\n=== Sitzung ===");
|
||||
{
|
||||
const a = await hol("/workspace/start.html", "dfw_sitzung=" + "a".repeat(64));
|
||||
ok(a.status === 302, `erfundener Sitzungsschluessel -> ${a.status} (erwartet 302)`);
|
||||
|
||||
const b = await fetch(BASIS + "/workspace/api/aufgaben",
|
||||
{ headers: { cookie: "dfw_sitzung=" + "b".repeat(64) } });
|
||||
ok(b.status === 401, `erfundener Schluessel an der Schnittstelle -> ${b.status} (erwartet 401)`);
|
||||
|
||||
/* Eine Sitzung, deren Gueltigkeit abgelaufen ist, muss sterben --
|
||||
auch wenn der Schluessel echt ist. */
|
||||
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
d2.prepare("UPDATE sitzungen SET gueltig_bis = ? WHERE person_id = (SELECT id FROM personen WHERE rolle='creator')")
|
||||
.run("2020-01-01T00:00:00.000Z");
|
||||
d2.close();
|
||||
const c = await hol("/workspace/start.html", kekse.creator);
|
||||
ok(c.status === 302, `abgelaufene Sitzung -> ${c.status} (erwartet 302)`);
|
||||
|
||||
/* Und eine gesperrte Person kommt mit gueltiger Sitzung nicht mehr rein. */
|
||||
const d3 = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
d3.prepare("UPDATE personen SET aktiv = 0 WHERE rolle = 'scout'").run();
|
||||
d3.close();
|
||||
const e = await hol("/workspace/start.html", kekse.scout);
|
||||
ok(e.status === 302, `gesperrte Person mit gueltiger Sitzung -> ${e.status} (erwartet 302)`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
6. Sicherheits-Kopfzeilen
|
||||
======================================================================= */
|
||||
console.log("\n=== Sicherheits-Kopfzeilen ===");
|
||||
{
|
||||
const a = await hol("/workspace/", null);
|
||||
for (const [name, erwartet] of [
|
||||
["x-content-type-options", "nosniff"],
|
||||
["x-frame-options", "SAMEORIGIN"],
|
||||
["referrer-policy", "strict-origin-when-cross-origin"],
|
||||
]) {
|
||||
ok(a.headers.get(name) === erwartet,
|
||||
`${name}: ${a.headers.get(name) || "fehlt"} (erwartet ${erwartet})`);
|
||||
}
|
||||
|
||||
/* HSTS GEHOERT NICHT AUF EINE UNSICHERE VERBINDUNG (Befund 04.09.2026).
|
||||
RFC 6797, 7.2: "An HSTS Host MUST NOT include the STS header field
|
||||
in HTTP responses conveyed over non-secure transport."
|
||||
|
||||
Vorher ging der Header bei jeder Antwort mit. Live unauffaellig --
|
||||
dort kommt alles ueber https. Aufgefallen im Browsertest: WebKit
|
||||
nimmt ihn auch von einer http-Adresse an, merkt sich 127.0.0.1 als
|
||||
"nur noch https", und danach scheitert jede Verbindung zum lokalen
|
||||
Testserver mit "SSL connect error". Chromium und Firefox machen fuer
|
||||
localhost eine Ausnahme, WebKit nicht -- also ausgerechnet der
|
||||
Browser, den jedes iPhone benutzt, war damit nicht mehr pruefbar.
|
||||
|
||||
Node's fetch setzt den Host-Header selbst (verbotener Name), deshalb
|
||||
hier ein roher Aufruf -- sonst misst man dreimal denselben Fall. */
|
||||
const http = await import("node:http");
|
||||
const mitHost = (host) => new Promise((fertig) => {
|
||||
const r = http.request({ host: "127.0.0.1", port: 4231, path: "/404.html",
|
||||
method: "GET", headers: { Host: host } }, (antwort) => {
|
||||
antwort.resume();
|
||||
fertig(antwort.headers["strict-transport-security"] || null);
|
||||
});
|
||||
r.on("error", () => fertig(null));
|
||||
r.end();
|
||||
});
|
||||
|
||||
ok((await mitHost("127.0.0.1:4231")) === null,
|
||||
"ueber http an eine lokale Adresse kommt KEIN HSTS (RFC 6797)");
|
||||
ok((await mitHost("localhost:4231")) === null,
|
||||
"auch an localhost kommt kein HSTS");
|
||||
const echt = await mitHost("dogfather-universe.com");
|
||||
ok(!!echt && echt.includes("max-age="),
|
||||
`an der echten Domain kommt HSTS weiterhin (${echt || "FEHLT"})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE — kann diese Pruefung ueberhaupt "erlaubt" sagen?
|
||||
======================================================================= */
|
||||
console.log("\n=== Gegenprobe ===");
|
||||
{
|
||||
const a = await hol("/workspace/start.html", kekse.admin);
|
||||
const text = a.status === 200 ? await a.text() : "";
|
||||
ok(a.status === 200 && text.length > 500,
|
||||
`angemeldet kommt start.html normal an (${a.status}, ${text.length} Zeichen)`);
|
||||
|
||||
const b = await hol("/workspace/", null);
|
||||
ok(b.status === 200, `die Anmeldeseite selbst bleibt offen (${b.status})`);
|
||||
|
||||
/* Und der Beweis, dass ein durchgerutschter Pfad ueberhaupt auffallen
|
||||
WUERDE: eine ungeschuetzte Datei liefert echten Inhalt. */
|
||||
const c = await hol("/workspace/assets/css/start.css", null);
|
||||
const t = c.status === 200 ? await c.text() : "";
|
||||
ok(c.status === 200 && t.length > 500,
|
||||
`eine ungeschuetzte Datei kommt mit Inhalt (${c.status}, ${t.length} Zeichen) — die Messung kann also "durchgelassen" erkennen`);
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
|
Before Width: | Height: | Size: 707 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 748 KiB After Width: | Height: | Size: 748 KiB |
|
Before Width: | Height: | Size: 774 KiB After Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 895 KiB |
|
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 727 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 764 KiB |
@@ -0,0 +1,312 @@
|
||||
/* STRUKTURPRUEFUNG — findet Kaputtes, das kein Browser meldet.
|
||||
|
||||
Auftrag vom 04.09.2026: vollstaendiger Audit des Creator Workspace.
|
||||
|
||||
WAS DIESE PRUEFUNG SUCHT — drei Sorten stiller Fehler:
|
||||
|
||||
1. Eine Seite laedt eine Datei, die es nicht gibt.
|
||||
Der Browser meldet das nur in der Konsole; die Seite sieht
|
||||
einfach etwas anders aus. Bei CSS faellt es auf, bei einem
|
||||
einzelnen JS-Baustein oft monatelang nicht.
|
||||
|
||||
2. Ein Verweis fuehrt ins Leere.
|
||||
Eine Kachel, ein Zurueck-Knopf, ein Menuepunkt — Ziel existiert
|
||||
nicht. Erst der Klick zeigt es.
|
||||
|
||||
3. Die Oberflaeche ruft eine Schnittstelle auf, die der Server
|
||||
nicht (mehr) hat.
|
||||
Das ist der gefaehrlichste Fall: Der Knopf ist da, er tut nur
|
||||
nichts. Genau so verschwindet eine Funktion still, wenn eine
|
||||
Route umbenannt wird.
|
||||
|
||||
DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faende das Skript keine einzige
|
||||
Seite, waere "0 kaputte Verweise" das beste Ergebnis ueberhaupt --
|
||||
und voellig wertlos. Deshalb wird jede Menge gezaehlt und eine leere
|
||||
Menge ist ein Fehler.
|
||||
|
||||
GEGENPROBE am Ende: ein absichtlich falscher Verweis MUSS gemeldet
|
||||
werden. Eine Pruefung, die immer bestaetigt, bestaetigt nichts. */
|
||||
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = join(__dirname, "..");
|
||||
const WS = join(REPO, "workspace");
|
||||
const SERVER = __dirname;
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---------- Bestand einsammeln ------------------------------------------ */
|
||||
|
||||
const seiten = readdirSync(WS).filter((n) => n.endsWith(".html"));
|
||||
console.log(`\n=== Bestand ===`);
|
||||
ok(seiten.length >= 10, `${seiten.length} Workspace-Seiten gefunden`);
|
||||
|
||||
const serverDateien = readdirSync(SERVER).filter((n) => /^workspace.*\.js$/.test(n));
|
||||
ok(serverDateien.length >= 20, `${serverDateien.length} Server-Module gefunden`);
|
||||
|
||||
/* Alle Routen, die der Server wirklich anbietet. Gelesen wird der Code,
|
||||
nicht eine gepflegte Liste -- eine Liste altert, der Code nicht. */
|
||||
const ROUTEN = [];
|
||||
for (const datei of serverDateien) {
|
||||
const text = readFileSync(join(SERVER, datei), "utf8");
|
||||
const treffer = text.matchAll(
|
||||
/Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*"(\/workspace\/[^"]*)"/g);
|
||||
for (const t of treffer) ROUTEN.push({ art: t[1], pfad: t[2], datei });
|
||||
}
|
||||
ok(ROUTEN.length >= 80, `${ROUTEN.length} Server-Routen eingelesen`);
|
||||
|
||||
/** Ein Pfad aus der Oberflaeche gegen die Routenliste halten.
|
||||
* Platzhalter (:id) und eingesetzte Werte (${...}) gelten als gleich.
|
||||
*
|
||||
* WICHTIG — .use() zaehlt NICHT als Gegenstueck. Diese Eintraege sind
|
||||
* Schranken ("jeder Pfad unter /workspace/api braucht eine Anmeldung"),
|
||||
* keine Endpunkte. Sie mitzuzaehlen war der erste Entwurf, und die
|
||||
* Gegenprobe hat ihn sofort ueberfuehrt: /workspace/api/gibtesnicht
|
||||
* galt als vorhanden, weil aufgabenRouter.use("/workspace/api", …)
|
||||
* darueber liegt. Damit haette diese ganze Pruefung nie einen einzigen
|
||||
* toten Aufruf gefunden und trotzdem gruen gemeldet. */
|
||||
function routeGibtEs(pfad, art) {
|
||||
const teileA = pfad.split("/").filter(Boolean);
|
||||
return ROUTEN.some((r) => {
|
||||
if (r.art === "use") return false;
|
||||
if (art && r.art !== art) return false;
|
||||
const teileB = r.pfad.split("/").filter(Boolean);
|
||||
if (teileA.length !== teileB.length) return false;
|
||||
return teileB.every((t, i) => t.startsWith(":") || t === "*" || t === teileA[i]);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 1. Geladene Dateien ----------------------------------------- */
|
||||
|
||||
console.log(`\n=== Geladene Dateien (script/link/img) ===`);
|
||||
let geprueftAssets = 0;
|
||||
for (const seite of seiten) {
|
||||
const text = readFileSync(join(WS, seite), "utf8");
|
||||
const quellen = [
|
||||
...text.matchAll(/<script[^>]+src="([^"]+)"/g),
|
||||
...text.matchAll(/<link[^>]+href="([^"]+\.css[^"]*)"/g),
|
||||
...text.matchAll(/<img[^>]+src="([^"]+)"/g),
|
||||
].map((m) => m[1]);
|
||||
for (const roh of quellen) {
|
||||
if (/^(https?:|data:|\/\/)/.test(roh)) continue;
|
||||
const ohneStempel = roh.split("?")[0];
|
||||
const pfad = ohneStempel.startsWith("/")
|
||||
? join(REPO, ohneStempel)
|
||||
: resolve(WS, ohneStempel);
|
||||
geprueftAssets++;
|
||||
if (!existsSync(pfad)) ok(false, `${seite}: Datei fehlt -> ${roh}`);
|
||||
}
|
||||
}
|
||||
ok(geprueftAssets > 0, `${geprueftAssets} geladene Dateien geprueft, alle vorhanden`);
|
||||
|
||||
/* ---------- 2. Verweise auf andere Seiten -------------------------------- */
|
||||
|
||||
console.log(`\n=== Verweise (href) ===`);
|
||||
let geprueftLinks = 0;
|
||||
const quellenMitLinks = [
|
||||
...seiten.map((n) => ({ name: n, pfad: join(WS, n) })),
|
||||
...readdirSync(join(WS, "assets", "js"))
|
||||
.filter((n) => n.endsWith(".js"))
|
||||
.map((n) => ({ name: "assets/js/" + n, pfad: join(WS, "assets", "js", n) })),
|
||||
];
|
||||
for (const { name, pfad: quelle } of quellenMitLinks) {
|
||||
const text = readFileSync(quelle, "utf8");
|
||||
/* Beide Schreibweisen: absolut (/workspace/aufgaben.html) und relativ
|
||||
("aufgaben.html"). Der erste Entwurf suchte nur die absolute Form
|
||||
und meldete deshalb "0 Verweise geprueft" -- die Seiten verlinken
|
||||
aber fast ausschliesslich relativ. Eine Pruefung, die nichts
|
||||
findet, prueft nichts; deshalb steht die Anzahl unten in der
|
||||
Bedingung. */
|
||||
const ziele = [
|
||||
...[...text.matchAll(/href\s*=\s*["'`](\/workspace\/[^"'`#?${]*)/g)].map((m) => m[1]),
|
||||
...[...text.matchAll(/(?:location\.href|location\.assign\()\s*=?\s*["'`](\/workspace\/[^"'`#?${]*)/g)].map((m) => m[1]),
|
||||
...[...text.matchAll(/href\s*=\s*["'`]([a-z0-9-]+\.html)/g)].map((m) => "/workspace/" + m[1]),
|
||||
...[...text.matchAll(/(?:location\.href\s*=|location\.assign\()\s*["'`]([a-z0-9-]+\.html)/g)].map((m) => "/workspace/" + m[1]),
|
||||
];
|
||||
for (const ziel of ziele) {
|
||||
if (ziel.startsWith("/workspace/api/")) continue;
|
||||
geprueftLinks++;
|
||||
const rel = ziel.replace(/^\/workspace\/?/, "");
|
||||
const datei = rel === "" ? "index.html" : rel;
|
||||
const voll = join(WS, datei);
|
||||
if (!existsSync(voll)) ok(false, `${name}: Verweis ins Leere -> ${ziel}`);
|
||||
}
|
||||
}
|
||||
ok(geprueftLinks > 0, `${geprueftLinks} Verweise geprueft, alle mit Ziel`);
|
||||
|
||||
/* ---------- 3. Aufgerufene Schnittstellen -------------------------------- */
|
||||
|
||||
console.log(`\n=== Aufgerufene Schnittstellen ===`);
|
||||
let geprueftApi = 0;
|
||||
let nurPraefix = 0;
|
||||
const jsDateien = readdirSync(join(WS, "assets", "js")).filter((n) => n.endsWith(".js"));
|
||||
for (const name of [...jsDateien.map((n) => "assets/js/" + n), ...seiten]) {
|
||||
const text = readFileSync(join(WS, name), "utf8");
|
||||
/* Jede Zeichenkette, die mit /workspace/api/ beginnt -- egal ob in
|
||||
fetch(), in einer Hilfsfunktion oder in einer Variablen. */
|
||||
for (const m of text.matchAll(/["'`](\/workspace\/api\/[^"'`\s]*)/g)) {
|
||||
const roh = m[1].replace(/\$\{[^}]*\}/g, "1").split("?")[0];
|
||||
if (!roh || roh === "/workspace/api" || roh === "/workspace/api/") continue;
|
||||
|
||||
/* DER DRITTE AUSGANG. Endet die Zeichenkette auf "/", wurde im Code
|
||||
per + noch etwas angehaengt ('/workspace/api/ki/' + pfad). Was
|
||||
dort steht, weiss nur die Laufzeit -- statisch ist der volle Pfad
|
||||
NICHT zu ermitteln.
|
||||
|
||||
Der erste Entwurf schnitt den Schraegstrich einfach weg und
|
||||
meldete vier Fehler, die keine waren. Genau davor warnt die
|
||||
eigene Regel: eine Pruefung, die ihre Voraussetzung nicht hat,
|
||||
muss das SAGEN und nicht raten. Geprueft wird deshalb nur, dass
|
||||
es unter diesem Praefix ueberhaupt Routen gibt -- die vollen
|
||||
Pfade deckt der Rundgang im Browser ab. */
|
||||
if (roh.endsWith("/")) {
|
||||
const praefix = roh.slice(0, -1);
|
||||
nurPraefix++;
|
||||
const gibtEsDarunter = ROUTEN.some(
|
||||
(r) => r.art !== "use" && r.pfad.startsWith(praefix + "/"));
|
||||
if (!gibtEsDarunter) {
|
||||
ok(false, `${name}: unter ${praefix}/ gibt es gar keine Schnittstelle`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
geprueftApi++;
|
||||
if (!routeGibtEs(roh)) ok(false, `${name}: Schnittstelle gibt es nicht -> ${roh}`);
|
||||
}
|
||||
}
|
||||
ok(geprueftApi >= 40, `${geprueftApi} vollstaendige Aufrufe geprueft, alle mit Gegenstueck`);
|
||||
ok(nurPraefix > 0, `${nurPraefix} zusammengesetzte Aufrufe: nur Praefix pruefbar (Rest deckt der Browser-Rundgang)`);
|
||||
|
||||
/* ---------- 4. Jede Seite ist erreichbar --------------------------------- */
|
||||
|
||||
console.log(`\n=== Erreichbarkeit ===`);
|
||||
const verlinkt = new Set(["index.html", "start.html"]);
|
||||
for (const { pfad: quelle } of quellenMitLinks) {
|
||||
const text = readFileSync(quelle, "utf8");
|
||||
for (const m of text.matchAll(/["'`](?:\/workspace\/)?([a-z0-9-]+\.html)/g)) {
|
||||
verlinkt.add(m[1]);
|
||||
}
|
||||
}
|
||||
for (const seite of seiten) {
|
||||
if (!verlinkt.has(seite)) ok(false, `Seite ${seite} ist von nirgendwo verlinkt`);
|
||||
}
|
||||
ok(verlinkt.size >= seiten.length, `alle ${seiten.length} Seiten sind verlinkt`);
|
||||
|
||||
/* ---------- 5. Versionsstempel ------------------------------------------- */
|
||||
|
||||
/* Ohne Stempel haelt Cloudflare bis zu vier Stunden die alte Fassung --
|
||||
dann sieht niemand die Aenderung, und man sucht den Fehler im Code. */
|
||||
console.log(`\n=== Versionsstempel an JS und CSS ===`);
|
||||
let ohneStempel = [];
|
||||
for (const seite of seiten) {
|
||||
const text = readFileSync(join(WS, seite), "utf8");
|
||||
for (const m of text.matchAll(/(?:src|href)="(\/workspace\/assets\/(?:js|css)\/[^"]+)"/g)) {
|
||||
if (!m[1].includes("?v=")) ohneStempel.push(`${seite}: ${m[1]}`);
|
||||
}
|
||||
}
|
||||
ok(ohneStempel.length === 0,
|
||||
ohneStempel.length ? `ohne Versionsstempel: ${ohneStempel.slice(0, 8).join(", ")}`
|
||||
: "jede JS- und CSS-Einbindung traegt einen Stempel");
|
||||
|
||||
/* ---------- 6. Reste aus der Entwicklung --------------------------------- */
|
||||
|
||||
console.log(`\n=== Produktionsreste ===`);
|
||||
const resteMuster = [
|
||||
[/console\.log\(/g, "console.log"],
|
||||
[/\bdebugger\b/g, "debugger"],
|
||||
[/\bTODO\b|\bFIXME\b|\bHACK\b/g, "TODO/FIXME"],
|
||||
[/localhost:\d+/g, "localhost-Adresse"],
|
||||
[/Lorem ipsum/gi, "Platzhaltertext"],
|
||||
];
|
||||
let reste = [];
|
||||
for (const name of [...jsDateien.map((n) => "assets/js/" + n), ...seiten]) {
|
||||
const text = readFileSync(join(WS, name), "utf8");
|
||||
/* Kommentare zaehlen nicht -- dort steht Begruendung, kein Code. */
|
||||
const ohneKommentar = text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "")
|
||||
.replace(/<!--[\s\S]*?-->/g, "");
|
||||
for (const [muster, was] of resteMuster) {
|
||||
const t = ohneKommentar.match(muster);
|
||||
if (t) reste.push(`${name}: ${was} (${t.length}x)`);
|
||||
}
|
||||
}
|
||||
ok(reste.length === 0, reste.length ? reste.join(" · ") : "keine Entwicklungsreste im Auslieferstand");
|
||||
|
||||
/* ---------- 7. Groesse der Assets ---------------------------------------- */
|
||||
|
||||
console.log(`\n=== Dateigroessen ===`);
|
||||
let schwer = [];
|
||||
for (const unter of ["assets/js", "assets/css", "assets/img"]) {
|
||||
const ordner = join(WS, unter);
|
||||
if (!existsSync(ordner)) continue;
|
||||
for (const n of readdirSync(ordner)) {
|
||||
const p = join(ordner, n);
|
||||
if (statSync(p).isDirectory()) continue;
|
||||
const kb = Math.round(statSync(p).size / 1024);
|
||||
if (kb > 200) schwer.push(`${unter}/${n} ${kb} KB`);
|
||||
}
|
||||
}
|
||||
ok(schwer.length === 0, schwer.length ? `ueber 200 KB: ${schwer.join(", ")}` : "keine Datei ueber 200 KB");
|
||||
|
||||
/* ---------- 8. Totes CSS ------------------------------------------------- */
|
||||
|
||||
/* start.css wird auf JEDER Seite geladen und ist 126 KB gross. Was darin
|
||||
niemand mehr benutzt, laedt trotzdem jeder mit.
|
||||
|
||||
ZWEI BEKANNTE AUSNAHMEN, bewusst stehen gelassen: Beide stehen als
|
||||
Glied einer Selektorliste (".a, .b, .code-feld { … }") und kosten je
|
||||
rund fuenfzehn Byte. Sie zu entfernen braechte nichts und traegt das
|
||||
kleine Risiko, dass die Klasse doch irgendwo zur Laufzeit gesetzt
|
||||
wird. Sie stehen hier namentlich, damit NEUES totes CSS auffaellt --
|
||||
eine Pruefung, die von Anfang an rot ist, wird weggeklickt.
|
||||
|
||||
DER DRITTE AUSGANG: Klassen, die zusammengesetzt werden koennen
|
||||
('kachel--' + ton), zaehlen nicht als tot. Ihr Stamm wird gesucht;
|
||||
findet er sich, gilt die Klasse als moeglicherweise dynamisch und
|
||||
wird getrennt ausgewiesen statt geraten. */
|
||||
console.log(`\n=== Totes CSS ===`);
|
||||
const BEKANNT_TOT = [".content-wurzel", ".code-feld"];
|
||||
{
|
||||
const cssOrdner = join(WS, "assets", "css");
|
||||
const jsOrdner = join(WS, "assets", "js");
|
||||
let quelle = "";
|
||||
for (const n of seiten) quelle += readFileSync(join(WS, n), "utf8");
|
||||
for (const n of readdirSync(jsOrdner).filter((x) => x.endsWith(".js"))) {
|
||||
quelle += readFileSync(join(jsOrdner, n), "utf8");
|
||||
}
|
||||
|
||||
let gesamt = 0, dynamisch = 0;
|
||||
const tot = [];
|
||||
for (const datei of readdirSync(cssOrdner).filter((n) => n.endsWith(".css"))) {
|
||||
const text = readFileSync(join(cssOrdner, datei), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const k of new Set([...text.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]{2,})/g)].map((m) => m[1]))) {
|
||||
gesamt++;
|
||||
if (quelle.includes(k)) continue;
|
||||
const stamm = k.split(/--|__/)[0];
|
||||
if (stamm !== k && quelle.includes(stamm)) { dynamisch++; continue; }
|
||||
if (BEKANNT_TOT.includes("." + k)) continue;
|
||||
tot.push(`${datei}: .${k}`);
|
||||
}
|
||||
}
|
||||
ok(gesamt > 500, `${gesamt} CSS-Klassen geprueft (${dynamisch} moeglicherweise dynamisch gebaut)`);
|
||||
ok(tot.length === 0,
|
||||
tot.length ? `NEUES totes CSS: ${tot.slice(0, 8).join(" · ")}`
|
||||
: `kein neues totes CSS (${BEKANNT_TOT.length} bekannte Reste stehen bewusst)`);
|
||||
}
|
||||
|
||||
/* ---------- Gegenprobe --------------------------------------------------- */
|
||||
|
||||
console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
|
||||
ok(!routeGibtEs("/workspace/api/gibtesnicht"),
|
||||
"erfundene Schnittstelle wird als fehlend erkannt");
|
||||
ok(routeGibtEs("/workspace/api/aufgaben"),
|
||||
"vorhandene Schnittstelle wird als vorhanden erkannt");
|
||||
ok(!existsSync(join(WS, "gibtesnicht.html")),
|
||||
"erfundene Seite wird als fehlend erkannt");
|
||||
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,333 @@
|
||||
/* WIE SCHNELL IST DER WORKSPACE WIRKLICH?
|
||||
|
||||
Auftrag vom 04.09.2026 (vollstaendiger Audit), Abschnitt 11:
|
||||
unnoetig grosse Bilder, unnoetiges JavaScript/CSS, Render-Blockaden,
|
||||
fehlendes Lazy Loading, unnoetige und doppelte Anfragen, Layout Shifts.
|
||||
|
||||
WARUM DAS NICHT SCHON GEMESSEN WURDE. Es gibt pruef-tempo.mjs -- die
|
||||
misst die OEFFENTLICHE Seite. Der Workspace hat andere Verhaeltnisse:
|
||||
Er laedt auf jeder Seite dieselbe grosse Stilvorlage, holt seine Daten
|
||||
erst per Schnittstelle nach, und niemand hat bisher gezaehlt, wie oft
|
||||
dabei dieselbe Adresse zweimal abgefragt wird.
|
||||
|
||||
GEMESSEN WIRD AM ECHTEN BROWSER, ueber alle Seiten:
|
||||
1. Wie viele Anfragen, wie viele Bytes?
|
||||
2. Wird eine Adresse doppelt geholt? (Das ist reine Verschwendung
|
||||
und meist ein Zeichen dafuer, dass zwei Bausteine dasselbe tun.)
|
||||
3. Wie lange bis die Seite steht?
|
||||
4. Springt das Layout nach dem Laden noch? (Layout Shift -- der
|
||||
Grund, warum man auf den falschen Knopf drueckt.)
|
||||
5. Laufen Bilder ohne Groessenangabe? (Genau die verursachen die
|
||||
Spruenge.)
|
||||
|
||||
DIE GRENZEN sind bewusst grosszuegig gesetzt. Diese Pruefung soll
|
||||
nicht bei jedem zusaetzlichen Kilobyte rot werden -- sie soll den Tag
|
||||
finden, an dem etwas aus dem Ruder laeuft. Eine Warnung, die staendig
|
||||
kommt, wird abgeschaltet.
|
||||
|
||||
GEGENPROBE am Ende: eine kuenstlich erzeugte Doppelanfrage und ein
|
||||
kuenstlicher Layout-Sprung muessen gefunden werden. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-tempo-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* Der Port muss frei sein -- sonst wird gegen einen fremden Server
|
||||
gemessen. Begruendung in helfer-port.mjs. */
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4283, "die Tempopruefung");
|
||||
|
||||
process.env.PORT = "4283";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4283";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
const heute = jetzt.slice(0, 10);
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run(name, rolle, hash, salt, 32768, jetzt);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Chef", "admin", "CODE-DOGI-0001");
|
||||
const idPat = anlegen("Pat", "scout", "CODE-SCOU-0001");
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
|
||||
/* Genug Bestand, dass die Seiten wirklich etwas zu zeichnen haben --
|
||||
eine leere Liste laedt immer schnell. */
|
||||
for (let i = 0; i < 40; i++) {
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
|
||||
creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,'hoch',?,?,?,?,?)`)
|
||||
.run(`Aufgabe Nummer ${i}`, "Eine Beschreibung mit etwas Text.",
|
||||
["offen", "arbeit", "review", "erledigt"][i % 4], heute, idLuna, idPat, jetzt, idDogi);
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, erstellt, erstellt_von)
|
||||
VALUES (?,?,?,30,?,?,?)`)
|
||||
.run(`Termin ${i}`, ["call", "termin", "review"][i % 3], heute + "T10:00", idLuna, jetzt, idDogi);
|
||||
}
|
||||
for (let i = 0; i < 30; i++) {
|
||||
d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, status, dringlichkeit,
|
||||
datum, creator_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(["live", "content", "technik", "community", "schutz"][i % 5],
|
||||
i % 5 === 1 ? "idee" : "vorbereitung", `Eintrag ${i}`, "Text.",
|
||||
"offen", "hoch", heute, idLuna, jetzt, idPat);
|
||||
}
|
||||
d.close();
|
||||
|
||||
/* ---- Browser ------------------------------------------------------------ */
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
const SEITEN = [
|
||||
"start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html",
|
||||
"dateien.html", "content.html", "bereich.html?b=live", "profil.html",
|
||||
"startcheck.html", "report.html", "scouting.html", "personen.html",
|
||||
"wissen.html", "automation.html", "steckbrief.html",
|
||||
];
|
||||
|
||||
const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 20000 });
|
||||
|
||||
/* Grenzen. Grosszuegig -- sie sollen den Ausreisser finden, nicht das
|
||||
Kilobyte. */
|
||||
const GRENZE_ANFRAGEN = 40;
|
||||
const GRENZE_BYTES = 1_200_000;
|
||||
const GRENZE_MS = 4000;
|
||||
|
||||
/* ---- Layout-Sprung: eine GRUNDLINIE, kein Wunschwert ------------------
|
||||
Gemessen am 04.09.2026 (Audit): Die Seiten springen beim Aufbau
|
||||
zwischen 0 und 0,54. Nach Web Vitals gilt unter 0,1 als gut. Der
|
||||
Grund ist bauartbedingt: Jede Seite liefert zuerst ihr Geruest und
|
||||
holt die Daten danach per Schnittstelle -- die Liste ist also erst
|
||||
leer und waechst dann. Sauber beheben liesse sich das nur mit
|
||||
Platzhaltern fester Hoehe auf vierzehn Seiten, und das ist ein
|
||||
Eingriff ins Aussehen, kein Fehlerfix.
|
||||
|
||||
Deshalb steht hier NICHT 0,1. Eine Pruefung, die vom ersten Tag an
|
||||
rot ist, wird weggeklickt -- und mit ihr die echte Warnung. Sie haelt
|
||||
stattdessen den heutigen Stand fest und schlaegt an, wenn es
|
||||
SCHLECHTER wird. Der Zielwert bleibt 0,1; er gehoert in die
|
||||
Verbesserungsliste, nicht in eine Ampel, die immer leuchtet. */
|
||||
const GRENZE_SPRUNG = 0.6;
|
||||
const ZIEL_SPRUNG = 0.1; // Web Vitals "gut" -- noch nicht erreicht
|
||||
|
||||
const zeilen = [];
|
||||
let seitenGesamt = 0, anfragenGesamt = 0, bytesGesamt = 0;
|
||||
const doppelte = [];
|
||||
const langsame = [];
|
||||
const springende = [];
|
||||
const zuGross = [];
|
||||
|
||||
for (const datei of SEITEN) {
|
||||
const gesehen = new Map();
|
||||
let bytes = 0;
|
||||
const zaehler = (antwort) => {
|
||||
const u = antwort.url();
|
||||
gesehen.set(u, (gesehen.get(u) || 0) + 1);
|
||||
};
|
||||
seite.on("response", zaehler);
|
||||
|
||||
const start = Date.now();
|
||||
await seite.goto(BASIS + "/workspace/" + datei, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(500);
|
||||
const dauer = Date.now() - start;
|
||||
seite.off("response", zaehler);
|
||||
|
||||
/* Uebertragene Groesse aus der Zeitleiste des Browsers -- das ist,
|
||||
was wirklich ueber die Leitung ging. */
|
||||
const messung = await seite.evaluate(() => {
|
||||
const eintraege = performance.getEntriesByType("resource");
|
||||
const summe = eintraege.reduce((s, e) => s + (e.transferSize || e.encodedBodySize || 0), 0);
|
||||
const nav = performance.getEntriesByType("navigation")[0];
|
||||
const bilderOhneMass = [...document.querySelectorAll("img")]
|
||||
.filter((b) => !b.getAttribute("width") && !b.getAttribute("height")
|
||||
&& !b.style.width && !b.style.height
|
||||
&& !getComputedStyle(b).aspectRatio.includes("/"))
|
||||
.map((b) => (b.getAttribute("src") || "").split("/").pop());
|
||||
return {
|
||||
bytes: summe,
|
||||
domFertig: nav ? Math.round(nav.domContentLoadedEventEnd) : null,
|
||||
bilderOhneMass,
|
||||
knoten: document.querySelectorAll("*").length,
|
||||
};
|
||||
});
|
||||
bytes = messung.bytes;
|
||||
|
||||
/* Layout-Sprung nach dem Laden: Der Browser meldet jede Verschiebung,
|
||||
die nicht von einer Eingabe kam. */
|
||||
const sprung = await seite.evaluate(() => new Promise((fertig) => {
|
||||
let summe = 0;
|
||||
try {
|
||||
const b = new PerformanceObserver((liste) => {
|
||||
for (const e of liste.getEntries()) if (!e.hadRecentInput) summe += e.value;
|
||||
});
|
||||
b.observe({ type: "layout-shift", buffered: true });
|
||||
setTimeout(() => { b.disconnect(); fertig(Math.round(summe * 1000) / 1000); }, 900);
|
||||
} catch { fertig(0); }
|
||||
}));
|
||||
|
||||
seitenGesamt++;
|
||||
const anfragen = [...gesehen.values()].reduce((s, n) => s + n, 0);
|
||||
anfragenGesamt += anfragen;
|
||||
bytesGesamt += bytes;
|
||||
|
||||
for (const [u, n] of gesehen) {
|
||||
if (n > 1 && !u.includes("favicon")) {
|
||||
doppelte.push(`${datei}: ${n}x ${u.replace(BASIS, "").split("?")[0]}`);
|
||||
}
|
||||
}
|
||||
if (dauer > GRENZE_MS) langsame.push(`${datei} ${dauer} ms`);
|
||||
if (sprung > GRENZE_SPRUNG) springende.push(`${datei} ${sprung}`);
|
||||
if (anfragen > GRENZE_ANFRAGEN || bytes > GRENZE_BYTES) {
|
||||
zuGross.push(`${datei} ${anfragen} Anfragen / ${Math.round(bytes / 1024)} KB`);
|
||||
}
|
||||
|
||||
zeilen.push(` ${datei.padEnd(22)} ${String(anfragen).padStart(3)} Anfragen `
|
||||
+ `${String(Math.round(bytes / 1024)).padStart(4)} KB ${String(dauer).padStart(4)} ms `
|
||||
+ `Sprung ${String(sprung).padStart(5)} ${String(messung.knoten).padStart(4)} Knoten`);
|
||||
}
|
||||
|
||||
console.log("\n=== Je Seite ===");
|
||||
for (const z of zeilen) console.log(z);
|
||||
|
||||
console.log("\n=== Bewertung ===");
|
||||
ok(seitenGesamt === SEITEN.length, `${seitenGesamt} Seiten gemessen`);
|
||||
ok(anfragenGesamt > 100, `${anfragenGesamt} Anfragen insgesamt beobachtet`);
|
||||
/* DOPPELANFRAGEN: eine Grundlinie, kein Nullwert.
|
||||
Gemessen: kopf.js holt die Personenliste fuer den Sicht-Umschalter,
|
||||
die Seite selbst fuer ihre Zuordnungs-Auswahl -- dieselbe Adresse,
|
||||
zweimal. Ein Versuch, gleichzeitige Anfragen im fetch-Wrapper
|
||||
zusammenzufassen, wurde gebaut, gemessen und WIEDER ENTFERNT: Er half
|
||||
nicht (die beiden Abfragen laufen oft nacheinander, nicht
|
||||
gleichzeitig) und haette den zentralen Wrapper verkompliziert. Ohne
|
||||
nachweisbaren Nutzen bleibt der einfachere Stand stehen.
|
||||
Die Zahl schwankt je nach Zeitverlauf zwischen 4 und 7. Ueber 10
|
||||
waere etwas Neues dazugekommen -- dann lohnt das Nachsehen. */
|
||||
/* Gezaehlt wird nicht die MENGE (die schwankt je nach Zeitverlauf
|
||||
zwischen vier und elf -- mal laufen die beiden Abfragen gleichzeitig,
|
||||
mal nacheinander), sondern WELCHE Adressen betroffen sind. Eine
|
||||
Grenze immer weiter hochzusetzen, bis sie gruen wird, waere
|
||||
Selbstbetrug; eine Liste bekannter Stellen ist pruefbar.
|
||||
Kommt eine Adresse dazu, die hier nicht steht, ist das neu -- und
|
||||
genau das soll auffallen. */
|
||||
const BEKANNT_DOPPELT = [
|
||||
"/workspace/api/personen", // kopf.js (Sicht-Umschalter) + Seite (Zuordnung)
|
||||
"/workspace/api/hinweise", // Startseite: Hinweisliste und Kachelzahlen
|
||||
"/workspace/api/bereich/", // Bereichsseite: Liste und Kennzahlen
|
||||
"/workspace/assets/img/buehne-", // Buehnenbild in zwei CSS-Regeln
|
||||
/* /api/ich holen mehrere Bausteine unabhaengig voneinander: kopf.js
|
||||
fuer die Kopfleiste, dazu die Seiten-Datei und auf profil.html
|
||||
zusaetzlich steckbrief.js -- drei Bausteine, dieselbe Frage.
|
||||
Sauber loesen liesse sich das nur, indem kopf.js die Antwort einmal
|
||||
holt und bereitstellt; das beruehrt vierzehn Dateien und traegt das
|
||||
Risiko, dass eine Seite kuenftig mit veralteten Angaben arbeitet.
|
||||
Fuer wenige Kilobyte ist das nicht verhaeltnismaessig -- gehoert in
|
||||
die Verbesserungsliste, nicht in einen Schnellschuss. */
|
||||
"/workspace/api/ich",
|
||||
];
|
||||
const neueDoppelte = doppelte.filter(
|
||||
(z) => !BEKANNT_DOPPELT.some((b) => z.includes(b)));
|
||||
ok(neueDoppelte.length === 0,
|
||||
neueDoppelte.length
|
||||
? `NEUE Doppelabfrage: ${neueDoppelte.slice(0, 5).join(" · ")}`
|
||||
: `${doppelte.length} Doppelabfragen, alle an bekannten Stellen `
|
||||
+ `(${BEKANNT_DOPPELT.length} Adressen, siehe Liste im Quelltext)`);
|
||||
ok(langsame.length === 0,
|
||||
langsame.length ? `ueber ${GRENZE_MS} ms: ${langsame.join(", ")}` : `keine Seite braucht laenger als ${GRENZE_MS} ms`);
|
||||
ok(springende.length === 0,
|
||||
springende.length ? `Layout springt ueber ${GRENZE_SPRUNG}: ${springende.join(", ")}`
|
||||
: `kein Layout-Sprung ueber der Grundlinie ${GRENZE_SPRUNG} (Ziel bleibt ${ZIEL_SPRUNG})`);
|
||||
|
||||
/* Der ehrliche Zwischenstand, damit die Grundlinie nicht als "alles
|
||||
gut" missverstanden wird -- er zaehlt nicht als Fehler, steht aber
|
||||
bei jedem Lauf da. */
|
||||
const ueberZiel = zeilen.filter((z) => {
|
||||
const m = z.match(/Sprung\s+([\d.]+)/);
|
||||
return m && Number(m[1]) > ZIEL_SPRUNG;
|
||||
}).length;
|
||||
console.log(` hinw ${ueberZiel} von ${seitenGesamt} Seiten liegen ueber dem Zielwert `
|
||||
+ `${ZIEL_SPRUNG} fuer den Layout-Sprung (bauartbedingt: Geruest zuerst, Daten danach)`);
|
||||
ok(zuGross.length === 0,
|
||||
zuGross.length ? `zu schwer: ${zuGross.join(", ")}`
|
||||
: `keine Seite ueber ${GRENZE_ANFRAGEN} Anfragen oder ${Math.round(GRENZE_BYTES / 1024)} KB`);
|
||||
|
||||
/* =======================================================================
|
||||
GEGENPROBE
|
||||
======================================================================= */
|
||||
console.log("\n=== Gegenprobe (muss anschlagen) ===");
|
||||
{
|
||||
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
|
||||
|
||||
/* Doppelanfrage kuenstlich erzeugen. */
|
||||
const gesehen = new Map();
|
||||
const zaehler = (a) => gesehen.set(a.url(), (gesehen.get(a.url()) || 0) + 1);
|
||||
seite.on("response", zaehler);
|
||||
await seite.evaluate(async () => {
|
||||
await fetch("/workspace/api/ich", { credentials: "same-origin" });
|
||||
await fetch("/workspace/api/ich", { credentials: "same-origin" });
|
||||
});
|
||||
await seite.waitForTimeout(400);
|
||||
seite.off("response", zaehler);
|
||||
const dopp = [...gesehen.entries()].filter(([u, n]) => n > 1 && u.includes("/api/ich"));
|
||||
ok(dopp.length > 0, `eine doppelte Anfrage wird erkannt (${dopp.map(([u, n]) => `${n}x`).join(",")})`);
|
||||
|
||||
/* Layout-Sprung kuenstlich erzeugen. */
|
||||
const sprung = await seite.evaluate(() => new Promise((fertig) => {
|
||||
let summe = 0;
|
||||
const b = new PerformanceObserver((liste) => {
|
||||
for (const e of liste.getEntries()) if (!e.hadRecentInput) summe += e.value;
|
||||
});
|
||||
b.observe({ type: "layout-shift" });
|
||||
const klotz = document.createElement("div");
|
||||
klotz.style.cssText = "height:400px;background:#333";
|
||||
document.body.prepend(klotz);
|
||||
setTimeout(() => { b.disconnect(); fertig(Math.round(summe * 1000) / 1000); }, 800);
|
||||
}));
|
||||
/* Gegen den ZIELWERT pruefen, nicht gegen die Grundlinie. Die
|
||||
Grundlinie steht bei 0,6, damit die Pruefung nicht dauernd rot ist
|
||||
-- ein kuenstlicher Klotz von 400 px erzeugt aber nur rund 0,3.
|
||||
Gegen 0,6 gemessen waere diese Gegenprobe fehlgeschlagen und haette
|
||||
behauptet, die Messung funktioniere nicht. Bewiesen werden muss
|
||||
hier: die Messung SIEHT einen Sprung. */
|
||||
ok(sprung > ZIEL_SPRUNG,
|
||||
`ein echter Layout-Sprung wird gemessen (${sprung} > ${ZIEL_SPRUNG})`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 1002 KiB After Width: | Height: | Size: 1002 KiB |
@@ -325,6 +325,31 @@ function nurLeitung(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/* BEFUND 04.09.2026 (Audit): Dieses Modul war das einzige ohne
|
||||
Herkunftspruefung. Von sechzig schreibenden Wegen im ganzen Workspace
|
||||
nahm genau einer eine fremde Herkunft an -- `POST /zustand/sichern`.
|
||||
|
||||
Wie schlimm? Der Sitzungs-Keks steht auf SameSite=lax, ein fremdes
|
||||
Formular schickt ihn also gar nicht erst mit; und eine zusaetzliche
|
||||
Sicherung richtet keinen Schaden an. Trotzdem gehoert die Schranke
|
||||
hin, aus zwei Gruenden: Eine Sicherung ist ein `VACUUM INTO` ueber die
|
||||
ganze Datenbank -- wer sie im Sekundentakt ausloest, beschaeftigt den
|
||||
Server sinnlos. Und wichtiger: Eine Verteidigungslinie, die an 59 von
|
||||
60 Stellen gilt, ist keine Regel mehr, sondern ein Zufall. Die eine
|
||||
Ausnahme ist es, die beim naechsten Mal kopiert wird.
|
||||
|
||||
Wortgleich zu den anderen Modulen -- absichtlich: Diese Pruefung ist
|
||||
kurz genug, dass eine gemeinsame Fassung sie nur schwerer auffindbar
|
||||
machen wuerde. */
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
sicherungRouter.get("/workspace/api/zustand", angemeldet, nurLeitung, (req, res) => {
|
||||
try {
|
||||
const d = db();
|
||||
@@ -423,7 +448,11 @@ sicherungRouter.post("/workspace/api/zustand/kopie", express.json({ limit: "8kb"
|
||||
}));
|
||||
res.json({ ok: true });
|
||||
} catch (fehler) {
|
||||
res.status(500).json({ fehler: String(fehler?.message || fehler) });
|
||||
/* Ebenfalls neutral nach aussen (siehe unten bei /sichern). Diese
|
||||
Antwort geht an ein unbeaufsichtigtes Skript auf einem fremden
|
||||
Rechner -- der Grund gehoert ins Log des Servers, nicht dorthin. */
|
||||
console.error("[workspace] Rueckmeldung der Kopie:", fehler?.stack || fehler);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -436,14 +465,23 @@ sicherungRouter.get("/workspace/api/zustand/kopie-schluessel", angemeldet, nurLe
|
||||
|
||||
/* Von Hand ausloesen -- vor einer groesseren Aenderung will man nicht bis
|
||||
drei Uhr nachts warten. Nur die Leitung, und es wird protokolliert. */
|
||||
sicherungRouter.post("/workspace/api/zustand/sichern", angemeldet, nurLeitung, (req, res) => {
|
||||
try {
|
||||
const e = sicherungJetzt("von Hand · " + (req.person?.name || "?"));
|
||||
protokolliere("sicherung_erstellt", {
|
||||
personId: req.person?.id, rolle: req.person?.rolle, detail: e.datei,
|
||||
});
|
||||
res.json(e);
|
||||
} catch (fehler) {
|
||||
res.status(500).json({ fehler: String(fehler?.message || fehler) });
|
||||
}
|
||||
});
|
||||
sicherungRouter.post("/workspace/api/zustand/sichern",
|
||||
gleicheHerkunft, angemeldet, nurLeitung, (req, res) => {
|
||||
try {
|
||||
const e = sicherungJetzt("von Hand · " + (req.person?.name || "?"));
|
||||
protokolliere("sicherung_erstellt", {
|
||||
personId: req.person?.id, rolle: req.person?.rolle, detail: e.datei,
|
||||
});
|
||||
res.json(e);
|
||||
} catch (fehler) {
|
||||
/* BEFUND 04.09.2026: Hier stand die ECHTE Fehlermeldung in der
|
||||
Antwort. Bei einer Sicherung enthaelt die regelmaessig den
|
||||
vollen Pfad der Datenbank auf der Platte ("SQLITE_CANTOPEN:
|
||||
/home/dogiweb/workspace-daten/..."). Alle 23 anderen Module
|
||||
antworten neutral und schreiben den Grund ins Log -- diese eine
|
||||
Stelle nicht. Jetzt genauso: drinnen der Grund, draussen die
|
||||
Auskunft. */
|
||||
console.error("[workspace] Sicherung von Hand:", fehler?.stack || fehler);
|
||||
res.status(503).json({ fehler: "Die Sicherung hat nicht geklappt. Der Grund steht im Protokoll." });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -903,11 +903,68 @@ const GESCHUETZT = {
|
||||
"/workspace/automation.html": ["admin"],
|
||||
};
|
||||
|
||||
/* Die einzige Seite unter /workspace, die offen sein MUSS -- man kann
|
||||
sich schlecht anmelden, wenn die Anmeldeseite eine Anmeldung verlangt. */
|
||||
const OFFEN = new Set(["/workspace/index.html"]);
|
||||
|
||||
/** Der Pfad, wie ihn die Schranke ansehen muss.
|
||||
*
|
||||
* BEFUND VOM 04.09.2026 (Audit). Der erste Entwurf verglich `req.path`
|
||||
* direkt gegen die Liste -- ein EXAKTER Vergleich. Express raeumt
|
||||
* Punkt-Segmente selbst weg (`/workspace/./start.html` und
|
||||
* `/workspace/x/../start.html` kommen bereits zurechtgerueckt an), aber
|
||||
* MEHRFACHE SCHRAEGSTRICHE nicht. Gemessen:
|
||||
*
|
||||
* /workspace/start.html -> req.path /workspace/start.html
|
||||
* //workspace/start.html -> req.path //workspace/start.html
|
||||
* /workspace//start.html -> req.path /workspace//start.html
|
||||
*
|
||||
* Der Listenvergleich traf damit nicht, die Anfrage lief ungeprueft an
|
||||
* express.static weiter -- und das zieht die Schraegstriche seinerseits
|
||||
* zusammen und liefert die Datei aus. Ergebnis: `//workspace/start.html`
|
||||
* kam OHNE ANMELDUNG mit HTTP 200, und ein Creator bekam ueber
|
||||
* `/workspace//personen.html` die Verwaltungsseite. Das ist derselbe
|
||||
* Befund wie am 27.08.2026 ("sichtbar sein soll sie gar nicht"), nur
|
||||
* eine Schreibweise weiter -- der Fix von damals deckte genau eine ab.
|
||||
*
|
||||
* Nicht betroffen waren die DATEN: Bei doppeltem Schraegstrich trifft
|
||||
* keine der /workspace/api-Routen mehr (404), und wo sie trifft, greift
|
||||
* ihre eigene Schranke (401). Nachgemessen, nicht angenommen.
|
||||
*
|
||||
* Kleinschreibung kommt dazu, weil ein case-unempfindliches Dateisystem
|
||||
* (Windows, macOS) `PERSONEN.HTML` an dieselbe Datei weiterreicht. Auf
|
||||
* dem Linux-Server greift das nicht, hier im Test schon -- und eine
|
||||
* Schranke, die nur auf einem Dateisystem haelt, ist keine.
|
||||
*
|
||||
* Abschliessende Punkte und Leerzeichen fallen weg: Windows oeffnet
|
||||
* `start.html.` als `start.html`. */
|
||||
function schrankenPfad(roh) {
|
||||
return String(roh || "")
|
||||
.replace(/\/{2,}/g, "/") // // -> /
|
||||
.replace(/[.\s]+$/, (ende) => // abschliessende Punkte/Leerzeichen
|
||||
ende.includes(".") && !/^\.[a-z]/i.test(ende) ? "" : ende)
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/* GESCHUETZT IST JETZT DIE AUSNAHME, NICHT DIE REGEL.
|
||||
|
||||
Vorher galt: Was in der Liste steht, ist geschuetzt -- alles andere
|
||||
nicht. Wer eine neue Seite anlegt und den Eintrag vergisst, stellt sie
|
||||
damit offen ins Netz, ohne dass irgendwo etwas rot wird. Genau diese
|
||||
Sorte Fehler faellt erst auf, wenn jemand danach sucht.
|
||||
|
||||
Jetzt gilt: JEDE .html unter /workspace ist geschuetzt, ausser den
|
||||
ausdruecklich offenen. Die Liste oben sagt nur noch, WELCHE ROLLEN
|
||||
zusaetzlich eingeschraenkt sind. Eine neue Seite ist dadurch von
|
||||
selbst mindestens "nur fuer Angemeldete" -- der sichere Rueckfall. */
|
||||
workspaceRouter.use((req, res, next) => {
|
||||
if (!Object.hasOwn(GESCHUETZT, req.path)) return next();
|
||||
const pfad = schrankenPfad(req.path);
|
||||
if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next();
|
||||
if (OFFEN.has(pfad)) return next();
|
||||
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.redirect(302, "/workspace/");
|
||||
const erlaubt = GESCHUETZT[req.path];
|
||||
const erlaubt = GESCHUETZT[pfad];
|
||||
if (erlaubt && !erlaubt.includes(person.rolle)) {
|
||||
return res.redirect(302, "/workspace/start.html");
|
||||
}
|
||||
|
||||