fix: global library edits check private and scheduled pages, name the backup on failure (P8a, P14)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,8 @@ defined('ABSPATH') || exit;
|
||||
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
|
||||
* 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 pages that show the entry (ruling P8, stricter than Divi).
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
@@ -90,6 +91,7 @@ final class GlobalEdits
|
||||
|
||||
$usages = $this->usages->of($id);
|
||||
$details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
|
||||
$blocked = $this->blocked($id);
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(
|
||||
sprintf(
|
||||
@@ -97,13 +99,13 @@ final class GlobalEdits
|
||||
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
|
||||
$id,
|
||||
$usages['count'],
|
||||
$usages['count'] === 1 ? 'Seite' : 'Seiten'
|
||||
),
|
||||
$details
|
||||
self::pages($usages['count'])
|
||||
) . self::blockedNote($blocked),
|
||||
$details + self::blockedDetails($blocked)
|
||||
);
|
||||
}
|
||||
|
||||
$refusal = $this->pagesRefusal($id) ?? $this->backupRefusal($item);
|
||||
$refusal = $this->pagesRefusal($id, $blocked) ?? $this->backupRefusal($item);
|
||||
if ($refusal !== null) {
|
||||
return $refusal;
|
||||
}
|
||||
@@ -125,44 +127,105 @@ final class GlobalEdits
|
||||
return $error;
|
||||
}
|
||||
|
||||
$usages = $this->usages->of((int) $page->post->ID);
|
||||
$id = (int) $page->post->ID;
|
||||
$usages = $this->usages->of($id);
|
||||
$blocked = $this->blocked($id);
|
||||
$data = $error->get_error_data();
|
||||
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||||
|
||||
return Errors::confirmRequired(
|
||||
Errors::clientMessage($error)
|
||||
. sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']),
|
||||
array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']])
|
||||
. sprintf(
|
||||
' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).',
|
||||
$usages['count'],
|
||||
self::pages($usages['count'])
|
||||
)
|
||||
. self::blockedNote($blocked),
|
||||
array_merge(
|
||||
$details,
|
||||
['affected' => $usages['items'], 'affected_count' => $usages['count']],
|
||||
self::blockedDetails($blocked)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling P8: every published page that shows entry $id must be editable by the user. */
|
||||
private function pagesRefusal(int $id): ?WP_Error
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
private function blocked(int $id): array
|
||||
{
|
||||
$blocked = array_values(array_filter(
|
||||
$this->usages->publishedIds($id),
|
||||
static fn (int $pageId): bool => ! current_user_can('edit_post', $pageId)
|
||||
$live = $this->usages->liveIds($id);
|
||||
if ($live === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
_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 === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$listed = implode(', ', array_slice($blocked, 0, self::LISTED_IDS));
|
||||
$more = count($blocked) - self::LISTED_IDS;
|
||||
|
||||
return Errors::forbidden(
|
||||
sprintf(
|
||||
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten Einträgen, die dieses Konto nicht '
|
||||
. 'bearbeiten darf (IDs %s%s); nichts wurde gespeichert.',
|
||||
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, '
|
||||
. 'die dieses Konto nicht bearbeiten darf (IDs %s); nichts wurde gespeichert.',
|
||||
$id,
|
||||
$listed,
|
||||
$more > 0 ? sprintf(' und %d weitere', $more) : ''
|
||||
self::listed($blocked)
|
||||
),
|
||||
'Die Änderung von jemandem vornehmen lassen, der alle diese Seiten bearbeiten darf (z. B. Redakteur '
|
||||
'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
|
||||
{
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $blocked
|
||||
* @return array<string, list<int>>
|
||||
*/
|
||||
private static function blockedDetails(array $blocked): array
|
||||
{
|
||||
return $blocked === [] ? [] : ['blocked_ids' => $blocked];
|
||||
}
|
||||
|
||||
/** @param list<int> $ids At most LISTED_IDS of them, then "und N weitere". */
|
||||
private static function listed(array $ids): string
|
||||
{
|
||||
$more = count($ids) - self::LISTED_IDS;
|
||||
|
||||
return implode(', ', array_slice($ids, 0, self::LISTED_IDS))
|
||||
. ($more > 0 ? sprintf(' und %d weitere', $more) : '');
|
||||
}
|
||||
|
||||
private static function pages(int $count): string
|
||||
{
|
||||
return $count === 1 ? 'Seite' : 'Seiten';
|
||||
}
|
||||
|
||||
/** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */
|
||||
private function backupRefusal(WP_Post $item): ?WP_Error
|
||||
{
|
||||
|
||||
@@ -15,8 +15,11 @@ class GlobalUsages
|
||||
{
|
||||
public const LIMIT = 50;
|
||||
|
||||
private const WHERE = "post_content LIKE %s AND post_type <> 'revision' "
|
||||
. "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')";
|
||||
/** 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)
|
||||
{
|
||||
@@ -29,12 +32,12 @@ class GlobalUsages
|
||||
$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, $like)
|
||||
$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
|
||||
. ' ORDER BY ID ASC LIMIT %d',
|
||||
'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
|
||||
),
|
||||
@@ -57,18 +60,17 @@ class GlobalUsages
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of every published post that shows $libraryId – no limit (ruling P8 checks edit_post on
|
||||
* each of them before a confirmed global edit).
|
||||
* 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).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function publishedIds(int $libraryId): array
|
||||
public function liveIds(int $libraryId): array
|
||||
{
|
||||
$db = $this->db();
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||||
$ids = $db->get_col($db->prepare(
|
||||
'SELECT ID FROM ' . $db->posts . " WHERE post_content LIKE %s AND post_type <> 'revision' "
|
||||
. "AND post_status = 'publish' ORDER BY ID ASC",
|
||||
'SELECT ID FROM ' . $db->posts . ' WHERE ' . self::where(self::LIVE) . ' ORDER BY ID ASC',
|
||||
self::like($db, $libraryId)
|
||||
));
|
||||
// phpcs:enable
|
||||
@@ -76,13 +78,22 @@ class GlobalUsages
|
||||
return array_values(array_map('intval', is_array($ids) ? $ids : []));
|
||||
}
|
||||
|
||||
private function db(): \wpdb
|
||||
/**
|
||||
* 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 $this->db ?? $GLOBALS['wpdb'];
|
||||
return "post_content LIKE %s AND post_type <> 'revision' AND " . $status;
|
||||
}
|
||||
|
||||
private static function like(\wpdb $db, int $libraryId): string
|
||||
{
|
||||
return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%';
|
||||
}
|
||||
|
||||
private function db(): \wpdb
|
||||
{
|
||||
return $this->db ?? $GLOBALS['wpdb'];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user