Add an own-HTML block, and build the DEEG offers page on the embed block

Umut asked for a block that takes custom HTML, and for an iframe on a new
DEEG offers page. Only one of those was missing.

New core block "Eigenes HTML" (document schema v24): one property, the
markup, up to 20.000 characters, written to the page unchanged. Changing it
is reserved for administrators and compared against what is stored - on
saving, on saving an area, and on restoring an older revision - exactly like
an own script. <script> and <iframe> are refused with the way to the right
place instead of being swallowed by the content security policy: scripts
belong in the script field, frames in the "Externer Inhalt" block, because
only that one asks for consent and puts the host into frame-src (ADR 106).

DEEG: /angebote existed as a draft with placeholder text. It is now built
like the legal pages - small caps line, large light heading, lead on the
tinted band - with the IS24 result list below it and a line pointing to the
portal and to the contact form. Navigation and the offers band on the home
page point there. The consent question is switched on, so the frame loads
only after the visitor agrees, and both the banner and the placeholder carry
the site's own shapes and colours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 08:27:43 +02:00
1 parent 6b1aa50d02
commit 59a40157a4
15 files changed
+465 -8

No files matched your search

+71 -2
View File
@@ -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: `<script>`, which
* belongs in the script field, and `<iframe>`, which belongs in the „Externer Inhalt“ block,
* because only that one asks for consent and puts the host into `frame-src`.
*
* **Script** is arbitrary JavaScript on the published site. There is no way to make that harmless:
* whoever may set it can run code in the browser of every visitor and every administrator. It is
* therefore reserved for administrators, checked on the way in by comparing against what is
@@ -24,6 +32,7 @@ final class CustomCode
{
public const MAX_CSS = 4000;
public const MAX_SCRIPT = 8000;
public const MAX_HTML = 20000;
public static function supports(mixed $schemaVersion): bool
{
@@ -62,6 +71,66 @@ final class CustomCode
return $script;
}
/**
* Checks the markup of an „Eigenes HTML“ block and returns it unchanged.
*
* @throws InvalidDocument
*/
public function checkHtml(string $html): string
{
if (mb_strlen($html) > self::MAX_HTML) {
throw new InvalidDocument(sprintf('Eigenes HTML ist auf %d Zeichen begrenzt.', self::MAX_HTML));
}
// Beide Tags wuerden von der Sicherheitsrichtlinie der Seite ohnehin nicht ausgefuehrt
// bzw. nicht geladen. Eine Meldung mit dem richtigen Weg ist besser als ein Baustein,
// der etwas annimmt und nachher nichts tut.
if (1 === preg_match('/<\s*script/i', $html)) {
throw new InvalidDocument('Eigenes HTML führt keine Skripte aus. Dafür gibt es unter „Erweitert“ das Feld „Eigenes Skript“.');
}
if (1 === preg_match('/<\s*iframe/i', $html)) {
throw new InvalidDocument('Für einen eingebetteten Rahmen gibt es den Baustein „Externer Inhalt“: nur er fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein.');
}
return $html;
}
/**
* The raw HTML of every „Eigenes HTML“ block, keyed by block ID.
*
* @return array<string, string>
*/
public function htmlOf(array $document): array
{
$found = [];
$walk = function (array $blocks) use (&$walk, &$found): void {
foreach ($blocks as $block) {
if ('html' === ($block['type'] ?? '') && is_string($block['props']['html'] ?? null) && is_string($block['id'] ?? null)) {
$found[$block['id']] = $block['props']['html'];
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
/**
* Refuses a write that would add, change or remove raw HTML without the administrator role.
*
* @throws InvalidDocument
*/
public function assertHtmlUnchanged(array $incoming, array $stored): void
{
$before = $this->htmlOf($stored);
$after = $this->htmlOf($incoming);
ksort($before);
ksort($after);
if ($before !== $after) {
throw new InvalidDocument('Eigenes HTML darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
}
}
/**
* Turns own CSS into rules scoped to one block.
*
+11 -2
View File
@@ -38,8 +38,9 @@ final class DocumentValidator
public const V15_TYPES = ['icon', 'progress', 'slider'];
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
public const V23_TYPES = ['social'];
public const V24_TYPES = ['html'];
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
public const SCHEMA_VERSION = 23;
public const SCHEMA_VERSION = 24;
/**
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
@@ -98,7 +99,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -389,6 +390,14 @@ final class DocumentValidator
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
}
if ('html' === $type) {
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
// stillschweigend zu verschwinden. Wer es setzen darf, ist eine Frage der Rolle und
// wird im Controller beantwortet - wie beim eigenen Skript.
$this->require(is_string($props['html'] ?? null), 'Eigenes HTML ist Text.');
$this->code->checkHtml($props['html']);
}
if ('form' === $type) {
// Only the address is content. Fields, recipient and consent text stay in the database,
// so a stored document can never redirect a submission or change what is asked.
+2
View File
@@ -292,6 +292,7 @@ final class EditorController extends AbstractController
// ueber den Umweg einer alten Fassung.
if (!$this->isGranted('ROLE_ADMIN')) {
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
$this->code->assertHtmlUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
}
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
} catch (InvalidPage|StaleWrite $e) {
@@ -488,6 +489,7 @@ final class EditorController extends AbstractController
}
if (!$this->isGranted('ROLE_ADMIN')) {
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
$this->code->assertHtmlUnchanged($body['document'], $page['document']);
}
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
// Die gespeicherte Fassung, nicht die geschickte: Rich Text wird beim Speichern
+1
View File
@@ -145,6 +145,7 @@ final class PartialController extends AbstractController
}
if (!$this->isGranted('ROLE_ADMIN')) {
$this->code->assertScriptsUnchanged($document, $partial['document']);
$this->code->assertHtmlUnchanged($document, $partial['document']);
}
$this->partials->save($id, $document, $body['expectedVersion']);
// Die gespeicherte Fassung zurueck - bereinigter Rich Text steht sofort im Editor.