diff --git a/.github/changelog/1518-from-description b/.github/changelog/1518-from-description new file mode 100644 index 000000000..88195743d --- /dev/null +++ b/.github/changelog/1518-from-description @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Support multiple layers of nested Outbox activities when searching for the Object ID. diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 81a6bfa4e..f9da8b26f 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -9,7 +9,8 @@ namespace Activitypub\Activity; -use Activitypub\Link; +use Activitypub\Activity\Extended_Object\Event; +use Activitypub\Activity\Extended_Object\Place; /** * \Activitypub\Activity\Activity implements the common @@ -23,6 +24,43 @@ class Activity extends Base_Object { 'https://www.w3.org/ns/activitystreams', ); + /** + * The default types for Activities. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + * + * @var array + */ + const TYPES = array( + 'Accept', + 'Add', + 'Announce', + 'Arrive', + 'Block', + 'Create', + 'Delete', + 'Dislike', + 'Follow', + 'Flag', + 'Ignore', + 'Invite', + 'Join', + 'Leave', + 'Like', + 'Listen', + 'Move', + 'Offer', + 'Read', + 'Reject', + 'Remove', + 'TentativeAccept', + 'TentativeReject', + 'Travel', + 'Undo', + 'Update', + 'View', + ); + /** * The type of the object. * @@ -124,58 +162,87 @@ class Activity extends Base_Object { * * @see https://www.w3.org/TR/activitypub/#object-without-create * - * @param array|string|Base_Object|Link|null $data Activity object. + * @param array|string|Base_Object|Activity|Actor|null $data Activity object. */ public function set_object( $data ) { - // Convert array to object. + $object = $data; + + // Convert array to appropriate object type. if ( is_array( $data ) ) { - $data = Generic_Object::init_from_array( $data ); + $type = $data['type'] ?? null; + + if ( in_array( $type, self::TYPES, true ) ) { + $object = self::init_from_array( $data ); + } elseif ( in_array( $type, Actor::TYPES, true ) ) { + $object = Actor::init_from_array( $data ); + } elseif ( in_array( $type, Base_Object::TYPES, true ) ) { + switch ( $type ) { + case 'Event': + $object = Event::init_from_array( $data ); + break; + case 'Place': + $object = Place::init_from_array( $data ); + break; + default: + $object = Base_Object::init_from_array( $data ); + break; + } + } else { + $object = Generic_Object::init_from_array( $data ); + } } - // Set object. - $this->set( 'object', $data ); + $this->set( 'object', $object ); + $this->pre_fill_activity_from_object(); + } + + /** + * Fills the Activity with the specified activity object. + */ + public function pre_fill_activity_from_object() { + $object = $this->get_object(); // Check if `$data` is a URL and use it to generate an ID then. - if ( is_string( $data ) && filter_var( $data, FILTER_VALIDATE_URL ) && ! $this->get_id() ) { - $this->set( 'id', $data . '#activity-' . strtolower( $this->get_type() ) . '-' . time() ); + if ( is_string( $object ) && filter_var( $object, FILTER_VALIDATE_URL ) && ! $this->get_id() ) { + $this->set( 'id', $object . '#activity-' . strtolower( $this->get_type() ) . '-' . time() ); return; } // Check if `$data` is an object and copy some properties otherwise do nothing. - if ( ! is_object( $data ) ) { + if ( ! is_object( $object ) ) { return; } foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) { - $value = $data->get( $i ); + $value = $object->get( $i ); if ( $value && ! $this->get( $i ) ) { $this->set( $i, $value ); } } - if ( $data->get_published() && ! $this->get_published() ) { - $this->set( 'published', $data->get_published() ); + if ( $object->get_published() && ! $this->get_published() ) { + $this->set( 'published', $object->get_published() ); } - if ( $data->get_updated() && ! $this->get_updated() ) { - $this->set( 'updated', $data->get_updated() ); + if ( $object->get_updated() && ! $this->get_updated() ) { + $this->set( 'updated', $object->get_updated() ); } - if ( $data->get_attributed_to() && ! $this->get_actor() ) { - $this->set( 'actor', $data->get_attributed_to() ); + if ( $object->get_attributed_to() && ! $this->get_actor() ) { + $this->set( 'actor', $object->get_attributed_to() ); } - if ( $data->get_in_reply_to() && ! $this->get_in_reply_to() ) { - $this->set( 'in_reply_to', $data->get_in_reply_to() ); + if ( $object->get_in_reply_to() && ! $this->get_in_reply_to() ) { + $this->set( 'in_reply_to', $object->get_in_reply_to() ); } - if ( $data->get_id() && ! $this->get_id() ) { - $id = strtok( $data->get_id(), '#' ); - if ( $data->get_updated() ) { - $updated = $data->get_updated(); - } elseif ( $data->get_published() ) { - $updated = $data->get_published(); + if ( $object->get_id() && ! $this->get_id() ) { + $id = strtok( $object->get_id(), '#' ); + if ( $object->get_updated() ) { + $updated = $object->get_updated(); + } elseif ( $object->get_published() ) { + $updated = $object->get_published(); } else { $updated = time(); } diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 2656938be..bbf2128b2 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -61,6 +61,21 @@ class Actor extends Base_Object { ), ); + /** + * The default types for Actors. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#actor-types + * + * @var array + */ + const TYPES = array( + 'Application', + 'Group', + 'Organization', + 'Person', + 'Service', + ); + /** * The type of the object. * diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 7e1dcbeb6..cc1d885e1 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -78,6 +78,28 @@ class Base_Object extends Generic_Object { ), ); + /** + * The default types for Objects. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#object-types + * + * @var array + */ + const TYPES = array( + 'Article', + 'Audio', + 'Document', + 'Event', + 'Image', + 'Note', + 'Page', + 'Place', + 'Profile', + 'Relationship', + 'Tombstone', + 'Video', + ); + /** * The type of the object. * diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index db8f1bbfb..0eacd0b11 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -10,6 +10,7 @@ use Activitypub\Dispatcher; use Activitypub\Scheduler; use Activitypub\Activity\Activity; +use Activitypub\Activity\Base_Object; use function Activitypub\add_to_outbox; @@ -296,28 +297,29 @@ public static function maybe_get_activity( $outbox_item ) { /** * Get the object ID of an activity. * - * @param Activity $activity The activity object. + * @param Activity|Base_Object|string $data The activity object. + * * @return string The object ID. */ - private static function get_object_id( $activity ) { - // Most common. - if ( is_object( $activity->get_object() ) ) { - return $activity->get_object()->get_id(); + private static function get_object_id( $data ) { + $object = $data->get_object(); + + if ( is_object( $object ) ) { + return self::get_object_id( $object ); } - // Rare. - if ( is_string( $activity->get_object() ) ) { - return $activity->get_object(); + if ( is_string( $object ) ) { + return $object; } - // Exceptional. - return $activity->get_actor() ?? $activity->get_id(); + return $data->get_id() ?? $data->get_actor(); } /** * Get the title of an activity recursively. * - * @param \Activitypub\Activity\Base_Object $activity_object The activity object. + * @param Base_Object $activity_object The activity object. + * * @return string The title. */ private static function get_object_title( $activity_object ) { @@ -333,7 +335,7 @@ private static function get_object_title( $activity_object ) { $title = $activity_object->get_name() ?? $activity_object->get_content(); - if ( ! $title && $activity_object->get_object() instanceof \Activitypub\Activity\Base_Object ) { + if ( ! $title && $activity_object->get_object() instanceof Base_Object ) { $title = $activity_object->get_object()->get_name() ?? $activity_object->get_object()->get_content(); } diff --git a/includes/functions.php b/includes/functions.php index bb024a6f8..08dcef4da 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -9,6 +9,7 @@ use WP_Error; use Activitypub\Activity\Activity; +use Activitypub\Activity\Actor; use Activitypub\Activity\Base_Object; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; @@ -1535,38 +1536,7 @@ function is_activity( $data ) { * * @param array $types The activity types. */ - $types = apply_filters( - 'activitypub_activity_types', - array( - 'Accept', - 'Add', - 'Announce', - 'Arrive', - 'Block', - 'Create', - 'Delete', - 'Dislike', - 'Follow', - 'Flag', - 'Ignore', - 'Invite', - 'Join', - 'Leave', - 'Like', - 'Listen', - 'Move', - 'Offer', - 'Read', - 'Reject', - 'Remove', - 'TentativeAccept', - 'TentativeReject', - 'Travel', - 'Undo', - 'Update', - 'View', - ) - ); + $types = apply_filters( 'activitypub_activity_types', Activity::TYPES ); if ( is_string( $data ) ) { return in_array( $data, $types, true ); @@ -1598,16 +1568,7 @@ function is_actor( $data ) { * * @param array $types The actor types. */ - $types = apply_filters( - 'activitypub_actor_types', - array( - 'Application', - 'Group', - 'Organization', - 'Person', - 'Service', - ) - ); + $types = apply_filters( 'activitypub_actor_types', Actor::TYPES ); if ( is_string( $data ) ) { return in_array( $data, $types, true ); diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index f423ba074..77fc45558 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -9,6 +9,7 @@ use Activitypub\Activity\Activity; use Activitypub\Activity\Base_Object; +use Activitypub\Activity\Generic_Object; use Activitypub\Activity\Extended_Object\Event; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; @@ -283,35 +284,95 @@ public function test_delete_invalidates_all_activities() { } /** - * Test get_object_id with various activity object types using reflection. + * Test get_object_id with different nested structures. * + * @dataProvider data_provider_get_object_id * @covers ::get_object_id + * + * @param Activity $activity The activity data to test. + * @param string $expected The expected object ID. */ - public function test_get_object_id() { - $object = $this->get_dummy_activity_object(); + public function test_get_object_id( $activity, $expected ) { + // Get the object ID using reflection since it's a private method. + $get_object_id = new \ReflectionMethod( Outbox::class, 'get_object_id' ); + $get_object_id->setAccessible( true ); - // Activity object of type object. - $create_id = \Activitypub\add_to_outbox( $object, 'Create', 1 ); - $this->assertEquals( 'https://example.com/test-object', get_post_meta( $create_id, '_activitypub_object_id', true ) ); - - // Activity object of type string. - $activity = new Activity(); - $activity->set_type( 'Like' ); - $activity->set_object( 'https://example.com/test-string' ); - - $like_id = \Activitypub\add_to_outbox( $activity, null, 1 ); - $this->assertEquals( 'https://example.com/test-string', get_post_meta( $like_id, '_activitypub_object_id', true ) ); - - // No object. - $actor = Actors::get_by_id( 1 ); - $activity = new Activity(); - $activity->set_type( 'Move' ); - $activity->set_actor( $actor->get_id() ); - $activity->set_origin( $actor->get_id() ); - $activity->set_target( home_url( '/author/1' ) ); - - $move_id = \Activitypub\add_to_outbox( $activity, null, 1 ); - $this->assertEquals( $actor->get_id(), get_post_meta( $move_id, '_activitypub_object_id', true ) ); + $result = $get_object_id->invoke( null, $activity ); + + $this->assertEquals( $expected, $result ); + } + + /** + * Data provider for test_get_object_id. + * + * @return array + */ + public function data_provider_get_object_id() { + $create_with_id = Activity::init_from_array( + array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'id' => 'https://example.com/note/123', + ), + ) + ); + $create_no_id = Activity::init_from_array( + array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ); + + return array( + 'object is a string' => array( + 'activity' => Activity::init_from_array( + array( + 'type' => 'Create', + 'object' => 'https://example.com/note/123', + ) + ), + 'expected' => 'https://example.com/note/123', + ), + 'object is an object with id' => array( + 'activity' => $create_with_id, + 'expected' => 'https://example.com/note/123', + ), + 'object is an object without id' => array( + 'activity' => $create_no_id, + 'expected' => null, // Will use activity ID as fallback. + ), + 'nested object with id' => array( + 'activity' => Activity::init_from_array( + array( + 'type' => 'Announce', + 'object' => $create_with_id, + ) + ), + 'expected' => 'https://example.com/note/123', + ), + 'nested object without id' => array( + 'activity' => Activity::init_from_array( + array( + 'type' => 'Announce', + 'object' => $create_no_id, + ) + ), + 'expected' => null, // Will use activity ID as fallback. + ), + 'activity with no object' => array( + 'activity' => Activity::init_from_array( + array( + 'type' => 'Delete', + 'actor' => 'https://example.com/user/1', + ) + ), + 'expected' => 'https://example.com/user/1', // Will use actor as fallback. + ), + ); } /**