fix: bounded usage scan and count-only unreadable posts in global edits (L1, L4)
GlobalUsages runs one query per request (ID, status, type – no content), checks read_post/edit_post in chunks of 200 with a primed and released post cache, lists at most 50 readable posts and refuses a confirmed global edit above 2000 live references (unsupported, edit in the Divi builder). Posts the caller may not read appear only as hidden_count / blocked_hidden_count; blocked_ids names readable posts only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,87 +4,151 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Library;
|
||||
|
||||
use WP_Post;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Pages (and Theme Builder layouts) that show global library entry $id: every living post whose
|
||||
* content has "globalModule":"<id>" – Divi's own way to find instances (string scan,
|
||||
* BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it.
|
||||
* Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
|
||||
* living post whose content has "globalModule":"<id>" – 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; 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')";
|
||||
|
||||
/** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */
|
||||
private const LIVE = "post_status IN ('publish', 'private', 'future')";
|
||||
|
||||
public function __construct(private ?\wpdb $db = null)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} */
|
||||
public function of(int $libraryId): array
|
||||
/**
|
||||
* 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<array{id: int, status: string, type: string}>
|
||||
*/
|
||||
public function rows(int $libraryId): array
|
||||
{
|
||||
$db = $this->db();
|
||||
$like = self::like($db, $libraryId);
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||||
$count = (int) $db->get_var(
|
||||
$db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::where(self::LISTED), $like)
|
||||
);
|
||||
$rows = $db->get_results(
|
||||
$db->prepare(
|
||||
'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE '
|
||||
. self::where(self::LISTED) . ' ORDER BY ID ASC LIMIT %d',
|
||||
$like,
|
||||
self::LIMIT
|
||||
'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
|
||||
|
||||
$items = [];
|
||||
$result = [];
|
||||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||||
$id = (int) $row['ID'];
|
||||
$items[] = [
|
||||
'post_id' => $id,
|
||||
'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)',
|
||||
$result[] = [
|
||||
'id' => (int) $row['ID'],
|
||||
'status' => (string) $row['post_status'],
|
||||
'type' => (string) $row['post_type'],
|
||||
];
|
||||
}
|
||||
|
||||
return ['items' => $items, 'count' => max($count, count($items))];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of every live post (published, private, scheduled) that shows $libraryId – no limit
|
||||
* (ruling P8a checks edit_post on each of them before a confirmed global edit).
|
||||
* Ruling P8a: whether the caller may edit_post the live post $row.
|
||||
*
|
||||
* @return list<int>
|
||||
* @param array{id: int, status: string, type: string} $row
|
||||
*/
|
||||
public function liveIds(int $libraryId): array
|
||||
protected function editable(array $row): bool
|
||||
{
|
||||
$db = $this->db();
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||||
$ids = $db->get_col($db->prepare(
|
||||
'SELECT ID FROM ' . $db->posts . ' WHERE ' . self::where(self::LIVE) . ' ORDER BY ID ASC',
|
||||
self::like($db, $libraryId)
|
||||
));
|
||||
// phpcs:enable
|
||||
|
||||
return array_values(array_map('intval', is_array($ids) ? $ids : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* The one condition of both queries: posts (no revisions) whose content holds an instance
|
||||
* (placeholder %s = like()), with the fixed status clause $status (a class constant).
|
||||
*/
|
||||
private static function where(string $status): string
|
||||
{
|
||||
return "post_content LIKE %s AND post_type <> 'revision' AND " . $status;
|
||||
return current_user_can('edit_post', $row['id']);
|
||||
}
|
||||
|
||||
private static function like(\wpdb $db, int $libraryId): string
|
||||
|
||||
Reference in New Issue
Block a user