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:
@@ -127,7 +127,7 @@ class GlobalUsages
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
$rows = $db->get_results(
|
||||
$db->prepare(
|
||||
'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 "
|
||||
// Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation.
|
||||
'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',
|
||||
'%' . $db->esc_like('globalModule') . '%',
|
||||
'%' . $db->esc_like('globalmodule') . '%',
|
||||
'%' . $db->esc_like('global_module') . '%',
|
||||
$escapedLetter
|
||||
),
|
||||
|
||||
@@ -7,13 +7,16 @@ namespace LIT\MCP\Integrations\Divi\Library;
|
||||
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
|
||||
* 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.
|
||||
* - Divi 4 shortcodes – in the content outside block openers and in every decoded string value of an
|
||||
* opener (Divi runs them there too): attributes parsed like shortcode_parse_atts(), global_module
|
||||
* 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.
|
||||
*/
|
||||
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+'
|
||||
. '({(?:(?:[^}]+|}+(?=})|(?!}\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';
|
||||
/** An opening shortcode tag and its attribute text (get_shortcode_regex(), any tag name). */
|
||||
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
|
||||
{
|
||||
@@ -32,40 +39,107 @@ final class UsageMatcher
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (! str_contains($json, $escape) && ! str_contains($json, 'globalModule')) {
|
||||
if (
|
||||
! str_contains($json, $escape)
|
||||
&& ! str_contains($json, 'globalModule')
|
||||
&& stripos($json, 'global_module') === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$attrs = json_decode($json, true);
|
||||
if (! is_array($attrs) || self::names($attrs, $id)) {
|
||||
return true;
|
||||
}
|
||||
foreach (self::strings($attrs) as $value) {
|
||||
if (self::shortcodeShows($value, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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. */
|
||||
private static function names(array $attrs, int $id): bool
|
||||
{
|
||||
@@ -84,4 +158,20 @@ final class UsageMatcher
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user