From 790f42213c5d3d4f69c2e5e8b947ebe724cbf7ae Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sat, 12 Sep 2026 16:54:35 +0200 Subject: [PATCH] Formatierter Text mit Werkzeugleiste und HTML-Ansicht (Schema v12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 --- docs/ARCHITECTURE.md | 16 +++ docs/AUSBAUPLAN.md | 4 +- docs/QUALITY.md | 10 ++ docs/STATUS.md | 11 +- public/assets/admin.css | 15 +++ public/assets/editor-model.js | 4 +- public/assets/editor.js | 29 ++++- public/assets/rich-text.js | 142 +++++++++++++++++++++ src/Content/DocumentValidator.php | 6 +- src/Content/PageRepository.php | 6 +- src/Content/PartialRepository.php | 4 +- src/Content/RichText.php | 203 ++++++++++++++++++++++++++++++ templates/archive.html.twig | 4 +- templates/blocks/block.html.twig | 24 ++-- templates/page.html.twig | 6 +- tests/CmsTest.php | 103 +++++++++++++++ 16 files changed, 557 insertions(+), 30 deletions(-) create mode 100644 public/assets/rich-text.js create mode 100644 src/Content/RichText.php diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d018f61..0d028e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `
`. Vorher behielt ein Text, der zufällig ein `<` enthielt, echte Zeilenumbrüche, während alles andere `
` 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. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 8a58395..cac2a25 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -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. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 2c1712e..ed17b2f 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -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: `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 `` verschwindet vollständig, ein `ok', 'ok']; + yield 'closes unbalanced markup' => ['ungeschlossen', 'ungeschlossen']; + yield 'keeps a local link' => ['hier', 'hier']; + yield 'marks an external link' => ['dort', 'dort']; + yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3
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'] = 'Fett und verpackt'; + $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('Fett 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'] = ''; + 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 starker Satz mit Link.'; + + // 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('starker', $html); + self::assertStringContainsString('Link', $html); + // A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one. + self::assertStringContainsString('
', $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('<strong>starker</strong>', $plain); + self::assertStringNotContainsString('starker', $plain); + self::assertStringContainsString('

', $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'] = + 'kant cms'; + $this->partials->save($header['id'], $document, $header['version']); + + $stored = $this->partials->findByKind('header')['document']; + self::assertSame('kant 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('kant 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 = [];