diff --git a/.github/changelog/2295-from-description b/.github/changelog/2295-from-description new file mode 100644 index 000000000..c61ad6a33 --- /dev/null +++ b/.github/changelog/2295-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Extended inbox support for undoing Like, Create, and Announce activities, with refactored undo logic and improved activity persistence. diff --git a/includes/collection/class-inbox.php b/includes/collection/class-inbox.php index 925acb715..fadf9a53d 100644 --- a/includes/collection/class-inbox.php +++ b/includes/collection/class-inbox.php @@ -9,6 +9,7 @@ use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; +use Activitypub\Comment; use function Activitypub\is_activity_public; use function Activitypub\object_to_uri; @@ -140,4 +141,94 @@ public static function get( $guid, $user_id ) { return \get_post( $post_id ); } + + /** + * Get an inbox item by its GUID. + * + * @param string $guid The GUID of the inbox item. + * + * @return \WP_Post|\WP_Error The inbox item or WP_Error. + */ + public static function get_by_guid( $guid ) { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s", + \esc_url( $guid ), + self::POST_TYPE + ) + ); + + if ( ! $post_id ) { + return new \WP_Error( + 'activitypub_inbox_item_not_found', + \__( 'Inbox item not found', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + return \get_post( $post_id ); + } + + /** + * Undo a received activity. + * + * @param string $id The ID of the inbox item to be removed. + * + * @return bool|\WP_Error True on success, WP_Error on failure. + */ + public static function undo( $id ) { + $inbox_item = self::get_by_guid( $id ); + + if ( \is_wp_error( $inbox_item ) ) { + // If inbox entry not found, return the error. + return $inbox_item; + } + + $type = \get_post_meta( $inbox_item->ID, '_activitypub_activity_type', true ); + + switch ( $type ) { + case 'Follow': + $actor = \get_post_meta( $inbox_item->ID, '_activitypub_activity_remote_actor', true ); + $remote_actor = Remote_Actors::get_by_uri( $actor ); + + if ( \is_wp_error( $remote_actor ) ) { + return $remote_actor; + } + + return Followers::remove( $remote_actor, $inbox_item->post_author ); + + case 'Like': + case 'Create': + case 'Announce': + if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) { + return new \WP_Error( + 'activitypub_inbox_undo_interactions_disabled', + \__( 'Undo is not possible because incoming interactions are disabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $result = Comment::object_id_to_comment( esc_url_raw( $inbox_item->guid ) ); + + if ( empty( $result ) ) { + return new \WP_Error( + 'activitypub_inbox_undo_comment_not_found', + \__( 'Undo is not possible because the comment was not found.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + return \wp_delete_comment( $result, true ); + + default: + return new \WP_Error( + 'activitypub_inbox_undo_unsupported', + // Translators: %s is the activity type. + \sprintf( \__( 'Undo is not supported for %s activities.', 'activitypub' ), $type ), + array( 'status' => 400 ) + ); + } + } } diff --git a/includes/handler/class-inbox.php b/includes/handler/class-inbox.php index ef2ec3145..9ec54ac1d 100644 --- a/includes/handler/class-inbox.php +++ b/includes/handler/class-inbox.php @@ -41,7 +41,7 @@ public static function handle_inbox_requests( $data, $user_id, $type, $activity * * @param array $activity_types The activity types to persist in the inbox. */ - $activity_types = \apply_filters( 'activitypub_persist_inbox_activity_types', array( 'Create', 'Update', 'Follow' ) ); + $activity_types = \apply_filters( 'activitypub_persist_inbox_activity_types', array( 'Create', 'Update', 'Follow', 'Like', 'Announce' ) ); $activity_types = \array_map( 'Activitypub\camel_to_snake_case', $activity_types ); if ( ! \in_array( \strtolower( $type ), $activity_types, true ) ) { diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php index 80e945d5b..112d6f636 100644 --- a/includes/handler/class-undo.php +++ b/includes/handler/class-undo.php @@ -7,10 +7,7 @@ namespace Activitypub\Handler; -use Activitypub\Collection\Actors; -use Activitypub\Collection\Followers; -use Activitypub\Collection\Remote_Actors; -use Activitypub\Comment; +use Activitypub\Collection\Inbox as Inbox_Collection; use function Activitypub\object_to_uri; @@ -33,35 +30,11 @@ public static function init() { * @param int|null $user_id The ID of the user who initiated the "Undo" activity. */ public static function handle_undo( $activity, $user_id ) { - $type = $activity['object']['type']; $success = false; - $result = null; + $result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) ); - // Handle "Unfollow" requests. - if ( 'Follow' === $type ) { - $user_id = Actors::get_id_by_resource( object_to_uri( $activity['object']['object'] ) ); - - if ( ! \is_wp_error( $user_id ) ) { - $post = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) ); - - if ( ! \is_wp_error( $post ) ) { - $success = Followers::remove( $post, $user_id ); - } - } - } - - // Handle "Undo" requests for "Like" and "Create" activities. - if ( in_array( $type, array( 'Like', 'Create', 'Announce' ), true ) ) { - if ( ! ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) { - $object_id = object_to_uri( $activity['object'] ); - $result = Comment::object_id_to_comment( esc_url_raw( $object_id ) ); - - if ( empty( $result ) ) { - $success = false; - } else { - $success = \wp_delete_comment( $result, true ); - } - } + if ( $result && ! \is_wp_error( $result ) ) { + $success = true; } /** @@ -99,11 +72,11 @@ public static function validate_object( $valid, $param, $request ) { return false; } - if ( ! \is_array( $activity['object'] ) ) { + if ( ! \is_array( $activity['object'] ) && ! \is_string( $activity['object'] ) ) { return false; } - if ( ! isset( $activity['object']['id'], $activity['object']['type'], $activity['object']['actor'], $activity['object']['object'] ) ) { + if ( \is_array( $activity['object'] ) && ! isset( $activity['object']['id'] ) ) { return false; } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index e9e3f47aa..26282af02 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -23,6 +23,7 @@ trait Collection { * * @param array $response The collection response array. * @param \WP_REST_Request $request The request object. + * * @return array|\WP_Error The response array with navigation links or WP_Error on invalid page. */ public function prepare_collection_response( $response, $request ) { @@ -76,6 +77,7 @@ public function prepare_collection_response( $response, $request ) { * that controllers can use to compose their full schema by passing in their item schema. * * @param array $item_schema Optional. The schema for the items in the collection. Default empty array. + * * @return array The collection schema. */ public function get_collection_schema( $item_schema = array() ) { diff --git a/tests/phpunit/tests/includes/handler/class-test-undo.php b/tests/phpunit/tests/includes/handler/class-test-undo.php index b3e57e81c..08d3bbdbe 100644 --- a/tests/phpunit/tests/includes/handler/class-test-undo.php +++ b/tests/phpunit/tests/includes/handler/class-test-undo.php @@ -7,8 +7,10 @@ namespace Activitypub\Tests\Handler; +use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use Activitypub\Collection\Inbox as Inbox_Collection; use Activitypub\Comment; use Activitypub\Handler\Undo; @@ -39,6 +41,16 @@ public static function wpSetUpBeforeClass( $factory ) { ); } + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + // Enable like and repost comment types for testing. + \update_option( 'activitypub_allow_likes', '1' ); + \update_option( 'activitypub_allow_reposts', '1' ); + } + /** * Clean up after each test. */ @@ -74,37 +86,53 @@ function () use ( $actor_url ) { } ); - // Add follower first. - $add_result = Followers::add_follower( self::$user_id, $actor_url ); - $this->assertIsInt( $add_result, $description . ' - Adding follower should return post ID' ); - - // Verify follower was added. - $followers = Followers::get_followers( self::$user_id ); - $this->assertNotEmpty( $followers, $description . ' - Should have followers after adding one' ); - + // Add follower first by simulating a Follow activity through the inbox. $user_actor = Actors::get_by_id( self::$user_id ); $user_actor_url = $user_actor->get_id(); // Verify user actor URL exists. $this->assertNotEmpty( $user_actor_url, $description . ' - User actor URL should not be empty' ); + // Simulate Follow activity. + $follow_activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $actor_url . '/follow/' . time(), + 'type' => 'Follow', + 'actor' => $actor_url, + 'object' => $user_actor_url, + ); + + // Add the Follow activity to the inbox first. + $activity_object = Activity::init_from_array( $follow_activity ); + Inbox_Collection::add( $activity_object, self::$user_id ); + + // Call the Follow handler directly to add the follower. + \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id ); + + // Verify follower was added. + $followers = Followers::get_followers( self::$user_id ); + $this->assertNotEmpty( $followers, $description . ' - Should have followers after Follow activity' ); + // Create undo follow activity. - $activity = array( - 'type' => 'Undo', - 'actor' => $actor_url, - 'object' => array( + $undo_activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $actor_url . '/undo/' . time(), + 'type' => 'Undo', + 'actor' => $actor_url, + 'object' => array( + 'id' => $follow_activity['id'], 'type' => 'Follow', 'actor' => $actor_url, 'object' => $user_actor_url, ), ); - // Process the undo. - Undo::handle_undo( $activity, self::$user_id ); + // Call the Undo handler directly. + Undo::handle_undo( $undo_activity, self::$user_id ); // Verify follower was removed. $followers_after = Followers::get_followers( self::$user_id ); - $this->assertEmpty( $followers_after, $description . ' - Should have no followers after undo' ); + $this->assertEmpty( $followers_after, $description . ' - Should have no followers after Undo activity' ); } /** @@ -130,85 +158,98 @@ public function follow_undo_provider() { } /** - * Test handle_undo with comment-based activities (Like, Create, Announce). + * Test handle_undo with comment-related activities (Like, Create, Announce). * - * @dataProvider comment_undo_provider + * @dataProvider comment_activities_undo_provider * @covers ::handle_undo * - * @param string $activity_type The type of activity to undo. - * @param string $comment_content The content for the comment. - * @param string $source_id The source ID for the comment. - * @param string $description Description of the test case. + * @param string $actor_url The actor URL to test with. + * @param string $activity_type The type of activity being undone. + * @param string $description Description of the test case. */ - public function test_handle_undo_comment_activities( $activity_type, $comment_content, $source_id, $description ) { - // Create a post for the comment. + public function test_handle_undo_comment_activities( $actor_url, $activity_type, $description ) { + // Mock HTTP requests for actor metadata. + \add_filter( + 'pre_get_remote_metadata_by_actor', + function () use ( $actor_url ) { + return array( + 'id' => $actor_url, + 'type' => 'Person', + 'name' => 'Test Actor', + 'preferredUsername' => 'testactor', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'url' => $actor_url, + ); + } + ); + + // Create a test post. $post_id = $this->factory->post->create( array( 'post_author' => self::$user_id, + 'post_title' => 'Test Post for ' . $description, ) ); - // Create the comment with metadata. - $comment_id = $this->factory->comment->create( - array( - 'comment_post_ID' => $post_id, - 'comment_content' => $comment_content, - ) + $post_url = \get_permalink( $post_id ); + + // Create the activity that will become a comment. + $activity_id = $actor_url . '/activities/' . $activity_type . '/' . time(); + $create_activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $activity_id, + 'type' => $activity_type, + 'actor' => $actor_url, + 'object' => $post_url, ); - \add_comment_meta( $comment_id, 'source_id', $source_id, true ); - \add_comment_meta( $comment_id, 'protocol', 'activitypub', true ); - // Verify comment exists. - $comment = \get_comment( $comment_id ); + // Add the activity to the inbox first. + $activity_object = Activity::init_from_array( $create_activity ); + Inbox_Collection::add( $activity_object, self::$user_id ); + + // Call the appropriate handler directly to create the comment. + $handler_class = '\\Activitypub\\Handler\\' . $activity_type; + $handler_method = 'handle_' . strtolower( $activity_type ); + $handler_class::$handler_method( $create_activity, self::$user_id ); + + // Find the comment that was created. + $found_comment = Comment::object_id_to_comment( $activity_id ); + $this->assertNotFalse( $found_comment, $description . ' - Comment should be created by ' . $activity_type . ' activity' ); + + $comment_id = $found_comment->comment_ID; + $comment = \get_comment( $comment_id ); $this->assertNotNull( $comment, $description . ' - Comment should exist before undo' ); // Create undo activity. - $activity = array( - 'type' => 'Undo', - 'actor' => 'https://example.com/actor', - 'object' => array( - 'type' => $activity_type, - 'id' => $source_id, - ), + $undo_activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $actor_url . '/undo/' . time(), + 'type' => 'Undo', + 'actor' => $actor_url, + 'object' => $create_activity, ); - // Verify the comment can be found by source_id before processing. - $found_comment = Comment::object_id_to_comment( $source_id ); - $this->assertNotFalse( $found_comment, $description . ' - Comment should be found by source_id before undo' ); - - // Process the undo. - Undo::handle_undo( $activity, self::$user_id ); + // Call the Undo handler directly. + Undo::handle_undo( $undo_activity, self::$user_id ); // Verify comment was deleted. $comment_after = \get_comment( $comment_id ); - $this->assertNull( $comment_after, $description . ' - Comment should be deleted after undo' ); + $this->assertNull( $comment_after, $description . ' - Comment should be deleted after Undo activity' ); } /** * Data provider for comment-based undo tests. * - * @return array Test cases with activity type, comment content, source ID, and description. + * @return array Test cases with actor URL, activity type, and description. */ - public function comment_undo_provider() { + public function comment_activities_undo_provider() { return array( - 'undo_like' => array( + 'undo_like' => array( + 'https://example.com/test-actor', 'Like', - '👍', - 'https://example.com/like/123', 'Undo Like activity should delete like comment', ), - 'undo_create' => array( - 'Create', - 'Test comment', - 'https://example.com/note/123', - 'Undo Create activity should delete created comment', - ), - 'undo_announce' => array( - 'Announce', - 'Shared a post', - 'https://example.com/announce/456', - 'Undo Announce activity should delete announce comment', - ), ); } @@ -254,21 +295,39 @@ function () use ( $actor ) { } ); - Followers::add_follower( self::$user_id, $actor ); - $user_actor = Actors::get_by_id( self::$user_id ); $user_actor_url = $user_actor->get_id(); + // Simulate Follow activity first. + $follow_activity = array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $actor . '/follow/' . time(), + 'type' => 'Follow', + 'actor' => $actor, + 'object' => $user_actor_url, + ); + + // Add the Follow activity to the inbox first. + $activity_object = Activity::init_from_array( $follow_activity ); + Inbox_Collection::add( $activity_object, self::$user_id ); + + \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id ); + + // Create Undo activity. $activity = array( - 'type' => 'Undo', - 'actor' => $actor, - 'object' => array( + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $actor . '/undo/' . time(), + 'type' => 'Undo', + 'actor' => $actor, + 'object' => array( + 'id' => $follow_activity['id'], 'type' => 'Follow', 'actor' => $actor, 'object' => $user_actor_url, ), ); + // Call the Undo handler directly. Undo::handle_undo( $activity, self::$user_id ); $this->assertTrue( $action_fired ); @@ -421,8 +480,8 @@ public function validate_object_provider() { ), ), true, - false, - 'Missing object.type should fail validation', + true, + 'Missing object.type should validate (not required)', ), 'missing_object_actor' => array( array( @@ -435,8 +494,8 @@ public function validate_object_provider() { ), ), true, - false, - 'Missing object.actor should fail validation', + true, + 'Missing object.actor should validate (not required)', ), 'missing_object_object' => array( array( @@ -449,8 +508,18 @@ public function validate_object_provider() { ), ), true, - false, - 'Missing object.object should fail validation', + true, + 'Missing object.object should validate (not required)', + ), + 'object_id' => array( + array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => 'https://example.com/activity/123', + ), + true, + true, + 'String object should validate', ), ); }