From e5ff993e9233be83a3c26a25f393c0e37ea5ddaf Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Wed, 9 Apr 2025 14:22:48 +0200 Subject: [PATCH 1/5] feat: add granular capabilities to RSS Feed CPT --- includes/class-capabilities.php | 58 +++++++++++++++++++++++++ includes/class-newspack.php | 1 + includes/class-patches.php | 5 ++- includes/optional-modules/class-rss.php | 23 ++++++++-- 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 includes/class-capabilities.php diff --git a/includes/class-capabilities.php b/includes/class-capabilities.php new file mode 100644 index 0000000000..e3e56d99aa --- /dev/null +++ b/includes/class-capabilities.php @@ -0,0 +1,58 @@ + $old_post_type ) { + $post_type_object = get_post_type_object( $post_type ); + if ( ! $post_type_object ) { + continue; + } + $post_type_object_old = get_post_type_object( $old_post_type ); + if ( ! $post_type_object_old ) { + continue; + } + foreach ( $caps as $requested_cap ) { + if ( stripos( $requested_cap, $post_type ) !== false ) { + $found_old_cap_name = array_search( $requested_cap, (array) $post_type_object->cap, true ); + if ( $found_old_cap_name !== false ) { + if ( isset( $allcaps[ $found_old_cap_name ] ) && $allcaps[ $found_old_cap_name ] ) { + $allcaps[ $requested_cap ] = true; + } + } + } + } + } + return $allcaps; + } +} +Capabilities::init(); diff --git a/includes/class-newspack.php b/includes/class-newspack.php index 96af7bf9ac..c79d93bca1 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -181,6 +181,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/class-amp-enhancements.php'; include_once NEWSPACK_ABSPATH . 'includes/class-newspack-image-credits.php'; include_once NEWSPACK_ABSPATH . 'includes/class-rss-add-image.php'; + include_once NEWSPACK_ABSPATH . 'includes/class-capabilities.php'; /* Integrations with other plugins. */ include_once NEWSPACK_ABSPATH . 'includes/plugins/class-jetpack.php'; diff --git a/includes/class-patches.php b/includes/class-patches.php index 4aadf50a67..5c720f30f6 100644 --- a/includes/class-patches.php +++ b/includes/class-patches.php @@ -368,6 +368,8 @@ public static function maybe_display_author_page( $query ) { /** * Restrict non-privileged users from seeing posts not owned by them. + * An author without the edit_others_* cap will not be able to edit the posts, + * but still can view the list of posts. This method prevents that. * Affects all admin post lists and the legacy (non-AJAX) media library list page. * * @param WP_Query $query Query to alter. @@ -385,7 +387,8 @@ public static function restrict_others_posts( $query ) { $is_posts_list = 'edit' === $current_screen->base; // If the user can't edit others' posts, only allow them to view their own posts. - if ( ( $is_media_library || $is_posts_list ) && ! current_user_can( 'edit_others_posts' ) ) { + $post_type_object = get_post_type_object( $current_screen->post_type ); + if ( ( $is_media_library || $is_posts_list ) && ! current_user_can( $post_type_object->cap->edit_others_posts ) ) { $query->set( 'author', $current_user_id ); // phpcs:ignore WordPressVIPMinimum.Hooks.PreGetPosts.PreGetPosts add_filter( 'wp_count_posts', [ __CLASS__, 'fix_post_counts' ], 10, 2 ); } diff --git a/includes/optional-modules/class-rss.php b/includes/optional-modules/class-rss.php index 8235e69698..7034fbbb6c 100644 --- a/includes/optional-modules/class-rss.php +++ b/includes/optional-modules/class-rss.php @@ -13,8 +13,8 @@ * RSS feed enhancements. */ class RSS { - const FEED_CPT = 'partner_rss_feed'; - const FEED_QUERY_ARG = 'partner-feed'; + const FEED_CPT = 'partner_rss_feed'; + const FEED_QUERY_ARG = 'partner-feed'; const FEED_SETTINGS_META = 'partner_feed_settings'; /** @@ -43,6 +43,8 @@ public static function init() { add_filter( 'wpseo_include_rss_footer', [ __CLASS__, 'maybe_suppress_yoast' ] ); add_action( 'rss2_ns', [ __CLASS__, 'maybe_inject_yahoo_namespace' ] ); add_filter( 'the_title_rss', [ __CLASS__, 'maybe_wrap_titles_in_cdata' ] ); + + add_filter( 'newspack_capabilities_map', [ __CLASS__, 'newspack_capabilities_map' ] ); } /** @@ -141,7 +143,8 @@ public static function register_feed_cpt() { 'show_in_menu' => true, 'menu_icon' => 'dashicons-rss', 'query_var' => true, - 'capability_type' => 'post', + 'capability_type' => self::FEED_CPT, + 'map_meta_cap' => true, 'has_archive' => false, 'hierarchical' => false, 'menu_position' => null, @@ -506,7 +509,8 @@ public static function save_settings( $feed_post_id ) { return; } - if ( ! current_user_can( 'edit_posts' ) ) { + $post_type_object = get_post_type_object( self::FEED_CPT ); + if ( ! current_user_can( $post_type_object->cap->edit_posts ) ) { return; } @@ -871,5 +875,16 @@ public static function maybe_wrap_titles_in_cdata( $title ) { private static function is_republication_tracker_plugin_active() { return class_exists( 'Republication_Tracker_Tool' ); } + + /** + * Map the capabilities for the RSS feed custom post type. + * + * @param array $capabilities_map The existing capabilities map. + * @return array The modified capabilities map with RSS feed CPT capabilities. + */ + public static function newspack_capabilities_map( $capabilities_map ) { + $capabilities_map[ self::FEED_CPT ] = 'post'; + return $capabilities_map; + } } RSS::init(); From 15728064d48129e795575643a7b448b1a8b41b0a Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Fri, 11 Apr 2025 14:30:58 +0200 Subject: [PATCH 2/5] test: add tests --- includes/class-capabilities.php | 23 ++++---- tests/unit-tests/capabilities.php | 92 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 tests/unit-tests/capabilities.php diff --git a/includes/class-capabilities.php b/includes/class-capabilities.php index e3e56d99aa..a049d63d49 100644 --- a/includes/class-capabilities.php +++ b/includes/class-capabilities.php @@ -17,35 +17,34 @@ final class Capabilities { * Initialize Hooks. */ public static function init() { - add_filter( 'user_has_cap', [ __CLASS__, 'map_capabilities' ], 10, 4 ); + add_filter( 'user_has_cap', [ __CLASS__, 'map_capabilities' ], 10, 2 ); } /** * Map legacy capabilities to granularly-controlled capabilities. - * If a CPT had its capabilities inherrited from the regular post, - * this should be maintained so the users don't lose access. + * This allows custom post type capabilities to be mapped from regular post + * capabilities. This way, when a custom post type becomes more granularily controlled + * with its own caps, users won't need to have their capabilities updated. * * @param bool[] $allcaps All caps. * @param string[] $caps Required primitive capabilities for the requested capability. - * @param array $args Args for the capability check. - * @param WP_User $user The user object. */ - public static function map_capabilities( $allcaps, $caps, $args, $user ) { + public static function map_capabilities( $allcaps, $caps ) { $capabilities_map = apply_filters( 'newspack_capabilities_map', [] ); - foreach ( $capabilities_map as $post_type => $old_post_type ) { + foreach ( $capabilities_map as $post_type => $base_post_type ) { $post_type_object = get_post_type_object( $post_type ); if ( ! $post_type_object ) { continue; } - $post_type_object_old = get_post_type_object( $old_post_type ); - if ( ! $post_type_object_old ) { + $post_type_object_base = get_post_type_object( $base_post_type ); + if ( ! $post_type_object_base ) { continue; } foreach ( $caps as $requested_cap ) { if ( stripos( $requested_cap, $post_type ) !== false ) { - $found_old_cap_name = array_search( $requested_cap, (array) $post_type_object->cap, true ); - if ( $found_old_cap_name !== false ) { - if ( isset( $allcaps[ $found_old_cap_name ] ) && $allcaps[ $found_old_cap_name ] ) { + $found_base_cap_name = array_search( $requested_cap, (array) $post_type_object->cap, true ); + if ( $found_base_cap_name !== false ) { + if ( isset( $allcaps[ $found_base_cap_name ] ) && $allcaps[ $found_base_cap_name ] ) { $allcaps[ $requested_cap ] = true; } } diff --git a/tests/unit-tests/capabilities.php b/tests/unit-tests/capabilities.php new file mode 100644 index 0000000000..467241c8a0 --- /dev/null +++ b/tests/unit-tests/capabilities.php @@ -0,0 +1,92 @@ + [ + // 'newspack_post' caps should inherit from 'post'. + 'newspack_post' => 'post', + ] + ); + + // Mock the post type object. + $this->mock_post_type_object( + 'newspack_post', + [ + 'edit_posts' => 'edit_newspack_posts', + 'delete_posts' => 'delete_newspack_posts', + ] + ); + + $user_all_caps = [ + 'edit_posts' => true, + 'delete_posts' => false, + ]; + $required_caps = [ 'edit_newspack_posts' ]; + $result = Capabilities::map_capabilities( $user_all_caps, $required_caps ); + $this->assertEquals( array_merge( $user_all_caps, [ 'edit_newspack_posts' => true ] ), $result, 'User with edit_posts cap should get the edit_newspack_post cap, too' ); + + $user_all_caps = [ + 'edit_posts' => false, + 'delete_posts' => false, + ]; + $result = Capabilities::map_capabilities( $user_all_caps, $required_caps ); + $this->assertEquals( $user_all_caps, $result, 'User without edit_posts cap should not get the edit_newspack_post cap' ); + + $user_all_caps = [ + 'edit_posts' => true, + 'delete_posts' => true, + ]; + $required_caps = [ 'edit_newspack_posts', 'delete_newspack_posts' ]; + $result = Capabilities::map_capabilities( $user_all_caps, $required_caps ); + $this->assertEquals( + array_merge( + $user_all_caps, + [ + 'edit_newspack_posts' => true, + 'delete_newspack_posts' => true, + ] + ), + $result, + 'Multiple required caps are supported.' + ); + } + + /** + * Mock a post type object. + * + * @param string $post_type Post type name. + * @param array $capabilities Capabilities array. + */ + private function mock_post_type_object( $post_type, $capabilities ) { + add_filter( + 'register_post_type_args', + function( $args, $name ) use ( $post_type, $capabilities ) { + if ( $name === $post_type ) { + $args['cap'] = $capabilities; + } + return $args; + }, + 10, + 2 + ); + + register_post_type( $post_type ); + } +} From 37fa34d310aa078296373b86079de097282a59d8 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Mon, 14 Apr 2025 14:44:57 +0200 Subject: [PATCH 3/5] fix: conditions in mapping --- includes/class-capabilities.php | 11 ++++++---- tests/unit-tests/capabilities.php | 34 +++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/includes/class-capabilities.php b/includes/class-capabilities.php index a049d63d49..3aca130f10 100644 --- a/includes/class-capabilities.php +++ b/includes/class-capabilities.php @@ -43,10 +43,13 @@ public static function map_capabilities( $allcaps, $caps ) { foreach ( $caps as $requested_cap ) { if ( stripos( $requested_cap, $post_type ) !== false ) { $found_base_cap_name = array_search( $requested_cap, (array) $post_type_object->cap, true ); - if ( $found_base_cap_name !== false ) { - if ( isset( $allcaps[ $found_base_cap_name ] ) && $allcaps[ $found_base_cap_name ] ) { - $allcaps[ $requested_cap ] = true; - } + $required_mapped_cap = $post_type_object_base->cap->$found_base_cap_name; + if ( + $required_mapped_cap !== false + && isset( $allcaps[ $required_mapped_cap ] ) + && $allcaps[ $required_mapped_cap ] + ) { + $allcaps[ $requested_cap ] = true; } } } diff --git a/tests/unit-tests/capabilities.php b/tests/unit-tests/capabilities.php index 467241c8a0..43e7ee54d6 100644 --- a/tests/unit-tests/capabilities.php +++ b/tests/unit-tests/capabilities.php @@ -15,7 +15,6 @@ class Test_Capabilities extends WP_UnitTestCase { * Test the map_capabilities method. */ public function test_capabilities_mapping() { - // Use the capabilities map filter. add_filter( 'newspack_capabilities_map', @@ -25,20 +24,15 @@ public function test_capabilities_mapping() { ] ); - // Mock the post type object. - $this->mock_post_type_object( - 'newspack_post', - [ - 'edit_posts' => 'edit_newspack_posts', - 'delete_posts' => 'delete_newspack_posts', - ] - ); + // Mock the post types. + $this->mock_post_type_object( 'newspack_post' ); $user_all_caps = [ 'edit_posts' => true, 'delete_posts' => false, ]; $required_caps = [ 'edit_newspack_posts' ]; + $result = Capabilities::map_capabilities( $user_all_caps, $required_caps ); $this->assertEquals( array_merge( $user_all_caps, [ 'edit_newspack_posts' => true ] ), $result, 'User with edit_posts cap should get the edit_newspack_post cap, too' ); @@ -66,15 +60,33 @@ public function test_capabilities_mapping() { $result, 'Multiple required caps are supported.' ); + + add_filter( + 'newspack_capabilities_map', + fn() => [ + // 'newspack_post' caps should inherit from 'page'. + 'newspack_post' => 'page', + ] + ); + + $result = Capabilities::map_capabilities( $user_all_caps, [ 'edit_newspack_posts' ] ); + $this->assertEquals( + false, + isset( $result['edit_newspack_posts'] ), + "User can't edit posts which inherrit caps from pages (even though they can edit posts)." + ); } /** * Mock a post type object. * * @param string $post_type Post type name. - * @param array $capabilities Capabilities array. */ - private function mock_post_type_object( $post_type, $capabilities ) { + private function mock_post_type_object( $post_type ) { + $capabilities = [ + 'edit_posts' => 'edit_' . $post_type . 's', + 'delete_posts' => 'delete_' . $post_type . 's', + ]; add_filter( 'register_post_type_args', function( $args, $name ) use ( $post_type, $capabilities ) { From bc4be9d84b28f23e14a432e77e5fdf50472b9fa7 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Mon, 14 Apr 2025 15:09:13 +0200 Subject: [PATCH 4/5] feat: handle more capabilty mapping formats --- includes/class-capabilities.php | 26 ++++++++++------------- tests/unit-tests/capabilities.php | 35 +++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/includes/class-capabilities.php b/includes/class-capabilities.php index 3aca130f10..2cd45223b1 100644 --- a/includes/class-capabilities.php +++ b/includes/class-capabilities.php @@ -31,23 +31,19 @@ public static function init() { */ public static function map_capabilities( $allcaps, $caps ) { $capabilities_map = apply_filters( 'newspack_capabilities_map', [] ); - foreach ( $capabilities_map as $post_type => $base_post_type ) { - $post_type_object = get_post_type_object( $post_type ); - if ( ! $post_type_object ) { - continue; - } - $post_type_object_base = get_post_type_object( $base_post_type ); - if ( ! $post_type_object_base ) { - continue; - } + foreach ( $capabilities_map as $cap_or_post_type => $base_cap_or_post_type ) { + $post_type_object = get_post_type_object( $cap_or_post_type ); + $post_type_object_base = get_post_type_object( $base_cap_or_post_type ); foreach ( $caps as $requested_cap ) { - if ( stripos( $requested_cap, $post_type ) !== false ) { - $found_base_cap_name = array_search( $requested_cap, (array) $post_type_object->cap, true ); - $required_mapped_cap = $post_type_object_base->cap->$found_base_cap_name; + if ( stripos( $requested_cap, $cap_or_post_type ) !== false ) { + if ( $post_type_object && $post_type_object_base ) { + $base = array_search( $requested_cap, (array) $post_type_object->cap, true ); + $base_cap_or_post_type = $post_type_object_base->cap->$base; + } if ( - $required_mapped_cap !== false - && isset( $allcaps[ $required_mapped_cap ] ) - && $allcaps[ $required_mapped_cap ] + $base_cap_or_post_type !== false + && isset( $allcaps[ $base_cap_or_post_type ] ) + && $allcaps[ $base_cap_or_post_type ] ) { $allcaps[ $requested_cap ] = true; } diff --git a/tests/unit-tests/capabilities.php b/tests/unit-tests/capabilities.php index 43e7ee54d6..e8dad114d1 100644 --- a/tests/unit-tests/capabilities.php +++ b/tests/unit-tests/capabilities.php @@ -14,7 +14,7 @@ class Test_Capabilities extends WP_UnitTestCase { /** * Test the map_capabilities method. */ - public function test_capabilities_mapping() { + public function test_capabilities_mapping_post_to_post() { // Use the capabilities map filter. add_filter( 'newspack_capabilities_map', @@ -48,7 +48,6 @@ public function test_capabilities_mapping() { 'delete_posts' => true, ]; $required_caps = [ 'edit_newspack_posts', 'delete_newspack_posts' ]; - $result = Capabilities::map_capabilities( $user_all_caps, $required_caps ); $this->assertEquals( array_merge( $user_all_caps, @@ -57,7 +56,7 @@ public function test_capabilities_mapping() { 'delete_newspack_posts' => true, ] ), - $result, + Capabilities::map_capabilities( $user_all_caps, $required_caps ), 'Multiple required caps are supported.' ); @@ -68,7 +67,6 @@ public function test_capabilities_mapping() { 'newspack_post' => 'page', ] ); - $result = Capabilities::map_capabilities( $user_all_caps, [ 'edit_newspack_posts' ] ); $this->assertEquals( false, @@ -77,6 +75,35 @@ public function test_capabilities_mapping() { ); } + /** + * Test the map_capabilities method. + */ + public function test_capabilities_mapping_other_caps() { + add_filter( + 'newspack_capabilities_map', + fn() => [ + // 'newspack_widgets' caps should inherit from 'manage_options'. + 'newspack_widgets' => 'manage_options', + ] + ); + $user_all_caps = [ + 'manage_options' => true, + ]; + $this->assertEquals( + array_merge( $user_all_caps, [ 'newspack_widgets' => true ] ), + Capabilities::map_capabilities( $user_all_caps, [ 'newspack_widgets' ] ), + 'User who can manage_options can newspack_widgets too.' + ); + $user_all_caps = [ + 'manage_options' => false, + ]; + $this->assertEquals( + $user_all_caps, + Capabilities::map_capabilities( $user_all_caps, [ 'newspack_widgets' ] ), + "User who can't manage_options can't newspack_widgets neither." + ); + } + /** * Mock a post type object. * From 46d9242d5688572c66bcaae61aa0f5db45666f03 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Mon, 14 Apr 2025 15:09:43 +0200 Subject: [PATCH 5/5] feat: custom capability for campaigns wizard --- includes/class-capabilities.php | 11 +++++++ .../audience/class-audience-campaigns.php | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/includes/class-capabilities.php b/includes/class-capabilities.php index 2cd45223b1..e55c980879 100644 --- a/includes/class-capabilities.php +++ b/includes/class-capabilities.php @@ -18,6 +18,7 @@ final class Capabilities { */ public static function init() { add_filter( 'user_has_cap', [ __CLASS__, 'map_capabilities' ], 10, 2 ); + add_filter( 'cme_plugin_capabilities', [ __CLASS__, 'cme_plugin_capabilities' ] ); } /** @@ -52,5 +53,15 @@ public static function map_capabilities( $allcaps, $caps ) { } return $allcaps; } + + /** + * Filter the capability-manager-enhanced (PublishPress Capabilties) plugin UI. + * + * @param array $plugin_caps Array of per-plugin caps. + */ + public static function cme_plugin_capabilities( $plugin_caps ) { + $plugin_caps['Newspack'] = apply_filters( 'newspack_capabilities_in_cme_plugin', [] ); + return $plugin_caps; + } } Capabilities::init(); diff --git a/includes/wizards/audience/class-audience-campaigns.php b/includes/wizards/audience/class-audience-campaigns.php index 326b9af45b..18478b2cf9 100644 --- a/includes/wizards/audience/class-audience-campaigns.php +++ b/includes/wizards/audience/class-audience-campaigns.php @@ -28,6 +28,13 @@ class Audience_Campaigns extends Wizard { */ protected $parent_slug = 'newspack-audience'; + /** + * Required capability. + * + * @var string + */ + protected $capability = 'newspack_campaigns'; + /** * Constructor. */ @@ -39,6 +46,8 @@ public function __construct() { // Determine active menu items. add_filter( 'parent_file', [ $this, 'parent_file' ] ); add_filter( 'submenu_file', [ $this, 'submenu_file' ] ); + add_filter( 'newspack_capabilities_map', [ $this, 'newspack_capabilities_map' ] ); + add_filter( 'newspack_capabilities_in_cme_plugin', [ $this, 'newspack_capabilities_in_cme_plugin' ] ); } /** @@ -1017,4 +1026,24 @@ public function submenu_file( $submenu_file ) { return $submenu_file; } + + /** + * Map this wizard capability from 'manage_options' capability. + * + * @param array $capabilities_map Mapping of capabilities. + */ + public function newspack_capabilities_map( $capabilities_map ) { + $capabilities_map[ $this->capability ] = 'manage_options'; + return $capabilities_map; + } + + /** + * Register this capability in Newspack capabilities list in the capability-manager-enhanced plugin. + * + * @param array $capabilities Mapping of capabilities. + */ + public function newspack_capabilities_in_cme_plugin( $capabilities ) { + $capabilities[] = $this->capability; + return $capabilities; + } }