Formatierter Text mit Werkzeugleiste und HTML-Ansicht (Schema v12)

Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv,
unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und
tiefgestellt, Absätze, Listen und Links.

Die Entscheidung, was erlaubt ist, fällt auf dem Server beim
Speichern - nicht im Editor und nicht beim Rendern. Was in der
Datenbank landet, ist damit schon sicher, also kann kein Renderer das
Filtern vergessen, und eine manipulierte Anfrage speichert nichts,
was der Editor nicht anbietet.

Nicht erlaubte Elemente werden ausgepackt, nicht gelöscht: der Text
bleibt, nur die Auszeichnung geht. script, style, iframe und
Verwandte verschwinden samt Inhalt, weil Auspacken ihren Code als
Text sichtbar machen würde. Attribute fallen weg außer href an a,
und auch das nur nach Adressprüfung.

Ausgegeben wird formatierter Text nur ab v12, dann in einem div statt
einem p, weil die Positivliste Absätze und Listen enthält. Ältere
Dokumente behalten Klartextsemantik und werden weiter maskiert.

Eine Inkonsistenz nebenbei behoben: ein Text mit einem < behielt
echte Zeilenumbrüche, während alles andere <br> benutzte.

Geprüft: 182 PHP-Tests / 685 Assertions unter PHP 8.2.12 und 8.4.22,
darunter dreizehn Fälle durch den Filter mit acht Angriffen, dazu
Editortest mit axe und öffentliche Browserprüfung. Die Werkzeugleiste
selbst ist nicht bedient und nicht mit einem Screenreader geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 16:54:35 +02:00
1 parent d0938eec69
commit 790f42213c
16 files changed
+557 -30

No files matched your search

+16
View File
@@ -316,3 +316,19 @@ Geteilte Bereiche haben **keinen Entwurf und keine Veröffentlichung**. Sie ersc
Damit ein Kopfbereich überhaupt frei baubar ist, gibt es einen Block `menu`: er zeigt einen Menüplatz, dessen Einträge weiterhin in der Menüverwaltung liegen. Die Marke ist ein gewöhnlicher Text- oder Bildblock. Im Template steht keine Navigation und keine Marke mehr.
**Ein echter Fehler kam dabei heraus, der nicht nur den eingesetzten Fußbereich betraf:** die Token-CSS war für die flache Struktur Sektion → Text geschrieben. `.block-column>.block-text:first-child` setzt eine Farbe für helle Hintergründe und gewinnt auf Spezifität gegen `.tone-ink .block-text`. Text in einer Spalte auf dunklem Grund war damit unlesbar – gemessen 1,87:1. Zwei gezielte Selektoren gleicher Spezifität, später in der Datei, stellen das richtig; helle Farbwelten bleiben unberührt.
## ADR 027 – Formatierter Text (Dokumentschema v12, 12.09.2026)
Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv, unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und tiefgestellt, Absätze, Listen und Links. Die Liste steht in `RichText::ALLOWED`.
**Die Entscheidung, was erlaubt ist, fällt auf dem Server beim Speichern** – nicht im Editor und nicht beim Rendern. Zwei Gründe: was in der Datenbank landet, ist damit schon sicher, also kann kein Renderer das Filtern vergessen; und eine manipulierte Anfrage kann nichts speichern, was der Editor nicht anbietet. Der Editor ist ein Vorschlag, der Server ist die Instanz.
Nicht erlaubte Elemente werden **ausgepackt, nicht gelöscht**: der Text bleibt, nur die Auszeichnung geht. Ein eingefügtes Word-Dokument verliert seine Gestaltung, nicht seinen Inhalt. Ausnahme sind `script`, `style`, `iframe`, `object`, `embed`, `template`, `noscript`, `svg` und `math` – sie werden samt Inhalt entfernt, weil Auspacken ihren Code als Text sichtbar machen würde. Attribute fallen restlos weg, außer `href` an `a`, und auch das nur nach derselben Adressprüfung wie überall; ein unbrauchbares Ziel macht aus dem Link Text. Externe Links bekommen `rel="noopener"`.
Ausgegeben wird formatierter Text **nur ab v12**, und dann unescaped in einem `div` statt einem `p` – die Positivliste enthält Absätze und Listen, die in einem `p` nicht stehen dürfen. Ältere Dokumente behalten Klartextsemantik und werden weiterhin maskiert; der bestehende XSS-Test für v1 gilt unverändert. Ein Dokument wechselt erst auf v12, wenn jemand einen Textblock formatiert bearbeitet.
Zeilenumbrüche werden in beiden Zweigen zu `<br>`. Vorher behielt ein Text, der zufällig ein `<` enthielt, echte Zeilenumbrüche, während alles andere `<br>` benutzte – zwei Darstellungen desselben Umbruchs, die beim nächsten CSS-Wechsel auseinandergelaufen wären. Innerhalb von `code` bleiben Umbrüche unangetastet.
Ein Textblock, der nach dem Filtern leer ist, wird abgewiesen – dieselbe Regel wie für einen von Hand geleerten Textblock. Wer nur ein verbotenes Element einfügt, bekommt also eine Meldung statt eines stillschweigend leeren Blocks.
Im Editor gibt es zwei Sichten auf denselben Wert: eine Formatierungsansicht mit Werkzeugleiste und eine HTML-Ansicht mit der Auszeichnung. Eingefügter Inhalt wird als reiner Text übernommen. Die Werkzeugleiste nutzt `execCommand`; das ist abgekündigt, aber das Einzige, was alle Browser für diese Aufgabe umsetzen, und da der Server ohnehin normalisiert, kann eine Browsereigenheit nichts Unerwartetes speichern.
+2 -2
View File
@@ -126,8 +126,8 @@ Abnahme: Die Pilotwebsite lässt sich vollständig ohne Code pflegen. Eine geän
Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
1. **Kopf- und Fußbereich frei aus Elementen bauen**, genau wie eine Sektion – keine feste Marke, keine feste Navigation im Template. Dazu wiederverwendbare Sektionen, die *verknüpft* und nicht kopiert werden: eine Änderung erreicht jede Seite, die sie nutzt.
2. **Rich Text in Textelementen**: fett, kursiv, Link, Liste und Ähnliches, plus eine **HTML-Ansicht** zum direkten Bearbeiten der Auszeichnung. Gespeichert wird eingeschränktes HTML, serverseitig gegen eine Positivliste gefiltert – der Editor darf nie entscheiden, was erlaubt ist.
1. ~~**Kopf- und Fußbereich frei aus Elementen bauen**~~ — **erledigt am 12.09.2026** (ADR 026). Ursprünglicher Wortlaut: **Kopf- und Fußbereich frei aus Elementen bauen**, genau wie eine Sektion – keine feste Marke, keine feste Navigation im Template. Dazu wiederverwendbare Sektionen, die *verknüpft* und nicht kopiert werden: eine Änderung erreicht jede Seite, die sie nutzt.
2. ~~**Rich Text in Textelementen**~~ — **erledigt am 12.09.2026** (ADR 027). Umgesetzt für Textblöcke mit Werkzeugleiste und HTML-Ansicht; gefiltert wird auf dem Server beim Speichern. Überschrift, Button und Zitat bleiben bewusst Klartext.
3. **Eigenes CSS und eigenes Skript je Element.** Eigenes CSS ist unkritisch und für alle mit Editorrolle nutzbar. **Eigenes Skript ist es nicht:** es ist gespeichertes, ausführbares JavaScript auf der Website. Wer es setzen darf, kann Code im Browser jedes Administrators ausführen. Deshalb: nur für Administratoren, Ausgabe über eine seiteneigene Skriptdatei statt inline, keine Ausführung in der Editorvorschau, und `script-src 'self'` nur auf Seiten, die tatsächlich ein Skript tragen. Der Preis steht in der Oberfläche.
4. **Verwaltung neu gestalten** – einfacher, schöner, ohne Nachdenken bedienbar. Gestaltungsfreiheit ausdrücklich bei mir; der bisherige Stil ist nicht bindend. Das betrifft Navigation zwischen den Bereichen, die Seitenliste, die Panels im Editor und die Formularseiten, die derzeit alle als lange Formulartabellen gebaut sind.
+10
View File
@@ -1,5 +1,15 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: formatierter Text, 12.09.2026
- **182 PHP-Tests / 685 Assertions** unter PHP 8.2.12 und PHP 8.4.22, **15 JavaScript-Modelltests**, der vollständige Playwright-Editortest mit axe (null Verstöße) und die öffentliche Browserprüfung.
- **Dreizehn Fälle durch den Filter**, davon acht Angriffe: `<script>alert(1)</script>Text danach` behält nur den Text, ein `onclick`-Attribut verschwindet, `class` und `style` verschwinden mitsamt dem Element (der Text bleibt), ein `javascript:`-Link wird zu Text, ein `<img src=x onerror=…>` verschwindet vollständig, ein `<iframe>` verschwindet, unausgeglichene Auszeichnung wird geschlossen, und Klartext mit `<` wird maskiert.
- Geprüft, **wo** gefiltert wird: nach dem Speichern liegt in der Datenbank bereits die gefilterte Fassung. Damit kann kein Renderer das Filtern vergessen, und eine manipulierte Anfrage speichert nichts, was der Editor nicht anbietet.
- Geprüft, dass ältere Dokumente unberührt bleiben: dieselbe Auszeichnung wird in einem v3-Dokument maskiert ausgegeben und in einem v12-Dokument als Auszeichnung. Der bestehende XSS-Test für v1 gilt unverändert.
- Geprüft, dass derselbe Filter in einem geteilten Bereich greift, und dass ein nach dem Filtern leerer Textblock abgewiesen wird, ohne zu schreiben.
- **Eine Inkonsistenz wurde dabei behoben:** ein Text, der zufällig ein `<` enthielt, behielt echte Zeilenumbrüche, während alles andere `<br>` benutzte – zwei Darstellungen desselben Umbruchs. Beide Zweige erzeugen jetzt `<br>`; innerhalb von `code` bleiben Umbrüche erhalten.
- **Die Werkzeugleiste selbst ist nicht bedient worden** und nicht mit einem Screenreader geprüft. Geprüft ist der Filter, der Speicherweg und die Ausgabe.
## Ergänzung: Kopf, Fuß und verknüpfte Sektionen, 12.09.2026
- **165 PHP-Tests / 660 Assertions** unter PHP 8.2.12 und PHP 8.4.22, **15 JavaScript-Modelltests**, der vollständige Playwright-Editortest mit axe (null Verstöße) und die öffentliche Browserprüfung.
+7 -4
View File
@@ -1,6 +1,6 @@
# Projektstatus
Stand: 12.09.2026. Arbeitstitel Kant CMS. **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: 12.09.2026. Arbeitstitel Kant CMS. **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.
@@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
- **Formatierter Text in Textblöcken** (Dokumentschema v12): fett, kursiv, unterstrichen, durchgestrichen, Code, hervorgehoben, hoch-/tiefgestellt, Absätze, Listen und Links. Im Editor eine Werkzeugleiste plus **HTML-Ansicht**; eingefügter Inhalt kommt als reiner Text an. **Gefiltert wird auf dem Server beim Speichern**, nicht im Editor und nicht beim Rendern – was in der Datenbank liegt, ist schon sicher. Nicht erlaubte Elemente werden ausgepackt, der Text bleibt; `script`, `style`, `iframe` und Verwandte werden samt Inhalt entfernt. Attribute fallen weg außer `href` nach Adressprüfung. Ältere Dokumente behalten Klartextsemantik und werden weiter maskiert.
- **Kopf- und Fußbereich sind Blockdokumente** unter `/admin/partials`, im selben Editor gestaltbar wie eine Seite. Im Template steht keine Marke und keine Navigation mehr; ein neuer Block `menu` zeigt einen Menüplatz. Geteilte Bereiche haben keinen Entwurf und keine Veröffentlichung – gespeichert heißt live – und dürfen keine Hauptüberschrift enthalten, weil jede Seite ihre eigene bringt.
- **Wiederverwendbare Sektionen werden verknüpft, nicht kopiert** (Block `partialref`, Dokumentschema v11): die Seite speichert nur die Adresse, eine Änderung erreicht jede Seite, die sie einbindet. Zyklen werden abgewiesen, Verschachtelung auf drei Ebenen begrenzt, eine entfernte Sektion hinterlässt keine Lücke und der Verweis bleibt gespeichert. Die Abschnittsvorlagen kopieren weiterhin – beide Wege stehen bewusst nebeneinander.
- **Frei wählbare Startseite** unter Einstellungen. Es gibt jetzt eine Quelle dafür – `SiteSettings::homePath()` und `publicPath()`; das Literal `home` kommt im Anwendungscode nicht mehr vor. Die gewählte Seite muss veröffentlicht und nicht im Papierkorb sein, ihre eigene Adresse leitet dauerhaft auf `/`, und solange sie die Wurzel ist, lässt sie sich nicht umbenennen oder löschen.
@@ -58,8 +59,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Geprüft
- 165 PHP-Tests / 660 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- 182 PHP-Tests / 685 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
- Formatierter Text geprüft: **dreizehn Fälle** durch den Filter, darunter erlaubte Auszeichnung und Listen, ein `script` samt Inhalt entfernt und der Text danach erhalten, Ereignisattribut entfernt, `class`/`style` entfernt und das Element ausgepackt, `javascript:`-Link zu Text geworden, Bild mit `onerror` entfernt, `iframe` entfernt, unausgeglichene Auszeichnung geschlossen, lokaler Link erhalten, externer Link mit `rel="noopener"`, Klartext maskiert und Umbruch zu `<br>`. Dazu: gefiltert wird beim Speichern (die Datenbank enthält schon die sichere Fassung), ein nach dem Filtern leerer Textblock wird abgewiesen ohne zu schreiben, ab v12 erscheint Auszeichnung als Auszeichnung in einem `div`, vor v12 maskiert in einem `p`, und in einem geteilten Bereich gilt derselbe Filter.
- Bereiche geprüft: die eingesetzten Kopf- und Fußbereiche erscheinen als gewöhnliche Blöcke, der Navigationsblock zeigt das Menü, und eine Änderung am Kopfbereich erreicht beide Seiten gleichzeitig. Ein leerer Bereich rendert nichts statt einer kaputten Hülle. Verknüpfte Sektion geprüft: auf zwei Seiten sichtbar, die Seitendokumente enthalten nur die Adresse, eine Änderung wirkt an beiden Stellen, `usedBy` nennt beide Seiten, und nach dem Entfernen bleiben die Seiten heil und der Verweis erhalten. Abgewiesen werden: Hauptüberschrift in einem geteilten Bereich, Sektion mit Selbstbezug, zweiter Kopfbereich und das Löschen von Kopf- oder Fußbereich.
- Startseite geprüft: eine andere veröffentlichte Seite wird zur Wurzel, antwortet unter `/`, ihre eigene Adresse leitet mit 301 dorthin, die bisherige Wurzel wird eine gewöhnliche Seite und ist wieder verschiebbar. Sitemap, strukturierte Daten und Menüauflösung folgen derselben Entscheidung. Solange eine Seite die Wurzel ist, werden Umbenennen und Papierkorb abgewiesen. Drei ungültige Wurzelangaben (nicht vorhanden, leer, Großbuchstaben) ändern nichts.
- **Öffentliche Browserprüfung wieder grün**, inklusive der vier maschinenlesbaren Adressen über HTTP mit richtigem Inhaltstyp. Sechs Seiten-/Breitenkombinationen ohne Code-Verstoß; die redaktionelle Feststellung zu `/grundlagen` wird ausgegeben.
@@ -101,7 +103,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): Rich Text mit HTML-Ansicht in Textelementen, eigenes CSS und eigenes Skript je Element, und die Neugestaltung der Verwaltung. Die Reihenfolge und die Sicherheitsfolgen des Skriptfelds stehen dort.
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): eigenes CSS und eigenes Skript je Element, und die Neugestaltung der Verwaltung. Die Sicherheitsfolgen des Skriptfelds stehen dort.
- Formatierter Text gilt nur für Textblöcke. Überschrift, Button, Zitat und die Titel von Karten, Tabs und Akkordeon bleiben Klartext; Auszeichnung in einer Überschrift bringt mehr Ärger als Nutzen. Die Werkzeugleiste ist nicht mit einem Screenreader geprüft.
- Offen: der Papierkorb wird nicht automatisch geleert, es gibt keine Aufbewahrungsfrist, und Weiterleitungen aus einem Verschiebevorgang lassen sich nur einzeln in den Einstellungen entfernen.
- Weiterhin keine Erprobung mit einem Screenreader. Bei der Direktbearbeitung wiegt das: dass der Fokus sinnvoll wandert und der Zustandswechsel angekündigt wird, ist durch axe nicht abgedeckt.
- Gestaltungsvorlagen lassen sich in der Oberfläche nur anlegen und anwenden, nicht umbenennen; das Entfernen geht bisher nur über die Schnittstelle. Es gibt keine Vorschau einer Vorlage.
@@ -121,7 +124,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
## Nächster konkreter Schritt
Paket G2 aus `docs/AUSBAUPLAN.md`: Rich Text mit HTML-Ansicht, eigenes CSS und Skript je Element, danach die Neugestaltung der Verwaltung. Dazu den Editor mit einem Screenreader prüfen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
Paket G2 aus `docs/AUSBAUPLAN.md`: eigenes CSS und Skript je Element, danach die Neugestaltung der Verwaltung. Dazu den Editor mit einem Screenreader prüfen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
+15
View File
@@ -72,3 +72,18 @@
#preset-dialog .field-error{margin:10px 0 0;font-size:12px;color:#8b2f22;font-weight:600}
.page-card.depth-1{margin-left:28px}.page-card.depth-2{margin-left:56px}.page-card.depth-3{margin-left:84px}
/* Formatted text: toolbar, editing surface and HTML view. */
.rich-text{border:1px solid var(--line);border-radius:8px;background:#fff;overflow:hidden}
.rt-bar{display:flex;flex-wrap:wrap;gap:2px;padding:5px;border-bottom:1px solid var(--line);background:#fafbf8}
.rt-button{min-width:30px;min-height:30px;padding:2px 7px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--ink);font-size:12px;line-height:1}
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
.rt-link input{font-size:11px;min-height:30px}
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
.rt-surface a{color:#a4510f}
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
+2 -2
View File
@@ -102,7 +102,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
@@ -119,7 +119,7 @@ export function effectiveDesign(block, device, key) {
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
+25 -4
View File
@@ -1,6 +1,7 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
@@ -175,7 +176,23 @@ function renderProperties() {
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
return;
}
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
if (block.type === 'text') {
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
const rich = createRichText(block.props.text, markup => {
history.change(doc => {
upgradeDocument(doc, 12);
const current = locate(doc.blocks, block.id)?.block;
if (current) current.props.text = markup;
}, `rich-${block.id}`);
controls(); schedulePreview();
}, { label: 'Text' });
const label = document.createElement('label'); label.textContent = 'Text';
const help = document.createElement('p'); help.className = 'field-help';
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
panel.append(label, rich.element, help);
}
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
if (INLINE_TEXT[block.type]) {
const inline = document.createElement('button');
inline.className = 'secondary full-width';
@@ -369,7 +386,9 @@ function startInlineEdit(blockId) {
const target = inlineTarget(blockId);
if (!target || busy) return;
const { rule, element } = target;
element.setAttribute('contenteditable', 'plaintext-only');
// A v12 text block keeps its markup while editing in place; everything else stays plain.
const formatted = 'text' === target.block.type && (history.document.schemaVersion ?? 1) >= 12;
element.setAttribute('contenteditable', formatted ? 'true' : 'plaintext-only');
element.spellcheck = true;
element.classList.add('editor-inline');
element.focus();
@@ -382,10 +401,12 @@ function startInlineEdit(blockId) {
selection.addRange(range);
const onInput = () => {
const value = element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
const value = formatted
? element.innerHTML
: element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
history.change(doc => {
const current = locate(doc.blocks, blockId)?.block;
if (current) current.props[rule.prop] = rule.multiline ? value : value.replace(/\s*\n+\s*/g, ' ');
if (current) current.props[rule.prop] = (rule.multiline || formatted) ? value : value.replace(/\s*\n+\s*/g, ' ');
}, `inline-${blockId}-${rule.prop}`);
controls();
};
+142
View File
@@ -0,0 +1,142 @@
/**
* Formatted text for text blocks.
*
* Two views on the same value: a formatting view with a toolbar and an HTML view with the markup.
* Whatever is typed here is a *proposal* – the server filters it against its allowlist when it is
* stored, so this file never decides what is allowed. It only offers what the allowlist accepts.
*
* execCommand is deprecated but is still the only thing every browser implements for this job, and
* the alternative would be a selection engine of our own. The formatting it produces is normalised
* on the server anyway, so a browser quirk cannot store something unexpected.
*/
/** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */
export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a'];
const COMMANDS = [
{ command: 'bold', label: 'B', title: 'Fett (Strg+B)', style: 'font-weight:700' },
{ command: 'italic', label: 'I', title: 'Kursiv (Strg+I)', style: 'font-style:italic' },
{ command: 'underline', label: 'U', title: 'Unterstrichen (Strg+U)', style: 'text-decoration:underline' },
{ command: 'strikeThrough', label: 'S', title: 'Durchgestrichen', style: 'text-decoration:line-through' },
{ command: 'insertUnorderedList', label: '•', title: 'Liste' },
{ command: 'insertOrderedList', label: '1.', title: 'Nummerierte Liste' },
{ command: 'removeFormat', label: '⌫', title: 'Formatierung entfernen' },
];
function button(label, title, onClick, className = 'rt-button') {
const node = document.createElement('button');
node.type = 'button';
node.className = className;
node.textContent = label;
node.title = title;
node.setAttribute('aria-label', title);
node.addEventListener('mousedown', event => event.preventDefault()); // keep the selection
node.addEventListener('click', onClick);
return node;
}
/**
* Builds the control. `apply` is called with the markup whenever it changes; the caller decides
* what to do with it (history entry, preview refresh).
*/
export function createRichText(value, apply, { label = 'Text' } = {}) {
const wrap = document.createElement('div');
wrap.className = 'rich-text';
const bar = document.createElement('div');
bar.className = 'rt-bar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', `${label} formatieren`);
const surface = document.createElement('div');
surface.className = 'rt-surface';
surface.contentEditable = 'true';
surface.spellcheck = true;
surface.setAttribute('role', 'textbox');
surface.setAttribute('aria-multiline', 'true');
surface.setAttribute('aria-label', label);
surface.innerHTML = value ?? '';
const source = document.createElement('textarea');
source.className = 'rt-source';
source.hidden = true;
source.setAttribute('aria-label', `${label} als HTML`);
source.value = value ?? '';
const push = markup => { source.value = markup; apply(markup); };
for (const entry of COMMANDS) {
const node = button(entry.label, entry.title, () => {
surface.focus();
document.execCommand(entry.command);
push(surface.innerHTML);
});
if (entry.style) node.style.cssText = entry.style;
bar.append(node);
}
// Link insertion without a browser prompt: a field appears in the bar and takes the target.
const linkRow = document.createElement('div');
linkRow.className = 'rt-link';
linkRow.hidden = true;
const linkInput = document.createElement('input');
linkInput.type = 'text';
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
linkInput.setAttribute('aria-label', 'Linkziel');
let savedRange = null;
const confirmLink = () => {
const target = linkInput.value.trim();
linkRow.hidden = true;
surface.focus();
if (savedRange) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(savedRange);
}
if (target) document.execCommand('createLink', false, target);
else document.execCommand('unlink');
push(surface.innerHTML);
linkInput.value = '';
};
linkInput.addEventListener('keydown', event => {
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
});
linkRow.append(linkInput, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
const selection = window.getSelection();
savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
linkRow.hidden = false;
linkInput.focus();
}));
const toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
const toSource = source.hidden;
if (toSource) {
source.value = surface.innerHTML;
} else {
surface.innerHTML = source.value;
apply(source.value);
}
source.hidden = !toSource;
surface.hidden = toSource;
toggle.setAttribute('aria-pressed', String(toSource));
(toSource ? source : surface).focus();
}, 'rt-button rt-toggle');
toggle.setAttribute('aria-pressed', 'false');
bar.append(toggle);
surface.addEventListener('input', () => push(surface.innerHTML));
// Paste as plain text: pasted markup would be stripped on the server anyway, and pasting a
// whole web page into a text block is never what someone means.
surface.addEventListener('paste', event => {
event.preventDefault();
const text = event.clipboardData?.getData('text/plain') ?? '';
document.execCommand('insertText', false, text);
});
source.addEventListener('change', () => { surface.innerHTML = source.value; apply(source.value); });
wrap.append(bar, linkRow, surface, source);
return { element: wrap, focus: () => surface.focus() };
}
+4 -2
View File
@@ -32,7 +32,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -91,7 +91,9 @@ final class DocumentValidator
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow'], true), 'Invalid design token.');
}
if (in_array($type, ['heading', 'text', 'button'], true)) {
$this->text($props['text'] ?? null, 'text' === $type ? 10000 : 300);
// From v12 a text block may carry the markup RichText allows, which needs more room.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300;
$this->text($props['text'] ?? null, $limit);
}
if ('heading' === $type) {
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
+5 -1
View File
@@ -12,7 +12,7 @@ final class PageRepository
public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege'];
public const MAX_DEPTH = 4;
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings)
public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings, private RichText $richText = new RichText())
{
}
@@ -30,6 +30,7 @@ final class PageRepository
if (!in_array($type, ['page', 'post'], true)) {
throw new InvalidPage('Unbekannter Inhaltstyp.');
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
@@ -304,6 +305,9 @@ final class PageRepository
public function saveDraft(string $pageId, array $document, int $expectedVersion): string
{
// Sanitising happens before validating and before storing, so the database never holds
// markup the allowlist would reject and no renderer has to remember to filter.
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document);
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
+3 -1
View File
@@ -23,7 +23,7 @@ final class PartialRepository
/** A linked section may itself link one, but not deeper – and never in a circle. */
public const MAX_NESTING = 3;
public function __construct(private Connection $db, private DocumentValidator $validator)
public function __construct(private Connection $db, private DocumentValidator $validator, private RichText $richText = new RichText())
{
}
@@ -79,6 +79,7 @@ final class PartialRepository
if ('section' !== $kind && null !== $this->findByKind($kind, 'default', $locale)) {
throw new InvalidPage(sprintf('Es gibt bereits einen %s.', self::KINDS[$kind]));
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
if (null !== $this->findByKind($kind, $slug, $locale)) {
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
@@ -96,6 +97,7 @@ final class PartialRepository
public function save(string $id, array $document, int $expectedVersion): void
{
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
// A section must not end up containing itself, directly or through another section.
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Content;
/**
* The allowlist for formatted text.
*
* Text blocks may carry a little markup from document schema v12 on. The decision what is allowed
* is made **here, on the server, when the text is stored** – never in the editor and never at
* render time. Two reasons: whatever reaches the database is already safe, so no renderer can
* forget to filter, and a manipulated request cannot store something the editor would not offer.
*
* Everything outside the list is unwrapped, not deleted: the text survives, only the markup goes.
* A pasted Word document therefore loses its styling instead of its content.
*/
final class RichText
{
/** Tags that may stay, each with the attributes it may keep. */
private const ALLOWED = [
'p' => [], 'br' => [],
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
'ul' => [], 'ol' => [], 'li' => [],
'a' => ['href'],
];
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
public const MAX_LENGTH = 20000;
/** True when a document version stores formatted text instead of plain text. */
public static function supports(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 12;
}
/**
* Returns the text with only allowed markup left. The result is safe to print unescaped.
*/
public function sanitise(string $html): string
{
$html = trim($html);
if ('' === $html) {
return '';
}
if (!str_contains($html, '<')) {
// Plain text: escape it and keep line breaks, so typing without formatting still works.
return nl2br(htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false);
}
$document = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
// The wrapper gives the fragment a single root; the meta charset keeps UTF-8 intact.
$document->loadHTML(
'<?xml encoding="UTF-8"><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body><div id="kant-root">'
.mb_substr($html, 0, self::MAX_LENGTH).'</div></body></html>',
LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING,
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $document->getElementById('kant-root');
if (null === $root) {
return '';
}
$this->clean($root);
// Both branches end up with the same representation of a line break. Otherwise a text
// that happens to contain "<" would keep literal newlines while everything else uses
// <br>, and the two would drift apart the moment the CSS changes.
$this->breakLines($root, $document);
$out = '';
foreach ($root->childNodes as $child) {
$out .= $document->saveHTML($child);
}
return trim($out);
}
/** Walks the tree once, bottom up, so unwrapping cannot skip a child. */
private function clean(\DOMNode $node): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof \DOMText) {
continue;
}
if ($child instanceof \DOMComment) {
$child->parentNode?->removeChild($child);
continue;
}
if (!$child instanceof \DOMElement) {
// Processing instructions, CDATA and anything else has no place in a text block.
$child->parentNode?->removeChild($child);
continue;
}
$name = strtolower($child->nodeName);
if (in_array($name, self::DROP_ENTIRELY, true)) {
$child->parentNode?->removeChild($child);
continue;
}
$this->clean($child);
if (!isset(self::ALLOWED[$name])) {
$this->unwrap($child);
continue;
}
foreach (iterator_to_array($child->attributes ?? []) as $attribute) {
if (!in_array(strtolower($attribute->nodeName), self::ALLOWED[$name], true)) {
$child->removeAttribute($attribute->nodeName);
}
}
if ('a' === $name) {
$href = trim($child->getAttribute('href'));
if (!$this->safeHref($href)) {
// An unusable target makes the link a span of text, not a dead link.
$this->unwrap($child);
continue;
}
$child->setAttribute('href', $href);
if (str_starts_with($href, 'https://')) {
$child->setAttribute('rel', 'noopener');
}
}
}
}
/** Turns newlines inside text into <br>, except inside code where they are meaningful. */
private function breakLines(\DOMNode $node, \DOMDocument $document): void
{
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof \DOMElement) {
if ('code' !== strtolower($child->nodeName)) {
$this->breakLines($child, $document);
}
continue;
}
if (!$child instanceof \DOMText || !str_contains($child->nodeValue ?? '', "\n")) {
continue;
}
$parts = preg_split('/\R/', $child->nodeValue);
$parent = $child->parentNode;
foreach ($parts as $index => $part) {
if ($index > 0) {
$parent?->insertBefore($document->createElement('br'), $child);
}
if ('' !== $part) {
$parent?->insertBefore($document->createTextNode($part), $child);
}
}
$parent?->removeChild($child);
}
}
/** Replaces an element with its children, so the text stays and only the tag disappears. */
private function unwrap(\DOMElement $element): void
{
$parent = $element->parentNode;
if (null === $parent) {
return;
}
foreach (iterator_to_array($element->childNodes) as $child) {
$parent->insertBefore($child, $element);
}
$parent->removeChild($element);
}
private function safeHref(string $href): bool
{
if ('' === $href || strlen($href) > 2048 || preg_match('/[\x00-\x20\\\\]/', $href)) {
return false;
}
return (str_starts_with($href, '/') && !str_starts_with($href, '//'))
|| 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $href)
|| (str_starts_with($href, 'https://') && false !== filter_var($href, FILTER_VALIDATE_URL))
|| 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $href)
|| 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $href);
}
/**
* Sanitises every rich text field of a document. Called before storing, so the database never
* holds markup the allowlist would reject.
*/
public function normaliseDocument(array $document): array
{
if (!self::supports($document['schemaVersion'] ?? null)) {
return $document;
}
$walk = function (array $blocks) use (&$walk): array {
foreach ($blocks as $index => $block) {
if ('text' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$index]['children'] = $walk($block['children']);
}
}
return $blocks;
};
$document['blocks'] = $walk($document['blocks'] ?? []);
return $document;
}
}
+2 -2
View File
@@ -17,7 +17,7 @@
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{% if areas.header|default(null) %}
<header class="site-header">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
@@ -43,7 +43,7 @@
</main>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true} only %}{% endfor %}
</footer>
{% endif %}
</body>
+15 -9
View File
@@ -10,13 +10,19 @@
{% set tag = type == 'section' ? 'section' : 'div' %}
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
{% if type == 'section' %}</div>{% endif %}
</{{ tag }}>
{% elseif type == 'heading' %}
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{{ props.text }}</h{{ props.level }}>
{% elseif type == 'text' %}
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
{% if richText|default(false) %}
{# Already filtered by RichText when it was stored, so printing it unescaped is safe.
A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one. #}
<div id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text|raw }}</div>
{% else %}
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
{% endif %}
{% elseif type == 'button' %}
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
{% elseif type == 'image' %}
@@ -39,7 +45,7 @@
</figure>
{% elseif type == 'logos' %}
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}</li>{% endfor %}
{% for child in block.children|default([]) %}<li>{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}</li>{% endfor %}
</ul>
{% elseif type == 'cta' %}
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
@@ -72,7 +78,7 @@
{% set section = sections[props.partial]|default(null) %}
{% if section %}
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
</div>
{% elseif editing|default(false) %}
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
@@ -101,22 +107,22 @@
<ul class="tabs-nav">
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
</ul>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
</div>
{% elseif type == 'tabitem' %}
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
<h3 class="tabs-heading">{{ props.title }}</h3>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
</section>
{% elseif type == 'accordion' %}
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
</div>
{% elseif type == 'accordionitem' %}
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
<summary>{{ props.title }}</summary>
<div class="accordion-body">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
</div>
</details>
{% elseif type == 'postlist' %}
@@ -125,7 +131,7 @@
</div>
{% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false)} only %}{% endfor %}
</div>
{% else %}
{#
+3 -3
View File
@@ -36,7 +36,7 @@
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
@@ -48,11 +48,11 @@
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p>
{% endif %}
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12} only %}{% endfor %}
</main>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({})} only %}{% endfor %}
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12} only %}{% endfor %}
</footer>
{% endif %}
</body>
+103
View File
@@ -10,6 +10,7 @@ use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\RichText;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidDocument;
@@ -1545,6 +1546,108 @@ final class CmsTest extends TestCase
$this->partials->delete($this->partials->findByKind('header')['id']);
}
/**
* @return iterable<string, array{string, string}>
*/
public static function richTextCases(): iterable
{
yield 'keeps allowed formatting' => ['Ganz <strong>fett</strong> und <em>kursiv</em>.', 'Ganz <strong>fett</strong> und <em>kursiv</em>.'];
yield 'keeps lists' => ['<ul><li>eins</li><li>zwei</li></ul>', '<ul><li>eins</li><li>zwei</li></ul>'];
yield 'drops a script and keeps the text after it' => ['<script>alert(1)</script>Text danach', 'Text danach'];
yield 'drops an event handler' => ['<p onclick="steal()">Mit Ereignis</p>', '<p>Mit Ereignis</p>'];
yield 'drops style and class' => ['<div class="x" style="position:fixed">Verpackt</div>', 'Verpackt'];
yield 'turns an unsafe link into text' => ['<a href="javascript:alert(1)">Boese</a>', 'Boese'];
yield 'removes an image with a handler' => ['<img src=x onerror=alert(1)>', ''];
yield 'turns a newline into a break next to markup' => ["<b>Eins</b>\nZwei", '<b>Eins</b><br>Zwei'];
yield 'removes an iframe' => ['<iframe src="https://evil.example"></iframe>ok', 'ok'];
yield 'closes unbalanced markup' => ['<strong>unge<em>schlossen', '<strong>unge<em>schlossen</em></strong>'];
yield 'keeps a local link' => ['<a href="/kontakt">hier</a>', '<a href="/kontakt">hier</a>'];
yield 'marks an external link' => ['<a href="https://example.com">dort</a>', '<a href="https://example.com" rel="noopener">dort</a>'];
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins &lt;3<br>Zwei'];
}
#[DataProvider('richTextCases')]
public function testTheAllowlistKeepsFormattingAndRemovesEverythingElse(string $input, string $expected): void
{
self::assertSame($expected, (new RichText())->sanitise($input));
}
public function testFilteringHappensWhenTheDocumentIsStored(): void
{
$document = DemoContent::starter('Formatiert');
$document['schemaVersion'] = 12;
$document['blocks'][1]['children'][1]['props']['text'] = '<b>Fett</b><script>alert(1)</script> und <span style="position:fixed">verpackt</span>';
$revision = $this->pages->saveDraft('page-1', $document, 0);
// The filtering happens on the way in, so the database already holds the safe version and
// no renderer has to remember to escape anything.
$stored = json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true);
self::assertSame('<b>Fett</b> und verpackt', $stored['blocks'][1]['children'][1]['props']['text']);
// A text that consists only of disallowed markup ends up empty, and an empty text block
// is refused - the same rule as for a text block somebody clears by hand.
$document['blocks'][1]['children'][1]['props']['text'] = '<img src=x onerror=alert(1)>';
try {
$this->pages->saveDraft('page-1', $document, 1);
self::fail('Expected an emptied text block to be refused.');
} catch (InvalidDocument) {
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testFormattedTextIsPrintedAsMarkupFromVersionTwelveOnAndEscapedBefore(): void
{
$markup = 'Ein <strong>starker</strong> Satz mit <a href="/kontakt">Link</a>.';
// v12: the stored markup reaches the page as markup.
$twelve = DemoContent::starter('Formatiert');
$twelve['schemaVersion'] = 12;
$twelve['blocks'][1]['children'][1]['props']['text'] = $markup;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $twelve, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<strong>starker</strong>', $html);
self::assertStringContainsString('<a href="/kontakt">Link</a>', $html);
// A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one.
self::assertStringContainsString('<div id="start-text" class="block-text">', $html);
// An older document keeps plain text semantics, so nothing there is ever printed as markup.
$old = DemoContent::starter('Einfach');
$old['schemaVersion'] = 3;
$old['blocks'][1]['children'][1]['props']['text'] = $markup;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3);
$plain = $this->request('/')->getContent();
self::assertStringContainsString('&lt;strong&gt;starker&lt;/strong&gt;', $plain);
self::assertStringNotContainsString('<strong>starker</strong>', $plain);
self::assertStringContainsString('<p id="start-text" class="block-text">', $plain);
}
public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void
{
$header = $this->partials->findByKind('header');
$document = $header['document'];
$document['schemaVersion'] = 12;
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] =
'<strong>kant</strong> cms<script>alert(1)</script>';
$this->partials->save($header['id'], $document, $header['version']);
$stored = $this->partials->findByKind('header')['document'];
self::assertSame('<strong>kant</strong> cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<strong>kant</strong> cms', $html);
self::assertStringNotContainsString('alert(1)', $html);
}
public function testOversizedFormattedTextIsRefused(): void
{
$document = DemoContent::starter('Zu lang');
$document['schemaVersion'] = 12;
$document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1);
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];