Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
296 lines
11 KiB
PHP
296 lines
11 KiB
PHP
<?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'];
|
||
}
|
||
}
|