require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], true), '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); } $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; // 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)) || $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.'); foreach ($layout as $key => $value) { 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. $limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300; $this->text($props['text'] ?? null, $limit); } 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 ('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.'); } } } 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); } $this->require('' !== trim($props['phone'].$props['email'].$props['address']), 'A contact block needs at least one detail.'); 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 ('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 ('menu' === $type) { // The entries live in the menu administration; the block only says which slot to show. $this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.'); } 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) && 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 ('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 ('embed' === $type) { // 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 ('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. $this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.'); } if ('image' === $type) { $this->url($props['src'] ?? null); $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'])) { $this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, '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) { $this->require(isset($tokens[$key]) && in_array($type, $tokens[$key]['types'], true), '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); } } }