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 ("", "%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; } }