From 5d19fddd704c4007b10640adba456763ee6b5a6b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 29 Jan 2026 17:24:20 +0100 Subject: [PATCH 1/9] Add bulk soft delete action for posts - Add bulk action "Soft Delete" to post list tables for all ActivityPub-enabled post types - Add row action "Soft Delete" for individual federated posts - Unify user and post soft delete confirmation templates into one - Add proper admin notices for success/failure/no selection cases - Add removable query args filter for one-time notices - Add comprehensive test coverage for Admin bulk delete functionality Fixes #2566 --- includes/wp-admin/class-admin.php | 389 +++++++++++++++++- templates/bulk-actor-delete-confirmation.php | 62 --- templates/bulk-delete-confirmation.php | 144 +++++++ .../includes/wp-admin/class-test-admin.php | 277 +++++++++++++ 4 files changed, 797 insertions(+), 75 deletions(-) delete mode 100644 templates/bulk-actor-delete-confirmation.php create mode 100644 templates/bulk-delete-confirmation.php create mode 100644 tests/phpunit/tests/includes/wp-admin/class-test-admin.php diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index a04b0dd201..cf9fccf4c3 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -15,8 +15,10 @@ use Activitypub\Moderation; use Activitypub\Scheduler\Actor; +use function Activitypub\add_to_outbox; use function Activitypub\count_followers; use function Activitypub\get_content_visibility; +use function Activitypub\get_wp_object_state; use function Activitypub\is_user_type_disabled; use function Activitypub\site_supports_blocks; use function Activitypub\user_can_activitypub; @@ -57,6 +59,15 @@ public static function init() { \add_action( 'admin_post_delete_actor_confirmed', array( self::class, 'handle_bulk_actor_delete_confirmation' ) ); \add_action( 'admin_action_activitypub_confirm_removal', array( self::class, 'handle_bulk_actor_delete_page' ) ); + // Post bulk actions for federated content. + self::register_post_bulk_actions(); + \add_action( 'admin_post_activitypub_delete_posts_confirmed', array( self::class, 'handle_bulk_post_delete_confirmation' ) ); + \add_action( 'admin_action_activitypub_confirm_post_removal', array( self::class, 'handle_bulk_post_delete_page' ) ); + \add_action( 'admin_post_activitypub_delete_post', array( self::class, 'handle_single_post_delete' ) ); + + // Register removable query args for one-time admin notices. + \add_filter( 'removable_query_args', array( self::class, 'add_removable_query_args' ) ); + if ( user_can_activitypub( \get_current_user_id() ) ) { \add_action( 'show_user_profile', array( self::class, 'add_profile' ) ); } @@ -115,6 +126,98 @@ public static function admin_notices() { 0 ) { + ?> +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ $users, + 'type' => 'users', + 'items' => $users, 'send_back' => $send_back, ) ); @@ -791,6 +895,238 @@ public static function process_capability_removal( $users, $remove_from_fedivers return $send_back; } + /** + * Register bulk actions for post types that support ActivityPub. + */ + public static function register_post_bulk_actions() { + $post_types = \get_post_types_by_support( 'activitypub' ); + + foreach ( $post_types as $post_type ) { + \add_filter( "bulk_actions-edit-{$post_type}", array( self::class, 'post_bulk_options' ) ); + \add_filter( "handle_bulk_actions-edit-{$post_type}", array( self::class, 'handle_post_bulk_request' ), 10, 3 ); + } + } + + /** + * Add options to the Bulk dropdown on the posts page. + * + * @param array $actions The existing bulk options. + * + * @return array The extended bulk options. + */ + public static function post_bulk_options( $actions ) { + $actions['activitypub_delete'] = __( 'Soft Delete', 'activitypub' ); + + return $actions; + } + + /** + * Handle bulk activitypub requests for posts. + * + * @param string $send_back The URL to send the user back to. + * @param string $action The requested action. + * @param array $post_ids The selected post IDs. + * + * @return string The URL to send the user back to. + */ + public static function handle_post_bulk_request( $send_back, $action, $post_ids ) { + if ( 'activitypub_delete' !== $action ) { + return $send_back; + } + + // Filter to only include federated posts. + $federated_posts = array(); + foreach ( $post_ids as $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post ) { + continue; + } + + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $state ) { + $federated_posts[] = $post_id; + } + } + + // If no federated posts, redirect back with a notice. + if ( empty( $federated_posts ) ) { + return \add_query_arg( 'activitypub_no_federated', '1', $send_back ); + } + + // Build the query args for the confirmation page. + $query_args = array( + 'action' => 'activitypub_confirm_post_removal', + 'send_back' => \rawurlencode( $send_back ), + ); + + // Add post IDs as separate parameters. + foreach ( $federated_posts as $index => $post_id ) { + $query_args[ sprintf( 'posts[%d]', $index ) ] = \absint( $post_id ); + } + + $confirmation_url = \add_query_arg( $query_args, \admin_url( 'edit.php' ) ); + + // Force redirect to confirmation page. + \wp_safe_redirect( $confirmation_url ); + exit; + } + + /** + * Handle the bulk post deletion page request directly. + */ + public static function handle_bulk_post_delete_page() { + // Check permissions. + if ( ! \current_user_can( 'edit_posts' ) ) { + \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'activitypub' ) ); + } + + // Get parameters. + // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput + $posts = \wp_unslash( $_GET['posts'] ?? array() ); + // phpcs:ignore WordPress.Security.NonceVerification + $send_back = \urldecode( \sanitize_text_field( \wp_unslash( $_GET['send_back'] ?? '' ) ) ); + + // Sanitize post IDs. + $posts = \array_map( 'absint', (array) $posts ); + $posts = \array_filter( $posts ); + + // Validate send_back URL. + if ( empty( $send_back ) && ! empty( $posts ) ) { + // Try to determine the post type from the first post to preserve context. + $first_post = \get_post( $posts[0] ); + if ( $first_post ) { + $send_back = \admin_url( 'edit.php?post_type=' . $first_post->post_type ); + } else { + $send_back = \admin_url( 'edit.php' ); + } + } elseif ( empty( $send_back ) ) { + $send_back = \admin_url( 'edit.php' ); + } + + // Load template and exit to prevent WordPress from trying to load other admin pages. + \load_template( + ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-delete-confirmation.php', + false, + array( + 'type' => 'posts', + 'items' => $posts, + 'send_back' => $send_back, + ) + ); + exit; + } + + /** + * Handle the bulk post deletion confirmation form submission. + */ + public static function handle_bulk_post_delete_confirmation() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'activitypub-bulk-post-delete' ) ) { + \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); + } + + // Check permissions. + if ( ! \current_user_can( 'edit_posts' ) ) { + \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) ); + } + + // Get form data. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $selected_posts = \wp_unslash( $_POST['selected_posts'] ?? array() ); + $send_back = \esc_url_raw( \wp_unslash( $_POST['send_back'] ?? '' ) ); + + // Sanitize post IDs. + $selected_posts = \array_map( 'absint', (array) $selected_posts ); + $selected_posts = \array_filter( $selected_posts ); + + if ( empty( $selected_posts ) ) { + \wp_safe_redirect( $send_back ); + exit; + } + + // Process deletion. + $deleted_count = 0; + foreach ( $selected_posts as $post_id ) { + $post = \get_post( $post_id ); + if ( ! $post ) { + continue; + } + + // Verify the post is still federated. + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED !== $state ) { + continue; + } + + // Check user can edit this post. + if ( ! \current_user_can( 'edit_post', $post_id ) ) { + continue; + } + + // Send Delete activity. + $result = add_to_outbox( $post, 'Delete', $post->post_author ); + if ( $result ) { + ++$deleted_count; + } + } + + // Add success count to redirect URL. + $send_back = \add_query_arg( 'activitypub_deleted', $deleted_count, $send_back ); + + // Redirect back. + \wp_safe_redirect( $send_back ); + exit; + } + + /** + * Handle single post deletion from Fediverse. + */ + public static function handle_single_post_delete() { + // Get and sanitize post ID. + $post_id = \absint( $_GET['post_id'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification + + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'activitypub-delete-post-' . $post_id ) ) { + \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); + } + + // Check post exists. + $post = \get_post( $post_id ); + if ( ! $post ) { + \wp_die( \esc_html__( 'Post not found.', 'activitypub' ) ); + } + + // Check permissions. + if ( ! \current_user_can( 'edit_post', $post_id ) ) { + \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) ); + } + + // Verify the post is federated. + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED !== $state ) { + \wp_die( \esc_html__( 'This post has not been federated.', 'activitypub' ) ); + } + + // Send Delete activity. + $result = add_to_outbox( $post, 'Delete', $post->post_author ); + + // Build redirect URL. + $send_back = \admin_url( 'edit.php' ); + if ( 'post' !== $post->post_type ) { + $send_back = \add_query_arg( 'post_type', $post->post_type, $send_back ); + } + + if ( $result ) { + $send_back = \add_query_arg( 'activitypub_deleted', 1, $send_back ); + } else { + $send_back = \add_query_arg( 'activitypub_delete_failed', 1, $send_back ); + } + + // Redirect back. + \wp_safe_redirect( $send_back ); + exit; + } + /** * Add ActivityPub infos to the dashboard glance items. * @@ -853,24 +1189,51 @@ public static function dashboard_glance_items( $items ) { * @return array The modified actions. */ public static function row_actions( $actions, $post ) { - // check if the post is enabled for ActivityPub. + // Check if the post type supports ActivityPub. if ( ! \post_type_supports( \get_post_type( $post ), 'activitypub' ) || - ! in_array( $post->post_status, array( 'pending', 'draft', 'future', 'publish' ), true ) || - ! \current_user_can( 'edit_post', $post->ID ) || - ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL === get_content_visibility( $post->ID ) || - ( site_supports_blocks() && \use_block_editor_for_post_type( $post->post_type ) ) + ! \current_user_can( 'edit_post', $post->ID ) ) { return $actions; } - $preview_url = add_query_arg( 'activitypub', 'true', \get_preview_post_link( $post ) ); + // Add preview link for non-local posts in block editor. + if ( + in_array( $post->post_status, array( 'pending', 'draft', 'future', 'publish' ), true ) && + ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL !== get_content_visibility( $post->ID ) && + ! ( site_supports_blocks() && \use_block_editor_for_post_type( $post->post_type ) ) + ) { + $preview_url = add_query_arg( 'activitypub', 'true', \get_preview_post_link( $post ) ); - $actions['activitypub'] = sprintf( - '%s', - \esc_url( $preview_url ), - \esc_html__( 'Fediverse Preview ⁂', 'activitypub' ) - ); + $actions['activitypub'] = sprintf( + '%s', + \esc_url( $preview_url ), + \esc_html__( 'Fediverse Preview ⁂', 'activitypub' ) + ); + } + + // Add "Delete from Fediverse" link for federated posts. + $state = get_wp_object_state( $post ); + if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $state ) { + $delete_url = \wp_nonce_url( + \add_query_arg( + array( + 'action' => 'activitypub_delete_post', + 'post_id' => $post->ID, + ), + \admin_url( 'admin-post.php' ) + ), + 'activitypub-delete-post-' . $post->ID + ); + + $actions['activitypub_delete'] = sprintf( + '%s', + \esc_url( $delete_url ), + \esc_attr__( 'Send Delete activity to the Fediverse', 'activitypub' ), + \esc_js( __( 'Are you sure you want to delete this post from the Fediverse?', 'activitypub' ) ), + \esc_html__( 'Soft Delete', 'activitypub' ) + ); + } return $actions; } diff --git a/templates/bulk-actor-delete-confirmation.php b/templates/bulk-actor-delete-confirmation.php deleted file mode 100644 index 918b5c2d52..0000000000 --- a/templates/bulk-actor-delete-confirmation.php +++ /dev/null @@ -1,62 +0,0 @@ - true ) ); -} - -// Prepare user data for display. -$users = get_users( array( 'include' => $users ) ); - -// If no users with ActivityPub capability, redirect back. -if ( ! $users ) { - wp_safe_redirect( $send_back ); - exit; -} - -$GLOBALS['plugin_page'] = ''; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -require_once ABSPATH . 'wp-admin/admin-header.php'; -?> -
-

-

-

This action is irreversible.', 'activitypub' ), array( 'strong' => array() ) ); ?>

-
- - - - - -
-
    - -
  • - -
  • - -
-
- -

- - -

-
-
- $item_ids ) ); +} else { + foreach ( $item_ids as $item_id ) { + $item = get_post( $item_id ); + if ( $item && current_user_can( 'edit_post', $item_id ) ) { + $items[] = $item; + } + } +} + +// If no valid items, redirect back with notice. +if ( empty( $items ) ) { + $notice_param = 'users' === $item_type ? 'activitypub_no_users' : 'activitypub_no_posts'; + wp_safe_redirect( add_query_arg( $notice_param, '1', $send_back ) ); + exit; +} + +// Set up type-specific variables. +if ( 'users' === $item_type ) { + $page_title = __( 'Delete Users from Fediverse', 'activitypub' ); + $description = __( 'You have removed the capability to publish to the Fediverse for the selected users. Do you also want to send a Delete activity to remove them from the Fediverse?', 'activitypub' ); + $note = __( 'Note: This sends a Delete activity to notify remote servers that these profiles no longer exist.', 'activitypub' ); + $nonce_action = 'bulk-users'; + $form_action = 'delete_actor_confirmed'; + $input_name = 'remove_from_fediverse[]'; + $hidden_name = 'selected_users[]'; + $columns = array( + 'name' => __( 'Name', 'activitypub' ), + ); +} else { + $page_title = __( 'Delete Posts from Fediverse', 'activitypub' ); + $description = __( 'You are about to send Delete activities for the following posts. This will remove them from the Fediverse while keeping them on your site.', 'activitypub' ); + $note = __( 'Note: This sends a Delete activity to notify remote servers. The posts will remain on your WordPress site.', 'activitypub' ); + $nonce_action = 'activitypub-bulk-post-delete'; + $form_action = 'activitypub_delete_posts_confirmed'; + $input_name = 'selected_posts[]'; + $hidden_name = ''; + $columns = array( + 'title' => __( 'Title', 'activitypub' ), + 'author' => __( 'Author', 'activitypub' ), + 'date' => __( 'Date', 'activitypub' ), + ); +} + +$GLOBALS['plugin_page'] = ''; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited +require_once ABSPATH . 'wp-admin/admin-header.php'; +?> +
+

+

+

array() ) ); ?>

+ +
+ + + + + + + + + + $column_label ) : ?> + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + display_name ); ?> +
+ user_email ); ?> +
+ + + post_author ) ); ?> + + +
+ +

+ + +

+
+
+ +user->create( array( 'role' => 'editor' ) ); + + // Add activitypub capability to the user. + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Clean up test resources. + */ + public static function tear_down_after_class() { + \wp_delete_user( self::$user_id ); + + parent::tear_down_after_class(); + } + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + \wp_set_current_user( self::$user_id ); + } + + /** + * Tear down. + */ + public function tear_down() { + parent::tear_down(); + + _delete_all_posts(); + } + + /** + * Test post_bulk_options adds the Soft Delete option. + * + * @covers ::post_bulk_options + */ + public function test_post_bulk_options() { + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Move to Trash', + ); + + $result = Admin::post_bulk_options( $actions ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertEquals( 'Soft Delete', $result['activitypub_delete'] ); + // Ensure original actions are preserved. + $this->assertArrayHasKey( 'edit', $result ); + $this->assertArrayHasKey( 'trash', $result ); + } + + /** + * Test handle_post_bulk_request returns early for non-activitypub actions. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_wrong_action() { + $send_back = 'http://example.org/wp-admin/edit.php'; + $post_ids = array( 1, 2, 3 ); + + $result = Admin::handle_post_bulk_request( $send_back, 'trash', $post_ids ); + + $this->assertEquals( $send_back, $result ); + } + + /** + * Test handle_post_bulk_request returns notice URL when no federated posts. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_no_federated_posts() { + // Create a post and mark it as not federated (pending state). + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Explicitly set to pending state (not federated). + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING ); + + $send_back = 'http://example.org/wp-admin/edit.php'; + + $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array( $post_id ) ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test row_actions adds Soft Delete for federated posts. + * + * @covers ::row_actions + */ + public function test_row_actions_adds_soft_delete_for_federated_post() { + // Create a federated post. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Mark as federated. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Trash', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertStringContainsString( 'Soft Delete', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'activitypub_delete_post', $result['activitypub_delete'] ); + } + + /** + * Test row_actions does not add Soft Delete for non-federated posts. + * + * @covers ::row_actions + */ + public function test_row_actions_no_soft_delete_for_non_federated_post() { + // Create a post and mark as pending (not federated). + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Explicitly set to pending state (not federated). + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Trash', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions returns unchanged for unsupported post types. + * + * @covers ::row_actions + */ + public function test_row_actions_unsupported_post_type() { + // Register an unsupported post type. + \register_post_type( 'unsupported_type', array( 'public' => true ) ); + + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_type' => 'unsupported_type', + 'post_status' => 'publish', + ) + ); + + // Even mark it as federated - shouldn't matter. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + + \unregister_post_type( 'unsupported_type' ); + } + + /** + * Test add_removable_query_args adds the correct args. + * + * @covers ::add_removable_query_args + */ + public function test_add_removable_query_args() { + $args = array( 'existing_arg' ); + + $result = Admin::add_removable_query_args( $args ); + + $this->assertContains( 'existing_arg', $result ); + $this->assertContains( 'activitypub_deleted', $result ); + $this->assertContains( 'activitypub_no_federated', $result ); + $this->assertContains( 'activitypub_no_posts', $result ); + $this->assertContains( 'activitypub_no_users', $result ); + } + + /** + * Test register_post_bulk_actions registers filters for supported post types. + * + * @covers ::register_post_bulk_actions + */ + public function test_register_post_bulk_actions() { + // Clear existing filters. + \remove_all_filters( 'bulk_actions-edit-post' ); + \remove_all_filters( 'handle_bulk_actions-edit-post' ); + + Admin::register_post_bulk_actions(); + + $this->assertTrue( \has_filter( 'bulk_actions-edit-post' ) !== false ); + $this->assertTrue( \has_filter( 'handle_bulk_actions-edit-post' ) !== false ); + } + + /** + * Test row_actions returns unchanged for users without edit capability. + * + * @covers ::row_actions + */ + public function test_row_actions_no_capability() { + // Create a subscriber user. + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + \wp_set_current_user( $subscriber_id ); + + // Create a federated post by another user. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array( + 'view' => 'View', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + + // Restore user. + \wp_set_current_user( self::$user_id ); + \wp_delete_user( $subscriber_id ); + } +} From 0857d4932e1946f3f45ecc623ecfac84394d3585 Mon Sep 17 00:00:00 2001 From: Automattic Bot Date: Thu, 29 Jan 2026 18:25:08 +0200 Subject: [PATCH 2/9] Add changelog --- .github/changelog/2840-from-description | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/2840-from-description diff --git a/.github/changelog/2840-from-description b/.github/changelog/2840-from-description new file mode 100644 index 0000000000..983fd648e1 --- /dev/null +++ b/.github/changelog/2840-from-description @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add bulk and row action to soft delete posts from the Fediverse. From 520b7c79d606c4a9d8039f9ea94acf9ea4a08a0a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 29 Jan 2026 17:28:25 +0100 Subject: [PATCH 3/9] Expand test coverage for Admin bulk soft delete Add 13 new tests covering: - Non-existent post handling - Mixed federated/non-federated posts - Deleted and failed post states - Nonce and confirmation dialog in row actions - Complete removable query args verification - Page post type support - Empty post array handling - Draft post edge case - Title attribute verification --- .../includes/wp-admin/class-test-admin.php | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/tests/phpunit/tests/includes/wp-admin/class-test-admin.php b/tests/phpunit/tests/includes/wp-admin/class-test-admin.php index 09194bd7eb..0393b565ec 100644 --- a/tests/phpunit/tests/includes/wp-admin/class-test-admin.php +++ b/tests/phpunit/tests/includes/wp-admin/class-test-admin.php @@ -274,4 +274,312 @@ public function test_row_actions_no_capability() { \wp_set_current_user( self::$user_id ); \wp_delete_user( $subscriber_id ); } + + /** + * Test handle_post_bulk_request filters out non-existent posts. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_non_existent_posts() { + $send_back = 'http://example.org/wp-admin/edit.php'; + + // Use non-existent post IDs. + $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array( 999999, 999998 ) ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test handle_post_bulk_request with mixed federated and non-federated posts. + * + * This test verifies that when selecting multiple posts, only federated ones + * are included in the confirmation redirect. Since the method redirects when + * federated posts exist, we test the filtering logic indirectly. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_mixed_posts() { + // Create a non-federated post. + $non_federated_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $non_federated_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_PENDING ); + + // Create a deleted post (should not be included). + $deleted_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $deleted_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED ); + + $send_back = 'http://example.org/wp-admin/edit.php'; + + // With only non-federated and deleted posts, should return notice. + $result = Admin::handle_post_bulk_request( + $send_back, + 'activitypub_delete', + array( $non_federated_id, $deleted_id ) + ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test row_actions does not add Soft Delete for already deleted posts. + * + * @covers ::row_actions + */ + public function test_row_actions_no_soft_delete_for_deleted_post() { + // Create a post marked as already deleted from Fediverse. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Mark as deleted. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Trash', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions does not add Soft Delete for failed posts. + * + * @covers ::row_actions + */ + public function test_row_actions_no_soft_delete_for_failed_post() { + // Create a post marked as failed. + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + + // Mark as failed. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FAILED ); + + $post = \get_post( $post_id ); + $actions = array( + 'edit' => 'Edit', + ); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayNotHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions includes correct nonce in delete URL. + * + * @covers ::row_actions + */ + public function test_row_actions_delete_url_has_nonce() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertStringContainsString( '_wpnonce', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'post_id=' . $post_id, $result['activitypub_delete'] ); + } + + /** + * Test row_actions includes confirmation dialog. + * + * @covers ::row_actions + */ + public function test_row_actions_delete_has_confirmation() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertStringContainsString( 'onclick', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'confirm', $result['activitypub_delete'] ); + } + + /** + * Test add_removable_query_args includes all ActivityPub query args. + * + * @covers ::add_removable_query_args + */ + public function test_add_removable_query_args_complete() { + $result = Admin::add_removable_query_args( array() ); + + $expected_args = array( + 'activitypub_deleted', + 'activitypub_delete_failed', + 'activitypub_no_federated', + 'activitypub_no_users', + 'activitypub_no_posts', + ); + + foreach ( $expected_args as $arg ) { + $this->assertContains( $arg, $result, "Missing removable query arg: {$arg}" ); + } + } + + /** + * Test register_post_bulk_actions registers for page post type. + * + * @covers ::register_post_bulk_actions + */ + public function test_register_post_bulk_actions_pages() { + // Ensure page post type supports activitypub. + \add_post_type_support( 'page', 'activitypub' ); + + // Clear existing filters. + \remove_all_filters( 'bulk_actions-edit-page' ); + \remove_all_filters( 'handle_bulk_actions-edit-page' ); + + Admin::register_post_bulk_actions(); + + $this->assertTrue( \has_filter( 'bulk_actions-edit-page' ) !== false ); + $this->assertTrue( \has_filter( 'handle_bulk_actions-edit-page' ) !== false ); + } + + /** + * Test post_bulk_options preserves action order. + * + * @covers ::post_bulk_options + */ + public function test_post_bulk_options_preserves_order() { + $actions = array( + 'edit' => 'Edit', + 'trash' => 'Move to Trash', + ); + + $result = Admin::post_bulk_options( $actions ); + + $keys = array_keys( $result ); + + // Original actions should come first. + $this->assertEquals( 'edit', $keys[0] ); + $this->assertEquals( 'trash', $keys[1] ); + // ActivityPub action added at end. + $this->assertEquals( 'activitypub_delete', $keys[2] ); + } + + /** + * Test handle_post_bulk_request with empty post array. + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_empty_array() { + $send_back = 'http://example.org/wp-admin/edit.php'; + + $result = Admin::handle_post_bulk_request( $send_back, 'activitypub_delete', array() ); + + $this->assertStringContainsString( 'activitypub_no_federated=1', $result ); + } + + /** + * Test row_actions works with page post type. + * + * @covers ::row_actions + */ + public function test_row_actions_with_page() { + // Ensure page post type supports activitypub. + \add_post_type_support( 'page', 'activitypub' ); + + $page_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_type' => 'page', + 'post_status' => 'publish', + ) + ); + \update_post_meta( $page_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $page = \get_post( $page_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $page ); + + $this->assertArrayHasKey( 'activitypub_delete', $result ); + $this->assertStringContainsString( 'Soft Delete', $result['activitypub_delete'] ); + } + + /** + * Test row_actions with draft post does not add Soft Delete even if federated. + * + * Draft posts shouldn't normally be federated, but if somehow they are, + * the soft delete action should still be available since the state is federated. + * + * @covers ::row_actions + */ + public function test_row_actions_federated_draft() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'draft', + ) + ); + // Hypothetically federated draft. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + // Should still show soft delete since it's marked as federated. + $this->assertArrayHasKey( 'activitypub_delete', $result ); + } + + /** + * Test row_actions delete link has proper title attribute. + * + * @covers ::row_actions + */ + public function test_row_actions_delete_has_title() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $post = \get_post( $post_id ); + $actions = array(); + + $result = Admin::row_actions( $actions, $post ); + + $this->assertStringContainsString( 'title=', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'Send Delete activity', $result['activitypub_delete'] ); + } } From 262d3b7eeb8a4c85e077c12878904ed6a4e20f8d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 29 Jan 2026 17:31:13 +0100 Subject: [PATCH 4/9] Add configurable checkbox state and cancel label to confirmation template - Add 'checked' param (default: true) to control checkbox default state - Add 'cancel_label' param (default: 'Cancel') for the cancel button text - Use wp_parse_args for cleaner default handling - Pass checked=false and cancel_label='Skip' for user deletion flow to preserve original UX behavior --- includes/wp-admin/class-admin.php | 8 +++++--- templates/bulk-delete-confirmation.php | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index cf9fccf4c3..04bcf2f61b 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -807,9 +807,11 @@ public static function handle_bulk_actor_delete_page() { ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-delete-confirmation.php', false, array( - 'type' => 'users', - 'items' => $users, - 'send_back' => $send_back, + 'type' => 'users', + 'items' => $users, + 'send_back' => $send_back, + 'checked' => false, + 'cancel_label' => \__( 'Skip', 'activitypub' ), ) ); exit; diff --git a/templates/bulk-delete-confirmation.php b/templates/bulk-delete-confirmation.php index 017f4b9637..e406150b1b 100644 --- a/templates/bulk-delete-confirmation.php +++ b/templates/bulk-delete-confirmation.php @@ -8,11 +8,22 @@ */ /* @var array $args Template arguments. */ -$args = wp_parse_args( $args ?? array() ); +$args = wp_parse_args( + $args ?? array(), + array( + 'type' => 'posts', + 'items' => array(), + 'send_back' => '', + 'checked' => true, + 'cancel_label' => __( 'Cancel', 'activitypub' ), + ) +); -$item_type = $args['type'] ?? 'posts'; -$item_ids = $args['items'] ?? array(); -$send_back = $args['send_back'] ?? ''; +$item_type = $args['type']; +$item_ids = $args['items']; +$send_back = $args['send_back']; +$checked = $args['checked']; +$cancel_label = $args['cancel_label']; // Validate items - redirect back with notice if empty. if ( empty( $item_ids ) ) { @@ -86,7 +97,7 @@ - + /> $column_label ) : ?> @@ -99,7 +110,7 @@ - + /> @@ -128,7 +139,7 @@

- +

From a91beb947ea786c18d469f30c5fe920fe8eb18eb Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 29 Jan 2026 17:39:20 +0100 Subject: [PATCH 5/9] Set visibility to private after soft delete After successfully sending a Delete activity, set the post's activitypub_content_visibility to private. This prevents accidental re-federation while allowing users to manually re-federate by changing visibility back to public. --- includes/wp-admin/class-admin.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 04bcf2f61b..649fb53e58 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -1068,6 +1068,8 @@ public static function handle_bulk_post_delete_confirmation() { // Send Delete activity. $result = add_to_outbox( $post, 'Delete', $post->post_author ); if ( $result ) { + // Set visibility to private to prevent re-federation. + \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); ++$deleted_count; } } @@ -1112,6 +1114,11 @@ public static function handle_single_post_delete() { // Send Delete activity. $result = add_to_outbox( $post, 'Delete', $post->post_author ); + // Set visibility to private to prevent re-federation. + if ( $result ) { + \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + } + // Build redirect URL. $send_back = \admin_url( 'edit.php' ); if ( 'post' !== $post->post_type ) { From 4555f6ce63390f70cd1e987004bbc3c7dfea191a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 29 Jan 2026 17:41:46 +0100 Subject: [PATCH 6/9] Use const/let instead of var in template JavaScript --- templates/bulk-delete-confirmation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/bulk-delete-confirmation.php b/templates/bulk-delete-confirmation.php index e406150b1b..9afe070bdb 100644 --- a/templates/bulk-delete-confirmation.php +++ b/templates/bulk-delete-confirmation.php @@ -145,8 +145,8 @@ assertStringContainsString( 'onclick', $result['activitypub_delete'] ); - $this->assertStringContainsString( 'confirm', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'class="activitypub-delete-link"', $result['activitypub_delete'] ); + $this->assertStringContainsString( 'data-activitypub-confirm', $result['activitypub_delete'] ); + $this->assertStringNotContainsString( 'onclick', $result['activitypub_delete'], 'Row action must not use an inline onclick handler.' ); } /** @@ -582,4 +583,74 @@ public function test_row_actions_delete_has_title() { $this->assertStringContainsString( 'title=', $result['activitypub_delete'] ); $this->assertStringContainsString( 'Send Delete activity', $result['activitypub_delete'] ); } + + /** + * Test that a single soft delete sends a Delete and marks the post local-only. + * + * @covers ::handle_single_post_delete + */ + public function test_handle_single_post_delete_marks_post_local() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $_GET['post_id'] = $post_id; + $_GET['_wpnonce'] = \wp_create_nonce( 'activitypub-delete-post-' . $post_id ); + + // Intercept the redirect so the handler does not exit the test run. + $redirect = static function () { + throw new \Exception( 'redirect' ); + }; + \add_filter( 'wp_redirect', $redirect ); + + try { + Admin::handle_single_post_delete(); + $this->fail( 'Expected a redirect.' ); + } catch ( \Exception $e ) { + $this->assertSame( 'redirect', $e->getMessage() ); + } finally { + \remove_filter( 'wp_redirect', $redirect ); + unset( $_GET['post_id'], $_GET['_wpnonce'] ); + } + + $this->assertSame( + ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + \get_post_meta( $post_id, 'activitypub_content_visibility', true ), + 'A soft-deleted post must be marked local-only, not private.' + ); + $this->assertSame( + ACTIVITYPUB_OBJECT_STATE_DELETED, + \get_post_meta( $post_id, 'activitypub_status', true ), + 'Sending the Delete activity must move the post to the deleted state.' + ); + } + + /** + * Test that a single soft delete with an invalid nonce is rejected. + * + * @covers ::handle_single_post_delete + */ + public function test_handle_single_post_delete_invalid_nonce() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $_GET['post_id'] = $post_id; + $_GET['_wpnonce'] = 'invalid-nonce'; + + try { + $this->expectException( \WPDieException::class ); + Admin::handle_single_post_delete(); + } finally { + unset( $_GET['post_id'], $_GET['_wpnonce'] ); + } + } } From bcb9e18fec04c83a37a3b3581202e725211ba3fd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 8 Jun 2026 14:37:14 +0200 Subject: [PATCH 8/9] Store bulk-delete IDs in a transient instead of the URL Large bulk selections previously passed every post or user ID as query args in the confirmation redirect, which could exceed URL length limits. Both the post and actor delete flows now store the IDs in a short-lived, per-user transient and reference them with a one-time token. The actor confirmation page now also verifies a nonce, matching the post flow. Add tests for the bulk request token redirect and the bulk confirmation handler (Delete sent, post marked local-only, invalid nonce rejected). --- includes/wp-admin/class-admin.php | 111 ++++++++++++------ .../includes/wp-admin/class-test-admin.php | 104 ++++++++++++++++ 2 files changed, 178 insertions(+), 37 deletions(-) diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 769b2c6053..f25cf1cd75 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -787,6 +787,47 @@ public static function user_bulk_options( $actions ) { return $actions; } + /** + * Store the IDs pending a bulk-delete confirmation and return a one-time token. + * + * The IDs are kept in a short-lived, per-user transient rather than passed + * through the redirect URL, so large selections cannot exceed URL length limits. + * + * @param int[] $ids The IDs to store. + * + * @return string The token to pass to the confirmation page. + */ + private static function store_bulk_delete_ids( $ids ) { + $token = \wp_generate_uuid4(); + \set_transient( + 'activitypub_bulk_delete_' . \get_current_user_id() . '_' . $token, + \array_values( \array_map( 'absint', (array) $ids ) ), + 5 * MINUTE_IN_SECONDS + ); + + return $token; + } + + /** + * Retrieve and delete the IDs stored for a bulk-delete confirmation token. + * + * @param string $token The token from the confirmation page request. + * + * @return int[] The stored IDs, or an empty array if none or expired. + */ + private static function consume_bulk_delete_ids( $token ) { + $token = \sanitize_key( $token ); + if ( '' === $token ) { + return array(); + } + + $key = 'activitypub_bulk_delete_' . \get_current_user_id() . '_' . $token; + $ids = \get_transient( $key ); + \delete_transient( $key ); + + return \is_array( $ids ) ? $ids : array(); + } + /** * Handle bulk activitypub requests. * @@ -837,19 +878,18 @@ public static function handle_bulk_request( $send_back, $action, $users ) { ++$removed_count; } - // Build the query args with proper array handling for fediverse deletion confirmation. - $query_args = array( - 'action' => 'activitypub_confirm_removal', - 'send_back' => \rawurlencode( $send_back ), + // Build the confirmation URL. The user IDs are stored in a transient and + // referenced by token to avoid URL length limits on large selections. + $confirmation_url = \add_query_arg( + array( + 'action' => 'activitypub_confirm_removal', + 'send_back' => \rawurlencode( $send_back ), + 'token' => self::store_bulk_delete_ids( $users ), + '_wpnonce' => \wp_create_nonce( 'activitypub-confirm-removal' ), + ), + \admin_url( 'users.php' ) ); - // Add user IDs as separate parameters. - foreach ( $users as $index => $user_id ) { - $query_args[ sprintf( 'users[%d]', $index ) ] = absint( $user_id ); - } - - $confirmation_url = \add_query_arg( $query_args, \admin_url( 'users.php' ) ); - // Force redirect instead of just returning URL. \wp_safe_redirect( $confirmation_url ); exit; @@ -865,22 +905,23 @@ public static function handle_bulk_request( $send_back, $action, $users ) { * Handle the bulk capability removal page request directly. */ public static function handle_bulk_actor_delete_page() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'activitypub-confirm-removal' ) ) { + \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); + } // Check permissions. if ( ! \current_user_can( 'edit_users' ) ) { \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'activitypub' ) ); } - // Get parameters. - // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput - $users = \wp_unslash( $_GET['users'] ?? array() ); + // Get the pending user IDs from the transient referenced by the token. + $users = self::consume_bulk_delete_ids( \sanitize_key( \wp_unslash( $_GET['token'] ?? '' ) ) ); + $users = \array_filter( $users ); + // phpcs:ignore WordPress.Security.NonceVerification $send_back = \urldecode( \sanitize_text_field( \wp_unslash( $_GET['send_back'] ?? '' ) ) ); - // Sanitize user IDs. - $users = \array_map( 'absint', (array) $users ); - $users = \array_filter( $users ); - // Validate send_back URL. if ( empty( $send_back ) ) { $send_back = \admin_url( 'users.php' ); @@ -1039,20 +1080,18 @@ public static function handle_post_bulk_request( $send_back, $action, $post_ids return \add_query_arg( 'activitypub_no_federated', '1', $send_back ); } - // Build the query args for the confirmation page. - $query_args = array( - 'action' => 'activitypub_confirm_post_removal', - 'send_back' => \rawurlencode( $send_back ), - '_wpnonce' => \wp_create_nonce( 'activitypub-confirm-post-removal' ), + // Build the confirmation URL. The post IDs are stored in a transient and + // referenced by token to avoid URL length limits on large selections. + $confirmation_url = \add_query_arg( + array( + 'action' => 'activitypub_confirm_post_removal', + 'send_back' => \rawurlencode( $send_back ), + 'token' => self::store_bulk_delete_ids( $federated_posts ), + '_wpnonce' => \wp_create_nonce( 'activitypub-confirm-post-removal' ), + ), + \admin_url( 'edit.php' ) ); - // Add post IDs as separate parameters. - foreach ( $federated_posts as $index => $post_id ) { - $query_args[ sprintf( 'posts[%d]', $index ) ] = \absint( $post_id ); - } - - $confirmation_url = \add_query_arg( $query_args, \admin_url( 'edit.php' ) ); - // Force redirect to confirmation page. \wp_safe_redirect( $confirmation_url ); exit; @@ -1072,15 +1111,13 @@ public static function handle_bulk_post_delete_page() { \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'activitypub' ) ); } - // Get parameters. - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput - $posts = \wp_unslash( $_GET['posts'] ?? array() ); - $send_back = \urldecode( \sanitize_text_field( \wp_unslash( $_GET['send_back'] ?? '' ) ) ); - - // Sanitize post IDs. - $posts = \array_map( 'absint', (array) $posts ); + // Get the pending post IDs from the transient referenced by the token. + $posts = self::consume_bulk_delete_ids( \sanitize_key( \wp_unslash( $_GET['token'] ?? '' ) ) ); $posts = \array_filter( $posts ); + // phpcs:ignore WordPress.Security.NonceVerification + $send_back = \urldecode( \sanitize_text_field( \wp_unslash( $_GET['send_back'] ?? '' ) ) ); + // Validate send_back URL. if ( empty( $send_back ) && ! empty( $posts ) ) { // Try to determine the post type from the first post to preserve context. diff --git a/tests/phpunit/tests/includes/wp-admin/class-test-admin.php b/tests/phpunit/tests/includes/wp-admin/class-test-admin.php index f4e01dc7f7..d2c59e83c1 100644 --- a/tests/phpunit/tests/includes/wp-admin/class-test-admin.php +++ b/tests/phpunit/tests/includes/wp-admin/class-test-admin.php @@ -653,4 +653,108 @@ public function test_handle_single_post_delete_invalid_nonce() { unset( $_GET['post_id'], $_GET['_wpnonce'] ); } } + + /** + * Test the bulk request stores IDs in a transient and redirects with a token (not in the URL). + * + * @covers ::handle_post_bulk_request + */ + public function test_handle_post_bulk_request_redirects_with_token() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $captured = null; + $redirect = static function ( $location ) use ( &$captured ) { + $captured = $location; + throw new \Exception( 'redirect' ); + }; + \add_filter( 'wp_redirect', $redirect ); + + try { + Admin::handle_post_bulk_request( \admin_url( 'edit.php' ), 'activitypub_delete', array( $post_id ) ); + $this->fail( 'Expected a redirect.' ); + } catch ( \Exception $e ) { + $this->assertSame( 'redirect', $e->getMessage() ); + } finally { + \remove_filter( 'wp_redirect', $redirect ); + } + + $this->assertStringContainsString( 'action=activitypub_confirm_post_removal', $captured ); + $this->assertStringContainsString( 'token=', $captured ); + $this->assertStringContainsString( '_wpnonce=', $captured ); + $this->assertStringNotContainsString( 'posts%5B', $captured, 'Post IDs must not be passed in the URL.' ); + + // The token resolves to the federated post via the transient. + \parse_str( (string) \wp_parse_url( $captured, PHP_URL_QUERY ), $query ); + $stored = \get_transient( 'activitypub_bulk_delete_' . \get_current_user_id() . '_' . \sanitize_key( $query['token'] ) ); + $this->assertSame( array( $post_id ), $stored ); + } + + /** + * Test the bulk confirmation sends a Delete and marks posts local-only. + * + * @covers ::handle_bulk_post_delete_confirmation + */ + public function test_handle_bulk_post_delete_confirmation_deletes() { + $post_id = self::factory()->post->create( + array( + 'post_author' => self::$user_id, + 'post_status' => 'publish', + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + $_POST['_wpnonce'] = \wp_create_nonce( 'activitypub-bulk-post-delete' ); + $_POST['selected_posts'] = array( $post_id ); + $_POST['send_back'] = \admin_url( 'edit.php' ); + + $captured = null; + $redirect = static function ( $location ) use ( &$captured ) { + $captured = $location; + throw new \Exception( 'redirect' ); + }; + \add_filter( 'wp_redirect', $redirect ); + + try { + Admin::handle_bulk_post_delete_confirmation(); + } catch ( \Exception $e ) { + $this->assertSame( 'redirect', $e->getMessage() ); + } finally { + \remove_filter( 'wp_redirect', $redirect ); + unset( $_POST['_wpnonce'], $_POST['selected_posts'], $_POST['send_back'] ); + } + + $this->assertSame( + ACTIVITYPUB_OBJECT_STATE_DELETED, + \get_post_meta( $post_id, 'activitypub_status', true ), + 'The post must move to the deleted state.' + ); + $this->assertSame( + ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + \get_post_meta( $post_id, 'activitypub_content_visibility', true ), + 'The post must be marked local-only.' + ); + $this->assertStringContainsString( 'activitypub_deleted=1', $captured ); + } + + /** + * Test the bulk confirmation rejects an invalid nonce. + * + * @covers ::handle_bulk_post_delete_confirmation + */ + public function test_handle_bulk_post_delete_confirmation_invalid_nonce() { + $_POST['_wpnonce'] = 'invalid-nonce'; + + try { + $this->expectException( \WPDieException::class ); + Admin::handle_bulk_post_delete_confirmation(); + } finally { + unset( $_POST['_wpnonce'] ); + } + } } From fdaf8a6ee0d0e9caabb3ceccfa558f1bea0631a3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 9 Jun 2026 10:37:04 +0200 Subject: [PATCH 9/9] Allow custom-capability editors to bulk soft delete and fix user select-all - Drop the blanket edit_posts gate from the bulk delete handlers; the per-post edit_post checks (template render loop and submit loop) are the authoritative, capability-type-aware gate, so editors of custom post types are no longer blocked from bulk soft delete while the single-row action works. - Enqueue the select-all/confirm handler on users.php as well as edit.php, so the select-all checkbox works on the user confirmation screen (rendered from users.php), not just the post screen. - Add tests for a custom-capability editor bulk delete and for the select-all handler being enqueued on the user list page. --- includes/wp-admin/class-admin.php | 17 ++-- .../includes/wp-admin/class-test-admin.php | 91 +++++++++++++++++++ 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index f25cf1cd75..c0250e122d 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -457,9 +457,9 @@ public static function enqueue_scripts( $hook_suffix ) { ); } - if ( 'edit.php' === $hook_suffix ) { + if ( 'edit.php' === $hook_suffix || 'users.php' === $hook_suffix ) { // Confirm the "Soft Delete" row action and drive the select-all checkbox on the - // confirmation screen without inline event-handler attributes. + // post and user confirmation screens without inline event-handler attributes. \wp_add_inline_script( 'common', '( function () { @@ -1106,10 +1106,9 @@ public static function handle_bulk_post_delete_page() { \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); } - // Check permissions. - if ( ! \current_user_can( 'edit_posts' ) ) { - \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'activitypub' ) ); - } + // Per-post edit permissions are enforced as the confirmation template renders each item + // (and again in the submit handler), so post types with custom capabilities are not + // blocked by a built-in `edit_posts` gate. // Get the pending post IDs from the transient referenced by the token. $posts = self::consume_bulk_delete_ids( \sanitize_key( \wp_unslash( $_GET['token'] ?? '' ) ) ); @@ -1153,10 +1152,8 @@ public static function handle_bulk_post_delete_confirmation() { \wp_die( \esc_html__( 'Security check failed.', 'activitypub' ) ); } - // Check permissions. - if ( ! \current_user_can( 'edit_posts' ) ) { - \wp_die( \esc_html__( 'You do not have sufficient permissions to perform this action.', 'activitypub' ) ); - } + // Per-post edit permissions are enforced in the deletion loop below, so post types with + // custom capabilities are handled correctly rather than blocked by `edit_posts`. // Get form data. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput diff --git a/tests/phpunit/tests/includes/wp-admin/class-test-admin.php b/tests/phpunit/tests/includes/wp-admin/class-test-admin.php index d2c59e83c1..2d2be28446 100644 --- a/tests/phpunit/tests/includes/wp-admin/class-test-admin.php +++ b/tests/phpunit/tests/includes/wp-admin/class-test-admin.php @@ -757,4 +757,95 @@ public function test_handle_bulk_post_delete_confirmation_invalid_nonce() { unset( $_POST['_wpnonce'] ); } } + + /** + * Test that an editor of a custom-capability post type can bulk soft delete its posts. + * + * @covers ::handle_bulk_post_delete_confirmation + */ + public function test_handle_bulk_post_delete_confirmation_allows_cpt_only_editor() { + \register_post_type( + 'ap_event', + array( + 'public' => true, + 'capability_type' => array( 'ap_event', 'ap_events' ), + 'map_meta_cap' => true, + 'supports' => array( 'title', 'editor', 'activitypub' ), + ) + ); + \add_role( + 'ap_event_editor', + 'AP Event Editor', + array( + 'read' => true, + 'edit_ap_events' => true, + 'edit_published_ap_events' => true, + 'publish_ap_events' => true, + ) + ); + $editor_id = self::factory()->user->create( array( 'role' => 'ap_event_editor' ) ); + \get_user_by( 'id', $editor_id )->add_cap( 'activitypub' ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'ap_event', + 'post_status' => 'publish', + 'post_author' => $editor_id, + ) + ); + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + \wp_set_current_user( $editor_id ); + + // Precondition: this user cannot edit regular posts but can edit its own CPT post. + $this->assertFalse( \current_user_can( 'edit_posts' ), 'Precondition: no edit_posts capability.' ); + $this->assertTrue( \current_user_can( 'edit_post', $post_id ), 'Precondition: can edit its own CPT post.' ); + + $_POST['_wpnonce'] = \wp_create_nonce( 'activitypub-bulk-post-delete' ); + $_POST['selected_posts'] = array( $post_id ); + $_POST['send_back'] = \admin_url( 'edit.php' ); + + $redirect = static function () { + throw new \Exception( 'redirect' ); + }; + \add_filter( 'wp_redirect', $redirect ); + + try { + Admin::handle_bulk_post_delete_confirmation(); + } catch ( \Exception $e ) { + $this->assertSame( 'redirect', $e->getMessage(), 'Handler must not die on the edit_posts gate for a CPT-only editor.' ); + } finally { + \remove_filter( 'wp_redirect', $redirect ); + unset( $_POST['_wpnonce'], $_POST['selected_posts'], $_POST['send_back'] ); + \wp_set_current_user( self::$user_id ); + \remove_role( 'ap_event_editor' ); + \unregister_post_type( 'ap_event' ); + } + + $this->assertSame( + ACTIVITYPUB_OBJECT_STATE_DELETED, + \get_post_meta( $post_id, 'activitypub_status', true ), + 'A custom-capability editor must be able to bulk soft delete its federated posts.' + ); + } + + /** + * Test the select-all/confirm handler is enqueued on the user confirmation page. + * + * Regression: the handler used to load only on edit.php, leaving the user + * confirmation screen (rendered from users.php) without a working select-all. + * + * @covers ::enqueue_scripts + */ + public function test_select_all_handler_enqueued_on_users_page() { + \wp_enqueue_script( 'common' ); + Admin::enqueue_scripts( 'users.php' ); + + $after = \implode( '', (array) \wp_scripts()->get_data( 'common', 'after' ) ); + $this->assertStringContainsString( + 'cb-select-all', + $after, + 'Select-all handler must be enqueued on the user confirmation page.' + ); + } }