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 { return $this->reportOfRows($this->rows($libraryId), $withBlocked); } /** report() for any reference – a library entry, a preset (Etappe 4) – described by $query. */ public function reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport { return $this->reportOfRows($this->rowsFor($query), $withBlocked); } /** @return list */ public function rows(int $libraryId): array { return $this->rowsFor(new LibraryUsageQuery($libraryId)); } /** * Every post (no revision, not trashed) whose content $query matches (fail closed). Candidates come from one * query (ID, status and type only); their content is read in chunks of CONTENT_CHUNK; beyond PARSE_CAP * candidates the rest counts as references unread (audit L1). * * @return list */ public function rowsFor(UsageQuery $query): array { $candidates = $this->candidates($query); $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']]) || $query->matches($contents[$row['id']])) { $result[] = $row; } } } return array_merge($result, array_slice($candidates, self::PARSE_CAP)); } /** @param list $rows */ private function reportOfRows(array $rows, bool $withBlocked): UsageReport { $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; $this->themeBuilderState = null; $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 = $this->readable($row); 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); } /** @return list */ private function candidates(UsageQuery $query): array { $db = $this->db(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $rows = $db->get_results( 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE ' . $query->candidateCondition($db) . " AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', '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; } /** * @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; } /** * Whether $row may be named to the caller: read_post; a Theme Builder post only while the Theme Builder is * visible to the caller (review I-2 B: otherwise it reads like a missing ID elsewhere, P12). * * @param array{id: int, status: string, type: string} $row */ private function readable(array $row): bool { if (self::isThemeBuilder($row) && ! $this->themeBuilder()['visible']) { return false; } return current_user_can('read_post', $row['id']); } /** * Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder post additionally needs the * Theme Builder rights (ruling Q17 M1 – as every Theme Builder gate, ThemeBuilderAccess::canUse(); audit L6). * * @param array{id: int, status: string, type: string} $row */ private function editable(array $row): bool { if (self::isThemeBuilder($row) && ! $this->themeBuilder()['usable']) { return false; } return current_user_can('edit_post', $row['id']); } /** @param array{id: int, status: string, type: string} $row */ private static function isThemeBuilder(array $row): bool { return in_array($row['type'], DiviThemeBuilder::postTypes(), true); } /** @return array{visible: bool, usable: bool} */ private function themeBuilder(): array { if ($this->themeBuilderState !== null) { return $this->themeBuilderState; } if ($this->themeBuilder !== null) { $state = ($this->themeBuilder)(); } else { $open = $this->roles->allows('theme_builder') && current_user_can('edit_others_posts') && current_user_can('edit_theme_options'); $state = ['visible' => $open, 'usable' => $open]; } return $this->themeBuilderState = [ 'visible' => (bool) ($state['visible'] ?? false), 'usable' => (bool) ($state['usable'] ?? false), ]; } private function db(): \wpdb { return $this->db ?? $GLOBALS['wpdb']; } }