" – 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: * - 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 * (audit L6); above HARD_CAP live posts the edit is refused as a whole. * Not final so tests can mock rows(). */ class GlobalUsages { /** At most this many readable posts are listed. */ public const LIMIT = 50; /** Above this many live posts a confirmed edit is refused without checking each of them (audit L1). */ public const HARD_CAP = 2000; /** At most this many posts are checked for read_post (the listing and hidden_count). */ public const CHECK_CAP = 2000; /** Posts whose rights are checked with one primed cache. */ public const CHUNK = 200; /** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */ public const LIVE = ['publish', 'private', 'future']; /** Every post that exists for the user: listed in the dry run. */ private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')"; /** Theme Builder post types (Divi's et_theme_builder_get_layout_post_types() plus the templates). */ public const THEME_BUILDER_TYPES = ['et_header_layout', 'et_body_layout', 'et_footer_layout', 'et_template']; private DiviRoles $roles; public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null) { $this->roles = $roles ?? new DiviRoles(); } /** * The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post * on every live post) – the dry run and the confirm of an edit need it, a listing does not. */ public function report(int $libraryId, bool $withBlocked = true): UsageReport { $rows = $this->rows($libraryId); $live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true))); $tooMany = $withBlocked && $live > self::HARD_CAP; $checkEdit = $withBlocked && ! $tooMany; $items = []; $hidden = 0; $blocked = []; $blockedHidden = 0; $checked = 0; foreach (array_chunk($rows, self::CHUNK) as $chunk) { $todo = array_values(array_filter( $chunk, static function (array $row) use (&$checked, $checkEdit): bool { $isLive = in_array($row['status'], self::LIVE, true); if ($checked < self::CHECK_CAP || ($checkEdit && $isLive)) { ++$checked; return true; } return false; } )); if ($todo === []) { continue; } $ids = array_map(static fn (array $row): int => $row['id'], $todo); $fresh = _get_non_cached_ids($ids, 'posts'); _prime_post_caches($ids, false, false); foreach ($todo as $row) { $id = $row['id']; $readable = current_user_can('read_post', $id); if ($readable && count($items) < self::LIMIT) { $post = get_post($id); $items[] = [ 'post_id' => $id, 'title' => $post instanceof WP_Post ? (string) $post->post_title : '', 'status' => $row['status'], 'type' => $row['type'], ]; } elseif (! $readable) { ++$hidden; } if ($checkEdit && in_array($row['status'], self::LIVE, true) && ! $this->editable($row)) { if ($readable) { $blocked[] = $id; } else { ++$blockedHidden; } } } foreach ($fresh as $id) { wp_cache_delete((int) $id, 'posts'); } } return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany); } /** * Every post (no revision, not trashed) that shows $libraryId, by ID – one query, no content. * * @return list */ public function rows(int $libraryId): array { $db = $this->db(); // 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_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', self::like($db, $libraryId) ), 'ARRAY_A' ); // phpcs:enable $result = []; foreach (is_array($rows) ? $rows : [] as $row) { $result[] = [ 'id' => (int) $row['ID'], 'status' => (string) $row['post_status'], 'type' => (string) $row['post_type'], ]; } return $result; } /** * 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 * et_pb_is_allowed check for TB layouts, builder/functions.php). * * @param array{id: int, status: string, type: string} $row */ private function editable(array $row): bool { if (in_array($row['type'], self::THEME_BUILDER_TYPES, true) && ! $this->roles->allows('theme_builder')) { return false; } return current_user_can('edit_post', $row['id']); } private static function like(\wpdb $db, int $libraryId): string { return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%'; } private function db(): \wpdb { return $this->db ?? $GLOBALS['wpdb']; } }