fix: interactions are builder-only for users without unfiltered_html (P16)
Divi's frontend applies the interaction effect "Attribute" with setAttribute (e.g. an onclick handler) and sets cookies; *.decoration.interactions joins the S7 gate in ValueCleaner, which also covers removals and StoredNodeGuard. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,8 +38,8 @@ zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType`
|
|||||||
|
|
||||||
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt
|
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt
|
||||||
(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst,
|
(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst,
|
||||||
geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute
|
geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS, HTML-Attribute und
|
||||||
(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im
|
Interaktionen (`…decoration.attributes`, `…decoration.interactions`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im
|
||||||
Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten
|
Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten
|
||||||
mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt).
|
mit eigenen HTML-/Attribut-Feldern beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (Warnung in der Antwort, neue `tree_rev` gilt).
|
||||||
|
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ Bedarf die Vorlage im Builder neu wählen).
|
|||||||
|
|
||||||
Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem
|
Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem
|
||||||
HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`.
|
HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`.
|
||||||
`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`,
|
`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS, HTML-Attribute und Interaktionen eines Moduls (`…decoration.attributes`,
|
||||||
`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der
|
`…decoration.interactions`, `…advanced.html`, `…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der
|
||||||
Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern
|
Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern
|
||||||
beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev`
|
beim Speichern – nur wenn die Seite noch nicht in Divis eigener gespeicherter Form vorliegt (z. B. importierter oder direkt in die Datenbank geschriebener Inhalt); über den Divi-Builder oder LIT MCP gespeicherte Seiten betrifft das praktisch nie (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev`
|
||||||
weiterarbeiten).
|
weiterarbeiten).
|
||||||
|
|||||||
@@ -54,9 +54,12 @@ final class ValueCleaner
|
|||||||
/**
|
/**
|
||||||
* Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by
|
* Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by
|
||||||
* path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes,
|
* path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes,
|
||||||
* any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss).
|
* any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss),
|
||||||
|
* and interactions (ruling P16: *.decoration.interactions – Divi's frontend script applies the
|
||||||
|
* "Attribute" effect with setAttribute, e.g. an onclick handler, and sets cookies/presets).
|
||||||
*/
|
*/
|
||||||
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
|
private const BUILDER_ONLY
|
||||||
|
= '/(^|\.)(decoration\.attributes|decoration\.interactions|advanced\.html|advanced\.customCss|css)(\.|$)/';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ModuleCatalog $catalog,
|
private ModuleCatalog $catalog,
|
||||||
@@ -134,7 +137,7 @@ final class ValueCleaner
|
|||||||
{
|
{
|
||||||
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
|
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
|
||||||
throw new InvalidArgumentException(sprintf(
|
throw new InvalidArgumentException(sprintf(
|
||||||
'"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar '
|
'"%s" (eigenes HTML, CSS, HTML-Attribute oder Interaktionen) ist nur im Divi-Builder änderbar '
|
||||||
. '(oder mit dem Recht unfiltered_html).',
|
. '(oder mit dem Recht unfiltered_html).',
|
||||||
$path
|
$path
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -376,4 +376,33 @@ final class AttrWriterTest extends TestCase
|
|||||||
$heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs');
|
$heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs');
|
||||||
self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure');
|
self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */
|
||||||
|
public function testInteractionsNeedUnfilteredHtml(): void
|
||||||
|
{
|
||||||
|
$interaction = ['interactions' => [[
|
||||||
|
'trigger' => 'click',
|
||||||
|
'effect' => 'addAttribute',
|
||||||
|
'attributeName' => 'data-probe',
|
||||||
|
'attributeValue' => '1',
|
||||||
|
]]];
|
||||||
|
$changes = ['module.decoration.interactions' => ['desktop' => $interaction]];
|
||||||
|
|
||||||
|
$editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs');
|
||||||
|
self::assertSame(self::TEXT, $editor['attrs']);
|
||||||
|
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.interactions']);
|
||||||
|
|
||||||
|
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs');
|
||||||
|
self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']);
|
||||||
|
|
||||||
|
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
|
||||||
|
self::assertSame([], $admin['errors']);
|
||||||
|
self::assertSame(
|
||||||
|
'data-probe',
|
||||||
|
$admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName']
|
||||||
|
);
|
||||||
|
$adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs');
|
||||||
|
self::assertSame([], $adminRemoval['errors']);
|
||||||
|
self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
|||||||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -426,4 +427,26 @@ final class ShortformWriterTest extends TestCase
|
|||||||
|
|
||||||
return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner);
|
return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling P16: no curated short field reaches interactions, and a field that did would be gated. */
|
||||||
|
public function testInteractionsAreBuilderOnlyInTheShortForm(): void
|
||||||
|
{
|
||||||
|
foreach (ShortformMap::types() as $type) {
|
||||||
|
foreach (ShortformMap::fields($type) as $name => $field) {
|
||||||
|
self::assertStringNotContainsString('interactions', $field['path'], $type . '.' . $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$modules = ['raw' => [
|
||||||
|
'effects' => ['path' => 'module.decoration.interactions', 'sub' => 'interactions', 'kind' => 'text', 'description' => 'x'],
|
||||||
|
]];
|
||||||
|
foreach (['data-probe', null] as $value) {
|
||||||
|
$result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n');
|
||||||
|
self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.effects']);
|
||||||
|
self::assertSame(self::BASE, $result['attrs']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['effects' => 'x'], 'n');
|
||||||
|
self::assertSame([], $admin['errors']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,17 @@ final class StoredNodeGuardTest extends TestCase
|
|||||||
])]]),
|
])]]),
|
||||||
'module.advanced.html',
|
'module.advanced.html',
|
||||||
],
|
],
|
||||||
|
'interactions (P16)' => [
|
||||||
|
self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([
|
||||||
|
'interactions' => [[
|
||||||
|
'trigger' => 'click',
|
||||||
|
'effect' => 'addAttribute',
|
||||||
|
'attributeName' => 'data-probe',
|
||||||
|
'attributeValue' => '1',
|
||||||
|
]],
|
||||||
|
])]]]),
|
||||||
|
'module.decoration.interactions',
|
||||||
|
],
|
||||||
// Code module (E6).
|
// Code module (E6).
|
||||||
'code module' => [
|
'code module' => [
|
||||||
self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
|
self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
|
||||||
|
|||||||
@@ -198,4 +198,38 @@ final class ValueCleanerTest extends TestCase
|
|||||||
self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test'));
|
self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test'));
|
||||||
self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x'));
|
self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling P16: Divi's interactions ("Attribute" effect → setAttribute on the frontend, Cookie,
|
||||||
|
* Preset) are builder-only like HTML attributes, at every depth.
|
||||||
|
*/
|
||||||
|
public function testInteractionPathsNeedUnfilteredHtml(): void
|
||||||
|
{
|
||||||
|
$value = ['interactions' => [[
|
||||||
|
'trigger' => 'click',
|
||||||
|
'effect' => 'addAttribute',
|
||||||
|
'attributeName' => 'data-probe',
|
||||||
|
'attributeValue' => '1',
|
||||||
|
]]];
|
||||||
|
$paths = [
|
||||||
|
'module.decoration.interactions',
|
||||||
|
'module.decoration.interactions.desktop',
|
||||||
|
'title.decoration.interactions',
|
||||||
|
'decoration.interactions',
|
||||||
|
];
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
try {
|
||||||
|
ComposeFixture::cleaner()->clean('divi/text', $path, $value);
|
||||||
|
self::fail('Accepted for an editor: ' . $path);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/text', $path, $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a whole "interactions" segment below "decoration" counts.
|
||||||
|
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x'));
|
||||||
|
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user