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". */