diff --git a/.github/changelog/add-soft-delete-draft-pending b/.github/changelog/add-soft-delete-draft-pending new file mode 100644 index 0000000000..8b6294d83d --- /dev/null +++ b/.github/changelog/add-soft-delete-draft-pending @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Extend soft delete to draft, pending, and private status changes for federated posts. diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index 19a70c4d98..13a0e78409 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -138,11 +138,21 @@ private static function invalidate_existing_items( $object_id, $activity_type, $ ), ); - // For non-Delete activities, only invalidate items of the same type. + /* + * For non-Delete activities, invalidate same type AND Delete activities. + * This ensures a re-published post invalidates any pending Delete from soft delete. + */ if ( 'Delete' !== $activity_type ) { $meta_query[] = array( - 'key' => '_activitypub_activity_type', - 'value' => $activity_type, + 'relation' => 'OR', + array( + 'key' => '_activitypub_activity_type', + 'value' => $activity_type, + ), + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Delete', + ), ); } diff --git a/includes/functions-post.php b/includes/functions-post.php index d1fe21c4b4..ef8798fd31 100644 --- a/includes/functions-post.php +++ b/includes/functions-post.php @@ -28,11 +28,12 @@ function is_post_disabled( $post ) { $visibility = \get_post_meta( $post->ID, 'activitypub_content_visibility', true ); $is_local_or_private = in_array( $visibility, array( ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ), true ); + $is_not_published = in_array( $post->post_status, array( 'draft', 'pending', 'private' ), true ); if ( $is_local_or_private || ! \post_type_supports( $post->post_type, 'activitypub' ) || - 'private' === $post->post_status || + $is_not_published || ! empty( $post->post_password ) ) { $disabled = true; @@ -40,14 +41,15 @@ function is_post_disabled( $post ) { /* * Check for posts that need special handling. - * Federated posts changed to local/private need Delete activity. + * Federated posts changed to local/private visibility or draft/pending/private status need Delete activity. * Deleted posts restored to public need Create activity. */ - $object_state = get_wp_object_state( $post ); + $object_state = get_wp_object_state( $post ); + $needs_tombstone = $is_local_or_private || $is_not_published; if ( ACTIVITYPUB_OBJECT_STATE_DELETED === $object_state || - ( $is_local_or_private && ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_state ) + ( $needs_tombstone && ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_state ) ) { $disabled = false; } diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index f5d114b708..cc8a18e22d 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -82,9 +82,8 @@ public static function triage( $post_id, $post, $update, $post_before ) { break; case 'draft': - $type = ( 'publish' === $old_status ) ? 'Update' : false; - break; - + case 'pending': + case 'private': case 'trash': $type = ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status ? 'Delete' : false; break; diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 700dfe2490..4fd3d0681a 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -543,7 +543,11 @@ protected function get_summary() { return $this->summary; } - // Remove Teaser from drafts. + /** + * Remove Teaser from drafts. + * + * @deprecated unreleased Drafts now trigger soft delete (Delete activity) instead of Update with placeholder. + */ if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { $this->summary = \__( '(This post is being modified)', 'activitypub' ); @@ -593,7 +597,11 @@ protected function get_content() { return $this->content; } - // Remove Content from drafts. + /** + * Remove Content from drafts. + * + * @deprecated unreleased Drafts now trigger soft delete (Delete activity) instead of Update with placeholder. + */ if ( ! $this->is_preview() && 'draft' === \get_post_status( $this->item ) ) { $this->content = \__( '(This post is being modified)', 'activitypub' ); diff --git a/tests/phpunit/tests/includes/collection/class-test-outbox.php b/tests/phpunit/tests/includes/collection/class-test-outbox.php index 3a4c073833..e360de0bbd 100644 --- a/tests/phpunit/tests/includes/collection/class-test-outbox.php +++ b/tests/phpunit/tests/includes/collection/class-test-outbox.php @@ -286,6 +286,51 @@ public function test_delete_invalidates_all_activities() { $this->assertEquals( 'pending', \get_post_status( $delete_id ) ); } + /** + * Test that Create/Update activities invalidate pending Delete activities. + * + * This handles the soft delete scenario: when a post is soft deleted (Delete scheduled) + * and then re-published before the Delete is sent, the Delete should be invalidated. + * + * @covers ::invalidate_existing_items + */ + public function test_create_invalidates_pending_delete() { + $object = $this->get_dummy_activity_object(); + + // Create a Delete activity (simulating soft delete). + $delete_id = \Activitypub\add_to_outbox( $object, 'Delete', 1 ); + $this->assertEquals( 'pending', \get_post_status( $delete_id ) ); + + // Now add a Create activity (simulating re-publish). + $create_id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); + + // Delete activity should be published (invalidated). + $this->assertEquals( 'publish', \get_post_status( $delete_id ) ); + // Create activity should still be pending. + $this->assertEquals( 'pending', \get_post_status( $create_id ) ); + } + + /** + * Test that Update activities also invalidate pending Delete activities. + * + * @covers ::invalidate_existing_items + */ + public function test_update_invalidates_pending_delete() { + $object = $this->get_dummy_activity_object(); + + // Create a Delete activity (simulating soft delete). + $delete_id = \Activitypub\add_to_outbox( $object, 'Delete', 1 ); + $this->assertEquals( 'pending', \get_post_status( $delete_id ) ); + + // Now add an Update activity. + $update_id = \Activitypub\add_to_outbox( $object, 'Update', 1 ); + + // Delete activity should be published (invalidated). + $this->assertEquals( 'publish', \get_post_status( $delete_id ) ); + // Update activity should still be pending. + $this->assertEquals( 'pending', \get_post_status( $update_id ) ); + } + /** * Test get_object_id with different nested structures. * diff --git a/tests/phpunit/tests/includes/handler/class-test-announce.php b/tests/phpunit/tests/includes/handler/class-test-announce.php index 1c6ec81bf9..99dbd9fa6c 100644 --- a/tests/phpunit/tests/includes/handler/class-test-announce.php +++ b/tests/phpunit/tests/includes/handler/class-test-announce.php @@ -50,6 +50,7 @@ public function set_up() { array( 'post_author' => $this->user_id, 'post_content' => 'test', + 'post_status' => 'publish', ) ); $this->post_permalink = \get_permalink( $this->post_id ); diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php index 6c80d9c699..40ec1cf1f6 100644 --- a/tests/phpunit/tests/includes/handler/class-test-create.php +++ b/tests/phpunit/tests/includes/handler/class-test-create.php @@ -67,6 +67,7 @@ public function set_up() { array( 'post_author' => $this->user_id, 'post_content' => 'test', + 'post_status' => 'publish', ) ); $this->post_permalink = \get_permalink( $this->post_id ); diff --git a/tests/phpunit/tests/includes/handler/class-test-like.php b/tests/phpunit/tests/includes/handler/class-test-like.php index 7ae710e426..8754f48764 100644 --- a/tests/phpunit/tests/includes/handler/class-test-like.php +++ b/tests/phpunit/tests/includes/handler/class-test-like.php @@ -57,6 +57,7 @@ public function set_up() { array( 'post_author' => $this->user_id, 'post_content' => 'test', + 'post_status' => 'publish', ) ); $this->post_permalink = \get_permalink( $this->post_id ); diff --git a/tests/phpunit/tests/includes/scheduler/class-test-post.php b/tests/phpunit/tests/includes/scheduler/class-test-post.php index 0e60841d9a..8fb2414cd4 100644 --- a/tests/phpunit/tests/includes/scheduler/class-test-post.php +++ b/tests/phpunit/tests/includes/scheduler/class-test-post.php @@ -446,4 +446,174 @@ public function test_visibility_change_to_public_no_delete_activity() { $this->assertEmpty( $outbox_items, 'Should not create a Delete activity when visibility changes to public' ); } + + /** + * Test that changing a federated post to draft creates a Delete activity. + * + * @covers ::triage + */ + public function test_status_change_to_draft_creates_delete_activity() { + // Create a post (will be federated). + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + $activitypub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + + // Simulate the post being marked as federated. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + // Change status to draft. + \wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'draft', + ) + ); + + // Query for the Delete activity. + $outbox_items = \get_posts( + array( + 'post_type' => 'ap_outbox', + 'post_status' => 'pending', + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_activitypub_object_id', + 'value' => $activitypub_id, + ), + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Delete', + ), + ), + ) + ); + + $this->assertCount( 1, $outbox_items, 'Should create a Delete activity when post status changes to draft' ); + } + + /** + * Test that changing a federated post to pending creates a Delete activity. + * + * @covers ::triage + */ + public function test_status_change_to_pending_creates_delete_activity() { + // Create a post (will be federated). + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + $activitypub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + + // Simulate the post being marked as federated. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + // Change status to pending. + \wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'pending', + ) + ); + + // Query for the Delete activity. + $outbox_items = \get_posts( + array( + 'post_type' => 'ap_outbox', + 'post_status' => 'pending', + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_activitypub_object_id', + 'value' => $activitypub_id, + ), + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Delete', + ), + ), + ) + ); + + $this->assertCount( 1, $outbox_items, 'Should create a Delete activity when post status changes to pending' ); + } + + /** + * Test that an unfederated post changing to draft does not create a Delete activity. + * + * @covers ::triage + */ + public function test_status_change_to_draft_no_delete_for_unfederated_post() { + // Create a post without federating it. + \remove_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33 ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + $activitypub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + \add_action( 'wp_after_insert_post', array( Post::class, 'triage' ), 33, 4 ); + + // Ensure the post has no federated status. + \delete_post_meta( $post_id, 'activitypub_status' ); + + // Change status to draft. + \wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'draft', + ) + ); + + // Query for any Delete activity. + $outbox_items = \get_posts( + array( + 'post_type' => 'ap_outbox', + 'post_status' => 'pending', + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_activitypub_object_id', + 'value' => $activitypub_id, + ), + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Delete', + ), + ), + ) + ); + + $this->assertEmpty( $outbox_items, 'Should not create a Delete activity when unfederated post changes to draft' ); + } + + /** + * Test that changing a federated post to private creates a Delete activity. + * + * @covers ::triage + */ + public function test_status_change_to_private_creates_delete_activity() { + // Create a post (will be federated). + $post_id = self::factory()->post->create( array( 'post_author' => self::$user_id ) ); + $activitypub_id = \add_query_arg( 'p', $post_id, \home_url( '/' ) ); + + // Simulate the post being marked as federated. + \update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED ); + + // Change status to private. + \wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'private', + ) + ); + + // Query for the Delete activity. + $outbox_items = \get_posts( + array( + 'post_type' => 'ap_outbox', + 'post_status' => 'pending', + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => '_activitypub_object_id', + 'value' => $activitypub_id, + ), + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Delete', + ), + ), + ) + ); + + $this->assertCount( 1, $outbox_items, 'Should create a Delete activity when post status changes to private' ); + } } diff --git a/tests/phpunit/tests/includes/transformer/class-test-post.php b/tests/phpunit/tests/includes/transformer/class-test-post.php index d09173c465..3a6e97cc25 100644 --- a/tests/phpunit/tests/includes/transformer/class-test-post.php +++ b/tests/phpunit/tests/includes/transformer/class-test-post.php @@ -273,6 +273,7 @@ public function test_content_visibility() { array( 'post_author' => 1, 'post_content' => 'test content visibility', + 'post_status' => 'publish', ) ); @@ -290,10 +291,16 @@ public function test_content_visibility() { \update_post_meta( $post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL ); - $this->assertTrue( \Activitypub\is_post_disabled( $post_id ) ); + // Federated posts with LOCAL visibility are not disabled (they need Delete activity for soft delete). + // Only non-federated LOCAL posts are disabled. + $this->assertFalse( \Activitypub\is_post_disabled( $post_id ) ); $object = Post::transform( get_post( $post_id ) )->to_object(); $this->assertEmpty( $object->get_to() ); $this->assertEmpty( $object->get_cc() ); + + // Test that an unfederated post with LOCAL visibility is disabled. + \delete_post_meta( $post_id, 'activitypub_status' ); + $this->assertTrue( \Activitypub\is_post_disabled( $post_id ) ); } /**