fix: GlobalUsages finds every reference Divi renders, fail closed (F1, ruling P23)
Candidates come from one ID/status/type query: content with globalModule, global_module or a JSON-escaped letter. Their content is read in chunks of 50 and parsed by UsageMatcher: Divi 5 openers are json-decoded (escaped keys and values, leading zeros, any depth), and Divi 4 global_module attributes are read in any quoting. An undecodable escaped opener counts as a possible reference. Beyond 5000 candidates the rest count unread. The guide names the formats and the wp_block parity. Also fixes the grammar of the blocked-hidden count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv
|
||||
- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type`
|
||||
(`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category`
|
||||
(Name), weiterblättern mit `cursor`. Gelistet werden nur Einträge, die das Konto bearbeiten darf. Mit `id`:
|
||||
ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die ihn als globales Modul
|
||||
zeigen (`affected`, `affected_count`, `hidden_count`).
|
||||
ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und die Seiten, die auf ihn verweisen
|
||||
(`affected`, `affected_count`, `hidden_count`). Gezählt wird jeder Verweis, den Divi darstellt: Divi-5-Instanzen
|
||||
in jeder Schreibweise und Divi-4-Shortcodes mit `global_module`; ein Verweis, der sich nicht eindeutig lesen
|
||||
lässt, zählt vorsichtshalber mit. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint
|
||||
dieser Block in der Liste, nicht die Seite, die ihn einbettet – wie bei Divi selbst.
|
||||
- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen
|
||||
Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie.
|
||||
Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev`
|
||||
|
||||
@@ -224,9 +224,9 @@ final class GlobalEdits
|
||||
}
|
||||
if ($report->blockedHidden > 0) {
|
||||
$parts[] = sprintf(
|
||||
'%d %s, die dieses Konto nicht lesen darf',
|
||||
'%d %s dieses Konto nicht lesen darf',
|
||||
$report->blockedHidden,
|
||||
$report->blockedHidden === 1 ? 'Eintrag' : 'Einträge'
|
||||
$report->blockedHidden === 1 ? 'Eintrag, den' : 'Einträge, die'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
|
||||
* living post whose content has "globalModule":"<id>" – Divi's own way to find instances (string
|
||||
* scan, BlockParserStore, F10). One query per report (ID, status and type only – never the content,
|
||||
* audit L1); the rights of the caller are then checked in chunks with a bounded post cache:
|
||||
* living post whose content references it in any form Divi renders (rows(), ruling P23 – Divi 5
|
||||
* instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed). Candidates come
|
||||
* from one query (ID, status and type), their content is parsed in bounded chunks (audit L1); the
|
||||
* rights of the caller are then checked in chunks with a bounded post cache:
|
||||
* - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4);
|
||||
* - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when
|
||||
* readable, else as a count; Theme Builder layouts also without Divi's theme_builder role option
|
||||
@@ -33,6 +34,12 @@ class GlobalUsages
|
||||
/** Posts whose rights are checked with one primed cache. */
|
||||
public const CHUNK = 200;
|
||||
|
||||
/** Candidates whose content is read and parsed per call; the rest count as references (ruling P23). */
|
||||
public const PARSE_CAP = 5000;
|
||||
|
||||
/** Candidate contents read per query (bounded memory, audit L1). */
|
||||
public const CONTENT_CHUNK = 50;
|
||||
|
||||
/** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */
|
||||
public const LIVE = ['publish', 'private', 'future'];
|
||||
|
||||
@@ -118,22 +125,45 @@ class GlobalUsages
|
||||
}
|
||||
|
||||
/**
|
||||
* Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. Like
|
||||
* Divi's render, "globalModule":"0123" names entry 123 (hardening); the LIKE only narrows the
|
||||
* rows the REGEXP looks at.
|
||||
* Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The
|
||||
* candidates come from one query (ID, status and type only): content with "globalModule", a
|
||||
* Divi 4 "global_module" or a JSON escape of a letter (an escaped key such as globalModule
|
||||
* always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by
|
||||
* UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1).
|
||||
*
|
||||
* @return list<array{id: int, status: string, type: string}>
|
||||
*/
|
||||
public function rows(int $libraryId): array
|
||||
{
|
||||
$candidates = $this->candidates();
|
||||
$result = [];
|
||||
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
|
||||
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
|
||||
foreach ($chunk as $row) {
|
||||
// A candidate whose content could not be read counts too (fail closed).
|
||||
if (! isset($contents[$row['id']]) || UsageMatcher::shows($contents[$row['id']], $libraryId)) {
|
||||
$result[] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
|
||||
}
|
||||
|
||||
/** @return list<array{id: int, status: string, type: string}> */
|
||||
private function candidates(): array
|
||||
{
|
||||
$db = $this->db();
|
||||
$escapedLetter = str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]';
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $db->get_results(
|
||||
$db->prepare(
|
||||
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE post_content LIKE %s AND '
|
||||
. "post_content REGEXP %s AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC',
|
||||
'%' . $db->esc_like('"globalModule":"') . '%',
|
||||
'"globalModule":"0*' . $libraryId . '"'
|
||||
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (post_content LIKE %s OR '
|
||||
. "post_content LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND "
|
||||
. self::LISTED . ' ORDER BY ID ASC',
|
||||
'%' . $db->esc_like('globalModule') . '%',
|
||||
'%' . $db->esc_like('global_module') . '%',
|
||||
$escapedLetter
|
||||
),
|
||||
'ARRAY_A'
|
||||
);
|
||||
@@ -151,6 +181,32 @@ class GlobalUsages
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
* @return array<int, string> Content by ID.
|
||||
*/
|
||||
private function contents(array $ids): array
|
||||
{
|
||||
$db = $this->db();
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||||
$rows = $db->get_results(
|
||||
$db->prepare(
|
||||
'SELECT ID, post_content FROM ' . $db->posts . ' WHERE ID IN ('
|
||||
. implode(', ', array_fill(0, count($ids), '%d')) . ')',
|
||||
$ids
|
||||
),
|
||||
'ARRAY_A'
|
||||
);
|
||||
// phpcs:enable
|
||||
|
||||
$contents = [];
|
||||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||||
$contents[(int) $row['ID']] = (string) $row['post_content'];
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or
|
||||
* template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Whether post content shows library entry $id (ruling P23, re-review F1), fail closed:
|
||||
* - Divi 5: the textual "globalModule":"<digits>" Divi's render takes (leading zeros = intval), and
|
||||
* every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a
|
||||
* globalModule with that ID at any depth;
|
||||
* - Divi 4: a shortcode attribute global_module=<digits> in any quoting (intval);
|
||||
* - an opener with JSON escapes (or a globalModule) that cannot be decoded, or content the regex
|
||||
* engine cannot scan, counts as a possible reference.
|
||||
* Pure PHP.
|
||||
*/
|
||||
final class UsageMatcher
|
||||
{
|
||||
/** A block opener and its attribute JSON (the Parser's delimiter grammar). */
|
||||
private const OPENER = '/<!--\s+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+'
|
||||
. '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\s+\/?-->/s';
|
||||
|
||||
/** Divi 4: global_module=, any quote characters (straight, typographic, entities) before the digits. */
|
||||
private const SHORTCODE = '/\bglobal_module\s*=\s*[^\d\s\]]{0,12}(\d+)/i';
|
||||
|
||||
public static function shows(string $content, int $id): bool
|
||||
{
|
||||
$textual = preg_match('/"globalModule":"0*' . $id . '"/', $content);
|
||||
if ($textual !== 0) {
|
||||
return true; // A match, or a regex failure (fail closed).
|
||||
}
|
||||
|
||||
if (stripos($content, 'global_module') !== false) {
|
||||
$found = preg_match_all(self::SHORTCODE, $content, $matches);
|
||||
if ($found === false) {
|
||||
return true;
|
||||
}
|
||||
foreach ($matches[1] as $value) {
|
||||
if ((int) $value === $id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$escape = chr(92) . 'u';
|
||||
if (! str_contains($content, 'globalModule') && ! str_contains($content, $escape)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$found = preg_match_all(self::OPENER, $content, $matches);
|
||||
if ($found === false) {
|
||||
return true;
|
||||
}
|
||||
foreach ($matches[1] as $json) {
|
||||
if (! str_contains($json, $escape) && ! str_contains($json, 'globalModule')) {
|
||||
continue;
|
||||
}
|
||||
$attrs = json_decode($json, true);
|
||||
if (! is_array($attrs) || self::names($attrs, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id. */
|
||||
private static function names(array $attrs, int $id): bool
|
||||
{
|
||||
foreach ($attrs as $key => $value) {
|
||||
if (
|
||||
$key === 'globalModule'
|
||||
&& (is_int($value) || (is_string($value) && preg_match('/^\d+$/D', $value) === 1))
|
||||
&& (int) $value === $id
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (is_array($value) && self::names($value, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ final class GlobalEditsTest extends TestCase
|
||||
self::assertStringNotContainsString('1081', (string) wp_json_encode($data));
|
||||
self::assertToolError($refused, 'forbidden');
|
||||
self::assertStringNotContainsString('1081', $refused->get_error_message());
|
||||
self::assertStringContainsString('nicht lesen darf', $refused->get_error_message());
|
||||
self::assertStringContainsString('1 Eintrag, den dieses Konto nicht lesen darf', $refused->get_error_message());
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,37 +13,78 @@ use WP_Post;
|
||||
|
||||
final class GlobalUsagesTest extends TestCase
|
||||
{
|
||||
/** Audit L1: one query, ID, status and type only – never the content, no COUNT, no second scan. */
|
||||
public function testFindsInstancesLikeDiviWithOneQueryWithoutContent(): void
|
||||
/**
|
||||
* Ruling P23 (re-review F1): candidates by a wide, fail-closed prefilter (ID, status and type
|
||||
* only), then their content in chunks, parsed in PHP (UsageMatcher) – every format Divi renders.
|
||||
*/
|
||||
public function testFindsEveryFormatDiviRendersFromCandidates(): void
|
||||
{
|
||||
$bs = chr(92);
|
||||
$contents = [
|
||||
1053 => '<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/text"} /-->',
|
||||
1054 => '[et_pb_section global_module="01058"][/et_pb_section]',
|
||||
1055 => '<!-- wp:divi/global-layout {"' . $bs . 'u0067lobalModule":"1058"} /-->',
|
||||
1056 => '<!-- wp:divi/global-layout {"globalModule":"' . $bs . 'u00310' . '58"} /-->',
|
||||
1057 => '<!-- wp:divi/global-layout {"globalModule":"1059"} /-->',
|
||||
1061 => '<!-- wp:divi/global-layout {"' . $bs . 'u0067lobalModule":"1058",} /-->',
|
||||
];
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$queries = [];
|
||||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
|
||||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries, $contents): array {
|
||||
$queries[] = $sql;
|
||||
if (str_contains($sql, 'post_content FROM')) {
|
||||
preg_match('/IN \(([0-9, ]+)\)/', $sql, $in);
|
||||
$ids = array_map('intval', explode(',', $in[1]));
|
||||
|
||||
return [
|
||||
['ID' => '1053', 'post_status' => 'draft', 'post_type' => 'page'],
|
||||
['ID' => '1061', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
|
||||
];
|
||||
return array_map(static fn (int $id): array => ['ID' => (string) $id, 'post_content' => $contents[$id]], $ids);
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'publish', 'post_type' => 'page'],
|
||||
array_keys($contents)
|
||||
);
|
||||
});
|
||||
|
||||
$rows = (new GlobalUsages($db))->rows(1058);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
['id' => 1053, 'status' => 'draft', 'type' => 'page'],
|
||||
['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'],
|
||||
],
|
||||
$rows
|
||||
);
|
||||
self::assertCount(1, $queries);
|
||||
self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed');
|
||||
self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]);
|
||||
self::assertStringContainsString("post_content LIKE '%\"globalModule\":\"%'", $queries[0]);
|
||||
self::assertStringContainsString("post_content REGEXP '\"globalModule\":\"0*1058\"'", $queries[0]);
|
||||
self::assertStringNotContainsString('post_content,', $queries[0]);
|
||||
self::assertStringContainsString("post_content LIKE '%globalModule%'", $queries[0]);
|
||||
self::assertStringContainsString("post_content LIKE '%global" . $bs . "_module%'", $queries[0]);
|
||||
self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]);
|
||||
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
|
||||
self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
|
||||
self::assertStringNotContainsString('LIMIT', $queries[0]);
|
||||
self::assertStringNotContainsString('post_content,', $queries[0]);
|
||||
self::assertCount(2, $queries, 'one candidate query, one content chunk');
|
||||
}
|
||||
|
||||
/** Audit L1: contents are read in chunks; beyond the parse cap candidates count as references. */
|
||||
public function testReadsContentsInChunksAndFailsClosedBeyondTheCap(): void
|
||||
{
|
||||
$total = GlobalUsages::PARSE_CAP + 3;
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$chunks = 0;
|
||||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$chunks, $total): array {
|
||||
if (str_contains($sql, 'post_content FROM')) {
|
||||
++$chunks;
|
||||
preg_match('/IN \(([0-9, ]+)\)/', $sql, $in);
|
||||
|
||||
return array_map(
|
||||
static fn (string $id): array => ['ID' => trim($id), 'post_content' => 'nichts'],
|
||||
explode(',', $in[1])
|
||||
);
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'draft', 'post_type' => 'page'],
|
||||
range(1, $total)
|
||||
);
|
||||
});
|
||||
|
||||
$rows = (new GlobalUsages($db))->rows(1058);
|
||||
|
||||
self::assertSame((int) ceil(GlobalUsages::PARSE_CAP / GlobalUsages::CONTENT_CHUNK), $chunks);
|
||||
self::assertSame(range(GlobalUsages::PARSE_CAP + 1, $total), array_column($rows, 'id'));
|
||||
}
|
||||
|
||||
/** Audit L4: readable posts with title, unreadable ones only counted. */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageMatcher;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Ruling P23 (re-review F1): every reference Divi renders counts – Divi 5 openers in any JSON
|
||||
* spelling, Divi 4 shortcodes in any quoting; an escaped opener that cannot be decoded counts as a
|
||||
* possible reference (fail closed). Escape bytes are built with chr(92), never written inline.
|
||||
*/
|
||||
final class UsageMatcherTest extends TestCase
|
||||
{
|
||||
private static function u(string $hex): string
|
||||
{
|
||||
return chr(92) . 'u' . $hex;
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, bool}> Content => whether it shows entry 123. */
|
||||
public static function contents(): array
|
||||
{
|
||||
$escapedKey = '"' . self::u('0067') . 'lobalModule"';
|
||||
|
||||
return [
|
||||
'divi 5 canonical' => ['<!-- wp:divi/global-layout {"globalModule":"123","blockName":"divi/text"} /-->', true],
|
||||
'divi 5 leading zeros' => ['<!-- wp:divi/global-layout {"globalModule":"0123","blockName":"divi/text"} /-->', true],
|
||||
'divi 5 escaped key' => ['<!-- wp:divi/global-layout {' . $escapedKey . ':"123","blockName":"divi/text"} /-->', true],
|
||||
'divi 5 escaped value' => [
|
||||
'<!-- wp:divi/global-layout {"globalModule":"' . self::u('0031') . '23","blockName":"divi/text"} /-->',
|
||||
true,
|
||||
],
|
||||
'divi 5 escaped key with leading zeros' => [
|
||||
'<!-- wp:divi/global-layout {' . $escapedKey . ':"00123"} /-->',
|
||||
true,
|
||||
],
|
||||
'divi 5 another entry' => ['<!-- wp:divi/global-layout {"globalModule":"1234","blockName":"divi/text"} /-->', false],
|
||||
'divi 5 escaped key, another entry' => ['<!-- wp:divi/global-layout {' . $escapedKey . ':"124"} /-->', false],
|
||||
'divi 5 id in local attributes' => [
|
||||
'<!-- wp:divi/global-layout {"globalModule":"5","localAttrs":{"x":{"globalModule":"123"}}} /-->',
|
||||
true,
|
||||
],
|
||||
'divi 4 double quotes' => ['[et_pb_section global_module="123"][/et_pb_section]', true],
|
||||
'divi 4 single quotes and zeros' => ["[et_pb_text global_module='0123']x[/et_pb_text]", true],
|
||||
'divi 4 unquoted' => ['[et_pb_row global_module=123 admin_label="x"][/et_pb_row]', true],
|
||||
'divi 4 typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]', true],
|
||||
'divi 4 another entry' => ['[et_pb_section global_module="1230"][/et_pb_section]', false],
|
||||
'undecodable escaped opener' => [
|
||||
'<!-- wp:divi/global-layout {' . $escapedKey . ':"123",} /-->',
|
||||
true,
|
||||
],
|
||||
'undecodable escaped opener naming nothing readable' => [
|
||||
'<!-- wp:divi/text {' . self::u('0067') . 'kaputt} /-->',
|
||||
true,
|
||||
],
|
||||
'escaped markup only' => [
|
||||
'<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"' . self::u('003c') . 'p'
|
||||
. self::u('003e') . 'x"}}}} /-->',
|
||||
false,
|
||||
],
|
||||
'plain page' => ['<!-- wp:divi/section --><!-- /wp:divi/section -->', false],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider contents */
|
||||
public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void
|
||||
{
|
||||
self::assertSame($shows, UsageMatcher::shows($content, 123));
|
||||
}
|
||||
|
||||
public function testTheEscapeBytesAreReal(): void
|
||||
{
|
||||
self::assertSame(6, strlen(self::u('0067')));
|
||||
self::assertSame(92, ord(self::u('0067')[0]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user