diff --git a/README.md b/README.md index 50048a1..8107576 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`. +## Eigenes HTML und eingebettete Seiten (ab 1.20.0) + +Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `', /Eigenes HTML führt keine Skripte aus/], + ['', /Externer Inhalt/], + ]) { + await page.getByLabel('HTML', { exact: true }).fill(markup); + await page.locator('#preview-status').click(); + await page.locator('#save').click(); + // Abgewiesen wird schon die Vorschau, und das Speichern meldet es in der Leiste über dem + // Editor - die Seite bleibt, wie sie veröffentlicht ist. + await page.locator('#message').filter({ hasText: meldung }).waitFor(); + } + await page.getByLabel('HTML', { exact: true }).fill('

Wieder gut.

'); + await page.locator('#preview-status').click(); + await page.locator('#save').click(); + await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); + await page.screenshot({ path: 'reports/editor/own-html.png', fullPage: true }); + checks.push({ name: 'own HTML block: inserted, previewed, published unchanged, script and iframe refused', passed: true }); + // Every management screen, not just the page list: they carry tables, disclosures and now the // media grid, and none of that was ever checked automatically before. for (const [name, path] of [ diff --git a/src/Content/CustomCode.php b/src/Content/CustomCode.php index 5031ff3..00e3dbe 100644 --- a/src/Content/CustomCode.php +++ b/src/Content/CustomCode.php @@ -5,15 +5,23 @@ namespace App\Content; use App\Domain\InvalidDocument; /** - * Own CSS and own script per element (document schema v13). + * Own CSS and own script per element (document schema v13), and the raw HTML of the „Eigenes + * HTML“ block (schema v24). * - * The two are not comparable in what they can do, so they are not treated alike. + * The three are not comparable in what they can do, so they are not treated alike. * * **CSS** is parsed and written out again by this class, scoped to the block. A declaration list * and nested rules starting with `&` are accepted; anything else is refused. Because the output is * re-serialised, a stray `}` cannot escape the block, and `@import` or a `javascript:` URL never * gets through. Everyone with the editor role may use it. * + * **HTML** is markup that goes onto the page unchanged. It cannot be made harmless either - it + * can load images from anywhere and point a form at a foreign address - so it follows the same + * rule as a script: administrators only, checked against what is already stored. Two tags are + * refused rather than silently swallowed by the content security policy: `' => 'Skript', + '' => 'Rahmen', + '< script >alert(1)' => 'Skript mit Leerzeichen', + ] as $markup => $fall) { + $attempt = $document; + $attempt['blocks'][0]['children'][2]['props']['html'] = $markup; + try { + $this->pages->saveDraft($id, $attempt, $this->pages->findForEditor($id)['version']); + self::fail(sprintf('%s hätte abgewiesen werden müssen.', $fall)); + } catch (InvalidDocument) { + } + } + + // Vor v24 gibt es den Baustein nicht. + $alt = $document; + $alt['schemaVersion'] = 23; + try { + $this->pages->saveDraft($id, $alt, $this->pages->findForEditor($id)['version']); + self::fail('Eigenes HTML vor Schema v24 hätte abgewiesen werden müssen.'); + } catch (InvalidDocument) { + } + } + + public function testOwnHtmlChangesAreComparedAgainstWhatIsStored(): void + { + // Dieselbe Rolle wie beim eigenen Skript: ändern darf es nur ein Administrator, und + // geprüft wird gegen den gespeicherten Stand - ein Redakteur kann die Seite also weiter + // speichern, ohne das HTML anfassen zu können. + $code = new CustomCode(); + $stored = DemoContent::starter('Mit HTML'); + $stored['schemaVersion'] = 24; + $stored['blocks'][0]['children'][] = ['id' => 'roh', 'type' => 'html', 'props' => ['html' => '

Original

']]; + + $unchanged = $stored; + $unchanged['title'] = 'Anderer Titel'; + $code->assertHtmlUnchanged($unchanged, $stored); + + foreach (['geändert' => '

Fremd

', 'geleert' => ''] as $fall => $markup) { + $attempt = $stored; + $attempt['blocks'][0]['children'][2]['props']['html'] = $markup; + try { + $code->assertHtmlUnchanged($attempt, $stored); + self::fail(sprintf('HTML %s: hätte abgewiesen werden müssen.', $fall)); + } catch (InvalidDocument) { + } + } + + // Und neu hinzufügen ebenso wenig. + $ohne = DemoContent::starter('Ohne HTML'); + $ohne['schemaVersion'] = 24; + $this->expectException(InvalidDocument::class); + $code->assertHtmlUnchanged($stored, $ohne); + } + /** Turns on the consent question and publishes a privacy page it can point to. */ private function askConsent(array $groups = ['external']): void { diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index 87ab3c0..153c45f 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -102,6 +102,17 @@ test('v1 to v2 migration preserves all content and IDs, is idempotent and reject assert.equal(original.schemaVersion, 1); }); +test('the own-HTML block starts empty, is offered in the toolbox and lifts the document to v24', () => { + // Eingefuegt wird er leer: bis jemand Code eintraegt, steht nichts auf der Seite. + const history = new EditorHistory(fixture()); + const gruppe = CATEGORIES.find(category => category.types.includes('html')); + assert.equal(gruppe?.name, 'Seite und Daten'); + assert.equal(blockLabels.html, 'Eigenes HTML'); + const id = history.change(doc => insertBlock(doc, 'html', 's1')); + assert.equal(locate(history.document.blocks, id).block.props.html, ''); + assert.equal(history.document.schemaVersion, 24); +}); + test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => { const history = new EditorHistory(fixture()); history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));