fix: UsageMatcher reads Divi 4 shortcodes like WordPress and Divi, everywhere (ruling P24)

- G1: every decoded string value of a Divi 5 opener is scanned for shortcodes;
  outside the openers only the remaining content is.
- G2: attributes are parsed like shortcode_parse_atts() and global_module is
  cast with (int), as Divi does.
- G3: a non-empty global_module other than "0" that casts to 0 makes Divi
  render the newest layout, so it counts for every entry (fail closed).
- The prefilter LIKEs use LOWER(post_content), so they are case-insensitive
  under any collation.
- The guide describes the fail-closed rule without an absolute claim.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 04:53:54 +02:00
co-authored by Claude Opus 5.5
parent 8f87ec6163
commit b6d791d46a
5 changed files with 172 additions and 33 deletions
+5 -3
View File
@@ -13,9 +13,11 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv
(`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search` (Titel), `category` (`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`: (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 auf ihn verweisen 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 (`affected`, `affected_count`, `hidden_count`). Erkannt werden Divi-5-Instanzen (auch mit maskierten Zeichen)
in jeder Schreibweise und Divi-4-Shortcodes mit `global_module`; ein Verweis, der sich nicht eindeutig lesen und Divi-4-Shortcodes mit `global_module` – auch in Texten von Divi-5-Modulen. Im Zweifel zählt eine Seite
lässt, zählt vorsichtshalber mit. Steckt der Eintrag in einem wiederverwendbaren Block (`wp_block`), erscheint mit: ein Verweis, der sich nicht eindeutig lesen lässt, und ein `global_module`, das keine gültige Zahl ist
(Divi zeigt dann den neuesten Eintrag), gelten als Verweis auf jeden Eintrag. Sehr ungewöhnliche Schreibweisen
können trotzdem unerkannt bleiben. 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. 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 - `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. Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie.
@@ -127,7 +127,7 @@ class GlobalUsages
/** /**
* Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The * 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 * 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 * Divi 4 "global_module" (both case-insensitive) 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 * 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). * UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1).
* *
@@ -158,10 +158,11 @@ class GlobalUsages
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$rows = $db->get_results( $rows = $db->get_results(
$db->prepare( $db->prepare(
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (post_content LIKE %s OR ' // Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation.
. "post_content LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND " 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR '
. "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND "
. self::LISTED . ' ORDER BY ID ASC', . self::LISTED . ' ORDER BY ID ASC',
'%' . $db->esc_like('globalModule') . '%', '%' . $db->esc_like('globalmodule') . '%',
'%' . $db->esc_like('global_module') . '%', '%' . $db->esc_like('global_module') . '%',
$escapedLetter $escapedLetter
), ),
+114 -24
View File
@@ -7,13 +7,16 @@ namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Whether post content shows library entry $id (ruling P23, re-review F1), fail closed: * Whether post content shows library entry $id (rulings P23, P24), fail closed:
* - Divi 5: the textual "globalModule":"<digits>" Divi's render takes (leading zeros = intval), and * - 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 * every block opener whose JSON – in any spelling, escaped keys or values included – decodes to a
* globalModule with that ID at any depth; * globalModule with that ID at any depth;
* - Divi 4: a shortcode attribute global_module=<digits> in any quoting (intval); * - Divi 4 shortcodes – in the content outside block openers and in every decoded string value of an
* - an opener with JSON escapes (or a globalModule) that cannot be decoded, or content the regex * opener (Divi runs them there too): attributes parsed like shortcode_parse_atts(), global_module
* engine cannot scan, counts as a possible reference. * cast with (int) like Divi; a non-empty value other than "0" that casts to 0 makes Divi render the
* newest layout (et_pb_load_global_module, WP_Query p=0), so it counts for EVERY entry;
* - an opener with JSON escapes (or a globalModule/global_module) that cannot be decoded, or content
* the regex engine cannot scan, counts as a possible reference.
* Pure PHP. * Pure PHP.
*/ */
final class UsageMatcher final class UsageMatcher
@@ -22,8 +25,12 @@ final class UsageMatcher
private const OPENER = '/<!--\s+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+' private const OPENER = '/<!--\s+wp:(?:[a-z][a-z0-9_-]*\/)?[a-z][a-z0-9_-]*\s+'
. '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\s+\/?-->/s'; . '({(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?})\s+\/?-->/s';
/** Divi 4: global_module=, any quote characters (straight, typographic, entities) before the digits. */ /** An opening shortcode tag and its attribute text (get_shortcode_regex(), any tag name). */
private const SHORTCODE = '/\bglobal_module\s*=\s*[^\d\s\]]{0,12}(\d+)/i'; private const TAG = '/\[\[?([\w-]+)(?![\w-])([^\]\/]*(?:\/(?!\])[^\]\/]*)*?)\/?\]/';
/** WordPress' get_shortcode_atts_regex(). */
private const ATTS = '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*'
. '([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|\'([^\']*)\'(?:\s|$)|(\S+)(?:\s|$)/';
public static function shows(string $content, int $id): bool public static function shows(string $content, int $id): bool
{ {
@@ -32,40 +39,107 @@ final class UsageMatcher
return true; // A match, or a regex failure (fail closed). 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); $found = preg_match_all(self::OPENER, $content, $matches);
if ($found === false) { if ($found === false) {
return true; return true;
} }
// Shortcodes outside the openers (Divi 4 pages, free text between blocks).
$outside = $found === 0 ? $content : str_replace($matches[1], '', $content);
if (self::shortcodeShows($outside, $id)) {
return true;
}
$escape = chr(92) . 'u';
foreach ($matches[1] as $json) { foreach ($matches[1] as $json) {
if (! str_contains($json, $escape) && ! str_contains($json, 'globalModule')) { if (
! str_contains($json, $escape)
&& ! str_contains($json, 'globalModule')
&& stripos($json, 'global_module') === false
) {
continue; continue;
} }
$attrs = json_decode($json, true); $attrs = json_decode($json, true);
if (! is_array($attrs) || self::names($attrs, $id)) { if (! is_array($attrs) || self::names($attrs, $id)) {
return true; return true;
} }
foreach (self::strings($attrs) as $value) {
if (self::shortcodeShows($value, $id)) {
return true;
}
}
} }
return false; return false;
} }
/** Whether a Divi 4 shortcode in $text renders entry $id (or, casting to 0, any entry). */
private static function shortcodeShows(string $text, int $id): bool
{
if (stripos($text, 'global_module') === false) {
return false;
}
$found = preg_match_all(self::TAG, $text, $tags);
if ($found === false) {
return true;
}
foreach ($tags[2] as $attributes) {
$atts = self::parseAtts($attributes);
if ($atts === null) {
return true;
}
$value = $atts['global_module'] ?? null;
// Divi: ! empty( $global_module_id ), then WP_Query p => (int) $global_module_id.
if (! is_string($value) || $value === '' || $value === '0') {
continue;
}
$cast = (int) $value;
if ($cast === 0 || $cast === $id) {
return true;
}
}
return false;
}
/**
* shortcode_parse_atts() of WordPress 6.9 (named attributes only); null when PCRE fails.
*
* @return array<string, string>|null
*/
private static function parseAtts(string $text): ?array
{
$text = preg_replace('/[\x{00a0}\x{200b}]+/u', ' ', $text);
if (! is_string($text)) {
return null;
}
$found = preg_match_all(self::ATTS, $text, $matches, PREG_SET_ORDER);
if ($found === false) {
return null;
}
$atts = [];
foreach ($matches as $m) {
foreach ([1, 3, 5] as $key) {
if (! empty($m[$key])) {
$atts[strtolower($m[$key])] = stripcslashes($m[$key + 1]);
break;
}
}
}
// WordPress rejects values with unclosed HTML elements.
foreach ($atts as $name => $value) {
if (str_contains($value, '<') && preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $value) !== 1) {
$atts[$name] = '';
}
}
return $atts;
}
/** @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id. */ /** @param array<array-key, mixed> $attrs Whether a globalModule key at any depth names $id. */
private static function names(array $attrs, int $id): bool private static function names(array $attrs, int $id): bool
{ {
@@ -84,4 +158,20 @@ final class UsageMatcher
return false; return false;
} }
/**
* @param array<array-key, mixed> $attrs
* @return list<string> Every string value at any depth.
*/
private static function strings(array $attrs): array
{
$strings = [];
array_walk_recursive($attrs, static function (mixed $value) use (&$strings): void {
if (is_string($value)) {
$strings[] = $value;
}
});
return $strings;
}
} }
@@ -50,8 +50,9 @@ final class GlobalUsagesTest extends TestCase
self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed'); 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::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]);
self::assertStringNotContainsString('post_content,', $queries[0]); self::assertStringNotContainsString('post_content,', $queries[0]);
self::assertStringContainsString("post_content LIKE '%globalModule%'", $queries[0]); // Ruling P24: case-insensitive whatever the column collation.
self::assertStringContainsString("post_content LIKE '%global" . $bs . "_module%'", $queries[0]); self::assertStringContainsString("LOWER(post_content) LIKE '%globalmodule%'", $queries[0]);
self::assertStringContainsString("LOWER(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_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_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
@@ -61,9 +61,54 @@ final class UsageMatcherTest extends TestCase
false, false,
], ],
'plain page' => ['<!-- wp:divi/section --><!-- /wp:divi/section -->', false], 'plain page' => ['<!-- wp:divi/section --><!-- /wp:divi/section -->', false],
// Ruling P24 G1: a Divi 4 shortcode inside a Divi 5 attribute value (quotes serialized as escapes).
'shortcode in a divi 5 text value' => [self::inText('[et_pb_section global_module=' . self::u('0022') . '123'
. self::u('0022') . '][/et_pb_section]'), true],
'shortcode in a divi 5 text value, another entry' => [self::inText('[et_pb_section global_module='
. self::u('0022') . '124' . self::u('0022') . '][/et_pb_section]'), false],
'shortcode in a divi 5 text value, escaped digits' => [self::inText('[et_pb_section global_module='
. self::u('0022') . self::u('0031') . self::u('0032') . '3' . self::u('0022') . '][/et_pb_section]'), true],
// G2: parsed like shortcode_parse_atts, cast with (int) like Divi.
'divi 4 leading space' => ['[et_pb_section global_module=" 123"][/et_pb_section]', true],
'divi 4 exponent' => ['[et_pb_section global_module="1.23e2"][/et_pb_section]', true],
'divi 4 upper-case attribute' => ['[et_pb_section GLOBAL_MODULE="123"][/et_pb_section]', true],
'divi 4 zero renders nothing' => ['[et_pb_section global_module="0"][/et_pb_section]', false],
'divi 4 empty renders nothing' => ['[et_pb_section global_module=""][/et_pb_section]', false],
'global_module as plain text' => ['<p>global_module="123" steht hier nur als Text</p>', false],
]; ];
} }
/** A Divi 5 text module whose content value holds $text, JSON-encoded like WordPress does. */
private static function inText(string $text): string
{
return '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"' . $text . '"}}}} /-->';
}
/**
* Ruling P24 G3: a global_module Divi casts to 0 renders the newest library or TB layout
* (et_pb_load_global_module, WP_Query p=0) – a reference to every entry.
*
* @return array<string, array{string}>
*/
public static function wildcards(): array
{
return [
'entity before the id' => ['[et_pb_section global_module="&quot;123"][/et_pb_section]'],
'letters' => ['[et_pb_row global_module="abc"][/et_pb_row]'],
'typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]'],
'double zero' => ['[et_pb_row global_module="00"][/et_pb_row]'],
'inside a divi 5 value' => [self::inText('[et_pb_text global_module=' . self::u('0022') . 'x'
. self::u('0022') . ']a[/et_pb_text]')],
];
}
/** @dataProvider wildcards */
public function testAValueThatCastsToZeroShowsEveryEntry(string $content): void
{
self::assertTrue(UsageMatcher::shows($content, 123));
self::assertTrue(UsageMatcher::shows($content, 98765));
}
/** @dataProvider contents */ /** @dataProvider contents */
public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void
{ {