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. diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 44236acd7e..c0250e122d 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -17,8 +17,10 @@ use Activitypub\Scheduler\Actor; use Activitypub\Tombstone; +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; @@ -59,6 +61,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' ) ); if ( \get_option( 'activitypub_api', false ) ) { @@ -124,6 +135,98 @@ public static function admin_notices() { 0 ) { + ?> +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ +
+

+ +

+
+ '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; @@ -735,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' ); @@ -758,11 +929,14 @@ public static function handle_bulk_actor_delete_page() { // Load template and exit to prevent WordPress from trying to load other admin pages. \load_template( - ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-actor-delete-confirmation.php', + ACTIVITYPUB_PLUGIN_DIR . 'templates/bulk-delete-confirmation.php', false, array( - 'users' => $users, - 'send_back' => $send_back, + 'type' => 'users', + 'items' => $users, + 'send_back' => $send_back, + 'checked' => false, + 'cancel_label' => \__( 'Skip', 'activitypub' ), ) ); exit; @@ -848,6 +1022,244 @@ 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 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' ) + ); + + // 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() { + // Verify nonce. + if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'activitypub-confirm-post-removal' ) ) { + \wp_die( \esc_html__( 'Security check failed.', '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'] ?? '' ) ) ); + $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. + $first_post = \get_post( $posts[0] ); + if ( $first_post ) { + $send_back = \add_query_arg( 'post_type', $first_post->post_type, \admin_url( 'edit.php' ) ); + } 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' ) ); + } + + // 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 + $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 && ! \is_wp_error( $result ) ) { + // Mark the post as local-only so it is not re-federated. + \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); + ++$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 ); + $result = $result && ! \is_wp_error( $result ); + + // Mark the post as local-only so it is not re-federated. + if ( $result ) { + \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); + } + + // 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. * @@ -910,24 +1322,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_attr__( '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() ) ); ?>

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

- - -

-
-
- 'posts', + 'items' => array(), + 'send_back' => '', + 'checked' => true, + 'cancel_label' => __( 'Cancel', 'activitypub' ), + ) +); + +$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 ) ) { + $notice_param = 'users' === $item_type ? 'activitypub_no_users' : 'activitypub_no_posts'; + wp_safe_redirect( add_query_arg( $notice_param, '1', $send_back ) ); + exit; +} + +// Get items based on type. +$items = array(); +if ( 'users' === $item_type ) { + $items = get_users( array( 'include' => $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 = sprintf( + /* translators: %s: the word "Note:" in bold. */ + __( '%s This sends a Delete activity to notify remote servers that these profiles no longer exist.', 'activitypub' ), + '' . esc_html__( 'Note:', '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 = sprintf( + /* translators: %s: the word "Note:" in bold. */ + __( '%s This sends a Delete activity to notify remote servers. The posts will remain on your WordPress site.', 'activitypub' ), + '' . esc_html__( 'Note:', '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 ) : ?> + + + + + + + + + + + + + + + + + + +
+ aria-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 ); + } + + /** + * 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( '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.' ); + } + + /** + * 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'] ); + } + + /** + * 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'] ); + } + } + + /** + * 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'] ); + } + } + + /** + * 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.' + ); + } +}