Files
WP-LIT-MCP/src/Integrations/Divi/Library/GlobalUsages.php
T
janniclas.loosenandClaude Opus 5.5 8aacc5dc4f fix: P8a blocks Theme Builder layouts without Divi's theme_builder role (L6)
et_header_layout, et_body_layout, et_footer_layout and et_template showing a
global entry now block a confirmed edit unless et_pb_is_allowed('theme_builder'),
like Divi's own check for TB layouts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 04:13:14 +02:00

177 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use WP_Post;
defined('ABSPATH') || exit;
/**
* 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; Theme Builder layouts also without Divi's theme_builder role option
* (audit L6); 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')";
/** Theme Builder post types (Divi's et_theme_builder_get_layout_post_types() plus the templates). */
public const THEME_BUILDER_TYPES = ['et_header_layout', 'et_body_layout', 'et_footer_layout', 'et_template'];
private DiviRoles $roles;
public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = 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
{
$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();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$rows = $db->get_results(
$db->prepare(
'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
$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;
}
/**
* Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or
* template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own
* et_pb_is_allowed check for TB layouts, builder/functions.php).
*
* @param array{id: int, status: string, type: string} $row
*/
private function editable(array $row): bool
{
if (in_array($row['type'], self::THEME_BUILDER_TYPES, true) && ! $this->roles->allows('theme_builder')) {
return false;
}
return current_user_can('edit_post', $row['id']);
}
private static function like(\wpdb $db, int $libraryId): string
{
return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%';
}
private function db(): \wpdb
{
return $this->db ?? $GLOBALS['wpdb'];
}
}