Files
WP-LIT-MCP/src/Integrations/Divi/Compose/ValueCleaner.php
T
janniclas.loosenandClaude Opus 5.5 9d92808918 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>
2026-09-27 01:54:25 +02:00

270 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
defined('ABSPATH') || exit;
/**
* The single choke point through which every client string reaches Divi post_content. Divi
* saves bypass kses (D7), so this pipeline is the guarantee. Pure PHP.
*
* Pipeline of clean() – in this order, each step on the string named:
* 0. (caller) S35: ControlChars::stripDeep() on the RAW input, before placeholder resolution
* and trim – AttrWriter::apply(), ShortformWriter::apply().
* 1. S7/S26: guardPath() – builder-only paths (HTML, attributes, custom CSS) need
* unfiltered_html; callers also run it for removals (AttrWriter::apply, ShortformWriter::set).
* 2. S13: keys inside values are plain identifiers (VALUE_KEY); per string, the rest:
* 3. UTF-8 check of the raw string.
* 4. S32/S32a: ControlChars::strip() on the RAW string.
* 5. S3/S3a/S23/S24: kind routing by catalog kind, sub-field, key name, path segment
* (kind(), a ValueKind); S33a: a kind override may only tighten the routed kind
* (ValueKind::tightens()).
* 6. S1/S2: a string with the $variable( marker → VariableRef::canonical() (whole value only;
* URL fields only content references) – its result is returned as it is.
* 7. the sanitizer of the kind: html / code / url / text (ValueSanitizer).
* 8. S28: final-string check – a marker or control characters in the cleaned string →
* rejection. Nothing transforms the string after step 7.
*
* Invariants:
* (I1) No client string reaches Block attrs except via ValueCleaner::clean() or
* VariableRef::canonical().
* (I2) After the sanitizer there is only rejection, never another transformation.
* (I3) A stored reference is always the output of VariableRef::serialize().
*
* Callers add their own rule on top: ShortformWriter S13 color and S33b attachment alt,
* Composer admin label and preset id, TextReplacer S45 (through textReplacementFor()),
* DynamicContentRules the same raw-strip/marker/final-check steps for reference settings.
*/
final class ValueCleaner
{
/** Keys whose string values are URLs when the catalog names no kind (ruling S3). */
private const URL_KEY = '/(url|src|link|href|image)$/i';
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */
private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/D';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i';
/**
* 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,
* 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|decoration\.interactions|advanced\.html|advanced\.customCss|css)(\.|$)/';
public function __construct(
private ModuleCatalog $catalog,
private VariableRef $references,
private ValueSanitizer $sanitizer
) {
}
/**
* Cleans one value written to $path (steps 1–8 above). $subKey names the sub-field of a
* composite path ("linkUrl" of button.innerContent) when $value is its string alone. $kind
* replaces the catalog routing for every string of $value: the short form passes its
* curated field kind (ShortformMap), so icon-list-item text is always plain text. It may only
* tighten (ruling S33a): code < html < text; url only where the routing says url – a weaker
* or other-family kind is refused.
*
* @throws InvalidArgumentException
*/
public function clean(
string $module,
string $path,
mixed $value,
?string $subKey = null,
?ValueKind $kind = null
): mixed {
$this->guardPath($path);
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
}
/**
* The kind clean() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text
* uses it to leave URL and code fields alone (D10).
*/
public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind
{
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);
}
/**
* The $clean callback of TextReplacer::apply() for a block of $module (D10): replace-text
* touches only text and html kinds, and the inserted text goes through clean() with the
* routed kind; URL and code places yield null and stay as they are.
*
* @return callable(string, ?string, string): ?string
*/
public function textReplacementFor(string $module): callable
{
return function (string $path, ?string $key, string $value) use ($module): ?string {
$kind = $this->kindOf($module, $path, $key);
return $kind === ValueKind::Text || $kind === ValueKind::Html
? (string) $this->clean($module, $path, $value, $key)
: null;
};
}
/**
* Step 1: rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html
* (S7/S26); also for removals, which the callers do without clean().
*
* @throws InvalidArgumentException
*/
public function guardPath(string $path): void
{
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
throw new InvalidArgumentException(sprintf(
'"%s" (eigenes HTML, CSS, HTML-Attribute oder Interaktionen) ist nur im Divi-Builder änderbar '
. '(oder mit dem Recht unfiltered_html).',
$path
));
}
}
/**
* Steps 2–8 for $value and everything inside it.
*
* @param string|null $subName First key below the state value: selects the sub-kind of a composite path.
* @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind.
* @param ValueKind|null $kind Fixed kind instead of the routing (short form).
* @throws InvalidArgumentException
*/
private function cleanNode(
string $module,
string $path,
mixed $value,
?string $subName,
?string $key,
?ValueKind $kind = null
): mixed {
if (is_array($value)) {
$clean = [];
foreach ($value as $childKey => $item) {
// Step 2 (S13).
if (is_string($childKey) && preg_match(self::VALUE_KEY, $childKey) !== 1) {
throw new InvalidArgumentException(sprintf(
'Ungültiger Schlüssel "%s" im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-").',
$childKey
));
}
$clean[$childKey] = is_string($childKey)
? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey, $kind)
: $this->cleanNode($module, $path, $item, $subName, $key, $kind);
}
return $clean;
}
if (! is_string($value)) {
return $value;
}
// Step 3.
if (! mb_check_encoding($value, 'UTF-8')) {
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
}
// Step 4 (S32/S32a): control characters go on the RAW value, before the marker check and
// before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<"
// for strip_tags), so any later strip could build a tag or a marker nobody checked.
$value = ControlChars::strip($value);
// Step 5 (S3/S3a/S23/S24, S33a).
$routed = $this->kind($module, $path, $subName, $key);
if ($kind !== null && ! $kind->tightens($routed)) {
throw new InvalidArgumentException(sprintf(
'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").',
$kind->value,
$path,
$routed->value
));
}
$kind ??= $routed;
// Step 6 (S1/S2): the only way a reference is stored – whole, straight from canonical().
if (VariableRef::contains($value)) {
return $this->references->canonical($value, $this->sanitizer, $kind === ValueKind::Url);
}
// Step 7.
$clean = match ($kind) {
ValueKind::Html => $this->sanitizer->html($value),
ValueKind::Code => $this->sanitizer->code($value),
ValueKind::Url => $this->sanitizer->url($value),
ValueKind::Text => $this->sanitizer->text($value),
};
// Step 8 (S28): sanitizers strip characters ("<x>", "%41") and can thereby build a marker
// the raw check above never saw – so the final string is checked again. After the
// sanitizer the only action is rejection (S32a): nothing transforms the cleaned string.
if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) {
throw VariableRef::notAlone();
}
return $clean;
}
/**
* Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the
* sub-field for a composite path – decides. A string reached through a key below a path
* with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys
* at any depth), then to the last path segment (…Url), then content.innerContent → rich
* text (S23: fields Divi defines only in JS), else text – never laxer than text.
*/
private function kind(string $module, string $path, ?string $subName, ?string $key): ValueKind
{
// "unknown" also for a composite path whose sub-kinds do not name $subName.
$kind = $this->catalog->pathKind($module, $path, $subName);
if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) {
$kind = 'unknown';
}
return match ($kind) {
'richtext' => ValueKind::Html,
'code' => ValueKind::Code,
'url', 'image' => ValueKind::Url,
'unknown' => $this->unknownKind($path, $key),
default => ValueKind::Text,
};
}
/** Html, Url or Text – never Code. */
private function unknownKind(string $path, ?string $key): ValueKind
{
if ($key !== null) {
return preg_match(self::URL_KEY, $key) === 1 ? ValueKind::Url : ValueKind::Text;
}
$segments = explode('.', $path);
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
return ValueKind::Url;
}
return $path === 'content.innerContent' ? ValueKind::Html : ValueKind::Text;
}
}