From 9726af9254984d5fd222240a81f275a702f81c18 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 14 Sep 2026 08:00:52 +0200 Subject: [PATCH] Fassung 1.5.3: der Editor blieb nach dem Update schwarz Nach 1.5.2 meldete der Editor im Browser "The requested module './editor-model.js' does not provide an export named 'dropList'", und der Werkzeugkasten war leer. Ursache war nicht der Code, sondern das Zwischenspeichern. public/.htaccess setzt fuer jede .js-Datei Cache-Control: immutable, max-age=31536000 - ein Jahr. Die Fassungskennung haengt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fuenf Module, die der Editor ueber import './...js' laedt, tragen keine: der Browser holte das neue editor.js und behielt das alte editor-model.js. Ein neues canvas-overlay.js traf damit auf ein Modell ohne dropList. Beide gemeldeten Symptome hatten diese eine Wurzel. Seit 1.5.1 baut das Skript den Werkzeugkasten auf, und editor.js importiert canvas-overlay.js in Zeile 5; bricht der Import, laeuft keine Zeile danach und die Elementliste bleibt leer. Das gemeldete fehlende Menue war der Werkzeugkasten, nicht die Verwaltungsnavigation - die wurde geprueft und ist in Chrome 152 in Ordnung. Behebung: eine Importkarte in base.html.twig haengt die Fassung an die aufgeloeste Adresse jedes Moduls, ohne dass eine Importzeile sie kennen muss. Das Zwischenspeichern ueber ein Jahr bleibt - die Leistung war nie das Problem, nur die fehlende Kennung. Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter script-src 'self' wurde sie wortlos verworfen. AdminHeadersSubscriber erzeugt deshalb je Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; csp_nonce() gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript, 'self' gilt unveraendert fuer alles andere. Geprueft: der Browsertest verlangt an jeder angeforderten .js-Adresse eine Fassungskennung (vorher fehlten fuenf, jetzt tragen alle acht), dazu ein Test, der jeden Modulimport gegen die Importkarte haelt - Gegenprobe gemacht. npm test 28/28, phpunit 452/452, npm run test:editor vollstaendig. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- docs/STATUS.md | 43 ++++++++++++++++++- scripts/editor-check.mjs | 15 +++++++ .../AdminHeadersSubscriber.php | 33 ++++++++++++-- src/Twig/CspExtension.php | 35 +++++++++++++++ templates/admin/base.html.twig | 14 ++++++ tests/editor-model.test.mjs | 26 ++++++++++- 7 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 src/Twig/CspExtension.php diff --git a/VERSION b/VERSION index a73b432..1d5e9e0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.2 \ No newline at end of file +1.5.3 \ No newline at end of file diff --git a/docs/STATUS.md b/docs/STATUS.md index 4693452..86128cc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Projektstatus -Stand: 14.09.2026, Fassung **1.5.2**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. +Stand: 14.09.2026, Fassung **1.5.3**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen. Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push. @@ -369,6 +369,47 @@ als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vors Suche im Werkzeugkasten, Vorlagen je Element mit Bild, und das Ziehen der Spaltenbreite in der Vorschau. +## 1.5.3: der Editor blieb nach dem Update schwarz + +**Der Fehler.** Nach 1.5.2 meldete der Editor im Browser +`The requested module './editor-model.js' does not provide an export named 'dropList'`, und der +Werkzeugkasten war leer. + +Ursache war nicht der Code, sondern das Zwischenspeichern. `public/.htaccess` setzt für **jede** +`.js`-Datei `Cache-Control: public, max-age=31536000, immutable` – ein Jahr. Die Fassungskennung +`?v=` hängt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fünf Module, die der +Editor über `import './…​.js'` lädt, tragen keine: der Browser holte das neue `editor.js` und +behielt das alte `editor-model.js`. Ein neues `canvas-overlay.js` traf damit auf ein Modell ohne +`dropList`. + +Beide gemeldeten Symptome hatten diese eine Wurzel: seit 1.5.1 baut das Skript den Werkzeugkasten +auf (`buildLibrary()`), und `editor.js` importiert `canvas-overlay.js` in Zeile 5. Bricht der +Import, läuft keine Zeile danach – und die Elementliste bleibt leer. Das gemeldete „Menü fehlt“ +war der Werkzeugkasten, nicht die Verwaltungsnavigation. Die wurde geprüft: in Chrome 152 stehen +`::details-content`, ausgeblendeter Schalter und sichtbare Liste korrekt. + +**Die Behebung.** Eine Importkarte in `base.html.twig` hängt die Fassung an die aufgelöste +Adresse jedes Moduls, ohne dass eine einzige Importzeile sie kennen muss. Das behält das +Zwischenspeichern über ein Jahr – die Leistung war nie das Problem, nur die fehlende Kennung. + +Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter +`script-src 'self'` wurde sie wortlos verworfen. `AdminHeadersSubscriber` erzeugt deshalb je +Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; die +Twig-Funktion `csp_nonce()` gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript – +`'self'` gilt unverändert für alles andere. + +**Geprüft.** Der Browsertest schreibt jede angeforderte `.js`-Adresse mit und verlangt an jeder +eine Fassungskennung; vor der Behebung nennt er die fünf fehlenden beim Namen, danach trägt sie +alle acht. Dazu ein Test, der jeden `./…​.js`-Import in `public/assets` gegen die Importkarte +hält – ein neues Modul, das dort fehlt, fällt damit im Test auf und nicht erst beim nächsten +Update beim Kunden. Gegenprobe gemacht: mit einem entfernten Eintrag schlägt er fehl. +`npm test` 28 von 28, `php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig. + +**Bekannte Grenze.** Ein Modulfehler legt den Editor weiterhin vollständig lahm, und der +Werkzeugkasten ist seit 1.5.1 nichts ohne sein Skript. Für einen Editor ist das vertretbar – die +öffentliche Website läuft ohne JavaScript –, aber die Fehleranzeige dafür ist noch die +Browserkonsole und kein Satz in der Oberfläche. + ## Nächster konkreter Schritt Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 619ffd7..50f6e30 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -39,6 +39,14 @@ try { page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); }); page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); }); page.on('dialog', dialog => dialog.accept()); + // Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als + // `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update + // fuer immer veraltet - genau so brach der Editor mit "does not provide an export named ...". + const skripte = []; + page.on('request', anfrage => { + const url = new URL(anfrage.url()); + if (url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js')) skripte.push(url); + }); const axe = async name => { console.log('Accessibility check:', name); // The no-script preview sandbox cannot host axe's frame worker. Audit the identical @@ -61,6 +69,13 @@ try { await page.locator('a[href="/admin/pages/test-home"]').first().click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); console.log('Editor data loaded.'); + // Der Editor laedt fuenf Module ueber Importe, die selbst keine Fassung tragen; die + // Importkarte in base.html.twig haengt sie an. Ohne sie kaeme das alte Modul aus dem Cache. + assert.ok(skripte.some(url => url.pathname === '/assets/editor-model.js'), 'editor-model.js wird geladen'); + const ohneFassung = [...new Set(skripte.filter(url => !url.searchParams.has('v')).map(url => url.pathname))]; + assert.deepEqual(ohneFassung, [], `Skripte ohne Fassungskennung: ${ohneFassung.join(', ')}`); + checks.push({ name: 'every module is requested with a version key', passed: true }); + console.log(`Fassungskennung an allen ${new Set(skripte.map(u => u.pathname)).size} Skriptdateien.`); console.log('Preview status:', await page.locator('#preview-status').innerText()); const frame = page.frameLocator('#preview'); await frame.locator('h1').waitFor(); diff --git a/src/EventSubscriber/AdminHeadersSubscriber.php b/src/EventSubscriber/AdminHeadersSubscriber.php index b632c2b..1eafd80 100644 --- a/src/EventSubscriber/AdminHeadersSubscriber.php +++ b/src/EventSubscriber/AdminHeadersSubscriber.php @@ -3,6 +3,7 @@ namespace App\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -14,12 +15,34 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface public static function getSubscribedEvents(): array { - return [KernelEvents::RESPONSE => ['onResponse', -300]]; + return [KernelEvents::REQUEST => ['onRequest', 300], KernelEvents::RESPONSE => ['onResponse', -300]]; + } + + private function isAdmin(string $path): bool + { + return 1 === preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $path); + } + + /** + * One value per administration page, for the single inline script there is: the import map. + * + * It has to be inline - the specification knows no external import maps - and it has to work + * under `script-src 'self'`. Without it the map is dropped without a word, the modules load + * without a version key, and a year-old `editor-model.js` out of the cache meets a new + * `editor.js`. That is not a theory: it broke the editor after 1.5.0 went out. + */ + public function onRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + if (!$event->isMainRequest() || !$this->isAdmin($request->getPathInfo())) { + return; + } + $request->attributes->set('csp_nonce', rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=')); } public function onResponse(ResponseEvent $event): void { - if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) { + if (!$this->isAdmin($event->getRequest()->getPathInfo())) { return; } $headers = $event->getResponse()->headers; @@ -30,6 +53,10 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface $headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()'); // The preview must show the same typeface as the published page, so the font hosts are // allowed here as well. Everything else stays as narrow as on the public side. - $headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); + // The nonce only ever adds the one script it names; 'self' keeps applying to everything + // else, so nothing is loosened by it. + $nonce = $event->getRequest()->attributes->get('csp_nonce'); + $script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'"; + $headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); } } diff --git a/src/Twig/CspExtension.php b/src/Twig/CspExtension.php new file mode 100644 index 0000000..621458c --- /dev/null +++ b/src/Twig/CspExtension.php @@ -0,0 +1,35 @@ +nonce(...))]; + } + + public function nonce(): string + { + $value = $this->requests->getCurrentRequest()?->attributes->get('csp_nonce'); + + return is_string($value) ? $value : ''; + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index ecda00b..ca2d967 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -7,6 +7,20 @@ {% block title %}Verwaltung{% endblock %} · KantSites {% include 'favicon.html.twig' %} + {# Die Fassungskennung fuer die Module untereinander. + + Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'` + darin nicht - und `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable` + zwischenspeichern. Nach einem Update lud der Browser damit das neue `editor.js`, behielt + aber das alte `editor-model.js` und brach mit "does not provide an export named ..." ab. + Die Importkarte haengt die Fassung an die aufgeloeste Adresse, ohne dass eine einzige + Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie + nicht mehr. #} + {% block styles %}{% endblock %} {% block scripts %}{% endblock %} diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index c2d2a89..8e6abaa 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js'; import { readFile } from 'node:fs/promises'; import { designCss } from '../scripts/build-design.mjs'; @@ -492,3 +492,27 @@ test('a row dropped at an exact position brings its columns along', () => { // Und in eine Zeile hinein gibt es keine zweite Zeile. assert.equal(insertRowAt(document, 2, row.id, 0), null); }); + +// --- Fassungskennung an den Modulen --------------------------------------------------------- + +test('every module imported by another one is listed in the import map', () => { + // `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable` zwischenspeichern. + // Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'` darin + // nicht - der Browser behielte das alte Modul und der Editor braeche mit "does not provide an + // export named ..." ab. Genau das ist nach 1.5.0 passiert. Die Importkarte haengt die Fassung + // an; ein neues Modul, das dort fehlt, faellt hier auf und nicht erst beim naechsten Update. + const quellen = readdirSync('public/assets').filter(name => name.endsWith('.js')); + const importiert = new Set(); + for (const name of quellen) { + for (const treffer of readFileSync(`public/assets/${name}`, 'utf8').matchAll(/from '\.\/([\w-]+\.js)'/g)) { + importiert.add(treffer[1]); + } + } + assert.ok(importiert.size > 0, 'es gibt ueberhaupt Modulimporte'); + const vorlage = readFileSync('templates/admin/base.html.twig', 'utf8'); + const karte = /