diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index bbac051..1fbf003 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -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` diff --git a/src/Integrations/Divi/Library/GlobalEdits.php b/src/Integrations/Divi/Library/GlobalEdits.php index a75616a..45b8ec5 100644 --- a/src/Integrations/Divi/Library/GlobalEdits.php +++ b/src/Integrations/Divi/Library/GlobalEdits.php @@ -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' ); } diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index f75de41..9fd86eb 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -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":"" – 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 */ 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 */ + 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 $ids + * @return array 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 diff --git a/src/Integrations/Divi/Library/UsageMatcher.php b/src/Integrations/Divi/Library/UsageMatcher.php new file mode 100644 index 0000000..6b8e912 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageMatcher.php @@ -0,0 +1,87 @@ +" 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= 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+\/?-->/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 $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; + } +} diff --git a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php index 18b19ed..ade02b3 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php @@ -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); } diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 783c9d0..87a5f31 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -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 => '', + 1054 => '[et_pb_section global_module="01058"][/et_pb_section]', + 1055 => '', + 1056 => '', + 1057 => '', + 1061 => '', + ]; $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. */ diff --git a/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php new file mode 100644 index 0000000..ed861dc --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/UsageMatcherTest.php @@ -0,0 +1,78 @@ + Content => whether it shows entry 123. */ + public static function contents(): array + { + $escapedKey = '"' . self::u('0067') . 'lobalModule"'; + + return [ + 'divi 5 canonical' => ['', true], + 'divi 5 leading zeros' => ['', true], + 'divi 5 escaped key' => ['', true], + 'divi 5 escaped value' => [ + '', + true, + ], + 'divi 5 escaped key with leading zeros' => [ + '', + true, + ], + 'divi 5 another entry' => ['', false], + 'divi 5 escaped key, another entry' => ['', false], + 'divi 5 id in local attributes' => [ + '', + 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' => [ + '', + true, + ], + 'undecodable escaped opener naming nothing readable' => [ + '', + true, + ], + 'escaped markup only' => [ + '', + false, + ], + 'plain page' => ['', 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])); + } +}