/* Sucht doppelt kodierte Zeichen auf ALLEN Webdesign-Seiten in ALLEN fünf Sprachen. Anlass: Im Portal stand sichtbar "Ideen &amp; Änderungswünsche". Ursache ist eine Falle, die sich leicht wiederholt — es gibt zwei Wege, wie ein Text in die Seite kommt: data-i18n="schluessel" -> wird als HTML eingesetzt. Dort ist "&" RICHTIG. s(window.WD.t("schluessel")) -> wird abgesichert eingesetzt. Dort wird "&" ein zweites Mal kodiert und erscheint wörtlich. Derselbe Textbaustein kann also je nach Verwendungsort richtig oder falsch sein. Das kann man nicht zuverlässig im Kopf behalten — deshalb prüft es der Browser: Was am Ende auf dem Bildschirm steht, darf keine Kodierungsreste enthalten. */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join, extname } from "node:path"; const W = process.cwd(); const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" }; const srv = createServer((q, r) => { const p = join(W, decodeURIComponent(q.url.split("?")[0])); if (existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" }); return r.end(readFileSync(p)); } r.writeHead(404).end(""); }); await new Promise((r) => srv.listen(4327, r)); const SEITEN = readdirSync(join(W, "webdesign")) .filter((f) => f.endsWith(".html")); const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"]; /* Reste, die eine doppelte Kodierung hinterlässt. Sie dürfen im sichtbaren Text nie vorkommen. */ const RESTE = ["&", "<", ">", """, "'", " "]; /* ===================================================================== TEIL 1 — Prüfung am QUELLTEXT Die Browserprüfung weiter unten sieht nur, was gerade auf dem Bildschirm steht. Grosse Teile des Portals und der Verwaltung erscheinen aber erst NACH einer Anmeldung — genau dort sass der Fehler, den es zu finden galt ("Ideen &amp; Änderungswünsche"). Gegengeprüft: Die Browserprüfung allein hätte ihn NICHT gefunden. Diese Prüfung braucht keine Anzeige. Sie fragt nur: Gibt es einen Textbaustein mit "&", der irgendwo durch die Absicherung s(...) geschickt wird? Dann ist er dort doppelt kodiert. ===================================================================== */ function quelltextPruefen() { const i18nDateien = readdirSync(join(W, "assets", "js")) .filter((f) => f.startsWith("i18n-wd-") && f.endsWith(".js")); // Alle Schlüssel einsammeln, deren Text ein "&" enthält. const verdaechtig = new Map(); // schluessel -> datei for (const datei of i18nDateien) { const inhalt = readFileSync(join(W, "assets", "js", datei), "utf-8"); /* Der Block eines Schlüssels endet dort, wo seine geschweifte Klammer wieder zugeht — nicht erst beim nächsten Schlüssel. Der Unterschied ist nicht theoretisch: Zwischen zwei Schlüsseln stehen Kommentare, und einer davon lautet "Ideen & Änderungs- wünsche". Mit der Regel "bis zum nächsten Schlüssel" hat der VORHERIGE Schlüssel (po_senden, ein schlichtes "Senden") diesen Kommentar mitgeschluckt und wurde fälschlich gemeldet. Ein Fehlalarm im Prüfwerkzeug ist fast so schädlich wie ein übersehener Fehler — man gewöhnt sich daran, ihn wegzusehen. */ const zeilen = inhalt.split("\n"); let aktuell = null; let text = ""; let tiefe = 0; const abschliessen = () => { if (aktuell && text.includes("&")) verdaechtig.set(aktuell, datei); aktuell = null; text = ""; tiefe = 0; }; for (const z of zeilen) { if (!aktuell) { const m = z.match(/^ ([a-zA-Z0-9_]+):\s*\{/); if (!m) continue; aktuell = m[1]; text = z; tiefe = (z.match(/\{/g) || []).length - (z.match(/\}/g) || []).length; if (tiefe <= 0) abschliessen(); } else { text += "\n" + z; tiefe += (z.match(/\{/g) || []).length - (z.match(/\}/g) || []).length; if (tiefe <= 0) abschliessen(); } } abschliessen(); } // Wo werden sie abgesichert eingesetzt? const quellen = [ ...readdirSync(join(W, "assets", "js")).filter((f) => f.startsWith("wd-") && f.endsWith(".js")) .map((f) => ["assets/js/" + f, readFileSync(join(W, "assets", "js", f), "utf-8")]), ...SEITEN.map((f) => ["webdesign/" + f, readFileSync(join(W, "webdesign", f), "utf-8")]), ]; const funde = []; for (const [schluessel, datei] of verdaechtig) { // s(window.WD.t("key")) oder s(t("key")) const muster = new RegExp( "\\bs\\(\\s*(?:window\\.WD\\.)?t\\(\\s*[\"']" + schluessel + "[\"']", ""); for (const [name, inhalt] of quellen) { if (muster.test(inhalt)) funde.push({ schluessel, datei, benutztIn: name }); } } console.log(`Quelltext: ${verdaechtig.size} Textbausteine enthalten "&".`); if (funde.length) { for (const f of funde) { console.log(` FEHL ${f.schluessel} (${f.datei}) wird in ${f.benutztIn} abgesichert eingesetzt`); console.log(` -> dort muss ein echtes "&" stehen, sonst erscheint "&" wörtlich.`); } } else { console.log(" ok keiner davon wird durch die Absicherung geschickt.\n"); } return funde.length; } let quellFehler = quelltextPruefen(); const browser = await chromium.launch(); const seite = await browser.newPage({ viewport: { width: 1280, height: 900 } }); // Die API gibt es hier nicht -- leere, gültige Antworten genügen. await seite.route("**/webdesign/admin/**", (r) => r.fulfill({ json: { ok: true, anfragen: [], kunden: [], projekte: [], verlaeufe: [] } })); await seite.route("**/webdesign/portal/**", (r) => r.fulfill({ json: { ok: true } })); await seite.route("**/webdesign/api-ausweis", (r) => r.fulfill({ json: {} })); await seite.addInitScript(() => { sessionStorage.setItem("wd-sitzung-aktiv", "1"); }); let treffer = 0; let geprueft = 0; for (const datei of SEITEN) { for (const sprache of SPRACHEN) { await seite.addInitScript(`localStorage.setItem("dogi-sprache", ${JSON.stringify(sprache)})`); await seite.goto("http://127.0.0.1:4327/webdesign/" + datei); await seite.waitForTimeout(320); geprueft++; const gefunden = await seite.evaluate((reste) => { const raus = []; const lauf = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let n; while ((n = lauf.nextNode())) { const eltern = n.parentElement; if (!eltern || eltern.closest("script, style, code, pre")) continue; const t = n.nodeValue; for (const r of reste) { if (t.includes(r)) { raus.push({ rest: r, text: t.trim().slice(0, 70), wo: eltern.tagName.toLowerCase() }); } } } return raus; }, RESTE); if (gefunden.length) { treffer += gefunden.length; for (const g of gefunden) { console.log(` FEHL ${datei} [${sprache}] <${g.wo}> ${g.rest} in: "${g.text}"`); } } } } await browser.close(); srv.close(); console.log(`\n${geprueft} Seiten-Sprach-Kombinationen geprüft (${SEITEN.length} Seiten x ${SPRACHEN.length} Sprachen).`); const gesamt = treffer + quellFehler; console.log(gesamt === 0 ? "===== keine doppelt kodierten Zeichen gefunden (Quelltext + Anzeige) =====" : `===== ${gesamt} Fundstellen (${quellFehler} im Quelltext, ${treffer} in der Anzeige) =====`); process.exitCode = gesamt ? 1 : 0;