Files
WP-LIT-MCP/src/Integrations/Divi/Library/GlobalUsages.php
T

296 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use Closure;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Posts (pages, Theme Builder layouts, other library entries) that reference something a UsageQuery describes –
* a library entry $id (LibraryUsageQuery: every living post whose content references it in any form Divi renders,
* ruling P23 – Divi 5 instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed) or, since
* Etappe 4, a preset. 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; above HARD_CAP live posts the edit is refused as a whole;
* - Theme Builder posts follow the Theme Builder access (ruling Q17 M1, review I-2, audit L1/L6): readable
* only while the Theme Builder is visible to the caller (else counted, never named – P12, like
* ThemeBuilderAccess), editable only while the caller may use it (canUse()) plus edit_post.
* 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;
/** 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'];
/** Every post that exists for the user: listed in the dry run. */
private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')";
private DiviRoles $roles;
/** @var array{visible: bool, usable: bool}|null The Theme Builder access of the current report(). */
private ?array $themeBuilderState = null;
/**
* @param (Closure(): array{visible: bool, usable: bool})|null $themeBuilder The caller's Theme Builder access
* (DiviIntegration wraps ThemeBuilderAccess – a closure keeps Library free of it): visible = TB objects exist
* for the caller, usable = the caller may change them; asked at most once per report(). null = what
* ThemeBuilderAccess::canUse() needs, for both (Divi's role option theme_builder, edit_others_posts,
* edit_theme_options).
*/
public function __construct(
private ?\wpdb $db = null,
?DiviRoles $roles = null,
private ?Closure $themeBuilder = 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
{
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<array{id: int, status: string, type: string}> */
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<array{id: int, status: string, type: string}>
*/
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<array{id: int, status: string, type: string}> $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<array{id: int, status: string, type: string}> */
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<int> $ids
* @return array<int, string> 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'];
}
}