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:
@@ -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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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=""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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user