diff --git a/activitypub.php b/activitypub.php index 75d35d4a..a07bb744 100644 --- a/activitypub.php +++ b/activitypub.php @@ -39,7 +39,7 @@ \defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false ); \defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false ); \defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false ); -// Disable reactions like `Like` and `Accounce` by default +// Disable reactions like `Like` and `Announce` by default \defined( 'ACTIVITYPUB_DISABLE_REACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_REACTIONS', true ); \defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false ); \defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) || \define( 'ACTIVITYPUB_SHARED_INBOX_FEATURE', false ); diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 44dc3c3a..e6e3ecbb 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -90,6 +90,21 @@ class Activity extends Base_Object { */ protected $result; + /** + * Identifies a Collection containing objects considered to be responses + * to this object. + * WordPress has a strong core system of approving replies. We only include + * approved replies here. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies + * + * @var array + * | ObjectType + * | Link + * | null + */ + protected $replies; + /** * An indirect object of the activity from which the * activity is directed. diff --git a/includes/class-comment.php b/includes/class-comment.php index 24b0a454..5f546b71 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -340,6 +340,46 @@ public static function comment_class( $classes, $css_class, $comment_id ) { return $classes; } + /** + * Gets the public comment id via the WordPress comments meta. + * + * @param int $wp_comment_id The internal WordPress comment ID. + * @param bool $fallback Whether the code should fall back to `source_url` if `source_id` is not set. + * + * @return string|null The ActivityPub id/url of the comment. + */ + public static function get_source_id( $wp_comment_id, $fallback = true ) { + $comment_meta = \get_comment_meta( $wp_comment_id ); + + if ( ! empty( $comment_meta['source_id'][0] ) ) { + return $comment_meta['source_id'][0]; + } elseif ( ! empty( $comment_meta['source_url'][0] && $fallback ) ) { + return $comment_meta['source_url'][0]; + } + + return null; + } + + /** + * Gets the public comment url via the WordPress comments meta. + * + * @param int $wp_comment_id The internal WordPress comment ID. + * @param bool $fallback Whether the code should fall back to `source_id` if `source_url` is not set. + * + * @return string|null The ActivityPub id/url of the comment. + */ + public static function get_source_url( $wp_comment_id, $fallback = true ) { + $comment_meta = \get_comment_meta( $wp_comment_id ); + + if ( ! empty( $comment_meta['source_url'][0] ) ) { + return $comment_meta['source_url'][0]; + } elseif ( ! empty( $comment_meta['source_id'][0] && $fallback ) ) { + return $comment_meta['source_id'][0]; + } + + return null; + } + /** * Link remote comments to source url. * @@ -353,15 +393,9 @@ public static function remote_comment_link( $comment_link, $comment ) { return $comment_link; } - $comment_meta = \get_comment_meta( $comment->comment_ID ); - - if ( ! empty( $comment_meta['source_url'][0] ) ) { - return $comment_meta['source_url'][0]; - } elseif ( ! empty( $comment_meta['source_id'][0] ) ) { - return $comment_meta['source_id'][0]; - } + $public_comment_link = self::get_source_url( $comment->comment_ID ); - return $comment_link; + return $public_comment_link ?? $comment_link; } @@ -373,14 +407,13 @@ public static function remote_comment_link( $comment_link, $comment ) { * @return string ActivityPub URI for comment */ public static function generate_id( $comment ) { - $comment = \get_comment( $comment ); - $comment_meta = \get_comment_meta( $comment->comment_ID ); + $comment = \get_comment( $comment ); // show external comment ID if it exists - if ( ! empty( $comment_meta['source_id'][0] ) ) { - return $comment_meta['source_id'][0]; - } elseif ( ! empty( $comment_meta['source_url'][0] ) ) { - return $comment_meta['source_url'][0]; + $public_comment_link = self::get_source_id( $comment->comment_ID ); + + if ( $public_comment_link ) { + return $public_comment_link; } // generate URI based on comment ID diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index b51224fd..fdd80daa 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -111,7 +111,7 @@ public static function get_follower( $user_id, $actor ) { } /** - * Get a Follower by Actor indepenent from the User. + * Get a Follower by Actor independent from the User. * * @param string $actor The Actor URL. * diff --git a/includes/collection/class-replies.php b/includes/collection/class-replies.php new file mode 100644 index 00000000..ade81836 --- /dev/null +++ b/includes/collection/class-replies.php @@ -0,0 +1,176 @@ + 'approve', + 'orderby' => 'comment_date_gmt', + 'order' => 'ASC', + ); + + if ( $wp_object instanceof WP_Post ) { + $args['parent'] = 0; // TODO: maybe this is unnecessary. + $args['post_id'] = $wp_object->ID; + } elseif ( $wp_object instanceof WP_Comment ) { + $args['parent'] = $wp_object->comment_ID; + } else { + return new WP_Error(); + } + + return $args; + } + + /** + * Adds pagination args comments query. + * + * @param array $args Query args built by self::build_args. + * @param int $page The current pagination page. + * @param int $comments_per_page The number of comments per page. + */ + private static function add_pagination_args( $args, $page, $comments_per_page ) { + $args['number'] = $comments_per_page; + + $offset = intval( $page ) * $comments_per_page; + $args['offset'] = $offset; + + return $args; + } + + + /** + * Get the replies collections ID. + * + * @param WP_Post|WP_Comment $wp_object + * + * @return string The rest URL of the replies collection. + */ + private static function get_id( $wp_object ) { + if ( $wp_object instanceof WP_Post ) { + return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) ); + } elseif ( $wp_object instanceof WP_Comment ) { + return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) ); + } else { + return new WP_Error(); + } + } + + /** + * Get the replies collection. + * + * @param WP_Post|WP_Comment $wp_object + * @param int $page + * + * @return array An associative array containing the replies collection without JSON-LD context. + */ + public static function get_collection( $wp_object ) { + $id = self::get_id( $wp_object ); + + if ( ! $id ) { + return null; + } + + $replies = array( + 'id' => $id, + 'type' => 'Collection', + ); + + $replies['first'] = self::get_collection_page( $wp_object, 0, $replies['id'] ); + + return $replies; + } + + /** + * Get the ActivityPub ID's from a list of comments. + * + * It takes only federated/non-local comments into account, others also do not have an + * ActivityPub ID available. + * + * @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from. + * + * @return string[] A list of the ActivityPub ID's. + */ + private static function get_reply_ids( $comments ) { + $comment_ids = array(); + // Only add external comments from the fediverse. + // Maybe use the Comment class more and the function is_local_comment etc. + foreach ( $comments as $comment ) { + if ( is_local_comment( $comment ) ) { + continue; + } + + $public_comment_id = Comment::get_source_id( $comment->comment_ID ); + if ( $public_comment_id ) { + $comment_ids[] = $public_comment_id; + } + } + return $comment_ids; + } + + /** + * Returns a replies collection page as an associative array. + * + * @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage + * + * @param WP_Post|WP_Comment $wp_object The post of comment the replies are for. + * @param int $page The current pagination page. + * @param string $part_of The collection id/url the returned CollectionPage belongs to. + * + * @return array A CollectionPage as an associative array. + */ + public static function get_collection_page( $wp_object, $page, $part_of = null ) { + // Build initial arguments for fetching approved comments. + $args = self::build_args( $wp_object ); + + // Retrieve the partOf if not already given. + $part_of = $part_of ?? self::get_id( $wp_object ); + + // If the collection page does not exist. + if ( is_wp_error( $args ) || is_wp_error( $part_of ) ) { + return null; + } + + // Get to total replies count. + $total_replies = \get_comments( array_merge( $args, array( 'count' => true ) ) ); + + // Modify query args to retrieve paginated results. + $comments_per_page = \get_option( 'comments_per_page' ); + + // Fetch internal and external comments for current page. + $comments = get_comments( self::add_pagination_args( $args, $page, $comments_per_page ) ); + + // Get the ActivityPub ID's of the comments, without out local-only comments. + $comment_ids = self::get_reply_ids( $comments ); + + // Build the associative CollectionPage array. + $collection_page = array( + 'id' => \add_query_arg( 'page', $page, $part_of ), + 'type' => 'CollectionPage', + 'partOf' => $part_of, + 'items' => $comment_ids, + ); + + if ( $total_replies / $comments_per_page > $page + 1 ) { + $collection_page['next'] = \add_query_arg( 'page', $page + 1, $part_of ); + } + + return $collection_page; + } +} diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php index 296789fb..aa7a3bbf 100644 --- a/includes/rest/class-collection.php +++ b/includes/rest/class-collection.php @@ -6,7 +6,10 @@ use Activitypub\Activity\Actor; use Activitypub\Activity\Base_Object; use Activitypub\Collection\Users as User_Collection; +use Activitypub\Collection\Replies; + use Activitypub\Transformer\Factory; +use WP_Error; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; @@ -69,6 +72,73 @@ public static function register_routes() { ), ) ); + + \register_rest_route( + ACTIVITYPUB_REST_NAMESPACE, + '/(?P[\w\-\.]+)s/(?P[\w\-\.]+)/replies', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( self::class, 'replies_get' ), + 'args' => self::request_parameters_for_replies(), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + /** + * The endpoint for replies collections + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response The response object. + */ + public static function replies_get( $request ) { + $type = $request->get_param( 'type' ); + + // Get the WordPress object of that "owns" the requested replies. + switch ( $type ) { + case 'comment': + $wp_object = \get_comment( $request->get_param( 'id' ) ); + break; + case 'post': + default: + $wp_object = \get_post( $request->get_param( 'id' ) ); + break; + } + + if ( ! isset( $wp_object ) || is_wp_error( $wp_object ) ) { + return new WP_Error( + 'activitypub_replies_collection_does_not_exist', + \sprintf( + // translators: %s: The type (post, comment, etc.) for which no replies collection exists. + \__( 'No reply collection exists for the type %s.', 'activitypub' ), + $type + ) + ); + } + + $page = intval( $request->get_param( 'page' ) ); + + // If the request parameter page is present get the CollectionPage otherwise the replies collection. + if ( isset( $page ) ) { + $response = Replies::get_collection_page( $wp_object, $page ); + } else { + $response = Replies::get_collection( $wp_object ); + } + + if ( is_wp_error( $response ) ) { + return $response; + } + + // Add ActivityPub Context. + $response = array_merge( + array( '@context' => Base_Object::JSON_LD_CONTEXT ), + $response + ); + + return new WP_REST_Response( $response, 200 ); } /** @@ -220,7 +290,29 @@ public static function request_parameters() { $params['user_id'] = array( 'required' => true, - 'type' => 'string', + 'type' => 'string', + ); + + return $params; + } + + /** + * The supported parameters + * + * @return array list of parameters + */ + public static function request_parameters_for_replies() { + $params = array(); + + $params['type'] = array( + 'required' => true, + 'type' => 'string', + 'enum' => array( 'post', 'comment' ), + ); + + $params['id'] = array( + 'required' => true, + 'type' => 'string', ); return $params; diff --git a/includes/rest/class-comment.php b/includes/rest/class-comment.php index a31b5978..c9f911b4 100644 --- a/includes/rest/class-comment.php +++ b/includes/rest/class-comment.php @@ -75,13 +75,9 @@ public static function remote_reply_get( WP_REST_Request $request ) { return $template; } - $comment_meta = \get_comment_meta( $comment_id ); + $resource = Comment_Utils::get_source_id( $comment_id ); - if ( ! empty( $comment_meta['source_id'][0] ) ) { - $resource = $comment_meta['source_id'][0]; - } elseif ( ! empty( $comment_meta['source_url'][0] ) ) { - $resource = $comment_meta['source_url'][0]; - } else { + if ( ! $resource ) { $resource = Comment_Utils::generate_id( $comment ); } diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 5041fa96..f33e5642 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -1,12 +1,13 @@ wp_object, $this->get_id() ); + return $replies; + } + /** * Returns the ID of the WordPress Object. * diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 72cf11f6..5b53c5e5 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -134,13 +134,8 @@ protected function get_in_reply_to() { } if ( $parent_comment ) { - $comment_meta = \get_comment_meta( $parent_comment->comment_ID ); - - if ( ! empty( $comment_meta['source_id'][0] ) ) { - $in_reply_to = $comment_meta['source_id'][0]; - } elseif ( ! empty( $comment_meta['source_url'][0] ) ) { - $in_reply_to = $comment_meta['source_url'][0]; - } elseif ( ! empty( $parent_comment->user_id ) ) { + $in_reply_to = Comment_Utils::get_source_id( $parent_comment->comment_ID ); + if ( ! $in_reply_to && ! empty( $parent_comment->user_id ) ) { $in_reply_to = Comment_Utils::generate_id( $parent_comment ); } } else { diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 3b9a9963..8fec1bfa 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -124,7 +124,7 @@ protected function get_actor_object() { * * @return string The Posts ID. */ - public function get_id() { + protected function get_id() { return $this->get_url(); } diff --git a/tests/test-class-activitypub-comment.php b/tests/test-class-activitypub-comment.php index d1447c3f..514fb67f 100644 --- a/tests/test-class-activitypub-comment.php +++ b/tests/test-class-activitypub-comment.php @@ -1,5 +1,62 @@ 'comment id', + 'comment_content' => 'This is a comment id test', + 'comment_author_url' => 'https://example.com', + 'comment_author_email' => '', + 'comment_meta' => array( + 'protocol' => 'activitypub', + 'source_id' => 'https://example.com/id', + ), + ) + ); + + $this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_url( $comment_id ) ); + $this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id ) ); + $this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id, false ) ); + $this->assertEquals( null, \Activitypub\Comment::get_source_url( $comment_id, false ) ); + + $comment_id = wp_insert_comment( + array( + 'comment_type' => 'comment url', + 'comment_content' => 'This is a comment url test', + 'comment_author_url' => 'https://example.com', + 'comment_author_email' => '', + 'comment_meta' => array( + 'protocol' => 'activitypub', + 'source_url' => 'https://example.com/url', + ), + ) + ); + + $this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_id( $comment_id ) ); + $this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id ) ); + $this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id, false ) ); + $this->assertEquals( null, \Activitypub\Comment::get_source_id( $comment_id, false ) ); + + $comment_id = wp_insert_comment( + array( + 'comment_type' => 'comment url and id', + 'comment_content' => 'This is a comment url and id test', + 'comment_author_url' => 'https://example.com', + 'comment_author_email' => '', + 'comment_meta' => array( + 'protocol' => 'activitypub', + 'source_url' => 'https://example.com/url', + 'source_id' => 'https://example.com/id', + ), + ) + ); + + $this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id ) ); + $this->assertEquals( 'https://example.com/id', \Activitypub\Comment::get_source_id( $comment_id, false ) ); + $this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id ) ); + $this->assertEquals( 'https://example.com/url', \Activitypub\Comment::get_source_url( $comment_id, false ) ); + } + /** * @dataProvider ability_to_federate_comment */ diff --git a/tests/test-class-activitypub-replies.php b/tests/test-class-activitypub-replies.php new file mode 100644 index 00000000..de8ffca4 --- /dev/null +++ b/tests/test-class-activitypub-replies.php @@ -0,0 +1,39 @@ + 1, + 'post_content' => 'test', + ) + ); + + $source_id = 'https://example.instance/notes/123'; + + $comment = array( + 'user_id' => 1, + 'comment_type' => 'comment', + 'comment_content' => 'This is a comment.', + 'comment_author_url' => 'https://example.com', + 'comment_author_email' => '', + 'comment_meta' => array( + 'protocol' => 'activitypub', + 'source_id' => $source_id, + ), + 'comment_post_ID' => $post_id, + ); + + $comment_id = wp_insert_comment( $comment ); + + wp_set_comment_status( $comment_id, 'hold' ); + $replies = Activitypub\Collection\Replies::get_collection( get_post( $post_id ) ); + $this->assertEquals( $replies['id'], sprintf( 'http://example.org/index.php?rest_route=/activitypub/1.0/posts/%d/replies', $post_id ) ); + $this->assertCount( 0, $replies['first']['items'] ); + + wp_set_comment_status( $comment_id, 'approve' ); + $replies = Activitypub\Collection\Replies::get_collection( get_post( $post_id ) ); + $this->assertCount( 1, $replies['first']['items'] ); + $this->assertEquals( $replies['first']['items'][0], $source_id ); + } +}