Files
KantSites/src/Content/DocumentValidator.php
T
kantarciandClaude Opus 5 790f42213c Formatierter Text mit Werkzeugleiste und HTML-Ansicht (Schema v12)
Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv,
unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und
tiefgestellt, Absätze, Listen und Links.

Die Entscheidung, was erlaubt ist, fällt auf dem Server beim
Speichern - nicht im Editor und nicht beim Rendern. Was in der
Datenbank landet, ist damit schon sicher, also kann kein Renderer das
Filtern vergessen, und eine manipulierte Anfrage speichert nichts,
was der Editor nicht anbietet.

Nicht erlaubte Elemente werden ausgepackt, nicht gelöscht: der Text
bleibt, nur die Auszeichnung geht. script, style, iframe und
Verwandte verschwinden samt Inhalt, weil Auspacken ihren Code als
Text sichtbar machen würde. Attribute fallen weg außer href an a,
und auch das nur nach Adressprüfung.

Ausgegeben wird formatierter Text nur ab v12, dann in einem div statt
einem p, weil die Positivliste Absätze und Listen enthält. Ältere
Dokumente behalten Klartextsemantik und werden weiter maskiert.

Eine Inkonsistenz nebenbei behoben: ein Text mit einem < behielt
echte Zeilenumbrüche, während alles andere <br> benutzte.

Geprüft: 182 PHP-Tests / 685 Assertions unter PHP 8.2.12 und 8.4.22,
darunter dreizehn Fälle durch den Filter mit acht Angriffen, dazu
Editortest mit axe und öffentliche Browserprüfung. Die Werkzeugleiste
selbst ist nicht bedient und nicht mit einem Screenreader geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:54:35 +02:00

256 lines
16 KiB
PHP

<?php
namespace App\Content;
use App\Domain\InvalidDocument;
final class DocumentValidator
{
public function __construct(private StyleCompiler $styles = new StyleCompiler())
{
}
/** 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'];
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'];
/**
* @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.
*/
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 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)) || $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.');
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'], 'entrance' => ['none', 'fade', 'rise']];
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'], 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 ('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'], 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);
}
}
}