Files
WP-LIT-MCP/src/Integrations/Divi/Pages/DiviTextSearch.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

128 lines
5.1 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\Pages;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\StoredForms;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\PostStatuses;
defined('ABSPATH') || exit;
/**
* The search part of lit/divi-replace-text (D10): the SQL prefilter for candidate pages and
* the hits of one page, computed without changing it. The replacement is cleaned per place by
* the entry's ValueCleaner (ValueCleaner::textReplacementFor(): field kind, markers rejected);
* URL and code fields are skipped; opaque nodes that mention the search are reported, never
* changed.
*/
final class DiviTextSearch
{
private const SAMPLES = 5;
public const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten';
public function __construct(private ?\wpdb $db = null)
{
}
/**
* IDs of Divi entries whose content contains the search text in one of its stored forms
* (StoredForms::of()). The exact match happens in the tree; this is only a prefilter.
*
* @return list<int>
*/
public function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
{
$db = $this->db();
$forms = StoredForms::of($search, $caseSensitive);
$likes = array_map(
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
$forms
);
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
. ' AND post_status NOT IN (' . PostStatuses::NOT_CONTENT . ')'
. ' AND post_content LIKE %s AND (' . implode(' OR ', array_fill(0, count($likes), 'post_content LIKE %s'))
. ')';
$args = array_merge([$postType, '%' . $db->esc_like('<!-- wp:divi/') . '%'], $likes);
$author = PostGuard::ownPostsOnly($postType);
if ($author !== null) {
$sql .= ' AND post_author = %d';
$args[] = $author;
}
$sql .= ' ORDER BY ID ASC LIMIT %d OFFSET %d';
array_push($args, $limit, $offset);
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql holds only the core posts table, a constant status list and one placeholder per argument.
$ids = $db->get_col($db->prepare($sql, ...$args));
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
return array_map('intval', is_array($ids) ? $ids : []);
}
/**
* Every hit of $page, computed without changing it.
*
* @return array{count: int, samples: list<array<string, string>>,
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
*/
public function replace(DiviPage $page, TextReplacer $replacer, ValueCleaner $cleaner): array
{
$count = 0;
$samples = [];
$blocks = [];
$skipped = [];
foreach ($page->index->entries() as $entry) {
$block = $entry['block'];
if ($block->isDivi() && $block->isOpaque()) {
if ($replacer->mentions(Serializer::block($block))) {
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
}
continue;
}
if (! $block->isDivi()) {
continue;
}
try {
$result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name()));
} catch (InvalidArgumentException $e) {
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
}
if ($result['count'] > 0) {
$count += $result['count'];
$blocks[] = [$block, $result['attrs']];
foreach ($result['samples'] as $sample) {
if (count($samples) < self::SAMPLES) {
$samples[] = ['id' => (string) $entry['id']] + $sample;
}
}
}
}
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
}
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
private static function opaqueReason(Block $block): string
{
return $block->isGlobal()
? self::GLOBAL_REASON
: 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten';
}
private function db(): \wpdb
{
return $this->db ?? $GLOBALS['wpdb'];
}
}