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:
2026-09-27 04:12:38 +02:00
co-authored by Claude Opus 5.5
parent bd28c32513
commit e8f69f67ea
12 changed files with 445 additions and 167 deletions
+71 -58
View File
@@ -28,7 +28,9 @@ defined('ABSPATH') || exit;
* copy is made first – the revision itself is DiviSaver's restore point, as for every page.
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may
* not edit_post one of the published, private or scheduled posts that show the entry (rulings
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids).
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids – posts the user may
* not read only as counts, audit L4). One GlobalUsages report per call (audit L1); above
* GlobalUsages::HARD_CAP live posts a confirmed edit is refused (Divi builder instead).
* The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37).
*/
@@ -89,27 +91,26 @@ final class GlobalEdits
return ['scope' => SaveScope::LibraryItem, 'details' => []];
}
$usages = $this->usages->of($id);
$details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
$blocked = $this->blocked($id);
$report = $this->usages->report($id);
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(
sprintf(
'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch '
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
$id,
$usages['count'],
self::pages($usages['count'])
) . self::blockedNote($blocked),
$details + self::blockedDetails($blocked)
$report->count,
self::pages($report->count)
) . self::hiddenNote($report) . self::blockedNote($report),
$report->details()
);
}
$refusal = $this->pagesRefusal($id, $blocked) ?? $this->backupRefusal($item);
$refusal = $this->pagesRefusal($id, $report) ?? $this->backupRefusal($item);
if ($refusal !== null) {
return $refusal;
}
$details = $report->listing();
if (! wp_revisions_enabled($item)) {
$details['backup_id'] = ($this->backup)($item);
}
@@ -127,9 +128,7 @@ final class GlobalEdits
return $error;
}
$id = (int) $page->post->ID;
$usages = $this->usages->of($id);
$blocked = $this->blocked($id);
$report = $this->usages->report((int) $page->post->ID);
$data = $error->get_error_data();
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
@@ -137,79 +136,93 @@ final class GlobalEdits
Errors::clientMessage($error)
. sprintf(
' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).',
$usages['count'],
self::pages($usages['count'])
$report->count,
self::pages($report->count)
)
. self::blockedNote($blocked),
array_merge(
$details,
['affected' => $usages['items'], 'affected_count' => $usages['count']],
self::blockedDetails($blocked)
)
. self::hiddenNote($report)
. self::blockedNote($report),
array_merge($details, $report->details())
);
}
/**
* Ruling P8a: the live posts (published, private, scheduled – they render now or will) that show
* entry $id and that the user may not edit_post. Drafts and pending posts never block.
*
* @return list<int>
* Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id; above
* GlobalUsages::HARD_CAP live posts it is refused as a whole (audit L1).
*/
private function blocked(int $id): array
private function pagesRefusal(int $id, UsageReport $report): ?WP_Error
{
$live = $this->usages->liveIds($id);
if ($live === []) {
return [];
if ($report->tooMany) {
return Errors::unsupported(
sprintf(
'Globaler Bibliothekseintrag %d erscheint auf %d veröffentlichten, privaten oder geplanten '
. 'Einträgen – mehr als %d; so große Änderungen nimmt LIT MCP nicht vor. Nichts wurde '
. 'gespeichert.',
$id,
$report->live,
GlobalUsages::HARD_CAP
),
'Den Eintrag im Divi-Builder bearbeiten.'
);
}
_prime_post_caches($live, false, false);
return array_values(array_filter(
$live,
static fn (int $postId): bool => ! current_user_can('edit_post', $postId)
));
}
/**
* Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id.
*
* @param list<int> $blocked
*/
private function pagesRefusal(int $id, array $blocked): ?WP_Error
{
if ($blocked === []) {
if (! $report->blocks()) {
return null;
}
return Errors::forbidden(
sprintf(
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, '
. 'die dieses Konto nicht bearbeiten darf (IDs %s); nichts wurde gespeichert.',
. 'die dieses Konto nicht bearbeiten darf (%s); nichts wurde gespeichert.',
$id,
self::listed($blocked)
self::blockedList($report)
),
'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator).'
);
}
/** @param list<int> $blocked */
private static function blockedNote(array $blocked): string
/** Audit L4: posts the caller may not read appear only as a count. */
private static function hiddenNote(UsageReport $report): string
{
return $blocked === [] ? '' : sprintf(
' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses '
. 'Konto nicht bearbeiten darf, zeigen den Eintrag (IDs %s, siehe blocked_ids).',
self::listed($blocked)
return $report->hidden === 0 ? '' : sprintf(
' %d davon darf dieses Konto nicht lesen (nur gezählt, siehe hidden_count).',
$report->hidden
);
}
/**
* @param list<int> $blocked
* @return array<string, list<int>>
*/
private static function blockedDetails(array $blocked): array
private static function blockedNote(UsageReport $report): string
{
return $blocked === [] ? [] : ['blocked_ids' => $blocked];
if ($report->tooMany) {
return sprintf(
' Achtung: Die Bestätigung würde abgelehnt – mehr als %d veröffentlichte, private oder geplante '
. 'Einträge zeigen den Eintrag; ihn im Divi-Builder bearbeiten.',
GlobalUsages::HARD_CAP
);
}
return ! $report->blocks() ? '' : sprintf(
' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses '
. 'Konto nicht bearbeiten darf, zeigen den Eintrag (%s).',
self::blockedList($report)
);
}
/** Readable blocking posts by ID (at most LISTED_IDS, see blocked_ids), unreadable ones as a count. */
private static function blockedList(UsageReport $report): string
{
$parts = [];
if ($report->blocked !== []) {
$parts[] = 'IDs ' . self::listed($report->blocked) . ', siehe blocked_ids';
}
if ($report->blockedHidden > 0) {
$parts[] = sprintf(
'%d %s, die dieses Konto nicht lesen darf',
$report->blockedHidden,
$report->blockedHidden === 1 ? 'Eintrag' : 'Einträge'
);
}
return implode('; ', $parts);
}
/** @param list<int> $ids At most LISTED_IDS of them, then "und N weitere". */
+108 -44
View File
@@ -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
@@ -103,6 +103,7 @@ final class LibraryListAbility implements Ability
'edit_url' => ['type' => 'string'],
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'],
'hidden_count' => ['type' => 'integer'],
],
'required' => ['items', 'next_cursor'],
];
@@ -232,9 +233,7 @@ final class LibraryListAbility implements Ability
'edit_url' => $page->editUrl(),
];
if ($item['scope'] === 'global') {
$usages = $this->usages->of((int) $page->post->ID);
$result['affected'] = $usages['items'];
$result['affected_count'] = $usages['count'];
$result += $this->usages->report((int) $page->post->ID, false)->listing();
}
return $result;
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* The posts that show one library entry, as the caller may see them (GlobalUsages::report()):
* readable posts by ID and title, unreadable ones only as counts (audit L4). Pure PHP.
*/
final class UsageReport
{
/**
* @param int $count Every post that shows the entry.
* @param list<array{post_id: int, title: string, status: string, type: string}> $items Readable
* posts (at most GlobalUsages::LIMIT).
* @param int $hidden Posts the caller may not read.
* @param int $live Published, private and scheduled posts among them (ruling P8a).
* @param list<int> $blocked Readable live posts the caller may not edit.
* @param int $blockedHidden Unreadable live posts the caller may not edit.
* @param bool $tooMany More live posts than GlobalUsages::HARD_CAP – not checked one by one (audit L1).
*/
public function __construct(
public readonly int $count,
public readonly array $items,
public readonly int $hidden,
public readonly int $live,
public readonly array $blocked,
public readonly int $blockedHidden,
public readonly bool $tooMany
) {
}
public function exists(): bool
{
return $this->count > 0;
}
/** Whether a confirmed edit would be refused (ruling P8a, audit L1). */
public function blocks(): bool
{
return $this->tooMany || $this->blocked !== [] || $this->blockedHidden > 0;
}
/** @return array<string, mixed> affected, affected_count and, when there are any, hidden_count. */
public function listing(): array
{
return ['affected' => $this->items, 'affected_count' => $this->count]
+ ($this->hidden > 0 ? ['hidden_count' => $this->hidden] : []);
}
/** @return array<string, mixed> listing() plus blocked_ids and blocked_hidden_count when there are any. */
public function details(): array
{
return $this->listing()
+ ($this->blocked !== [] ? ['blocked_ids' => $this->blocked] : [])
+ ($this->blockedHidden > 0 ? ['blocked_hidden_count' => $this->blockedHidden] : []);
}
}
@@ -404,7 +404,7 @@ final class DiviReplaceTextAbility implements Ability
. 'sondern nach Rückfrage mit lit-divi-update (confirm: true) ändern.',
'affected' => $data['affected'] ?? [],
'affected_count' => $data['affected_count'] ?? 0,
];
] + (isset($data['hidden_count']) ? ['hidden_count' => $data['hidden_count']] : []);
}
/**
@@ -39,6 +39,7 @@ abstract class DiviWriteAbility implements Ability
// made when revisions are off.
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
'affected_count' => ['type' => 'integer'],
'hidden_count' => ['type' => 'integer'],
'backup_id' => ['type' => 'integer'],
], ['tree_rev']);
}