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 = [];