From 4ff1c1e2a538f2fd212e8930c1ee636ac62d0351 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 15:24:32 +0200 Subject: [PATCH] fix: Theme Builder access needs edit_theme_options (ruling Q15) Divi's Theme Builder page is gated by edit_theme_options; without it every TB ID answers like a missing one. Docblocks (Q16): the wp_die guard cannot restore WP_Hook nesting after a third-party wp_die inside a hook, and canEditTemplate expects IDs from Divi's list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/ThemeBuilder/DiviThemeBuilder.php | 4 ++++ .../Divi/ThemeBuilder/ThemeBuilderAccess.php | 12 +++++++++-- .../ThemeBuilder/ThemeBuilderAccessTest.php | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index e3f3c88..f51d64b 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -334,6 +334,10 @@ class DiviThemeBuilder * Runs $call with every wp_die handler replaced by one that throws (F17/F18) and with its output * discarded. Only this request's in-memory hooks change; the handler filters (added at PHP_INT_MAX * with a closure of this call) and every output buffer opened meanwhile are removed on every path. + * Limit: when a third-party callback calls wp_die() inside a hook that Divi's wp_insert_post() fires, + * the exception leaves that WP_Hook mid-iteration; its nesting level and $wp_current_filter are not + * restored (WordPress offers no API for that), so later hooks of this request may see a stale filter + * stack. The request ends with the tool's error anyway; the check before Divi is the primary defense. */ private function guarded(callable $call): mixed { diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php index c9cdebd..691e2eb 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php @@ -15,7 +15,8 @@ defined('ABSPATH') || exit; /** * Rights on Theme Builder objects (spec §4.5, E3 – never laxer than Divi): the Theme Builder at all needs - * edit_others_posts (Divi's own TB requirement, F19/F23) and the Divi role option theme_builder (off for + * edit_others_posts (Divi's own TB requirement, F19/F23), edit_theme_options (the gate of Divi's Theme + * Builder page, theme-builder.php:2059-2063, ruling Q15) and the Divi role option theme_builder (off for * every role but administrator by default, F12); one layout or template additionally edit_post on it. * Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID * (P12 pattern). The group's "nur lesen" stops writes (E9). Not final so abilities can be tested with a mock. @@ -52,6 +53,9 @@ class ThemeBuilderAccess if (! current_user_can('edit_others_posts')) { return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_others_posts).'); } + if (! current_user_can('edit_theme_options')) { + return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_theme_options).'); + } return $this->roles->refusal('theme_builder'); } @@ -151,7 +155,11 @@ class ThemeBuilderAccess return $forWrite ? ($this->writableGate() ?? $post) : $post; } - /** Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11). */ + /** + * Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11). + * $id must come from Divi's own template list (templateIds()/liveTemplates()/detachedIds()): this checks + * only the rights, not that $id is a template. + */ public function canEditTemplate(int $id): bool { return $this->visible() && current_user_can('edit_post', $id); diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php index 7757a1b..b45d846 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Support\ThemeBuilderFixtures; use LIT\MCP\Tests\Unit\TestCase; @@ -31,6 +32,25 @@ final class ThemeBuilderAccessTest extends TestCase self::assertToolError(ThemeBuilderFixtures::access()->canUse(), 'forbidden'); } + /** Ruling Q15: Divi's Theme Builder page also needs edit_theme_options (theme-builder.php:2059-2063). */ + public function testWithoutEditThemeOptionsTheThemeBuilderIsHidden(): void + { + LibraryFixtures::given([ThemeBuilderFixtures::layout(1062), ThemeBuilderFixtures::templatePost()], [ + 'edit_theme_options', + ]); + $access = ThemeBuilderFixtures::access(); + + self::assertToolError($access->canUse(), 'forbidden'); + self::assertStringContainsString('edit_theme_options', self::clientError($access->canUse())['message']); + self::assertFalse($access->visible()); + self::assertFalse($access->canEditTemplate(ThemeBuilderFixtures::TEMPLATE)); + self::assertToolError($access->canCreate(), 'forbidden'); + $hidden = $access->layout(1062, false); + self::assertSame(self::clientError($hidden), self::clientError(PostGuard::notFound(1062))); + $template = self::assertToolError($access->template(ThemeBuilderFixtures::TEMPLATE, false), 'not_found'); + self::assertSame('Theme-Builder-Vorlage 1066 gibt es nicht.', $template['message'], 'like a missing ID'); + } + /** P12 pattern: without access a layout ID answers exactly like a missing one. */ public function testLayoutsAreHiddenLikeMissingIdsWithoutAccess(): void {