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>
270 lines
11 KiB
PHP
270 lines
11 KiB
PHP
<?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;
|
||
}
|
||
}
|