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>
508 lines
34 KiB
PHP
508 lines
34 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidDocument;
|
|
use App\Plugin\PluginRegistry;
|
|
|
|
final class DocumentValidator
|
|
{
|
|
private bool $writing = false;
|
|
|
|
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
|
|
{
|
|
}
|
|
|
|
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
|
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
|
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
|
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
|
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
|
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
|
public const V7_TYPES = ['tabs', 'tabitem'];
|
|
/** Schema v8: a form block references a form by its address; the definition lives in the database. */
|
|
public const V8_TYPES = ['form'];
|
|
/** Schema v9: the breadcrumb trail, built from the page hierarchy rather than from content. */
|
|
public const V9_TYPES = ['breadcrumb'];
|
|
/** Schema v11: a link to a reusable section, resolved when the page is served. */
|
|
public const V11_TYPES = ['partialref', 'menu'];
|
|
/** Schema v14: content from a third party, which stays unloaded until consent is given. */
|
|
public const V14_TYPES = ['embed'];
|
|
/** The sizes an icon can have; each one has a class in site.css. */
|
|
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
|
/** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
|
|
public const V18_TYPES = ['postnav'];
|
|
/** Die Rechtszeile als Baustein: gestaltbar und dort, wo sie hingehoert. */
|
|
public const V19_TYPES = ['legal'];
|
|
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
|
|
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 = 24;
|
|
|
|
/**
|
|
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
|
* reusable section that appears on many pages must not bring one
|
|
* of its own, or every page carrying it would have two.
|
|
* @param bool $writing Beim Schreiben werden die Eigenschaften von Plugin-Bausteinen
|
|
* gegen ihr Schema geprüft. Beim Lesen nicht: ein Plugin, das
|
|
* nach dem Speichern eingeschaltet oder geändert wurde, darf
|
|
* eine gespeicherte Seite nicht unlesbar machen - der Renderer
|
|
* lässt unpassende Eigenschaften ohnehin nicht durch.
|
|
*/
|
|
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
|
|
{
|
|
$this->writing = $writing;
|
|
$schemaVersion = $document['schemaVersion'] ?? null;
|
|
$this->require(is_int($schemaVersion) && $schemaVersion >= 1 && $schemaVersion <= self::SCHEMA_VERSION, '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.');
|
|
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
|
|
$ids = [];
|
|
$headings = 0;
|
|
foreach ($document['blocks'] as $block) {
|
|
$this->block($block, $ids, $headings, 0, $schemaVersion, null);
|
|
}
|
|
$anchors = function (array $blocks) use (&$anchors, &$ids): void {
|
|
foreach ($blocks as $block) {
|
|
if (isset($block['anchor'])) {
|
|
$this->require(!isset($ids[$block['anchor']]) && !in_array($block['anchor'], ['main', 'top', 'admin-main'], true), 'Dieses Sprungziel ist bereits belegt. Bitte einen eindeutigen Namen wählen.');
|
|
$ids[$block['anchor']] = true;
|
|
}
|
|
$anchors($block['children'] ?? []);
|
|
}
|
|
};
|
|
$anchors($document['blocks']);
|
|
$this->require(
|
|
$requireHeading ? 1 === $headings : 0 === $headings,
|
|
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
|
|
);
|
|
}
|
|
|
|
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void
|
|
{
|
|
$this->require(is_array($block) && $depth <= 10 && count($ids) < 300, 'Invalid block or document complexity limit exceeded.');
|
|
$id = $block['id'] ?? null;
|
|
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.');
|
|
$ids[$id] = true;
|
|
$type = $block['type'] ?? null;
|
|
if (isset($block['anchor'])) {
|
|
$this->require($schemaVersion >= 21 && in_array($type, ['section', 'row', 'column'], true)
|
|
&& is_string($block['anchor']) && 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $block['anchor']),
|
|
'Ein Sprungziel braucht einen Bereich und einen Namen wie kontakt (ohne #), Schema v21.');
|
|
}
|
|
// From schema v4 on, a namespaced type belongs to a plugin. The core does not know its
|
|
// property schema, so it keeps the block untouched instead of rejecting or dropping the
|
|
// 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)) || ($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.');
|
|
$this->require('tabitem' !== $type || 'tabs' === $parentType, 'A tab belongs into a tab group.');
|
|
foreach (['css', 'script'] as $own) {
|
|
if (!isset($block[$own])) {
|
|
continue;
|
|
}
|
|
// Own code is checked when it is stored, so a bad value fails with a message instead
|
|
// of disappearing silently at render time. Who may set a script is a question of role
|
|
// and is answered in the controller, not here.
|
|
$this->require(CustomCode::supports($schemaVersion), 'Own code requires schema v13.');
|
|
$this->require(is_string($block[$own]), sprintf('"%s" must be text.', $own));
|
|
'css' === $own ? $this->code->checkCss($block[$own]) : $this->code->checkScript($block[$own]);
|
|
}
|
|
if (isset($block['style'])) {
|
|
// Free styling: any property of StyleSchema, per device, with an optional hover value.
|
|
// The old token based layout.design keeps working unchanged next to it.
|
|
$this->require($schemaVersion >= 10, 'Free block styles require schema v10.');
|
|
$this->styles->validate($block['style']);
|
|
}
|
|
$props = $block['props'] ?? [];
|
|
$layout = $block['layout'] ?? [];
|
|
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
|
// Ein aktives Plugin kennt das Schema seines Bausteins - dann wird es auch geprüft, so
|
|
// streng wie bei einem Kernbaustein. Ohne Plugin bleibt der Erhaltungsvertrag: aufbewahren,
|
|
// nicht prüfen, nicht rendern.
|
|
if ($plugin && $this->writing && null !== $this->plugins && null !== ($definition = $this->plugins->block($type))) {
|
|
$this->plugins->validateProps($type, $props);
|
|
// Ein Container nimmt Kinder auf, ein gewöhnlicher Baustein nicht; eine Liste
|
|
// erlaubter Kinder im Manifest gilt wie ALLOW bei Galerie oder Akkordeon.
|
|
$kinder = is_array($block['children'] ?? null) ? $block['children'] : [];
|
|
$this->require($definition['container'] || [] === $kinder, sprintf('„%s“ nimmt keine Bausteine auf.', $definition['label']));
|
|
if (null !== $definition['children']) {
|
|
foreach ($kinder as $kind) {
|
|
$this->require(in_array($kind['type'] ?? '', $definition['children'], true), sprintf('In „%s“ passt nur: %s.', $definition['label'], implode(', ', $definition['children'])));
|
|
}
|
|
}
|
|
}
|
|
foreach ($layout as $key => $value) {
|
|
if ('visibility' === $key) {
|
|
$this->require($schemaVersion >= 22 && in_array($value, ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'], true), 'Unbekannte Sichtbarkeit oder Dokumentschema vor v22.');
|
|
continue;
|
|
}
|
|
if ('button' === $key) {
|
|
$this->require($schemaVersion >= 21 && 'button' === $type && is_array($value), 'Button-Varianten brauchen einen Button und Schema v21.');
|
|
foreach ($value as $device => $settings) {
|
|
$this->require(isset(StyleSchema::DEVICES[$device]) && is_array($settings), 'Unbekanntes Button-Gerät.');
|
|
foreach ($settings as $setting => $token) {
|
|
$this->require(is_string($token) && isset(ButtonDesign::OPTIONS[$setting][$token]), 'Unbekannte Button-Variante.');
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if ('effects' === $key && $schemaVersion >= 3) {
|
|
$this->require(is_array($value), 'Effects must be an object.');
|
|
$effects = [
|
|
'shadow' => ['none', 'soft', 'strong'],
|
|
'hover' => ['none', 'lift', 'zoom', 'sink', 'glow', 'tilt', 'grow-border'],
|
|
// Entrance animations run when the page loads, not when the element scrolls
|
|
// into view: that would need a script, and a public page runs none.
|
|
'entrance' => ['none', 'fade', 'rise', 'sink', 'left', 'right', 'zoom', 'flip', 'blur'],
|
|
'delay' => ['none', 'short', 'medium', 'long'],
|
|
];
|
|
foreach ($value as $effect => $token) {
|
|
$this->require(isset($effects[$effect]) && in_array($token, $effects[$effect], true), 'Unknown effect.');
|
|
}
|
|
continue;
|
|
}
|
|
if ('design' === $key && $schemaVersion >= 2) {
|
|
$this->design($value, $type);
|
|
continue;
|
|
}
|
|
$this->require(in_array($key, ['tone', 'width'], true), 'Unknown layout setting.');
|
|
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow', 'wide', 'full'], true), 'Invalid design token.');
|
|
}
|
|
if (in_array($type, ['heading', 'text', 'button'], true)) {
|
|
// From v12 a text block may carry the markup RichText allows, which needs more room.
|
|
// From v17 a heading may too - one highlighted word is a design need, not a luxury.
|
|
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
|
|
$this->text($props['text'] ?? null, $limit);
|
|
}
|
|
if (!$plugin && array_key_exists('newTab', $props)) {
|
|
$this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon', 'social'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
|
|
$this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.');
|
|
}
|
|
foreach (($block['style'] ?? []) as $values) {
|
|
if ('none' === ($values['backgroundImage'] ?? null)) {
|
|
$this->require($schemaVersion >= 20, 'Ein entferntes Hintergrundbild braucht Schema v20.');
|
|
}
|
|
}
|
|
if ('heading' === $type) {
|
|
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
|
$headings += 1 === $props['level'] ? 1 : 0;
|
|
}
|
|
if ('postnav' === $type) {
|
|
// Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der
|
|
// Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten.
|
|
$this->text($props['previousLabel'] ?? null, 60);
|
|
$this->text($props['nextLabel'] ?? null, 60);
|
|
}
|
|
if ('postlist' === $type) {
|
|
// The list is filled by the renderer, never by stored content: the block only says
|
|
// how many posts to show and which term to limit them to.
|
|
$this->require(is_int($props['count'] ?? null) && $props['count'] >= 1 && $props['count'] <= 24, 'Die Anzahl der Beiträge liegt zwischen 1 und 24.');
|
|
foreach (['category', 'tag'] as $kind) {
|
|
if (isset($props[$kind]) && '' !== $props[$kind]) {
|
|
$this->require(is_string($props[$kind]) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props[$kind]) && strlen($props[$kind]) <= 80, 'Ungültiger Filter für die Beitragsliste.');
|
|
}
|
|
}
|
|
// Was eine Karte zeigt. Ein Projektraster besteht aus Bild und Titel; Datum, Auszug
|
|
// und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch
|
|
// aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht
|
|
// ändern, wenn niemand etwas einstellt.
|
|
if (isset($props['layout'])) {
|
|
// Drei Darstellungen für dieselben Daten: Raster aus Karten, Liste mit Bild
|
|
// daneben, und eine reine Titelliste. Welche passt, hängt vom Ort ab - ein
|
|
// Projektraster auf der Startseite, eine Liste im Archiv, eine Titelliste in
|
|
// einer Randspalte.
|
|
$this->require($schemaVersion >= 18, 'Die Darstellung der Beitragsliste braucht Schema v18.');
|
|
$this->require(in_array($props['layout'], ['karten', 'liste', 'kompakt', 'wechsel'], true), 'Unbekannte Darstellung der Beitragsliste.');
|
|
}
|
|
if (isset($props['paginate'])) {
|
|
// Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst
|
|
// verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M.
|
|
$this->require($schemaVersion >= 17, 'Das Blättern einer Beitragsliste braucht Schema v17.');
|
|
$this->require(is_bool($props['paginate']), 'Das Blättern ist ja oder nein.');
|
|
}
|
|
foreach (['showMeta', 'showExcerpt', 'showTerms'] as $switch) {
|
|
if (isset($props[$switch])) {
|
|
$this->require($schemaVersion >= 17, 'Die Anzeigeoptionen der Beitragsliste brauchen Schema v17.');
|
|
$this->require(is_bool($props[$switch]), sprintf('"%s" ist ja oder nein.', $switch));
|
|
}
|
|
}
|
|
}
|
|
if ('button' === $type) {
|
|
$this->url($props['href'] ?? null);
|
|
}
|
|
if ('iconcard' === $type) {
|
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
|
$this->text($props['title'] ?? null, 120);
|
|
$this->text($props['text'] ?? null, 600, true);
|
|
if ('' !== ($props['href'] ?? '')) {
|
|
$this->url($props['href']);
|
|
}
|
|
}
|
|
if ('stat' === $type) {
|
|
$this->text($props['value'] ?? null, 16);
|
|
$this->text($props['label'] ?? null, 80);
|
|
}
|
|
if ('quote' === $type) {
|
|
$this->text($props['text'] ?? null, 1000);
|
|
$this->text($props['author'] ?? null, 120);
|
|
$this->text($props['role'] ?? null, 120, true);
|
|
$rating = $props['rating'] ?? 0;
|
|
$this->require(is_int($rating) && $rating >= 0 && $rating <= 5, 'A rating is a whole number between 0 and 5.');
|
|
}
|
|
if ('cta' === $type) {
|
|
$this->text($props['title'] ?? null, 160);
|
|
$this->text($props['text'] ?? null, 600, true);
|
|
$this->text($props['buttonText'] ?? null, 80);
|
|
$this->url($props['href'] ?? null);
|
|
}
|
|
if ('contact' === $type) {
|
|
foreach (['phone', 'email', 'address'] as $field) {
|
|
$this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true);
|
|
}
|
|
// Leer ist erlaubt: der Baustein steht nach dem Einfuegen erst einmal ohne Angaben da,
|
|
// und ein Dokument mit einem leeren Kontaktblock darf sich speichern lassen. Oeffentlich
|
|
// rendert er dann nichts, im Editor einen Hinweis.
|
|
if ('' !== $props['phone']) {
|
|
$this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.');
|
|
}
|
|
if ('' !== $props['email']) {
|
|
$this->require(false !== filter_var($props['email'], FILTER_VALIDATE_EMAIL), 'Invalid email address.');
|
|
}
|
|
}
|
|
if ('spacer' === $type) {
|
|
$this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.');
|
|
}
|
|
if ('accordion' === $type && array_key_exists('faq', $props)) {
|
|
// Ein Akkordeon ist nicht automatisch eine FAQ; erst die Kennzeichnung macht aus den
|
|
// Einträgen Frage-Antwort-Paare in den strukturierten Daten.
|
|
$this->require($schemaVersion >= 23 && is_bool($props['faq']), 'Die FAQ-Kennzeichnung ist an oder aus und braucht Schema v23.');
|
|
}
|
|
if ('accordionitem' === $type) {
|
|
$this->text($props['title'] ?? null, 160);
|
|
$this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.');
|
|
}
|
|
if ('tabitem' === $type) {
|
|
$this->text($props['title'] ?? null, 160);
|
|
}
|
|
if ('legal' === $type) {
|
|
// Die Beschriftung der Rechtszeile ist der Name der Landmarke fuer Vorlesesoftware.
|
|
$this->text($props['label'] ?? 'Rechtliches', 60, true);
|
|
}
|
|
if ('menu' === $type) {
|
|
// The entries live in the menu administration; the block only says which slot to show
|
|
// and how to lay it out.
|
|
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
|
|
if (array_key_exists('collapse', $props)) {
|
|
$this->require($schemaVersion >= 22 && in_array($props['collapse'], ['tablet', 'mobile', 'never'], true), 'Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22.');
|
|
}
|
|
if (array_key_exists('burgerLabel', $props)) {
|
|
// Wie der Knopf beschriftet ist. „Nur Symbol“ behält den Text für Vorlesesoftware:
|
|
// ein Knopf ohne Namen ist für sie ein Knopf ohne Zweck.
|
|
$this->require(in_array($props['burgerLabel'], ['both', 'text', 'icon'], true), 'Für den Menü-Knopf sind „both", „text" und „icon" vorgesehen.');
|
|
}
|
|
if (isset($props['layout'])) {
|
|
$this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.');
|
|
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
|
|
}
|
|
}
|
|
if ('partialref' === $type) {
|
|
// Only the address of the section is content. Its blocks live in the partial, so one
|
|
// change reaches every page that links it and a page cannot alter a shared section.
|
|
$this->require(is_string($props['partial'] ?? null) && ('' === $props['partial'] || (1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80)), 'Invalid section address.');
|
|
}
|
|
if ('icon' === $type) {
|
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
|
// A fixed list, because the size becomes a class: a value without a class would leave
|
|
// an icon with no dimensions at all, and an SVG without dimensions is invisible.
|
|
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
|
|
// A decorative icon is hidden from assistive technology; one that carries meaning
|
|
// needs a label, or a screen reader announces nothing at all.
|
|
$this->require(is_bool($props['decorative'] ?? null), 'Der Zweck des Symbols ist anzugeben.');
|
|
$this->text($props['label'] ?? null, 120, $props['decorative']);
|
|
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
|
|
if ('' !== ($props['href'] ?? '')) {
|
|
$this->url($props['href']);
|
|
}
|
|
}
|
|
if ('social' === $type) {
|
|
// Nur die Darstellung ist Inhalt des Blocks. Welche Profile es gibt, steht in den
|
|
// Einstellungen - ein Dokument kann kein Profil behaupten, das die Website nicht hat.
|
|
$this->require(in_array($props['size'] ?? null, self::ICON_SIZES, true), sprintf('Die Symbolgröße ist eine aus %s.', implode(', ', self::ICON_SIZES)));
|
|
$this->require(isset(SocialProfiles::SHAPES[$props['shape'] ?? '']), 'Unbekannte Form der Symbolfläche.');
|
|
$this->require(isset(SocialProfiles::LAYOUTS[$props['layout'] ?? '']), 'Unbekannte Anordnung der Symbole.');
|
|
$this->require(is_bool($props['labels'] ?? null), 'Die Beschriftung der Symbole ist an oder aus.');
|
|
$networks = $props['networks'] ?? [];
|
|
$this->require(is_array($networks) && array_is_list($networks), 'Die Auswahl der Netzwerke ist eine Liste.');
|
|
foreach ($networks as $network) {
|
|
$this->require(is_string($network) && isset(SocialProfiles::NETWORKS[$network]), 'Unbekanntes Netzwerk in der Auswahl.');
|
|
}
|
|
}
|
|
if ('progress' === $type) {
|
|
$this->text($props['label'] ?? null, 120);
|
|
$this->require(is_int($props['value'] ?? null) && $props['value'] >= 0 && $props['value'] <= 100, 'Der Wert liegt zwischen 0 und 100.');
|
|
$this->require(is_bool($props['showValue'] ?? null), 'Die Anzeige des Werts ist anzugeben.');
|
|
}
|
|
if ('slider' === $type) {
|
|
// The slider is a scroll container: how many slides are visible per device is the
|
|
// only thing it stores, the slides themselves are ordinary blocks.
|
|
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
|
$key = $device.'Visible';
|
|
$this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key));
|
|
}
|
|
$this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.');
|
|
if (isset($props['autoplay'])) {
|
|
// Sekunden bis zur nächsten Folie. 0 heißt aus, und unter drei Sekunden wäre es
|
|
// keine Hilfe mehr, sondern Hektik.
|
|
$this->require($schemaVersion >= 17, 'Das automatische Weiterblättern braucht Schema v17.');
|
|
$this->require(is_int($props['autoplay']) && (0 === $props['autoplay'] || ($props['autoplay'] >= 3 && $props['autoplay'] <= 30)), 'Das Weiterblättern liegt bei 0 (aus) oder 3 bis 30 Sekunden.');
|
|
}
|
|
if (isset($props['dots'])) {
|
|
// Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das
|
|
// geht erst ab v17, weil es vorher keine Eigenschaft dafür gab.
|
|
$this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.');
|
|
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
|
|
}
|
|
}
|
|
if ('embed' === $type && '' !== ($props['url'] ?? '')) {
|
|
// Ohne Adresse ist der Baustein ein leerer Rahmen, den der Editor benennt - erst mit
|
|
// Adresse gelten Titel, Anbieter und Hoehe.
|
|
// Everything about the embed is validated here, because the address ends up in an
|
|
// iframe and in the content security policy of the page.
|
|
$this->require(isset(Consent::GROUPS[$props['group'] ?? '']), 'Unbekannte Einwilligungsgruppe.');
|
|
// The title is what a screen reader announces for the frame, so it is not optional.
|
|
$this->text($props['title'] ?? null, 160);
|
|
$this->text($props['provider'] ?? null, 80);
|
|
$this->text($props['hint'] ?? null, 300, true);
|
|
$url = $props['url'] ?? null;
|
|
$this->require(is_string($url) && strlen($url) <= 400 && str_starts_with($url, 'https://'), 'Ein eingebetteter Inhalt braucht eine HTTPS-Adresse.');
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
// The host becomes part of frame-src, so it must be a plain name - no credentials, no
|
|
// port, nothing that could smuggle a second source into the policy.
|
|
$this->require(is_string($host) && 1 === preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/D', $host), 'Die Adresse des eingebetteten Inhalts braucht einen einfachen Hostnamen.');
|
|
$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.
|
|
// Leer heisst: noch kein Formular gewaehlt. Der Editor zeigt das, die Website nichts.
|
|
$this->require(is_string($props['form'] ?? null) && ('' === $props['form'] || (1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80)), 'Invalid form address.');
|
|
}
|
|
if ('gallery' === $type && isset($props['lightbox'])) {
|
|
$this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.');
|
|
$this->require(is_bool($props['lightbox']), 'Der Bildbetrachter ist an oder aus.');
|
|
}
|
|
if ('image' === $type) {
|
|
$this->url($props['src'] ?? null);
|
|
// Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im
|
|
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
|
|
if ('' !== ($props['href'] ?? '')) {
|
|
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
|
|
$this->url($props['href']);
|
|
}
|
|
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
|
|
if (isset($props['click'])) {
|
|
$this->require($schemaVersion >= 19, 'Die Klickwirkung eines Bildes braucht Schema v19.');
|
|
$this->require(in_array($props['click'], ['keine', 'vergroessern', 'link'], true), 'Für den Klick sind „keine", „vergroessern" und „link" vorgesehen.');
|
|
$this->require('link' !== $props['click'] || '' !== ($props['href'] ?? ''), 'Ein Bild, das auf eine Adresse zeigen soll, braucht eine.');
|
|
}
|
|
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
|
|
$this->text($props['alt'] ?? null, 300, $props['decorative']);
|
|
$this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.');
|
|
foreach (['width', 'height'] as $dimension) {
|
|
$this->require(is_int($props[$dimension] ?? null) && $props[$dimension] > 0 && $props[$dimension] <= 8000, 'Image dimensions are required.');
|
|
}
|
|
foreach (['caption', 'mediaId', 'sources'] as $extra) {
|
|
if (isset($props[$extra])) {
|
|
$this->require($schemaVersion >= 3, 'Media properties require schema v3.');
|
|
}
|
|
}
|
|
if (isset($props['caption'])) {
|
|
$this->text($props['caption'], 500, true);
|
|
}
|
|
if (isset($props['mediaId'])) {
|
|
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
|
|
}
|
|
if (isset($props['sources'])) {
|
|
// As many entries as the media library produces widths, not one more: the list ends
|
|
// up in a srcset, and the number has to move with the pipeline instead of standing
|
|
// here as a number nobody connects to it.
|
|
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= count(MediaLibrary::WIDTHS), 'Invalid image variants.');
|
|
$widths = [];
|
|
foreach ($props['sources'] as $source) {
|
|
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
|
|
$this->url($source['src'] ?? null);
|
|
$this->require(!str_contains($source['src'], ','), 'Invalid srcset URL.');
|
|
$widths[] = $source['width'];
|
|
}
|
|
}
|
|
}
|
|
$children = $block['children'] ?? [];
|
|
$this->require(is_array($children) && array_is_list($children), 'Children must be a list.');
|
|
// A plugin block may nest content; keeping its children is part of preserving the page.
|
|
$container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'], true);
|
|
$this->require($container || [] === $children, 'Leaf blocks cannot contain children.');
|
|
// Containers that only accept one kind of child, so the rendered markup stays valid.
|
|
$only = ['gallery' => 'image', 'logos' => 'image', 'accordion' => 'accordionitem', 'tabs' => 'tabitem'];
|
|
foreach ($children as $child) {
|
|
if (isset($only[$type])) {
|
|
$this->require(is_array($child) && $only[$type] === ($child['type'] ?? null), sprintf('A %s only contains %s blocks.', $type, $only[$type]));
|
|
}
|
|
$this->block($child, $ids, $headings, $depth + 1, $schemaVersion, $type);
|
|
}
|
|
}
|
|
|
|
private function design(mixed $design, string $type): void
|
|
{
|
|
static $tokens = null;
|
|
$tokens ??= json_decode(file_get_contents(__DIR__.'/../../public/assets/design-tokens.json'), true, 512, JSON_THROW_ON_ERROR);
|
|
$this->require(is_array($design), 'Design must be an object.');
|
|
foreach ($design as $device => $settings) {
|
|
$this->require(in_array($device, ['desktop', 'tablet', 'mobile'], true) && is_array($settings), 'Invalid responsive device.');
|
|
foreach ($settings as $key => $value) {
|
|
// Plugin-Bausteine dürfen jedes Token tragen: die Klassen dafür sind allgemein, und
|
|
// das Manifest kann nicht in dieser Datei stehen.
|
|
$this->require(isset($tokens[$key]) && (in_array($type, $tokens[$key]['types'], true) || str_contains($type, '.')), 'Design setting does not apply to this block.');
|
|
$this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.');
|
|
}
|
|
}
|
|
}
|
|
|
|
private function text(mixed $value, int $max, bool $emptyAllowed = false): void
|
|
{
|
|
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
|
|
}
|
|
|
|
private function url(mixed $url): void
|
|
{
|
|
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
|
|
$this->require(!preg_match('/[\x00-\x20\\\\]/', $url), 'Unsafe URL characters.');
|
|
$local = str_starts_with($url, '/') && !str_starts_with($url, '//');
|
|
$external = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL);
|
|
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
|
|
}
|
|
|
|
private function require(bool $condition, string $message): void
|
|
{
|
|
if (!$condition) {
|
|
throw new InvalidDocument($message);
|
|
}
|
|
}
|
|
}
|