refactor: ValueCleaner is the named choke point (H1)
The choke point moves out of AttrWriter into ValueCleaner (catalog, the composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue), kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged. - ValueCleaner's docblock is the one canonical description: steps 0-8 with their rulings in code order, invariants I1-I3; the step comments in cleanNode carry the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks point there and keep only their own rules. - ValueCleaner::textReplacementFor($module) holds the D10 rule that was a closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind). - AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition builds one per composition (cleaner()). Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner()); mixed tests call the cleaner in place; +1 test for textReplacementFor. Every provider row and per-test assertion count unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -15,33 +15,14 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
|||||||
* ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop.
|
* ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop.
|
||||||
* Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP.
|
* Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP.
|
||||||
*
|
*
|
||||||
* cleanValue() is the single choke point through which every client string reaches Divi
|
* Every value goes through ValueCleaner (the single choke point under the kses bypass, D7 –
|
||||||
* post_content, which is saved without kses (D7): strings are routed by the catalog's value
|
* see its pipeline); apply() adds step 0 (S35 control characters before placeholders), the
|
||||||
* kind (ruling S3/S3a), $variable(…)$ values are re-serialized by VariableRef (S1/S2), keys
|
* guard for removals (S7/S26) and its own rule: structure attributes are refused (S41).
|
||||||
* inside values are plain identifiers (S13), and Divi's own HTML/attribute fields need
|
|
||||||
* unfiltered_html (S7).
|
|
||||||
*/
|
*/
|
||||||
final class AttrWriter
|
final class AttrWriter
|
||||||
{
|
{
|
||||||
private const STATES = ['value', 'hover', 'sticky'];
|
private const STATES = ['value', 'hover', 'sticky'];
|
||||||
|
|
||||||
/** 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).
|
|
||||||
*/
|
|
||||||
/** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */
|
|
||||||
private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structure attributes (ruling S41), refused for everyone and whatever the catalog says:
|
* Structure attributes (ruling S41), refused for everyone and whatever the catalog says:
|
||||||
* the section type (regular/fullwidth/specialty) and a column's specialty columns decide
|
* the section type (regular/fullwidth/specialty) and a column's specialty columns decide
|
||||||
@@ -53,13 +34,10 @@ final class AttrWriter
|
|||||||
'divi/column' => ['module.advanced.specialtyColumns'],
|
'divi/column' => ['module.advanced.specialtyColumns'],
|
||||||
];
|
];
|
||||||
|
|
||||||
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ModuleCatalog $catalog,
|
private ModuleCatalog $catalog,
|
||||||
private Placeholders $placeholders,
|
private Placeholders $placeholders,
|
||||||
private VariableRef $references,
|
private ValueCleaner $cleaner
|
||||||
private ValueSanitizer $sanitizer
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +72,7 @@ final class AttrWriter
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->guardPath($path);
|
$this->cleaner->guardPath($path);
|
||||||
if ($map === null) {
|
if ($map === null) {
|
||||||
$attrs = AttrMap::remove($attrs, $path);
|
$attrs = AttrMap::remove($attrs, $path);
|
||||||
continue;
|
continue;
|
||||||
@@ -113,40 +91,6 @@ final class AttrWriter
|
|||||||
return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid];
|
return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans one value written to $path (used by the short form too). $subKey names the
|
|
||||||
* sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its
|
|
||||||
* string alone. $kind ('html'|'code'|'url'|'text') 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.
|
|
||||||
*
|
|
||||||
* @param 'html'|'code'|'url'|'text'|null $kind
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*/
|
|
||||||
public function cleanValue(
|
|
||||||
string $module,
|
|
||||||
string $path,
|
|
||||||
mixed $value,
|
|
||||||
?string $subKey = null,
|
|
||||||
?string $kind = null
|
|
||||||
): mixed {
|
|
||||||
$this->guardPath($path);
|
|
||||||
|
|
||||||
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kind cleanValue() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text
|
|
||||||
* uses it to leave URL and code fields alone (D10).
|
|
||||||
*
|
|
||||||
* @return 'html'|'code'|'url'|'text'
|
|
||||||
*/
|
|
||||||
public function kindOf(string $module, string $path, ?string $subKey = null): string
|
|
||||||
{
|
|
||||||
return $this->kind($module, $path, $subKey, $subKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<string, mixed>> Breakpoint => state => value.
|
* @return array<string, array<string, mixed>> Breakpoint => state => value.
|
||||||
* @throws InvalidArgumentException
|
* @throws InvalidArgumentException
|
||||||
@@ -189,157 +133,6 @@ final class AttrWriter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also
|
|
||||||
* for removals, which the short form does without cleanValue().
|
|
||||||
*
|
|
||||||
* @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 oder HTML-Attribute) ist nur im Divi-Builder änderbar '
|
|
||||||
. '(oder mit dem Recht unfiltered_html).',
|
|
||||||
$path
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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 'html'|'code'|'url'|'text'|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,
|
|
||||||
?string $kind = null
|
|
||||||
): mixed {
|
|
||||||
if (is_array($value)) {
|
|
||||||
$clean = [];
|
|
||||||
foreach ($value as $childKey => $item) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! mb_check_encoding($value, 'UTF-8')) {
|
|
||||||
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ruling 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);
|
|
||||||
|
|
||||||
$routed = $this->kind($module, $path, $subName, $key);
|
|
||||||
if ($kind !== null && ! self::tightens($kind, $routed)) {
|
|
||||||
throw new InvalidArgumentException(sprintf(
|
|
||||||
'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").',
|
|
||||||
$kind,
|
|
||||||
$path,
|
|
||||||
$routed
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$kind ??= $routed;
|
|
||||||
if (VariableRef::contains($value)) {
|
|
||||||
// The only way a reference is stored: whole, straight from canonical().
|
|
||||||
return $this->references->canonical($value, $this->sanitizer, $kind === 'url');
|
|
||||||
}
|
|
||||||
|
|
||||||
$clean = match ($kind) {
|
|
||||||
'html' => $this->sanitizer->html($value),
|
|
||||||
'code' => $this->sanitizer->code($value),
|
|
||||||
'url' => $this->sanitizer->url($value),
|
|
||||||
default => $this->sanitizer->text($value),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ruling 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 any more.
|
|
||||||
if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) {
|
|
||||||
throw VariableRef::notAlone();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the kind override $kind is at least as strict as the routed kind (ruling S33a).
|
|
||||||
* Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text
|
|
||||||
* (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is
|
|
||||||
* tightened by the others, so only url → url passes.
|
|
||||||
*/
|
|
||||||
private static function tightens(string $kind, string $routed): bool
|
|
||||||
{
|
|
||||||
if ($kind === 'url' || $routed === 'url') {
|
|
||||||
return $kind === $routed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* @return 'html'|'code'|'url'|'text'
|
|
||||||
*/
|
|
||||||
private function kind(string $module, string $path, ?string $subName, ?string $key): string
|
|
||||||
{
|
|
||||||
// "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' => 'html',
|
|
||||||
'code' => 'code',
|
|
||||||
'url', 'image' => 'url',
|
|
||||||
'unknown' => $this->unknownKind($path, $key),
|
|
||||||
default => 'text',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return 'html'|'url'|'text' */
|
|
||||||
private function unknownKind(string $path, ?string $key): string
|
|
||||||
{
|
|
||||||
if ($key !== null) {
|
|
||||||
return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
$segments = explode('.', $path);
|
|
||||||
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
|
|
||||||
return 'url';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path === 'content.innerContent' ? 'html' : 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<string, mixed>> $normalized
|
* @param array<string, array<string, mixed>> $normalized
|
||||||
* @return array<string, array<string, mixed>>
|
* @return array<string, array<string, mixed>>
|
||||||
@@ -349,7 +142,7 @@ final class AttrWriter
|
|||||||
{
|
{
|
||||||
foreach ($normalized as $breakpoint => $states) {
|
foreach ($normalized as $breakpoint => $states) {
|
||||||
foreach ($states as $state => $value) {
|
foreach ($states as $state => $value) {
|
||||||
$normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value);
|
$normalized[$breakpoint][$state] = $this->cleaner->clean($module, $path, $value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
|||||||
* Pure PHP.
|
* Pure PHP.
|
||||||
*
|
*
|
||||||
* Divi saves bypass kses (D7): every client string reaches the attributes only through
|
* Divi saves bypass kses (D7): every client string reaches the attributes only through
|
||||||
* ShortformWriter / AttrWriter::cleanValue() (the single choke point) – short-form fields,
|
* ShortformWriter / AttrWriter / ValueCleaner::clean() (the single choke point) – short-form fields,
|
||||||
* full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and
|
* full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and
|
||||||
* preset ids. A value that fails there is an error, never stored raw (S13). What Composer
|
* preset ids. A value that fails there is an error, never stored raw (S13). What Composer
|
||||||
* writes itself is not client text: the builder version (checked in the constructor), block
|
* writes itself is not client text: the builder version (checked in the constructor), block
|
||||||
@@ -69,6 +69,7 @@ final class Composer
|
|||||||
private StructureRules $rules,
|
private StructureRules $rules,
|
||||||
private ShortformWriter $shortform,
|
private ShortformWriter $shortform,
|
||||||
private AttrWriter $attrWriter,
|
private AttrWriter $attrWriter,
|
||||||
|
private ValueCleaner $cleaner,
|
||||||
private DesignSystem $design,
|
private DesignSystem $design,
|
||||||
private string $builderVersion
|
private string $builderVersion
|
||||||
) {
|
) {
|
||||||
@@ -502,7 +503,7 @@ final class Composer
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$clean = $this->attrWriter->cleanValue($name, self::LABEL_PATH, $label, null, 'text');
|
$clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, 'text');
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
$this->errors[$path] = $e->getMessage();
|
$this->errors[$path] = $e->getMessage();
|
||||||
|
|
||||||
@@ -552,7 +553,7 @@ final class Composer
|
|||||||
|
|
||||||
// Design-system data, but still through the choke point: an id text() would change is refused.
|
// Design-system data, but still through the choke point: an id text() would change is refused.
|
||||||
try {
|
try {
|
||||||
$clean = $this->attrWriter->cleanValue($name, 'modulePreset', $id, null, 'text');
|
$clean = $this->cleaner->clean($name, 'modulePreset', $id, null, 'text');
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
$clean = null;
|
$clean = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
|||||||
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
|
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
|
||||||
* null removes the field. Pure PHP.
|
* null removes the field. Pure PHP.
|
||||||
*
|
*
|
||||||
* Every string goes through AttrWriter::cleanValue() – the single choke point under the kses
|
* Every string goes through ValueCleaner::clean() – the single choke point under the kses
|
||||||
* bypass (D7) – with the curated kind of the field: $variable(…)$ values only whole and
|
* bypass (D7), see its pipeline – with the curated kind of the field; apply() adds step 0 (S35)
|
||||||
* re-serialized by VariableRef (S1/S2/S25), the final string checked again (S28), URL fields
|
* and set() the guard for removals (S7/S26). Own rules on top: colors are placeholders, color
|
||||||
* through the URL sanitizer (S3), builder-only paths guarded (S7/S26). Nothing is passed through.
|
* references or plain CSS colors, checked before and after cleaning (S13); an attachment's alt
|
||||||
|
* text carrying a reference is dropped (S33b). Nothing is passed through.
|
||||||
*/
|
*/
|
||||||
final class ShortformWriter
|
final class ShortformWriter
|
||||||
{
|
{
|
||||||
@@ -25,7 +26,7 @@ final class ShortformWriter
|
|||||||
|
|
||||||
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a';
|
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a';
|
||||||
|
|
||||||
/** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */
|
/** Curated kind => sanitizer kind of ValueCleaner::clean(). */
|
||||||
private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text'];
|
private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +36,7 @@ final class ShortformWriter
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private Placeholders $placeholders,
|
private Placeholders $placeholders,
|
||||||
private MediaResolver $media,
|
private MediaResolver $media,
|
||||||
private AttrWriter $attrs,
|
private ValueCleaner $cleaner,
|
||||||
private ?array $modules = null
|
private ?array $modules = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ final class ShortformWriter
|
|||||||
|
|
||||||
$value = trim($this->placeholders->resolve($value));
|
$value = trim($this->placeholders->resolve($value));
|
||||||
if (VariableRef::contains($value)) {
|
if (VariableRef::contains($value)) {
|
||||||
$reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
|
$reference = $this->cleaner->clean($module, $path, $value, $subKey, 'text');
|
||||||
if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') {
|
if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') {
|
||||||
throw new InvalidArgumentException(self::COLOR_ERROR);
|
throw new InvalidArgumentException(self::COLOR_ERROR);
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,7 @@ final class ShortformWriter
|
|||||||
throw new InvalidArgumentException(self::COLOR_ERROR);
|
throw new InvalidArgumentException(self::COLOR_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
$clean = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
|
$clean = $this->cleaner->clean($module, $path, $value, $subKey, 'text');
|
||||||
if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) {
|
if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) {
|
||||||
throw new InvalidArgumentException(self::COLOR_ERROR);
|
throw new InvalidArgumentException(self::COLOR_ERROR);
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ final class ShortformWriter
|
|||||||
$path = (string) $spec['path'];
|
$path = (string) $spec['path'];
|
||||||
$sub = $spec['sub'] === null ? null : (string) $spec['sub'];
|
$sub = $spec['sub'] === null ? null : (string) $spec['sub'];
|
||||||
$base = $path . '.desktop.value';
|
$base = $path . '.desktop.value';
|
||||||
$this->attrs->guardPath($path);
|
$this->cleaner->guardPath($path);
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub);
|
return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub);
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ final class ShortformWriter
|
|||||||
|
|
||||||
[$urlKey, $idKey, $altKey] = $keys;
|
[$urlKey, $idKey, $altKey] = $keys;
|
||||||
$base = $path . '.desktop.value.';
|
$base = $path . '.desktop.value.';
|
||||||
$url = $this->attrs->cleanValue($module, $path, $image['url'], $urlKey, 'url');
|
$url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, 'url');
|
||||||
if ($url === '') {
|
if ($url === '') {
|
||||||
throw new InvalidArgumentException($notFound);
|
throw new InvalidArgumentException($notFound);
|
||||||
}
|
}
|
||||||
@@ -205,7 +206,7 @@ final class ShortformWriter
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$clean = $this->attrs->cleanValue($module, $path, $alt, $altKey, 'text');
|
$clean = $this->cleaner->clean($module, $path, $alt, $altKey, 'text');
|
||||||
} catch (InvalidArgumentException) {
|
} catch (InvalidArgumentException) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -262,7 +263,7 @@ final class ShortformWriter
|
|||||||
$value = $this->placeholders->resolve($value);
|
$value = $this->placeholders->resolve($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
$clean = $this->attrs->cleanValue($module, $path, $value, $sub, $kind);
|
$clean = $this->cleaner->clean($module, $path, $value, $sub, $kind);
|
||||||
if ($kind === 'url' && $clean === '' && trim($value) !== '') {
|
if ($kind === 'url' && $clean === '' && trim($value) !== '') {
|
||||||
throw new InvalidArgumentException('ungültige URL');
|
throw new InvalidArgumentException('ungültige URL');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
|||||||
* Rulings S8/S45: each string is tokenized (quote-aware tags, comments, entities, references,
|
* Rulings S8/S45: each string is tokenized (quote-aware tags, comments, entities, references,
|
||||||
* raw-text bodies such as <script>); only text tokens are searched. A string with an
|
* raw-text bodies such as <script>); only text tokens are searched. A string with an
|
||||||
* unterminated tag, quote, comment or reference and a hit is refused. The inserted text is the
|
* unterminated tag, quote, comment or reference and a hit is refused. The inserted text is the
|
||||||
* replacement as the $clean callback returns it for that place (AttrWriter::cleanValue() in the
|
* replacement as the $clean callback returns it for that place (ValueCleaner::textReplacementFor()
|
||||||
* ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
|
* in the ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
|
||||||
* effective hit is returned as it is and never cleaned (Task 1 forward risk). A changed string
|
* effective hit is returned as it is and never cleaned (Task 1 forward risk). A changed string
|
||||||
* must keep every non-text token of the original byte for byte, gain only the inserted text's
|
* must keep every non-text token of the original byte for byte, gain only the inserted text's
|
||||||
* own, add no reference marker, and an independent scan (HtmlContext) must see each replaced
|
* own, add no reference marker, and an independent scan (HtmlContext) must see each replaced
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()); S33a: a kind override may only tighten the routed kind (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';
|
||||||
|
|
||||||
|
/** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */
|
||||||
|
private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|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
|
||||||
|
* ('html'|'code'|'url'|'text') 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.
|
||||||
|
*
|
||||||
|
* @param 'html'|'code'|'url'|'text'|null $kind
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function clean(
|
||||||
|
string $module,
|
||||||
|
string $path,
|
||||||
|
mixed $value,
|
||||||
|
?string $subKey = null,
|
||||||
|
?string $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).
|
||||||
|
*
|
||||||
|
* @return 'html'|'code'|'url'|'text'
|
||||||
|
*/
|
||||||
|
public function kindOf(string $module, string $path, ?string $subKey = null): string
|
||||||
|
{
|
||||||
|
return $this->kind($module, $path, $subKey, $subKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 === 'text' || $kind === '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 oder HTML-Attribute) 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 'html'|'code'|'url'|'text'|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,
|
||||||
|
?string $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 && ! self::tightens($kind, $routed)) {
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").',
|
||||||
|
$kind,
|
||||||
|
$path,
|
||||||
|
$routed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 === 'url');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7.
|
||||||
|
$clean = match ($kind) {
|
||||||
|
'html' => $this->sanitizer->html($value),
|
||||||
|
'code' => $this->sanitizer->code($value),
|
||||||
|
'url' => $this->sanitizer->url($value),
|
||||||
|
default => $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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the kind override $kind is at least as strict as the routed kind (ruling S33a).
|
||||||
|
* Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text
|
||||||
|
* (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is
|
||||||
|
* tightened by the others, so only url → url passes.
|
||||||
|
*/
|
||||||
|
private static function tightens(string $kind, string $routed): bool
|
||||||
|
{
|
||||||
|
if ($kind === 'url' || $routed === 'url') {
|
||||||
|
return $kind === $routed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @return 'html'|'code'|'url'|'text'
|
||||||
|
*/
|
||||||
|
private function kind(string $module, string $path, ?string $subName, ?string $key): string
|
||||||
|
{
|
||||||
|
// "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' => 'html',
|
||||||
|
'code' => 'code',
|
||||||
|
'url', 'image' => 'url',
|
||||||
|
'unknown' => $this->unknownKind($path, $key),
|
||||||
|
default => 'text',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'html'|'url'|'text' */
|
||||||
|
private function unknownKind(string $path, ?string $key): string
|
||||||
|
{
|
||||||
|
if ($key !== null) {
|
||||||
|
return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = explode('.', $path);
|
||||||
|
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
|
||||||
|
return 'url';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path === 'content.innerContent' ? 'html' : 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
|||||||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Warnings;
|
use LIT\MCP\Integrations\Divi\Compose\Warnings;
|
||||||
@@ -20,9 +21,9 @@ use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
|||||||
/**
|
/**
|
||||||
* Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content
|
* Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content
|
||||||
* registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31:
|
* registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31:
|
||||||
* ONE VariableRef (carrying the live dynamic content map of the entry, S22) and ONE Placeholders
|
* ONE VariableRef (carrying the live dynamic content map of the entry, S22), ONE Placeholders
|
||||||
* instance are shared by AttrWriter and ShortformWriter, and the same AttrWriter serves
|
* and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the
|
||||||
* ShortformWriter and Composer.
|
* same AttrWriter serves Composer and the abilities.
|
||||||
*
|
*
|
||||||
* forPost() binds the composition to the entry being written (DiviWriter::composition() does it
|
* forPost() binds the composition to the entry being written (DiviWriter::composition() does it
|
||||||
* for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
|
* for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
|
||||||
@@ -33,6 +34,7 @@ class DiviComposition
|
|||||||
private ?int $postId = null;
|
private ?int $postId = null;
|
||||||
private ?VariableRef $references = null;
|
private ?VariableRef $references = null;
|
||||||
private ?Placeholders $placeholders = null;
|
private ?Placeholders $placeholders = null;
|
||||||
|
private ?ValueCleaner $cleaner = null;
|
||||||
private ?AttrWriter $attrWriter = null;
|
private ?AttrWriter $attrWriter = null;
|
||||||
private ?Composer $composer = null;
|
private ?Composer $composer = null;
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ class DiviComposition
|
|||||||
$bound->postId = $postId;
|
$bound->postId = $postId;
|
||||||
$bound->references = null;
|
$bound->references = null;
|
||||||
$bound->placeholders = null;
|
$bound->placeholders = null;
|
||||||
|
$bound->cleaner = null;
|
||||||
$bound->attrWriter = null;
|
$bound->attrWriter = null;
|
||||||
$bound->composer = null;
|
$bound->composer = null;
|
||||||
|
|
||||||
@@ -73,14 +76,15 @@ class DiviComposition
|
|||||||
return $this->placeholders ??= new Placeholders($this->design->read());
|
return $this->placeholders ??= new Placeholders($this->design->read());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */
|
||||||
|
public function cleaner(): ValueCleaner
|
||||||
|
{
|
||||||
|
return $this->cleaner ??= new ValueCleaner($this->modules->catalog(), $this->references(), $this->sanitizer);
|
||||||
|
}
|
||||||
|
|
||||||
public function attrWriter(): AttrWriter
|
public function attrWriter(): AttrWriter
|
||||||
{
|
{
|
||||||
return $this->attrWriter ??= new AttrWriter(
|
return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->cleaner());
|
||||||
$this->modules->catalog(),
|
|
||||||
$this->placeholders(),
|
|
||||||
$this->references(),
|
|
||||||
$this->sanitizer
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function composer(): Composer
|
public function composer(): Composer
|
||||||
@@ -88,8 +92,9 @@ class DiviComposition
|
|||||||
return $this->composer ??= new Composer(
|
return $this->composer ??= new Composer(
|
||||||
$this->modules->catalog(),
|
$this->modules->catalog(),
|
||||||
$this->rules(),
|
$this->rules(),
|
||||||
new ShortformWriter($this->placeholders(), $this->media, $this->attrWriter()),
|
new ShortformWriter($this->placeholders(), $this->media, $this->cleaner()),
|
||||||
$this->attrWriter(),
|
$this->attrWriter(),
|
||||||
|
$this->cleaner(),
|
||||||
$this->design->read(),
|
$this->design->read(),
|
||||||
$this->modules->builderVersion()
|
$this->modules->builderVersion()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ use WP_Error;
|
|||||||
* (DiviWriter::composition). Should the entry's map refuse something (an option Divi offers
|
* (DiviWriter::composition). Should the entry's map refuse something (an option Divi offers
|
||||||
* only for other types), write() returns the errors and lit/content-create deletes the draft.
|
* only for other types), write() returns the errors and lit/content-create deletes the draft.
|
||||||
*
|
*
|
||||||
* Every client string goes through Composer (AttrWriter / ShortformWriter) – the single choke
|
* Every client string goes through Composer (AttrWriter / ShortformWriter → ValueCleaner) – the
|
||||||
* point that makes the kses bypass safe (spec D7).
|
* single choke point that makes the kses bypass safe (spec D7).
|
||||||
*/
|
*/
|
||||||
final class DiviContentComposer implements ContentComposer
|
final class DiviContentComposer implements ContentComposer
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use WP_Error;
|
|||||||
* it is written; DiviWriter::save() stores it with its own restore-point revision and change
|
* it is written; DiviWriter::save() stores it with its own restore-point revision and change
|
||||||
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged
|
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged
|
||||||
* and reported there, the others go on. Inserted text is cleaned by the field kind through
|
* and reported there, the others go on. Inserted text is cleaned by the field kind through
|
||||||
* AttrWriter::cleanValue() (S2/S28); URL and code fields are never touched.
|
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
|
||||||
*/
|
*/
|
||||||
final class DiviReplaceTextAbility implements Ability
|
final class DiviReplaceTextAbility implements Ability
|
||||||
{
|
{
|
||||||
@@ -379,7 +379,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Every hit of $page, computed without changing it. The replacement is cleaned per place by
|
* Every hit of $page, computed without changing it. The replacement is cleaned per place by
|
||||||
* the entry's AttrWriter (field kind; markers rejected); URL and code fields are skipped.
|
* the entry's ValueCleaner (field kind; markers rejected); URL and code fields are skipped.
|
||||||
*
|
*
|
||||||
* @return array{count: int, samples: list<array<string, string>>,
|
* @return array{count: int, samples: list<array<string, string>>,
|
||||||
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||||
@@ -387,7 +387,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
*/
|
*/
|
||||||
private function replace(DiviPage $page, TextReplacer $replacer): array
|
private function replace(DiviPage $page, TextReplacer $replacer): array
|
||||||
{
|
{
|
||||||
$attrWriter = $this->writer->composition($page)->attrWriter();
|
$cleaner = $this->writer->composition($page)->cleaner();
|
||||||
$count = 0;
|
$count = 0;
|
||||||
$samples = [];
|
$samples = [];
|
||||||
$blocks = [];
|
$blocks = [];
|
||||||
@@ -404,16 +404,8 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$module = $block->name();
|
|
||||||
$clean = static function (string $path, ?string $key, string $value) use ($attrWriter, $module): ?string {
|
|
||||||
$kind = $attrWriter->kindOf($module, $path, $key);
|
|
||||||
|
|
||||||
return $kind === 'text' || $kind === 'html'
|
|
||||||
? (string) $attrWriter->cleanValue($module, $path, $value, $key)
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
$result = $replacer->apply($block->attrs(), $clean);
|
$result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name()));
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
|
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
|||||||
/**
|
/**
|
||||||
* WordPress sanitizing of Divi values (spec §5.6/§10). Because DiviSaver bypasses kses for the
|
* WordPress sanitizing of Divi values (spec §5.6/§10). Because DiviSaver bypasses kses for the
|
||||||
* page as a whole (kses would re-serialize every untouched block, D7), every value this plugin
|
* page as a whole (kses would re-serialize every untouched block, D7), every value this plugin
|
||||||
* writes goes through here instead – always via AttrWriter::cleanValue(), which also checks
|
* writes goes through here instead – always via ValueCleaner::clean(), which also checks
|
||||||
* $variable(…)$ references before and after these sanitizers.
|
* $variable(…)$ references before and after these sanitizers.
|
||||||
*
|
*
|
||||||
* Rich text and code depend on unfiltered_html like in core (ruling S4): administrators keep
|
* Rich text and code depend on unfiltered_html like in core (ruling S4): administrators keep
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
|||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
@@ -138,9 +139,15 @@ final class ComposeFixture
|
|||||||
return new VariableRef(DiviFixtures::designSystem());
|
return new VariableRef(DiviFixtures::designSystem());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The choke point for the sample catalog and design system (editor unless $unfiltered). */
|
||||||
|
public static function cleaner(bool $unfiltered = false): ValueCleaner
|
||||||
|
{
|
||||||
|
return new ValueCleaner(DiviFixtures::catalog(), self::references(), self::sanitizer($unfiltered));
|
||||||
|
}
|
||||||
|
|
||||||
public static function attrWriter(bool $unfiltered = false): AttrWriter
|
public static function attrWriter(bool $unfiltered = false): AttrWriter
|
||||||
{
|
{
|
||||||
return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::references(), self::sanitizer($unfiltered));
|
return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::cleaner($unfiltered));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +156,7 @@ final class ComposeFixture
|
|||||||
*/
|
*/
|
||||||
public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter
|
public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter
|
||||||
{
|
{
|
||||||
return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules);
|
return new ShortformWriter(self::placeholders(), self::media(), self::cleaner($unfiltered), $modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function rules(): StructureRules
|
public static function rules(): StructureRules
|
||||||
@@ -158,20 +165,23 @@ final class ComposeFixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders and one
|
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders, one
|
||||||
* VariableRef instance shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31).
|
* VariableRef and one ValueCleaner shared by AttrWriter, ShortformWriter and Composer, like the
|
||||||
|
* live wiring – ruling S31).
|
||||||
*/
|
*/
|
||||||
public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer
|
public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer
|
||||||
{
|
{
|
||||||
$design ??= DiviFixtures::designSystem();
|
$design ??= DiviFixtures::designSystem();
|
||||||
$placeholders = new Placeholders($design);
|
$placeholders = new Placeholders($design);
|
||||||
$attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, new VariableRef($design), self::sanitizer($unfiltered));
|
$cleaner = new ValueCleaner(DiviFixtures::catalog(), new VariableRef($design), self::sanitizer($unfiltered));
|
||||||
|
$attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, $cleaner);
|
||||||
|
|
||||||
return new Composer(
|
return new Composer(
|
||||||
DiviFixtures::catalog(),
|
DiviFixtures::catalog(),
|
||||||
self::rules(),
|
self::rules(),
|
||||||
new ShortformWriter($placeholders, self::media(), $attrWriter),
|
new ShortformWriter($placeholders, self::media(), $cleaner),
|
||||||
$attrWriter,
|
$attrWriter,
|
||||||
|
$cleaner,
|
||||||
$design,
|
$design,
|
||||||
'5.2.1'
|
'5.2.1'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,65 +89,19 @@ final class AttrWriterTest extends TestCase
|
|||||||
|
|
||||||
public function testSanitizesByPlace(): void
|
public function testSanitizesByPlace(): void
|
||||||
{
|
{
|
||||||
$writer = ComposeFixture::attrWriter();
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
|
self::assertSame('', $cleaner->clean('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
|
||||||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
|
self::assertSame('<p>x</p>', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
|
||||||
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
|
self::assertSame('<script>ok()</script>', $cleaner->clean('divi/code', 'content.innerContent', '<script>ok()</script>'));
|
||||||
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
|
self::assertSame('10px', $cleaner->clean('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||||
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
|
$cleaner->clean('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
|
||||||
);
|
);
|
||||||
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
|
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
|
|
||||||
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
|
|
||||||
{
|
|
||||||
$writer = ComposeFixture::attrWriter();
|
|
||||||
|
|
||||||
// Unknown kind, URL keys deep inside the value.
|
|
||||||
self::assertSame(
|
|
||||||
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
|
|
||||||
$writer->cleanValue('divi/text', 'module.decoration.background', [
|
|
||||||
'color' => '<i>#fff</i>',
|
|
||||||
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
|
|
||||||
])
|
|
||||||
);
|
|
||||||
self::assertSame(
|
|
||||||
['url' => '', 'target' => 'on'],
|
|
||||||
$writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
|
|
||||||
);
|
|
||||||
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [
|
|
||||||
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
|
|
||||||
]));
|
|
||||||
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
|
|
||||||
self::assertSame('<p>Hallo <b>Welt</b></p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
|
|
||||||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
|
|
||||||
self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
|
|
||||||
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
|
|
||||||
self::assertSame(
|
|
||||||
['text' => 'Mehr', 'linkUrl' => ''],
|
|
||||||
$writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [
|
|
||||||
'text' => '<b>Mehr</b>',
|
|
||||||
'linkUrl' => 'javascript:alert(1)',
|
|
||||||
])
|
|
||||||
);
|
|
||||||
self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
|
|
||||||
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
|
|
||||||
self::assertSame(
|
|
||||||
['useIcon' => 'on', 'src' => ''],
|
|
||||||
$writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
|
|
||||||
);
|
|
||||||
self::assertSame(
|
|
||||||
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
|
|
||||||
$writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
|
|
||||||
);
|
|
||||||
// Numbers and booleans are no strings: unchanged.
|
|
||||||
self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
|
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
|
||||||
* unfiltered_html).
|
* unfiltered_html).
|
||||||
@@ -249,14 +203,6 @@ final class AttrWriterTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEmbeddedReferenceNamesTheRule(): void
|
|
||||||
{
|
|
||||||
$this->expectException(InvalidArgumentException::class);
|
|
||||||
$this->expectExceptionMessage('Verweise nur allein im Wert');
|
|
||||||
|
|
||||||
ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
|
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
|
||||||
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
|
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
|
||||||
{
|
{
|
||||||
@@ -281,75 +227,18 @@ final class AttrWriterTest extends TestCase
|
|||||||
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
|
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
|
||||||
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
|
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
|
||||||
try {
|
try {
|
||||||
ComposeFixture::attrWriter()->cleanValue('divi/canvas-portal', $path, 'x');
|
ComposeFixture::cleaner()->clean('divi/canvas-portal', $path, 'x');
|
||||||
self::fail('Accepted for an editor: ' . $path);
|
self::fail('Accepted for an editor: ' . $path);
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self::assertSame('x', ComposeFixture::attrWriter(true)->cleanValue('divi/canvas-portal', 'css.advanced.customCss', 'x'));
|
self::assertSame('x', ComposeFixture::cleaner(true)->clean('divi/canvas-portal', 'css.advanced.customCss', 'x'));
|
||||||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.htmlish', 'x'));
|
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.htmlish', 'x'));
|
||||||
|
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
$this->expectExceptionMessage('nur im Divi-Builder');
|
$this->expectExceptionMessage('nur im Divi-Builder');
|
||||||
ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]);
|
ComposeFixture::cleaner()->clean('divi/button', 'module.decoration.attributes', ['attributes' => []]);
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */
|
|
||||||
public function testCustomCssPathsNeedUnfilteredHtml(): void
|
|
||||||
{
|
|
||||||
$css = ['mainElement' => 'color:red;} body{display:none'];
|
|
||||||
foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) {
|
|
||||||
try {
|
|
||||||
ComposeFixture::attrWriter()->cleanValue('divi/text', $path, $css);
|
|
||||||
self::fail('Accepted for an editor: ' . $path);
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
self::assertSame($css, ComposeFixture::attrWriter(true)->cleanValue('divi/text', $path, $css));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a whole "css" segment counts.
|
|
||||||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.cssClass', 'x'));
|
|
||||||
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.cssish', 'x'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
|
|
||||||
public function testUnknownContentInnerContentIsRichText(): void
|
|
||||||
{
|
|
||||||
self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent'));
|
|
||||||
self::assertSame('<p>Hallo</p>', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
|
|
||||||
// Other unknown innerContent stays text.
|
|
||||||
self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** kindOf() names the routing cleanValue() applies (lit/divi-replace-text skips url/code). */
|
|
||||||
public function testKindOfNamesTheRouting(): void
|
|
||||||
{
|
|
||||||
$writer = ComposeFixture::attrWriter();
|
|
||||||
|
|
||||||
self::assertSame('html', $writer->kindOf('divi/text', 'content.innerContent'));
|
|
||||||
self::assertSame('text', $writer->kindOf('divi/heading', 'title.innerContent'));
|
|
||||||
self::assertSame('code', $writer->kindOf('divi/code', 'content.innerContent'));
|
|
||||||
self::assertSame('text', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
|
|
||||||
self::assertSame('url', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
|
|
||||||
self::assertSame('text', $writer->kindOf('divi/image', 'image.innerContent', 'alt'));
|
|
||||||
self::assertSame('url', $writer->kindOf('divi/image', 'image.innerContent', 'src'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
|
|
||||||
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
|
|
||||||
{
|
|
||||||
$writer = ComposeFixture::attrWriter();
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
['url' => '', 'text' => 'a'],
|
|
||||||
$writer->cleanValue('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
|
|
||||||
);
|
|
||||||
self::assertSame('', $writer->cleanValue('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
|
|
||||||
self::assertSame('', $writer->cleanValue('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
|
|
||||||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Titel</b>'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ruling S13: keys inside value maps are plain identifiers. */
|
/** Ruling S13: keys inside value maps are plain identifiers. */
|
||||||
@@ -387,41 +276,6 @@ final class AttrWriterTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
|
|
||||||
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
|
|
||||||
{
|
|
||||||
$writer = ComposeFixture::attrWriter();
|
|
||||||
|
|
||||||
self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
|
|
||||||
self::assertSame("<p>a\nb\r</p>", $writer->cleanValue('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
|
|
||||||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
|
|
||||||
|
|
||||||
// S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes
|
|
||||||
// through canonical() like any reference – validated, cleaned (S21) and re-serialized.
|
|
||||||
$tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>x()</script>","after":""}}})$';
|
|
||||||
$canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
|
|
||||||
$targets = [
|
|
||||||
'text' => ['divi/heading', 'title.innerContent'],
|
|
||||||
'html' => ['divi/text', 'content.innerContent'],
|
|
||||||
'code' => ['divi/code', 'content.innerContent'],
|
|
||||||
];
|
|
||||||
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
|
|
||||||
$value = '$variable' . $control . $tail;
|
|
||||||
// The fake keeps them like sanitize_text_field does: the production code handles it.
|
|
||||||
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x'));
|
|
||||||
foreach ($targets as $kind => [$module, $path]) {
|
|
||||||
self::assertSame($canonical, $writer->cleanValue($module, $path, $value), $kind . ' ' . ord($control));
|
|
||||||
}
|
|
||||||
// Embedded in text it is an embedded reference: refused.
|
|
||||||
try {
|
|
||||||
$writer->cleanValue('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail);
|
|
||||||
self::fail('embedded control-split reference accepted');
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
self::assertStringContainsString('allein im Wert', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
|
* Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
|
||||||
* stripping the control character only after the sanitizer would build a real tag. Controls
|
* stripping the control character only after the sanitizer would build a real tag. Controls
|
||||||
@@ -430,6 +284,7 @@ final class AttrWriterTest extends TestCase
|
|||||||
public function testControlCharactersGoBeforeTheSanitizer(): void
|
public function testControlCharactersGoBeforeTheSanitizer(): void
|
||||||
{
|
{
|
||||||
$writer = ComposeFixture::attrWriter();
|
$writer = ComposeFixture::attrWriter();
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
|
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
|
||||||
$script = '<' . chr(12) . 'script>alert(1)</script>';
|
$script = '<' . chr(12) . 'script>alert(1)</script>';
|
||||||
// The fake reproduces the WordPress behaviour the attack relies on.
|
// The fake reproduces the WordPress behaviour the attack relies on.
|
||||||
@@ -441,7 +296,7 @@ final class AttrWriterTest extends TestCase
|
|||||||
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
|
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
|
||||||
self::assertStringNotContainsString('<', $stored);
|
self::assertStringNotContainsString('<', $stored);
|
||||||
|
|
||||||
$button = $writer->cleanValue('divi/button', 'button.innerContent', $value, 'text');
|
$button = $cleaner->clean('divi/button', 'button.innerContent', $value, 'text');
|
||||||
self::assertStringNotContainsString('<', $button);
|
self::assertStringNotContainsString('<', $button);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,35 +311,6 @@ final class AttrWriterTest extends TestCase
|
|||||||
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
|
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
|
|
||||||
public function testKindOverrideMayOnlyTighten(): void
|
|
||||||
{
|
|
||||||
$writer = ComposeFixture::attrWriter();
|
|
||||||
|
|
||||||
self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
|
|
||||||
self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
|
|
||||||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
|
|
||||||
self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
|
|
||||||
self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
|
|
||||||
|
|
||||||
$weaker = [
|
|
||||||
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
|
|
||||||
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
|
|
||||||
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
|
|
||||||
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
|
|
||||||
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
|
|
||||||
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
|
|
||||||
];
|
|
||||||
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
|
|
||||||
try {
|
|
||||||
$writer->cleanValue($module, $path, 'x', $sub, $kind);
|
|
||||||
self::fail($name . ' was accepted');
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
|
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
|
||||||
* control-split placeholder is the placeholder (resolved and checked), never literal text
|
* control-split placeholder is the placeholder (resolved and checked), never literal text
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ final class ComposerTest extends TestCase
|
|||||||
ComposeFixture::rules(),
|
ComposeFixture::rules(),
|
||||||
ComposeFixture::shortform(),
|
ComposeFixture::shortform(),
|
||||||
ComposeFixture::attrWriter(),
|
ComposeFixture::attrWriter(),
|
||||||
|
ComposeFixture::cleaner(),
|
||||||
DiviFixtures::designSystem(),
|
DiviFixtures::designSystem(),
|
||||||
'5.2.1"},"x":{"'
|
'5.2.1"},"x":{"'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** The choke point (ValueCleaner): tests moved from AttrWriterTest with clean()/kindOf() (H1). */
|
||||||
|
final class ValueCleanerTest extends TestCase
|
||||||
|
{
|
||||||
|
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
|
||||||
|
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
|
||||||
|
{
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
|
// Unknown kind, URL keys deep inside the value.
|
||||||
|
self::assertSame(
|
||||||
|
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
|
||||||
|
$cleaner->clean('divi/text', 'module.decoration.background', [
|
||||||
|
'color' => '<i>#fff</i>',
|
||||||
|
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['url' => '', 'target' => 'on'],
|
||||||
|
$cleaner->clean('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
|
||||||
|
);
|
||||||
|
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $cleaner->clean('divi/text', 'module.advanced.link', [
|
||||||
|
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
|
||||||
|
]));
|
||||||
|
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
|
||||||
|
self::assertSame('<p>Hallo <b>Welt</b></p>', $cleaner->clean('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
|
||||||
|
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
|
||||||
|
self::assertSame('Los', $cleaner->clean('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
|
||||||
|
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
|
||||||
|
self::assertSame(
|
||||||
|
['text' => 'Mehr', 'linkUrl' => ''],
|
||||||
|
$cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', [
|
||||||
|
'text' => '<b>Mehr</b>',
|
||||||
|
'linkUrl' => 'javascript:alert(1)',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
self::assertSame('', $cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
|
||||||
|
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
|
||||||
|
self::assertSame(
|
||||||
|
['useIcon' => 'on', 'src' => ''],
|
||||||
|
$cleaner->clean('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
|
||||||
|
$cleaner->clean('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
|
||||||
|
);
|
||||||
|
// Numbers and booleans are no strings: unchanged.
|
||||||
|
self::assertSame(['size' => 12, 'on' => true], $cleaner->clean('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmbeddedReferenceNamesTheRule(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Verweise nur allein im Wert');
|
||||||
|
|
||||||
|
ComposeFixture::cleaner()->clean('divi/text', 'content.innerContent', 'a $variable({}) b');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */
|
||||||
|
public function testCustomCssPathsNeedUnfilteredHtml(): void
|
||||||
|
{
|
||||||
|
$css = ['mainElement' => 'color:red;} body{display:none'];
|
||||||
|
foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) {
|
||||||
|
try {
|
||||||
|
ComposeFixture::cleaner()->clean('divi/text', $path, $css);
|
||||||
|
self::fail('Accepted for an editor: ' . $path);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame($css, ComposeFixture::cleaner(true)->clean('divi/text', $path, $css));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a whole "css" segment counts.
|
||||||
|
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.cssClass', 'x'));
|
||||||
|
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.cssish', 'x'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
|
||||||
|
public function testUnknownContentInnerContentIsRichText(): void
|
||||||
|
{
|
||||||
|
self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent'));
|
||||||
|
self::assertSame('<p>Hallo</p>', ComposeFixture::cleaner()->clean('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
|
||||||
|
// Other unknown innerContent stays text.
|
||||||
|
self::assertSame('Los', ComposeFixture::cleaner()->clean('divi/button', 'button.innerContent', '<b>Los</b>'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** kindOf() names the routing clean() applies (lit/divi-replace-text skips url/code). */
|
||||||
|
public function testKindOfNamesTheRouting(): void
|
||||||
|
{
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
|
self::assertSame('html', $cleaner->kindOf('divi/text', 'content.innerContent'));
|
||||||
|
self::assertSame('text', $cleaner->kindOf('divi/heading', 'title.innerContent'));
|
||||||
|
self::assertSame('code', $cleaner->kindOf('divi/code', 'content.innerContent'));
|
||||||
|
self::assertSame('text', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
|
||||||
|
self::assertSame('url', $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
|
||||||
|
self::assertSame('text', $cleaner->kindOf('divi/image', 'image.innerContent', 'alt'));
|
||||||
|
self::assertSame('url', $cleaner->kindOf('divi/image', 'image.innerContent', 'src'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
|
||||||
|
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
|
||||||
|
{
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['url' => '', 'text' => 'a'],
|
||||||
|
$cleaner->clean('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
|
||||||
|
);
|
||||||
|
self::assertSame('', $cleaner->clean('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
|
||||||
|
self::assertSame('', $cleaner->clean('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
|
||||||
|
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Titel</b>'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
|
||||||
|
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
|
||||||
|
{
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
|
self::assertSame("a\tb\n", $cleaner->clean('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
|
||||||
|
self::assertSame("<p>a\nb\r</p>", $cleaner->clean('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
|
||||||
|
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
|
||||||
|
|
||||||
|
// S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes
|
||||||
|
// through canonical() like any reference – validated, cleaned (S21) and re-serialized.
|
||||||
|
$tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>x()</script>","after":""}}})$';
|
||||||
|
$canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
|
||||||
|
$targets = [
|
||||||
|
'text' => ['divi/heading', 'title.innerContent'],
|
||||||
|
'html' => ['divi/text', 'content.innerContent'],
|
||||||
|
'code' => ['divi/code', 'content.innerContent'],
|
||||||
|
];
|
||||||
|
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
|
||||||
|
$value = '$variable' . $control . $tail;
|
||||||
|
// The fake keeps them like sanitize_text_field does: the production code handles it.
|
||||||
|
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x'));
|
||||||
|
foreach ($targets as $kind => [$module, $path]) {
|
||||||
|
self::assertSame($canonical, $cleaner->clean($module, $path, $value), $kind . ' ' . ord($control));
|
||||||
|
}
|
||||||
|
// Embedded in text it is an embedded reference: refused.
|
||||||
|
try {
|
||||||
|
$cleaner->clean('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail);
|
||||||
|
self::fail('embedded control-split reference accepted');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('allein im Wert', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
|
||||||
|
public function testKindOverrideMayOnlyTighten(): void
|
||||||
|
{
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
|
self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
|
||||||
|
self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
|
||||||
|
self::assertSame('<p>x</p>', $cleaner->clean('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
|
||||||
|
self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
|
||||||
|
self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
|
||||||
|
|
||||||
|
$weaker = [
|
||||||
|
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
|
||||||
|
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
|
||||||
|
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
|
||||||
|
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
|
||||||
|
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
|
||||||
|
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
|
||||||
|
];
|
||||||
|
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
|
||||||
|
try {
|
||||||
|
$cleaner->clean($module, $path, 'x', $sub, $kind);
|
||||||
|
self::fail($name . ' was accepted');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** D10: replace-text inserts only into text/html places, cleaned by the routed kind; URL and code stay. */
|
||||||
|
public function testTextReplacementTouchesOnlyTextAndHtml(): void
|
||||||
|
{
|
||||||
|
$cleaner = ComposeFixture::cleaner();
|
||||||
|
|
||||||
|
self::assertSame('<p>x</p>', ($cleaner->textReplacementFor('divi/text'))('content.innerContent', null, '<p>x</p><script>y</script>'));
|
||||||
|
self::assertSame('Los', ($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'text', '<b>Los</b>'));
|
||||||
|
self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test'));
|
||||||
|
self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user