Files
WP-LIT-MCP/src/Integrations/Divi/Compose/TextReplacer.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:32:12 +02:00

387 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
defined('ABSPATH') || exit;
/**
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7, D10):
* only in content (…innerContent) – plain values and the text keys of object values (text,
* title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$ references.
* Pure PHP.
*
* Rulings S8/S45: each string is tokenized by HtmlTokens (quote-aware tags, comments, entities,
* references, 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
* replacement as the $clean callback returns it for that place (ValueCleaner::textReplacementFor()
* 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
* 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
* hit and both ends of each insertion in text context – otherwise the whole call is refused
* (InvalidArgumentException, the page is not changed).
*/
final class TextReplacer
{
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
private const CONTEXT = 30;
private string $pattern;
/** @var array<string, ?string> Cleaned replacement per place (path|key). */
private array $cleaned = [];
/** @throws InvalidArgumentException When problems() finds any. */
public function __construct(private string $search, private string $replace, private bool $caseSensitive = true)
{
$problems = self::problems($search, $replace);
if ($problems !== []) {
throw new InvalidArgumentException(implode(' ', $problems));
}
$this->pattern = '/' . preg_quote($search, '/') . '/u' . ($caseSensitive ? '' : 'i');
}
/**
* Input problems, field => message (empty when fine). The replacement must neither be nor
* contain a reference (rulings S2/D9) – also not one a sanitizer would build by stripping
* control characters, tags or %XX octets (S28/S32a); the search must not contain the marker.
*
* @return array<string, string>
*/
public static function problems(string $search, string $replace): array
{
$problems = [];
if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) {
$problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.';
} elseif (ControlChars::strip($search) !== $search) {
$problems['search'] = 'Suchtext darf keine Steuerzeichen enthalten.';
} elseif (VariableRef::contains($search)) {
$problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden '
. 'nie ersetzt.';
}
if (! mb_check_encoding($replace, 'UTF-8')) {
$problems['replace'] = 'Ersetzung ist kein gültiges UTF-8.';
} elseif (VariableRef::contains(self::stripped($replace))) {
$problems['replace'] = 'Ersetzung darf keinen $variable(…)$-Verweis enthalten (auch nicht zerlegt); '
. 'dynamische Inhalte nur über lit/divi-update einzeln setzen.';
}
return $problems;
}
/** Whether block markup (e.g. of a global module this tool does not edit) contains the search text. */
public function mentions(string $markup): bool
{
return StoredForms::mentions($markup, $this->search, $this->caseSensitive);
}
/**
* @param array<string, mixed> $attrs
* @param callable(string $path, ?string $key, string $replacement): ?string $clean
* The replacement as it may be inserted at that place, or null when the place is no text
* field (URL, code) and stays as it is. May throw InvalidArgumentException.
* @return array{attrs: array<string, mixed>, count: int,
* samples: list<array{path: string, before: string, after: string}>}
* @throws InvalidArgumentException When a replacement would change markup or references.
*/
public function apply(array $attrs, callable $clean): array
{
$count = 0;
$samples = [];
foreach (AttrMap::flatten($attrs) as $path => $map) {
// A key containing "." would make the dotted path address something else.
$content = str_ends_with($path, '.innerContent') && AttrMap::isBreakpointMap($map);
if (! $content || AttrMap::get($attrs, $path) !== $map) {
continue;
}
foreach ($map as $breakpoint => $states) {
foreach (is_array($states) ? $states : [] as $state => $value) {
$replaced = $this->value($value, $path, $clean, $count, $samples);
if ($replaced !== $value) {
$attrs = AttrMap::set($attrs, sprintf('%s.%s.%s', $path, $breakpoint, $state), $replaced);
}
}
}
}
return ['attrs' => $attrs, 'count' => $count, 'samples' => $samples];
}
/**
* @param callable(string, ?string, string): ?string $clean
* @param list<array{path: string, before: string, after: string}> $samples
*/
private function value(mixed $value, string $path, callable $clean, int &$count, array &$samples): mixed
{
if (is_string($value)) {
return $this->text($value, $path, null, $clean, $count, $samples);
}
if (is_array($value)) {
foreach (self::TEXT_KEYS as $key) {
if (is_string($value[$key] ?? null)) {
$value[$key] = $this->text($value[$key], $path, $key, $clean, $count, $samples);
}
}
}
return $value;
}
/**
* @param callable(string, ?string, string): ?string $clean
* @param list<array{path: string, before: string, after: string}> $samples
*/
private function text(
string $value,
string $path,
?string $key,
callable $clean,
int &$count,
array &$samples
): string {
$place = $path . ($key === null ? '' : '.' . $key);
$tokens = HtmlTokens::tokenize($value);
if ($tokens === null) {
// Unterminated markup, quotes, comments or references: where does the text end? Never guess.
if (preg_match($this->pattern, $value) === 1) {
throw self::refused($place);
}
return $value;
}
$hits = $this->hits($tokens);
if ($hits === null || $hits === []) {
return $value;
}
$replacement = $this->replacement($path, $key, $clean);
if ($replacement === null) {
return $value;
}
// Only hits that change something (search == replacement, or a cleaned replacement equal
// to the match, change nothing and are no hits).
$hits = array_values(array_filter($hits, static fn (array $hit): bool => $hit[2] !== $replacement));
if ($hits === []) {
return $value;
}
$inserted = HtmlTokens::tokenize($replacement);
if ($inserted === null) {
throw self::refused($place);
}
[$replaced, $insertedRanges, $expected] = self::splice($value, $tokens, $hits, $replacement, $inserted);
if (! self::safe($value, $tokens, $hits, $replaced, $insertedRanges, $expected)) {
throw self::refused($place);
}
$count += count($hits);
if (count($samples) < 5) {
$samples[] = [
'path' => $place,
'before' => $this->excerpt(NodeSummary::textOf($value), $this->search),
'after' => $this->excerpt(NodeSummary::textOf($replaced), $replacement),
];
}
return $replaced;
}
/**
* Hits in text tokens: [byte offset in the value, byte length, matched text]. Null when a
* token cannot be searched (invalid UTF-8): the string stays as it is.
*
* @param list<array{string, string, int}> $tokens
* @return list<array{int, int, string}>|null
*/
private function hits(array $tokens): ?array
{
$hits = [];
foreach ($tokens as [$type, $text, $offset]) {
if ($type !== 'text') {
continue;
}
if (preg_match_all($this->pattern, $text, $matches, PREG_OFFSET_CAPTURE) === false) {
return null;
}
foreach ($matches[0] as [$match, $at]) {
$hits[] = [$offset + (int) $at, strlen((string) $match), (string) $match];
}
}
return $hits;
}
/**
* The value with every hit replaced, the byte ranges of the inserted text in it, and the
* non-text tokens it must have: the original ones with the inserted text's own at each hit.
*
* @param list<array{string, string, int}> $tokens
* @param list<array{int, int, string}> $hits
* @param list<array{string, string, int}> $inserted
* @return array{string, list<array{int, int}>, list<array{string, string}>}
*/
private static function splice(
string $value,
array $tokens,
array $hits,
string $replacement,
array $inserted
): array {
$own = HtmlTokens::structure($inserted);
$replaced = '';
$ranges = [];
$expected = [];
$position = 0;
$next = 0;
foreach ($tokens as [$type, $text, $offset]) {
if ($type !== 'text') {
$expected[] = [$type, $text];
continue;
}
$end = $offset + strlen($text);
while (isset($hits[$next]) && $hits[$next][0] < $end) {
[$at, $length] = $hits[$next];
$replaced .= substr($value, $position, $at - $position);
$ranges[] = [strlen($replaced), strlen($replaced) + strlen($replacement)];
$replaced .= $replacement;
array_push($expected, ...$own);
$position = $at + $length;
$next++;
}
}
$replaced .= substr($value, $position);
return [$replaced, $ranges, $expected];
}
/**
* Rulings S8/S45: the changed value is safe when (1) every reference marker of the original
* is a whole reference and none is added, (2) its full non-text tokens (tags byte for byte,
* comments, entities, references, raw bodies) are exactly the expected ones, and (3) an
* independent scan (HtmlContext) puts every replaced hit of the original and both ends of
* every inserted text in text context.
*
* @param list<array{string, string, int}> $tokens
* @param list<array{int, int, string}> $hits
* @param list<array{int, int}> $insertedRanges
* @param list<array{string, string}> $expected
*/
private static function safe(
string $value,
array $tokens,
array $hits,
string $replaced,
array $insertedRanges,
array $expected
): bool {
$markers = substr_count($value, VariableRef::MARKER);
$references = count(array_filter(
$tokens,
static fn (array $token): bool => $token[0] === 'token' && str_starts_with($token[1], VariableRef::MARKER)
));
$after = HtmlTokens::tokenize($replaced);
$sameMarkers = $markers === $references && substr_count($replaced, VariableRef::MARKER) === $markers;
if (! $sameMarkers || $after === null || HtmlTokens::structure($after) !== $expected) {
return false;
}
$before = HtmlContext::markupSpans($value);
foreach ($hits as [$at, $length]) {
if (! HtmlContext::isText($before, $at, $at + $length)) {
return false;
}
}
$spans = HtmlContext::markupSpans($replaced);
foreach ($insertedRanges as [$start, $end]) {
if (! HtmlContext::isBoundary($spans, $start) || ! HtmlContext::isBoundary($spans, $end)) {
return false;
}
}
return true;
}
/** @param callable(string, ?string, string): ?string $clean */
private function replacement(string $path, ?string $key, callable $clean): ?string
{
$place = $path . '|' . ($key ?? '');
if (! array_key_exists($place, $this->cleaned)) {
$cleaned = $clean($path, $key, $this->replace);
if ($cleaned !== null && VariableRef::contains($cleaned)) {
throw VariableRef::notAlone();
}
$this->cleaned[$place] = $cleaned;
}
return $this->cleaned[$place];
}
/**
* $value as a sanitizer could leave it: controls gone, then tags (nested like strip_tags
* counts them, an unclosed one up to the end) and %XX octets removed until nothing changes.
*/
private static function stripped(string $value): string
{
$value = ControlChars::strip($value);
do {
$before = $value;
$kept = '';
$depth = 0;
foreach (str_split($value) as $char) {
if ($char === '<') {
$depth++;
} elseif ($depth > 0) {
$depth -= $char === '>' ? 1 : 0;
} else {
$kept .= $char;
}
}
$value = (string) preg_replace('/%[0-9A-Fa-f]{2}/', '', $kept);
} while ($value !== $before);
return $value;
}
private static function refused(string $place): InvalidArgumentException
{
return new InvalidArgumentException(sprintf(
'"%s": die Ersetzung würde dort HTML-Tags, Entities oder $variable(…)$-Verweise verändern oder neu '
. 'bilden (Text grenzt an ein einzelnes "<", ">", "&" oder einen unvollständigen Verweis); nichts '
. 'wurde ersetzt.',
$place
));
}
private function excerpt(string $text, string $needle): string
{
$position = $needle === '' ? false
: ($this->caseSensitive ? mb_strpos($text, $needle) : mb_stripos($text, $needle));
if ($position === false) {
return mb_substr($text, 0, 2 * self::CONTEXT);
}
$start = max(0, $position - self::CONTEXT);
$length = mb_strlen($needle) + 2 * self::CONTEXT;
return ($start > 0 ? '…' : '') . mb_substr($text, $start, $length)
. ($start + $length < mb_strlen($text) ? '…' : '');
}
}