// SW tools -- Worker vor den Static Assets. // // Laeuft nur fuer die Pfade in wrangler.jsonc `assets.run_worker_first`: // IT-Tools ganz, von CyberChef und BentoPDF nur die HTML-Seiten (Assets // per Negativmuster ausgenommen). Alles andere liefert Cloudflare direkt // aus build/site/, Header dort aus build/site/_headers (erzeugt aus // src/headers.json). // // Was der Worker tut, ohne eine Datei einer Sammlung zu aendern: // - IT-Tools: das Release hat ein absolutes `` und // History-Routing; beides wuerde unter einem Unterpfad brechen. // - Kleid: haengt in jede HTML-Seite einer Sammlung /kleid/basis.css und // /kleid/.css ein (SW-labs-Aussehen, site/kleid/README.md); // bei IT-Tools zusaetzlich /kleid/it-tools-dunkel.js (dunkler Modus). // Abschaltbar ohne Code-Aenderung mit der Variable KLEID=aus. import regeln from "./headers.json"; interface Env { ASSETS: Fetcher; // "aus" schaltet das Kleid ab (Messung vorher/nachher, Notbremse). KLEID?: string; } type Direktiven = Record; const IT_TOOLS = "/it-tools/"; const SAMMLUNGEN = [IT_TOOLS, "/cyberchef/", "/pdf/"] as const; type Sammlung = (typeof SAMMLUNGEN)[number]; // Dateiname des Kleids je Praefix, unter /kleid/. const KLEID: Record = { "/it-tools/": "it-tools", "/cyberchef/": "cyberchef", "/pdf/": "pdf", }; // CSP aus Basis plus Sammlungs-Ausnahmen; eine Direktive der Sammlung // ersetzt die gleichnamige der Basis (keine Vereinigung, damit jede Quelle // sichtbar in headers.json steht). export function csp(prefix: string | null): string { const basis = regeln.csp_basis as Direktiven; const sammlung = prefix ? ((regeln.csp_sammlung as Record)[prefix] ?? {}) : {}; const alle: Direktiven = { ...basis, ...sammlung }; return Object.entries(alle) .map(([name, d]) => `${name} ${d.werte.join(" ")}`) .join("; "); } function mitHeadern(res: Response, prefix: string | null): Response { const out = new Response(res.body, res); for (const [name, h] of Object.entries(regeln.global)) { out.headers.set(name, h.wert); } out.headers.set("Content-Security-Policy", csp(prefix)); return out; } // Praefix der Sammlung zu einem Pfad; "/pdf" ohne Schraegstrich zaehlt mit // (Assets leiten auf "/pdf/" um, die Umleitung bekommt dieselben Header). function sammlungZu(pathname: string): Sammlung | null { for (const s of SAMMLUNGEN) { if (pathname.startsWith(s) || pathname === s.slice(0, -1)) return s; } return null; } // Letztes Pfadsegment ohne Punkt = Route der Single-Page-App, kein Asset. // So bekommt eine fehlende .js-Datei weiter ihr 404 statt index.html. function istRoute(pathname: string): boolean { const letztes = pathname.slice(pathname.lastIndexOf("/") + 1); return !letztes.includes("."); } function istHtml(res: Response): boolean { return (res.headers.get("Content-Type") ?? "").includes("text/html"); } // Ein HTMLRewriter je Antwort: fuer IT-Tools, Kleid fuer alle. // Das Kleid kommt ans ENDE von , damit es nach den Stylesheets der // Sammlung steht und bei gleicher Spezifitaet gewinnt. Naive UI (IT-Tools) // setzt seine Laufzeit-Styles an den ANFANG von , also davor. // Das Dunkel-Skript von IT-Tools kommt an den Anfang von , vor das // Inline-Skript des Releases, das den Modus beim ersten Zeichnen setzt. function umschreiben(res: Response, sammlung: Sammlung, kleid: boolean): Response { let rw = new HTMLRewriter(); if (sammlung === IT_TOOLS) { // Das Release setzt ; relativ aufgeloeste Assets // (./assets/...) und der Router brauchen den Unterpfad. rw = rw.on("base", { element(el) { el.setAttribute("href", IT_TOOLS); }, }); } if (kleid) { const name = KLEID[sammlung]; rw = rw.on("head", { element(el) { if (sammlung === IT_TOOLS) { el.prepend('', { html: true }); } el.append( '' + ``, { html: true }, ); }, }); } return rw.transform(res); } async function itTools(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.pathname === "/it-tools") { url.pathname = IT_TOOLS; return mitHeadern(Response.redirect(url.toString(), 308), IT_TOOLS); } let res = await env.ASSETS.fetch(request); // Deep Link (/it-tools/base64-string-converter): Datei gibt es nicht, // Vue-Router braucht index.html. Nicht /it-tools/index.html anfragen -- // html_handling leitet das auf /it-tools/ um. if (res.status === 404 && istRoute(url.pathname)) { const index = new URL(IT_TOOLS, url); res = await env.ASSETS.fetch(new Request(index.toString(), request)); } if (res.ok && istHtml(res)) { res = umschreiben(res, IT_TOOLS, env.KLEID !== "aus"); } return mitHeadern(res, IT_TOOLS); } export default { async fetch(request: Request, env: Env): Promise { const { pathname } = new URL(request.url); const sammlung = sammlungZu(pathname); if (sammlung === IT_TOOLS) { return itTools(request, env); } // CyberChef und BentoPDF: nur HTML-Seiten kommen hier an // (run_worker_first). html_handling und not_found_handling gelten auch // fuer env.ASSETS.fetch -- Umleitungen und /pdf/404.html bleiben, wie // sie ohne Worker waeren. Die 404-Seite bekommt das Kleid mit. let res = await env.ASSETS.fetch(request); if (sammlung && env.KLEID !== "aus" && (res.ok || res.status === 404) && istHtml(res)) { res = umschreiben(res, sammlung, true); } return mitHeadern(res, sammlung); }, } satisfies ExportedHandler;