Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/add-soft-delete-draft-pending
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Extend soft delete to draft, pending, and private status changes for federated posts.
16 changes: 13 additions & 3 deletions includes/collection/class-outbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
);
}

Expand Down
10 changes: 6 additions & 4 deletions includes/functions-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,28 @@ 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;
}

/*
* 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;
}
Expand Down
5 changes: 2 additions & 3 deletions includes/scheduler/class-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions includes/transformer/class-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );

Expand Down Expand Up @@ -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' );

Expand Down
45 changes: 45 additions & 0 deletions tests/phpunit/tests/includes/collection/class-test-outbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
1 change: 1 addition & 0 deletions tests/phpunit/tests/includes/handler/class-test-create.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
1 change: 1 addition & 0 deletions tests/phpunit/tests/includes/handler/class-test-like.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down
170 changes: 170 additions & 0 deletions tests/phpunit/tests/includes/scheduler/class-test-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}
}
9 changes: 8 additions & 1 deletion tests/phpunit/tests/includes/transformer/class-test-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ public function test_content_visibility() {
array(
'post_author' => 1,
'post_content' => 'test content visibility',
'post_status' => 'publish',
)
);

Expand All @@ -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 ) );
}

/**
Expand Down