diff --git a/src/Integrations/Divi/Compose/StoredNodeGuard.php b/src/Integrations/Divi/Compose/StoredNodeGuard.php new file mode 100644 index 0000000..0068a01 --- /dev/null +++ b/src/Integrations/Divi/Compose/StoredNodeGuard.php @@ -0,0 +1,274 @@ + German problems; [] when $root may move to the target post. */ + public function problems(Block $root): array + { + if ($this->sanitizer->allowsUnfilteredHtml()) { + return []; + } + + $problems = []; + $this->walk($root, $problems); + + return array_values(array_unique($problems)); + } + + /** @param list $problems */ + private function walk(Block $block, array &$problems): void + { + if (! $block->isDivi()) { + $problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY); + + return; + } + + $attrs = $this->attributesOf($block); + if ($attrs === null) { + $problems[] = sprintf( + '%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.', + $block->name(), + self::BUILDER_ONLY + ); + + return; + } + + $module = GlobalInstance::effectiveName($block); + if (in_array($module, self::CODE_MODULES, true)) { + $problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY); + } + + foreach (AttrMap::flatten($attrs) as $path => $value) { + $this->value($module, (string) $path, $value, $problems); + } + + foreach ($block->parts() as $part) { + if ($part instanceof Block) { + $this->walk($part, $problems); + } elseif (trim($part) !== '') { + $problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY); + } + } + } + + /** + * The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over + * the library entry, F7); null for an opaque block that cannot be read that way. + * + * @return array|null + */ + private function attributesOf(Block $block): ?array + { + if (! $block->isOpaque()) { + return $block->attrs(); + } + + $attrs = $block->attrs(); + $name = GlobalInstance::effectiveName($block); + if ( + $block->name() !== GlobalInstance::BLOCK + || ! str_starts_with($name, 'divi/') + || $name === GlobalInstance::BLOCK + || ! is_string($attrs['globalModule'] ?? null) + ) { + return null; + } + + $local = $attrs['localAttrs'] ?? []; + if ($local instanceof stdClass && get_object_vars($local) === []) { + return []; + } + + return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null; + } + + /** + * One flattened path: the builder-only gate, then every string below it – a breakpoint map's + * state values the way AttrWriter hands them to ValueCleaner::clean(). + * + * @param list $problems + */ + private function value(string $module, string $path, mixed $value, array &$problems): void + { + if (! $this->allowedPath($path, $problems)) { + return; + } + + if (! AttrMap::isBreakpointMap($value)) { + $this->node($module, $path, $path, $value, null, null, $problems); + + return; + } + + foreach ($value as $states) { + if ($states instanceof stdClass) { + $states = get_object_vars($states); + } + if (! is_array($states) || array_is_list($states)) { + $this->node($module, $path, $path, $states, null, null, $problems); + continue; + } + foreach ($states as $state => $item) { + $field = in_array($state, self::STATE_KEYS, true) ? null : (string) $state; + $where = $field === null ? $path : $path . '.' . $field; + $this->node($module, $path, $where, $item, $field, $field, $problems); + } + } + } + + /** + * @param string $where $path plus the keys below the state value: builder-only keys at any depth. + * @param string|null $subName First key below the state value (ValueCleaner's sub-kind). + * @param string|null $key Nearest key above the value. + * @param list $problems + */ + private function node( + string $module, + string $path, + string $where, + mixed $value, + ?string $subName, + ?string $key, + array &$problems + ): void { + if ($where !== $path && ! $this->allowedPath($where, $problems)) { + return; + } + + if ($value instanceof stdClass) { + $value = get_object_vars($value); + } + + if (is_array($value)) { + foreach ($value as $childKey => $item) { + if (is_string($childKey)) { + $below = $where . '.' . $childKey; + $this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems); + } else { + $this->node($module, $path, $where, $item, $subName, $key, $problems); + } + } + + return; + } + + if (is_string($value)) { + $problem = $this->stringProblem($module, $path, $value, $subName, $key); + if ($problem !== null) { + $problems[] = sprintf('%s: %s', $where, $problem); + } + } + } + + /** @param list $problems */ + private function allowedPath(string $path, array &$problems): bool + { + try { + $this->cleaner->guardPath($path); + } catch (InvalidArgumentException $e) { + $problems[] = $e->getMessage(); + + return false; + } + + return true; + } + + private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string + { + if (ControlChars::strip($value) !== $value) { + return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.'; + } + + $kind = $this->cleaner->kindAt($module, $path, $subName, $key); + if (VariableRef::contains($value)) { + return $this->referenceProblem($value, $kind === ValueKind::Url); + } + + $stable = match ($kind) { + ValueKind::Html => $this->sanitizer->html($value) === $value, + ValueKind::Code => $this->sanitizer->code($value) === $value, + ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value, + ValueKind::Url => trim($value) === '' || $this->sanitizer->url($value) !== '', + }; + + return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – ' + . self::BUILDER_ONLY . '.'; + } + + /** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */ + private function referenceProblem(string $value, bool $url): ?string + { + try { + $canonical = $this->references->canonical($value, $this->sanitizer, $url); + } catch (InvalidArgumentException $e) { + return $e->getMessage(); + } + + $cleaned = self::normalized(VariableRef::settingsOf($canonical)); + + return $cleaned === self::normalized(VariableRef::settingsOf($value)) + ? null + : 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.'; + } + + /** + * @param array $settings + * @return array Keys sorted, scalars as strings (post_id 839 = "839"). + */ + private static function normalized(array $settings): array + { + ksort($settings); + + return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings); + } +} diff --git a/src/Integrations/Divi/Compose/ValueCleaner.php b/src/Integrations/Divi/Compose/ValueCleaner.php index d81d70f..142ef8a 100644 --- a/src/Integrations/Divi/Compose/ValueCleaner.php +++ b/src/Integrations/Divi/Compose/ValueCleaner.php @@ -93,7 +93,17 @@ final class ValueCleaner */ public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind { - return $this->kind($module, $path, $subKey, $subKey); + return $this->kindAt($module, $path, $subKey, $subKey); + } + + /** + * The kind clean() routes a string to that sits below the state value of $path: $subName is + * the first key there, $key the nearest key above the string (step 5, like cleanNode()). + * StoredNodeGuard checks stored values against exactly this routing. + */ + public function kindAt(string $module, string $path, ?string $subName, ?string $key): ValueKind + { + return $this->kind($module, $path, $subName, $key); } /** diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 2d1ee19..61260e0 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -76,6 +76,25 @@ final class VariableRef return $type === 'color' || $type === 'content' ? $type : null; } + /** + * The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares + * a stored reference with canonical(): different settings mean cleaning would change them. + * + * @return array + */ + public static function settingsOf(string $value): array + { + try { + $payload = self::payloadOf($value); + } catch (InvalidArgumentException) { + return []; + } + + $settings = $payload['value']['settings'] ?? []; + + return is_array($settings) ? $settings : []; + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index f67c2e3..c81604d 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use LIT\MCP\Integrations\Divi\Compose\MediaResolver; use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\ShortformWriter; +use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\StructureRules; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\ValueCleaner; @@ -118,6 +119,12 @@ class DiviComposition return new StructureRules($this->modules->catalog()); } + /** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */ + public function storedGuard(): StoredNodeGuard + { + return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer); + } + public function editor(): TreeEditor { return new TreeEditor($this->rules()); diff --git a/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php new file mode 100644 index 0000000..88b12d6 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php @@ -0,0 +1,290 @@ + that logs nothing). + */ +final class StoredNodeGuardTest extends TestCase +{ + private const PROBE_HTML = ''; + private const PROBE_SCRIPT = ''; + + private static function guard(bool $unfiltered = false): StoredNodeGuard + { + $references = new VariableRef( + DiviFixtures::designSystem(), + DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + ); + $sanitizer = ComposeFixture::sanitizer($unfiltered); + + return new StoredNodeGuard(new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer), $references, $sanitizer); + } + + private static function block(string $markup): Block + { + return (new Parser())->parse($markup)->children()[0]; + } + + /** + * A void block the way WordPress/Divi serialize it: quotes inside strings (so inside a + * $variable(…)$ payload) become " – ruling P2. + * + * @param array $attrs + */ + private static function node(string $name, array $attrs): string + { + return ''; + } + + /** @param array $settings */ + private static function ref(string $name, array $settings): string + { + return VariableRef::MARKER . json_encode( + ['type' => 'content', 'value' => ['name' => $name, 'settings' => (object) $settings]], + JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ) . VariableRef::END; + } + + /** @return array */ + private static function desktop(mixed $value): array + { + return ['desktop' => ['value' => $value]]; + } + + private static function text(string $html): string + { + return self::node('divi/text', ['content' => ['innerContent' => self::desktop($html)]]); + } + + public function testBuilderContentWithInstancesAndLinksPasses(): void + { + $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + $link = self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => self::ref('post_link_url_page', ['post_id' => '839']), + ])]])); + $title = self::block(self::text(self::ref('post_title', ['before' => 'Titel:', 'after' => '']))); + + self::assertFalse($link->isOpaque(), 'the link must really be parsed (P2)'); + self::assertFalse($title->isOpaque()); + self::assertSame([], self::guard()->problems($index->find('s0'))); + self::assertSame([], self::guard()->problems($index->find('g0'))); + self::assertSame([], self::guard()->problems($link)); + self::assertSame([], self::guard()->problems($title)); + self::assertSame([], self::guard()->problems(self::block(self::text('

Tom & Jerry

')))); + } + + /** @return array */ + public static function libraryEntries(): array + { + return [ + 'global section' => ['stage3/library-global--globaler-abschnitt.html'], + 'global module' => ['stage3/library-global--globales-modul.html'], + 'full page layout' => ['stage3/library--layout-ganze-seite.html'], + 'section' => ['stage3/library--library-abschnitt.html'], + 'module' => ['stage3/library--library-modul.html'], + 'row' => ['stage3/library--library-zeile.html'], + ]; + } + + /** @dataProvider libraryEntries */ + public function testRealLibraryEntriesPass(string $fixture): void + { + $document = (new Parser())->parse(DiviFixtures::markup($fixture)); + + foreach ($document->children() as $root) { + self::assertSame([], self::guard()->problems($root)); + } + } + + /** @return array Markup, part of the problem, parsed opaque. */ + public static function refused(): array + { + $section = static fn (string $inner): string => '' + . $inner . ''; + + return [ + // P3: builder-only paths (S7/S26). + 'custom css' => [ + self::node('divi/text', ['css' => self::desktop(['freeForm' => 'body{display:none}'])]), + '"css"', + ], + 'custom css of a sub-element' => [ + self::node('divi/blurb', ['title' => ['css' => self::desktop(['freeForm' => 'a{}'])]]), + 'title.css', + ], + 'html attributes' => [ + self::node('divi/text', ['module' => ['decoration' => ['attributes' => self::desktop([ + 'attributes' => [['name' => 'data-probe', 'value' => '1']], + ])]]]), + 'module.decoration.attributes', + ], + 'own html before the module' => [ + self::node('divi/text', ['module' => ['advanced' => ['html' => self::desktop([ + 'htmlBefore' => self::PROBE_SCRIPT, + ])]]]), + 'module.advanced.html', + ], + 'own html below a state value' => [ + self::node('divi/text', ['module' => ['advanced' => self::desktop([ + 'html' => ['htmlBefore' => self::PROBE_SCRIPT], + ])]]), + 'module.advanced.html', + ], + // Code module (E6). + 'code module' => [ + self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), + 'Code-Modul', + ], + 'code module deep inside a section' => [$section(''), 'Code-Modul'], + // P3: non-Divi blocks and raw text parts. + 'core html block' => [ + '' . self::PROBE_HTML . '', + 'kein Divi-Block', + ], + 'core block inside a section' => [ + $section('

x

'), + 'kein Divi-Block', + ], + 'raw text between blocks' => [$section(self::PROBE_HTML), 'Freitext'], + 'divi 4 shortcode between blocks' => [$section('[et_pb_code]probe[/et_pb_code]'), 'Freitext'], + // P3: opaque blocks. + 'undecodable attributes' => ['', 'nicht lesbar', true], + 'global module reference on a plain block' => [ + self::node('divi/text', ['globalModule' => '5']), + 'nicht lesbar', + true, + ], + 'instance without block name' => [ + self::node('divi/global-layout', ['globalModule' => '5']), + 'nicht lesbar', + true, + ], + 'instance with local attributes that are no object' => [ + self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x']), + 'nicht lesbar', + true, + ], + 'instance of a code module' => [ + self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/code']), + 'Code-Modul', + true, + ], + 'css in the local attributes of an instance' => [ + self::node('divi/global-layout', [ + 'globalModule' => '5', + 'blockName' => 'divi/text', + 'localAttrs' => ['css' => self::desktop(['freeForm' => 'a{}'])], + ]), + '"css"', + true, + ], + 'rich text in the local attributes of an instance' => [ + self::node('divi/global-layout', [ + 'globalModule' => '5', + 'blockName' => 'divi/text', + 'localAttrs' => ['content' => ['innerContent' => self::desktop(self::PROBE_HTML)]], + ]), + 'content.innerContent', + true, + ], + 'script in the snapshot of an instance' => [ + '' + . '' . self::text(self::PROBE_SCRIPT) + . '', + 'content.innerContent', + true, + ], + // P3: rich text that is not kses-stable. + 'probe element in rich text' => [self::text('

Hallo

' . self::PROBE_HTML), 'content.innerContent'], + 'script in rich text' => [self::text(self::PROBE_SCRIPT), 'content.innerContent'], + 'script in a text field' => [ + self::node('divi/heading', ['title' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]), + 'title.innerContent', + ], + 'script url' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'text' => 'Kontakt', + 'linkUrl' => 'javascript:void(0)', + ])]]), + 'button.innerContent', + ], + 'control character' => [self::text('

a' . chr(1) . 'b

'), 'Steuerzeichen'], + // Stored $variable references: the same check as client references (VariableRef). + 'script in before' => [ + self::text(self::ref('post_title', ['before' => self::PROBE_SCRIPT, 'after' => ''])), + 'Divi-Verweis mit Einstellungen', + ], + 'probe html in after' => [ + self::text(self::ref('post_title', ['before' => '', 'after' => self::PROBE_HTML])), + 'Divi-Verweis mit Einstellungen', + ], + 'protected meta' => [self::text(self::ref('custom_meta__edit_lock', [])), 'custom_meta__edit_lock'], + 'enable_html' => [ + self::text(self::ref('post_meta_key', [ + 'before' => '', + 'select_meta_key' => 'custom_meta_telefon', + 'enable_html' => 'on', + ])), + 'enable_html', + ], + 'reference embedded in text' => [ + self::text('

Hallo ' . self::ref('post_title', []) . '

'), + 'allein im Wert', + ], + 'content reference as a link' => [ + self::node('divi/button', ['button' => ['innerContent' => self::desktop([ + 'linkUrl' => self::ref('post_title', ['before' => self::PROBE_HTML]), + ])]]), + 'button.innerContent', + ], + ]; + } + + /** @dataProvider refused */ + public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml( + string $markup, + string $problem, + bool $opaque = false + ): void { + $block = self::block($markup); + self::assertSame($opaque, $block->isOpaque(), 'the row must parse as intended (P2)'); + + $problems = self::guard()->problems($block); + + self::assertNotSame([], $problems); + self::assertStringContainsString($problem, implode(' | ', $problems)); + self::assertSame([], self::guard(true)->problems($block), 'unfiltered_html may carry it'); + } + + public function testNamesEveryProblemOnceAndLeavesTheNodeUntouched(): void + { + $markup = '' + . self::node('divi/code', []) . self::node('divi/code', []) + . self::text(self::PROBE_SCRIPT) + . ''; + $block = self::block($markup); + + $problems = self::guard()->problems($block); + + self::assertCount(2, $problems); + self::assertSame($markup, Serializer::block($block)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index d4a656f..d54b5b8 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\AttrMap; use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Document; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; +use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard; use LIT\MCP\Integrations\Divi\Compose\TreeEditor; use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; @@ -16,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Tests\Support\ComposeFixture; use LIT\MCP\Tests\Support\DiviFixtures; +use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -137,4 +139,21 @@ final class DiviCompositionTest extends TestCase self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), [])); self::assertInstanceOf(TreeEditor::class, $composition->editor()); } + + public function testStoredGuardSharesTheCleanerAndReferences(): void + { + $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7); + $code = Block::create('divi/code', ['builderVersion' => '5.2.1']); + + self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard()); + self::assertSame( + [], + $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1'])) + ); + self::assertNotSame([], $composition->storedGuard()->problems($code), 'the editor sanitizer is used'); + self::assertSame( + [], + DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code) + ); + } }