From 58491c8ba250c43dd1f28a63a7ebde84f146c7d8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 18:44:40 +0100 Subject: [PATCH 1/4] Extend soft delete to draft and pending post statuses When a federated post is changed to draft or pending status, send a Delete activity to unfederate the content. This follows the same soft delete pattern as visibility changes to local/private (FEP-4f05). Changes: - Update scheduler to trigger Delete for draft/pending status changes - Update is_post_disabled() to handle draft/pending for federated posts - Mark placeholder content in transformer as deprecated - Add tests for draft/pending soft delete behavior --- .../changelog/add-soft-delete-draft-pending | 4 + includes/functions-post.php | 10 +- includes/scheduler/class-post.php | 4 +- includes/transformer/class-post.php | 12 +- .../includes/scheduler/class-test-post.php | 128 ++++++++++++++++++ .../includes/transformer/class-test-post.php | 9 +- 6 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 .github/changelog/add-soft-delete-draft-pending diff --git a/.github/changelog/add-soft-delete-draft-pending b/.github/changelog/add-soft-delete-draft-pending new file mode 100644 index 0000000000..5720cbf776 --- /dev/null +++ b/.github/changelog/add-soft-delete-draft-pending @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Extend soft delete to draft and pending status changes for federated posts. diff --git a/includes/functions-post.php b/includes/functions-post.php index d1fe21c4b4..e3b77cfdd1 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/draft/pending 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 e8ea1c32b0..b0f3109553 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -85,9 +85,7 @@ public static function triage( $post_id, $post, $update, $post_before ) { break; case 'draft': - $type = ( 'publish' === $old_status ) ? 'Update' : false; - break; - + case 'pending': 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 5d2cf4e39f..010816c0d5 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/scheduler/class-test-post.php b/tests/phpunit/tests/includes/scheduler/class-test-post.php index 0e60841d9a..cd683b03ec 100644 --- a/tests/phpunit/tests/includes/scheduler/class-test-post.php +++ b/tests/phpunit/tests/includes/scheduler/class-test-post.php @@ -446,4 +446,132 @@ 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' ); + } } diff --git a/tests/phpunit/tests/includes/transformer/class-test-post.php b/tests/phpunit/tests/includes/transformer/class-test-post.php index 34892af964..7b80d433f6 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 ) ); } /** From 905de76c8f54eca437a9fb24af65cbe0fc70c677 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 18:50:03 +0100 Subject: [PATCH 2/4] Add private status to soft delete and add test Also include WordPress private post status in soft delete handling, alongside draft and pending. --- .../changelog/add-soft-delete-draft-pending | 2 +- includes/functions-post.php | 2 +- includes/scheduler/class-post.php | 1 + .../includes/scheduler/class-test-post.php | 42 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.github/changelog/add-soft-delete-draft-pending b/.github/changelog/add-soft-delete-draft-pending index 5720cbf776..8b6294d83d 100644 --- a/.github/changelog/add-soft-delete-draft-pending +++ b/.github/changelog/add-soft-delete-draft-pending @@ -1,4 +1,4 @@ Significance: minor Type: added -Extend soft delete to draft and pending status changes for federated posts. +Extend soft delete to draft, pending, and private status changes for federated posts. diff --git a/includes/functions-post.php b/includes/functions-post.php index e3b77cfdd1..ef8798fd31 100644 --- a/includes/functions-post.php +++ b/includes/functions-post.php @@ -41,7 +41,7 @@ function is_post_disabled( $post ) { /* * Check for posts that need special handling. - * Federated posts changed to local/private/draft/pending 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 ); diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php index b0f3109553..19af765052 100644 --- a/includes/scheduler/class-post.php +++ b/includes/scheduler/class-post.php @@ -86,6 +86,7 @@ public static function triage( $post_id, $post, $update, $post_before ) { case 'draft': case 'pending': + case 'private': case 'trash': $type = ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status ? 'Delete' : false; break; diff --git a/tests/phpunit/tests/includes/scheduler/class-test-post.php b/tests/phpunit/tests/includes/scheduler/class-test-post.php index cd683b03ec..8fb2414cd4 100644 --- a/tests/phpunit/tests/includes/scheduler/class-test-post.php +++ b/tests/phpunit/tests/includes/scheduler/class-test-post.php @@ -574,4 +574,46 @@ public function test_status_change_to_draft_no_delete_for_unfederated_post() { $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' ); + } } From c6c4acf5a84caca5e80f195055f44382b47c1665 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 2 Feb 2026 18:55:39 +0100 Subject: [PATCH 3/4] Fix handler tests to use published posts The handler tests were creating draft posts, which are now disabled for ActivityPub interactions after the soft delete changes. Update tests to create published posts instead. --- tests/phpunit/tests/includes/handler/class-test-announce.php | 1 + tests/phpunit/tests/includes/handler/class-test-create.php | 1 + tests/phpunit/tests/includes/handler/class-test-like.php | 1 + 3 files changed, 3 insertions(+) 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 ); From 7823a2fddb12f351fe986f5fd6ac3fbb4aa55be8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 3 Feb 2026 09:07:24 +0100 Subject: [PATCH 4/4] Invalidate pending Delete activities when Create/Update is added When a soft-deleted post is re-published before the Delete activity is sent, the pending Delete should be invalidated. This prevents sending both Delete and Create activities for the same re-published post. --- includes/collection/class-outbox.php | 16 +++++-- .../includes/collection/class-test-outbox.php | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) 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/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. *