diff --git a/.github/changelog/add-seek-item-endpoint b/.github/changelog/add-seek-item-endpoint new file mode 100644 index 0000000000..e0b6bce301 --- /dev/null +++ b/.github/changelog/add-seek-item-endpoint @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Fediverse apps can now jump directly to the page of a collection that contains a specific item, instead of paging through from the start. diff --git a/FEDERATION.md b/FEDERATION.md index 59a64e69be..f5052322e8 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -5,9 +5,10 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio ## Supported federation protocols and standards - [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) +- [ActivityPub API: Actor Autocomplete](https://swicg.github.io/activitypub-api/autocomplete) (typeahead search over local and cached remote actors; requires the ActivityPub API to be enabled) - [ActivityPub API: Basic Profile](https://swicg.github.io/activitypub-api/basicprofile) (Client-to-Server, partial; see [OAuth 2.0 for Client-to-Server](#oauth-20-for-client-to-server)) +- [ActivityPub API: Seek Item](https://swicg.github.io/activitypub-api/seekitem) (followers, following, outbox, inbox, and liked collections) - [ActivityPub API: Server-Sent Events](https://swicg.github.io/activitypub-api/sse) (partial, see below) -- [ActivityPub API: Actor Autocomplete](https://swicg.github.io/activitypub-api/autocomplete) (typeahead search over local and cached remote actors; requires the ActivityPub API to be enabled) - [WebFinger](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/) - [HTTP Signatures](https://swicg.github.io/activitypub-http-signature/) - [NodeInfo](https://nodeinfo.diaspora.software/) diff --git a/activitypub.php b/activitypub.php index f73d1c6501..89f21e3f05 100644 --- a/activitypub.php +++ b/activitypub.php @@ -72,6 +72,7 @@ function rest_init() { } ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Post_Controller() )->register_routes(); + ( new Rest\Seek_Controller() )->register_routes(); ( new Rest\Replies_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index b834232f93..ed6304faeb 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -64,6 +64,7 @@ public function register_routes() { 'minimum' => 1, 'maximum' => 100, ), + 'item' => $this->get_seek_item_arg(), ), 'schema' => array( $this, 'get_collection_schema' ), ), @@ -176,7 +177,6 @@ public function validate_inbox_user_id( $user_id ) { * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $page = $request->get_param( 'page' ) ?? 1; $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); @@ -191,29 +191,12 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_inbox_pre', $request ); - $args = array( - 'posts_per_page' => $request->get_param( 'per_page' ), - 'paged' => $page, - 'post_type' => Inbox::POST_TYPE, - 'post_status' => 'publish', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - array( - 'key' => '_activitypub_user_id', - 'value' => $user_id, - ), - ), - ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } - /** - * Filters WP_Query arguments when querying Inbox items via the REST API. - * - * Enables adding extra arguments or setting defaults for an inbox collection request. - * - * @param array $args Array of arguments for WP_Query. - * @param \WP_REST_Request $request The REST API request. - */ - $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); + $args = $this->get_query_args( $request ); $inbox_query = new \WP_Query(); $query_result = $inbox_query->query( $args ); @@ -272,6 +255,90 @@ public function get_items( $request ) { return $response; } + /** + * Build the WP_Query arguments for the inbox collection. + * + * Shared by get_items() and get_item_index(), so the seek index is computed under the + * exact same query rules as the collection itself. + * + * @param \WP_REST_Request $request Full details about the request. + * + * @return array The WP_Query arguments. + */ + private function get_query_args( $request ) { + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'paged' => $request->get_param( 'page' ) ?? 1, + 'post_type' => Inbox::POST_TYPE, + 'post_status' => 'publish', + // Deterministic ordering: break post_date ties by ID, so pagination and seek agree. + 'orderby' => array( + 'date' => 'DESC', + 'ID' => 'DESC', + ), + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_user_id', + 'value' => $request->get_param( 'user_id' ), + ), + ), + ); + + /** + * Filters WP_Query arguments when querying Inbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an inbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. + */ + return \apply_filters( 'activitypub_rest_inbox_query', $args, $request ); + } + + /** + * Get the position of an activity in the inbox, under the collection's own query rules. + * + * The inbox route requires authentication, so unlike the outbox this method needs no seek gate + * of its own: a non-owner is already refused before it runs — unauthenticated with 401, and an + * authenticated non-owner with a 403 that Seek_Controller collapses to the uniform 404 so the + * seek discloses no membership. Any new seek surface must preserve that 403 → 404 collapse. + * + * @since unreleased + * + * @param string $item The ActivityPub activity ID. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + $inbox_item = Inbox::get_by_guid( $item ); + if ( \is_wp_error( $inbox_item ) ) { + return $inbox_item; + } + + $args = $this->get_query_args( $request ); + $args['fields'] = 'ids'; + $args['posts_per_page'] = 1; + unset( $args['paged'] ); + + // Confirm the item is part of this inbox before computing the index. + $membership = new \WP_Query( \array_merge( $args, array( 'post__in' => array( $inbox_item->ID ) ) ) ); + if ( ! $membership->found_posts ) { + return false; + } + + // Count the activities that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $this->get_preceding_by_date_where( $inbox_item->post_date, $inbox_item->ID ), + static function () use ( $args ) { + return new \WP_Query( $args ); + } + ); + + return (int) $preceding->found_posts; + } + /** * Prepares the item for the REST response. * diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index cc1e5303b4..1f6727e9f8 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -71,6 +71,7 @@ public function register_routes() { 'default' => 'simple', 'enum' => array( 'simple', 'full' ), ), + 'item' => $this->get_seek_item_arg(), ), ), 'schema' => array( $this, 'get_item_schema' ), @@ -185,6 +186,11 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_followers_pre' ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/followers', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + $order = $request->get_param( 'order' ); $per_page = $request->get_param( 'per_page' ); $page = $request->get_param( 'page' ) ?? 1; @@ -233,6 +239,59 @@ static function ( $item ) use ( $context ) { return $response; } + /** + * Get the position of a follower in the collection, under the collection's own query rules. + * + * @since unreleased + * + * @param string $item The ActivityPub actor ID of the follower. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + global $wpdb; + + if ( ! $this->show_social_graph( $request ) ) { + return false; + } + + $actor = Remote_Actors::get_by_uri( $item ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + + $user_id = $request->get_param( 'user_id' ); + $order = $request->get_param( 'order' ); + $args = array( + 'fields' => 'ids', + 'order' => \ucwords( $order ), + ); + + // Confirm membership through the collection's own query before computing the index. + $membership = Followers::query( $user_id, 1, null, \array_merge( $args, array( 'post__in' => array( $actor->ID ) ) ) ); + if ( ! $membership['total'] ) { + return false; + } + + // Posts sorting before the item: lower IDs for ascending order, higher IDs for descending. + if ( 'asc' === $order ) { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID ); + } else { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $actor->ID ); + } + + // Count the followers that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + static function () use ( $user_id, $args ) { + return Followers::query( $user_id, 1, null, $args ); + } + ); + + return (int) $preceding['total']; + } + /** * Retrieves partial followers list for FEP-8fcf synchronization. * diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 165de981e3..3ed45289db 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -70,6 +70,7 @@ public function register_routes() { 'default' => 'simple', 'enum' => array( 'simple', 'full' ), ), + 'item' => $this->get_seek_item_arg(), ), ), 'schema' => array( $this, 'get_public_item_schema' ), @@ -91,6 +92,11 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_following_pre' ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/following', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + $order = $request->get_param( 'order' ); $per_page = $request->get_param( 'per_page' ); $page = $request->get_param( 'page' ) ?? 1; @@ -139,6 +145,59 @@ static function ( $item ) use ( $context ) { return $response; } + /** + * Get the position of a followed actor in the collection, under the collection's own query rules. + * + * @since unreleased + * + * @param string $item The ActivityPub actor ID of the followed actor. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found. + */ + public function get_item_index( $item, $request ) { + global $wpdb; + + if ( ! $this->show_social_graph( $request ) ) { + return false; + } + + $actor = Remote_Actors::get_by_uri( $item ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + + $user_id = $request->get_param( 'user_id' ); + $order = $request->get_param( 'order' ); + $args = array( + 'fields' => 'ids', + 'order' => \ucwords( $order ), + ); + + // Confirm membership through the collection's own query before computing the index. + $membership = Following::query( $user_id, 1, null, \array_merge( $args, array( 'post__in' => array( $actor->ID ) ) ) ); + if ( ! $membership['total'] ) { + return false; + } + + // Posts sorting before the item: lower IDs for ascending order, higher IDs for descending. + if ( 'asc' === $order ) { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID ); + } else { + $where = $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $actor->ID ); + } + + // Count the followed actors that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + static function () use ( $user_id, $args ) { + return Following::query( $user_id, 1, null, $args ); + } + ); + + return (int) $preceding['total']; + } + /** * Retrieves the following schema, conforming to JSON Schema. * diff --git a/includes/rest/class-liked-controller.php b/includes/rest/class-liked-controller.php index 15fb253aca..e50dfd6e12 100644 --- a/includes/rest/class-liked-controller.php +++ b/includes/rest/class-liked-controller.php @@ -61,6 +61,7 @@ public function register_routes() { 'minimum' => 1, 'maximum' => 100, ), + 'item' => $this->get_seek_item_arg(), ), ), 'schema' => array( $this, 'get_item_schema' ), @@ -85,6 +86,11 @@ public function get_items( $request ) { $page = $request->get_param( 'page' ); $per_page = $request->get_param( 'per_page' ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/liked', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + $liked_objects = $this->get_liked_object_ids( $user_id ); // Paginate the results. @@ -113,6 +119,22 @@ public function get_items( $request ) { return $response; } + /** + * Get the position of an object in the liked collection. + * + * @since unreleased + * + * @param string $item The ActivityPub object ID of the liked object. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false Zero-based index of the item, false when not found. + */ + public function get_item_index( $item, $request ) { + $index = \array_search( $item, $this->get_liked_object_ids( $request->get_param( 'user_id' ) ), true ); + + return false === $index ? false : (int) $index; + } + /** * Get all currently liked object IDs for an actor. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 7f55238e9b..c45637ba88 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -86,6 +86,7 @@ public function register_routes() { 'minimum' => 1, 'maximum' => 100, ), + 'item' => $this->get_seek_item_arg(), ), ), array( @@ -144,7 +145,6 @@ public function validate_user_id( $user_id ) { * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $page = $request->get_param( 'page' ) ?? 1; $user_id = $request->get_param( 'user_id' ); $user = Actors::get_by_id( $user_id ); @@ -155,6 +155,92 @@ public function get_items( $request ) { */ \do_action( 'activitypub_rest_outbox_pre', $request ); + $seek = $this->maybe_seek_item( $request, get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $user_id ) ) ); + if ( null !== $seek ) { + return $seek; + } + + $args = $this->get_query_args( $request ); + $outbox_query = new \WP_Query(); + $query_result = $outbox_query->query( $args ); + + $response = array( + '@context' => Base_Object::JSON_LD_CONTEXT, + 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $user_id ) ), + 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(), + 'actor' => $user->get_id(), + 'type' => 'OrderedCollection', + 'totalItems' => (int) $outbox_query->found_posts, + 'eventStream' => $this->get_stream_url( $user_id, 'outbox' ), + 'orderedItems' => array(), + ); + + \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $outbox_item ) { + if ( ! $outbox_item instanceof \WP_Post ) { + /** + * Action triggered when an outbox item is not a WP_Post. + * + * @param mixed $outbox_item The outbox item. + * @param array $args The arguments used to query the outbox. + * @param array $query_result The result of the query. + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request ); + + continue; + } + + $item = $this->prepare_item_for_response( $outbox_item, $request ); + + if ( \is_wp_error( $item ) ) { + continue; + } + + $response['orderedItems'][] = $item; + } + + $response = $this->prepare_collection_response( $response, $request ); + if ( \is_wp_error( $response ) ) { + return $response; + } + + /** + * Filter the ActivityPub outbox array. + * + * @param array $response The ActivityPub outbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub profile has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_post', $request ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Build the WP_Query arguments for the outbox collection. + * + * Shared by get_items() and get_item_index(), so the seek index is computed under the + * exact same visibility rules as the collection itself. + * + * @param \WP_REST_Request $request Full details about the request. + * @param bool|null $is_owner Optional. Whether the requester owns the outbox, when the + * caller already knows. Default null, which re-derives it. + * + * @return array The WP_Query arguments. + */ + private function get_query_args( $request, $is_owner = null ) { + $user_id = $request->get_param( 'user_id' ); + /** * Filters the activity types included in the outbox collection. * @@ -165,9 +251,14 @@ public function get_items( $request ) { $args = array( 'posts_per_page' => $request->get_param( 'per_page' ), 'author' => $user_id > 0 ? $user_id : null, - 'paged' => $page, + 'paged' => $request->get_param( 'page' ) ?? 1, 'post_type' => Outbox::POST_TYPE, 'post_status' => 'any', + // Deterministic ordering: break post_date ties by ID, so pagination and seek agree. + 'orderby' => array( + 'date' => 'DESC', + 'ID' => 'DESC', + ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'meta_query' => array( @@ -188,7 +279,7 @@ public function get_items( $request ) { * session, matches the requested user by identity, and handles the blog actor via * user_can_act_as_blog(). A global capability never stands in for ownership. */ - $is_outbox_owner = true === $this->verify_owner( $request ); + $is_outbox_owner = null === $is_owner ? true === $this->verify_owner( $request ) : $is_owner; if ( ! $is_outbox_owner ) { $args['meta_query'][] = array( @@ -218,71 +309,66 @@ public function get_items( $request ) { * @param array $args Array of arguments for WP_Query. * @param \WP_REST_Request $request The REST API request. */ - $args = \apply_filters( 'activitypub_rest_outbox_query', $args, $request ); - - $outbox_query = new \WP_Query(); - $query_result = $outbox_query->query( $args ); - - $response = array( - '@context' => Base_Object::JSON_LD_CONTEXT, - 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/outbox', $user_id ) ), - 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(), - 'actor' => $user->get_id(), - 'type' => 'OrderedCollection', - 'totalItems' => (int) $outbox_query->found_posts, - 'eventStream' => $this->get_stream_url( $user_id, 'outbox' ), - 'orderedItems' => array(), - ); - - \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); - foreach ( $query_result as $outbox_item ) { - if ( ! $outbox_item instanceof \WP_Post ) { - /** - * Action triggered when an outbox item is not a WP_Post. - * - * @param mixed $outbox_item The outbox item. - * @param array $args The arguments used to query the outbox. - * @param array $query_result The result of the query. - * @param \WP_REST_Request $request The request object. - */ - \do_action( 'activitypub_rest_outbox_item_error', $outbox_item, $args, $query_result, $request ); - - continue; - } - - $item = $this->prepare_item_for_response( $outbox_item, $request ); + return \apply_filters( 'activitypub_rest_outbox_query', $args, $request ); + } - if ( \is_wp_error( $item ) ) { - continue; - } + /** + * Get the position of an activity in the outbox. + * + * Seeking the outbox is owner-only. The collection mixes public and private activities, so + * rather than rely on the per-item visibility filter, non-owners are refused before any item + * is resolved. That way the seek discloses nothing about a specific activity, public or private. + * A request without credentials is asked to authenticate (401); an authenticated non-owner is + * refused with the same 404 as a missing item, so it can infer nothing about the outbox. + * + * @since unreleased + * + * @param string $item The ActivityPub activity ID. + * @param \WP_REST_Request $request Full details about the request. + * + * @return int|false|\WP_Error Zero-based index of the item, false when not found or a non-owner, or WP_Error when unauthenticated. + */ + public function get_item_index( $item, $request ) { + // Ask an unauthenticated request to authenticate; the seek is owner-only. + if ( ! \is_user_logged_in() ) { + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'You need to authenticate to seek this collection.', 'activitypub' ), + array( 'status' => 401 ) + ); + } - $response['orderedItems'][] = $item; + // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + if ( true !== $this->verify_owner( $request ) ) { + return false; } - $response = $this->prepare_collection_response( $response, $request ); - if ( \is_wp_error( $response ) ) { - return $response; + $outbox_item = Outbox::get_by_guid( $item ); + if ( \is_wp_error( $outbox_item ) ) { + return $outbox_item; } - /** - * Filter the ActivityPub outbox array. - * - * @param array $response The ActivityPub outbox array. - * @param \WP_REST_Request $request The request object. - */ - $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + // Ownership is already established above, so the shared query builder need not re-derive it. + $args = $this->get_query_args( $request, true ); + $args['fields'] = 'ids'; + $args['posts_per_page'] = 1; + unset( $args['paged'] ); - /** - * Action triggered after the ActivityPub profile has been created and sent to the client. - * - * @param \WP_REST_Request $request The request object. - */ - \do_action( 'activitypub_rest_outbox_post', $request ); + // Confirm the item is visible through the collection's own query before computing the index. + $membership = new \WP_Query( \array_merge( $args, array( 'post__in' => array( $outbox_item->ID ) ) ) ); + if ( ! $membership->found_posts ) { + return false; + } - $response = \rest_ensure_response( $response ); - $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + // Count the activities that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $this->get_preceding_by_date_where( $outbox_item->post_date, $outbox_item->ID ), + static function () use ( $args ) { + return new \WP_Query( $args ); + } + ); - return $response; + return (int) $preceding->found_posts; } /** diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php new file mode 100644 index 0000000000..1c0f34a9b5 --- /dev/null +++ b/includes/rest/class-seek-controller.php @@ -0,0 +1,152 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'collection' => array( + 'description' => 'The ID of the collection to seek in.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'item' => array( + 'description' => 'The ActivityPub object ID of the item to seek.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Check permissions for a seek request. + * + * Supports both authentication methods commonly used with ActivityPub: a request carrying + * an OAuth 2.0 Bearer token goes through the same verify_authentication() used by the + * Client-to-Server endpoints; all other requests go through HTTP Signature verification, + * which allows anonymous reads unless Authorized Fetch is enabled. The dispatched + * collection additionally applies its own permission check, so a seek can never see more + * than the collection itself would reveal. + * + * @param \WP_REST_Request $request Full details about the request. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function get_item_permissions_check( $request ) { + if ( OAuth_Server::is_oauth_request() ) { + return $this->verify_authentication( $request ); + } + + return $this->verify_signature( $request ); + } + + /** + * Seek an item in a collection. + * + * Dispatches an internal request to the collection endpoint with the `item` parameter and + * passes its redirect through. A missing-authentication failure surfaces as 401 to tell the + * client to authenticate. Unknown collections, foreign URLs, items that are not part of the + * collection, and authenticated-but-not-authorized requests all produce the same 404, so + * collection membership is not leaked. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Redirect on success, or 401/404 WP_Error on failure. + */ + public function get_item( $request ) { + $not_found = new \WP_Error( + 'activitypub_item_not_found', + \__( 'The requested item could not be found in this collection.', 'activitypub' ), + array( 'status' => 404 ) + ); + + // Resolves only URLs served by this site's REST API, so no remote request is ever made. + $collection_request = \WP_REST_Request::from_url( $request->get_param( 'collection' ) ); + + if ( ! $collection_request || ! \str_starts_with( $collection_request->get_route(), '/' . ACTIVITYPUB_REST_NAMESPACE . '/' ) ) { + return $not_found; + } + + $collection_request->set_param( 'item', $request->get_param( 'item' ) ); + // Carry the original headers over, so a Bearer token reaches the collection's own permission check. + $collection_request->set_headers( $request->get_headers() ); + + /* + * The outer request's signature was verified for the /seek route and cannot be replayed + * against the inner route, so defer signature verification for the dispatch. Endpoints that + * force signatures (FEP-8fcf's /followers/sync) are deliberately not deferred: their + * mandatory verification must still run, and it will fail for a seek since the signature was + * never computed over their route. get_item() maps that failure to the same 404 as any other + * non-seekable collection, so no membership is leaked. + */ + $defer = static function ( $deferred, $inner_request, $force_signature ) { + // Decide deterministically: defer for the dispatch, except on forced-signature routes. + return ! $force_signature; + }; + // Latest priority, so a global defer filter (e.g. a local-dev __return_true) cannot reopen a forced route. + \add_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX, 3 ); + $response = \rest_do_request( $collection_request ); + \remove_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX ); + + /* + * A redirect is the sought page. A missing-authentication failure (401) is a property of the + * request, not the item, so it is surfaced as is (it discloses no membership) to tell the + * client to authenticate. Everything else — an authenticated-but-not-authorized request, or + * the collection's own item-not-found — collapses to a single 404, per the seekItem spec. + */ + if ( \in_array( $response->get_status(), array( 307, 308, 401 ), true ) ) { + return $response; + } + + return $not_found; + } +} diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 5258389ee3..9e49afae46 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -7,6 +7,8 @@ namespace Activitypub\Rest; +use function Activitypub\get_rest_url_by_path; + /** * Collection Trait. * @@ -14,6 +16,15 @@ * and type transitions between Collection and CollectionPage. */ trait Collection { + /** + * The JSON-LD context for the seekItem collection extension. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @var string + */ + private $seek_item_context = 'https://purl.archive.org/socialweb/seekitem/1.0'; + /** * The JSON-LD context for ActivityPub collections. * @@ -54,12 +65,37 @@ public function prepare_collection_response( $response, $request ) { $response = array( '@context' => $this->json_ld_context ) + $response; } + // The `item` seek parameter is handled before the collection is built and must not leak into navigation links. + $query_params = $request->get_query_params(); + unset( $query_params['item'] ); + + /* + * Advertise the seek endpoint on the Collection when the request offered an `item` argument, + * which is how a controller opts a route in. The seek endpoint receives the collection with + * its filtering arguments (order, per_page, context) but without page, so it resolves the item + * against the same ordering the client is traversing. + */ + $attributes = $request->get_attributes(); + if ( null === $page && isset( $attributes['args']['item'] ) ) { + // A Collection request never carries a page, so the query params already describe the base collection. + $collection_id = \add_query_arg( $query_params, $response['id'] ); + + // add_query_arg() does not encode values, so encode the nested collection URL to keep its query string intact. + $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $collection_id ), get_rest_url_by_path( 'seek' ) ); + + $context = (array) $response['@context']; + if ( ! \in_array( $this->seek_item_context, $context, true ) ) { + $context[] = $this->seek_item_context; + $response['@context'] = $context; + } + } + if ( empty( $response['items'] ) && empty( $response['orderedItems'] ) ) { // Skip pagination metadata when items are intentionally hidden or collection is empty. return $response; } - $response['id'] = \add_query_arg( $request->get_query_params(), $response['id'] ); + $response['id'] = \add_query_arg( $query_params, $response['id'] ); $response['first'] = \add_query_arg( 'page', 1, $response['id'] ); $response['last'] = \add_query_arg( 'page', $max_pages, $response['id'] ); @@ -86,6 +122,137 @@ public function prepare_collection_response( $response, $request ) { return $response; } + /** + * Argument definition for the seek `item` parameter. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @since unreleased + * + * @return array The argument definition. + */ + public function get_seek_item_arg() { + return array( + 'description' => 'The ActivityPub object ID of an item to seek. The response redirects to the collection page containing the item.', + 'type' => 'string', + 'format' => 'uri', + ); + } + + /** + * Handle a seek request by redirecting to the collection page that contains the sought item. + * + * Implements the seekItem collection extension: when the `item` parameter is present, the + * controller's get_item_index() resolves the item's position under the exact query and + * visibility rules of the collection, and the response is a temporary redirect whose + * Location is the id of the CollectionPage containing the item. A temporary redirect is + * used because the collections are ordered newest-first, so items drift across pages as + * new items arrive. A missing-authentication failure is surfaced as 401, because it describes + * the request rather than any item and discloses no membership. Everything else — unknown or + * invisible items, and authenticated-but-not-authorized requests — collapses to the same 404, + * so collection membership is not leaked. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @since unreleased + * + * @param \WP_REST_Request $request The request object. + * @param string $collection_id The plain collection ID (URL without query arguments). + * + * @return \WP_REST_Response|\WP_Error|null Redirect response, 401/404 error, or null when this is not a seek request. + */ + public function maybe_seek_item( $request, $collection_id ) { + $item = $request->get_param( 'item' ); + if ( empty( $item ) ) { + return null; + } + + $index = $this->get_item_index( $item, $request ); + + if ( \is_wp_error( $index ) ) { + /* + * Surface a missing-authentication failure (401) as is: it is a property of the request, + * not the item, so it discloses no membership while telling the client to authenticate. + * Everything else — an authenticated-but-not-authorized request, an absent item, or one + * hidden by the collection's visibility rules — collapses to a single 404 so the presence + * of a specific item can never be inferred, per the seekItem spec. + */ + $data = $index->get_error_data(); + $status = \is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; + + if ( 401 === $status ) { + return $index; + } + + $index = false; + } + + if ( false === $index ) { + return new \WP_Error( + 'activitypub_item_not_found', + \__( 'The requested item could not be found in this collection.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + $per_page = \max( 1, \absint( $request->get_param( 'per_page' ) ) ); + $page = (int) \floor( $index / $per_page ) + 1; + + $query_params = $request->get_query_params(); + unset( $query_params['item'] ); + $query_params['page'] = $page; + + $response = new \WP_REST_Response( null, 307 ); + $response->header( 'Location', \add_query_arg( $query_params, $collection_id ) ); + + return $response; + } + + /** + * Run a callback with an additional WHERE clause appended to WP_Query's SQL. + * + * Used by get_item_index() implementations to count the items that sort before the sought + * item, reusing the collection's own query (and thereby its visibility rules) unchanged. + * + * @param string $where Prepared SQL to append to the WHERE clause. + * @param callable $callback Callback executing the query. + * + * @return mixed The callback return value. + */ + private function with_posts_where( $where, $callback ) { + $filter = static function ( $sql ) use ( $where ) { + return $sql . $where; + }; + + \add_filter( 'posts_where', $filter ); + $result = $callback(); + \remove_filter( 'posts_where', $filter ); + + return $result; + } + + /** + * Build a WHERE clause matching the posts that sort before a cursor in newest-first order. + * + * Mirrors an `orderby` of post_date then ID, both descending, so the count of matching posts is + * the cursor's zero-based index. Shared by the date-ordered collections (outbox, inbox). + * + * @param string $post_date The cursor post's post_date. + * @param int $id The cursor post's ID. + * + * @return string Prepared SQL to append to the WHERE clause. + */ + private function get_preceding_by_date_where( $post_date, $id ) { + global $wpdb; + + return $wpdb->prepare( + " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", + $post_date, + $post_date, + $id + ); + } + /** * Get the schema for an ActivityPub Collection. * @@ -150,6 +317,11 @@ public function get_collection_schema( $item_schema = array() ) { 'type' => 'string', 'format' => 'uri', ), + 'seekItem' => array( + 'description' => 'Endpoint to resolve the collection page containing a given item.', + 'type' => 'string', + 'format' => 'uri', + ), ), ); diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php new file mode 100644 index 0000000000..fa4917bec2 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -0,0 +1,495 @@ +post->create( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'guid' => 'https://example.org/actor/' . $i, + 'post_content' => \wp_slash( + \wp_json_encode( + array( + 'id' => 'https://example.org/actor/' . $i, + 'type' => 'Person', + 'preferredUsername' => 'user' . $i, + ) + ) + ), + 'meta_input' => array( + Followers::FOLLOWER_META_KEY => '0', + ), + ) + ); + } + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class() { + \delete_option( 'activitypub_actor_mode' ); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek', $routes ); + } + + /** + * A seek on the collection itself redirects to the page containing the item. + * + * Followers are ordered by post ID descending, so the last-created follower (actor/25) is + * the first item. With 10 items per page, actor/13 has twelve followers before it and lives + * on page two. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_item_param_redirects_to_page() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + $request->set_param( 'per_page', 10 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 307, $response->get_status() ); + + $location = $response->get_headers()['Location']; + $this->assertStringContainsString( 'page=2', $location ); + $this->assertStringContainsString( 'per_page=10', $location ); + $this->assertStringNotContainsString( 'item=', $location ); + } + + /** + * The first item of the collection resolves to page one, the last one to the last page. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_page_boundaries() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/25' ); + $request->set_param( 'per_page', 10 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/1' ); + $request->set_param( 'per_page', 10 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=3', $response->get_headers()['Location'] ); + } + + /** + * Ascending order inverts the index math. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_respects_order() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/1' ); + $request->set_param( 'per_page', 10 ); + $request->set_param( 'order', 'asc' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + } + + /** + * An unknown item produces a 404. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_unknown_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/does-not-exist' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * A hidden social graph produces the same 404 as an unknown item. + * + * @covers \Activitypub\Rest\Followers_Controller::get_item_index + */ + public function test_followers_collection_seek_hidden_social_graph() { + \update_option( 'activitypub_hide_social_graph', '1' ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + \delete_option( 'activitypub_hide_social_graph' ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * The collection advertises the seek endpoint with the seekItem property and context. + * + * @covers \Activitypub\Rest\Collection::prepare_collection_response + */ + public function test_collection_advertises_seek_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $response = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertArrayHasKey( 'seekItem', $response ); + + \parse_str( (string) \wp_parse_url( $response['seekItem'], PHP_URL_QUERY ), $params ); + $this->assertStringContainsString( 'activitypub/1.0/seek', \rawurldecode( $response['seekItem'] ) ); + $this->assertSame( get_rest_url_by_path( 'actors/0/followers' ), \rawurldecode( $params['collection'] ) ); + $this->assertContains( 'https://purl.archive.org/socialweb/seekitem/1.0', $response['@context'] ); + } + + /** + * The seek endpoint declares its collection and item arguments. + * + * @covers ::register_routes + */ + public function test_get_item_schema() { + $request = new \WP_REST_Request( 'OPTIONS', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $response = rest_get_server()->dispatch( $request )->get_data(); + + $args = $response['endpoints'][0]['args']; + + $this->assertArrayHasKey( 'collection', $args ); + $this->assertTrue( $args['collection']['required'] ); + $this->assertArrayHasKey( 'item', $args ); + $this->assertTrue( $args['item']['required'] ); + } + + /** + * The seek endpoint dispatches to the collection and passes the redirect through. + * + * @covers ::get_item + */ + public function test_get_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', get_rest_url_by_path( 'actors/0/followers' ) ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 307, $response->get_status() ); + $this->assertStringContainsString( 'page=1', $response->get_headers()['Location'] ); + } + + /** + * The advertised seekItem URL round-trips: the collection parameter it carries resolves back + * to the collection. + * + * @covers ::get_item + */ + public function test_seek_endpoint_advertised_url_round_trips() { + $collection_request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $collection_response = rest_get_server()->dispatch( $collection_request )->get_data(); + + $query = \wp_parse_url( $collection_response['seekItem'], PHP_URL_QUERY ); + \parse_str( (string) $query, $params ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $params['collection'] ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 307, $response->get_status() ); + } + + /** + * Foreign and non-ActivityPub collection URLs produce a 404. + * + * @covers ::get_item + */ + public function test_seek_endpoint_rejects_unknown_collections() { + // A remote URL never dispatches. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', 'https://remote.example/actors/0/followers' ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + + // A local REST URL outside the ActivityPub namespace never dispatches. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', \rest_url( 'wp/v2/posts' ) ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * A collection without seek support produces a 404 instead of the collection body. + * + * @covers ::get_item + */ + public function test_seek_endpoint_collection_without_seek_support() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', get_rest_url_by_path( 'collections/moderators' ) ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Seeking into a forced-signature route does not bypass its mandatory signature verification. + * + * FEP-8fcf's /followers/sync forces signatures even when Authorized Fetch is off. A seek defers + * signature verification for the internal dispatch, but must leave forced routes verifying, so an + * unsigned seek into /followers/sync still fails verification. That failure is a property of the + * request, not any follower, so it surfaces as 401 and discloses no membership. + * + * @covers ::get_item + */ + public function test_seek_endpoint_does_not_bypass_forced_signature() { + $sync_url = \add_query_arg( 'authority', 'https://example.org', get_rest_url_by_path( 'actors/0/followers/sync' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $sync_url ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * A global signature-defer filter cannot reopen a forced-signature route through a seek. + * + * The seek registers its own defer decision at the latest priority, so a broad defer filter (as a + * local-dev setup might install) still leaves /followers/sync verifying and the unsigned seek fails. + * + * @covers ::get_item + */ + public function test_global_defer_filter_does_not_reopen_forced_signature() { + \add_filter( 'activitypub_defer_signature_verification', '__return_true', 20 ); + + $sync_url = \add_query_arg( 'authority', 'https://example.org', get_rest_url_by_path( 'actors/0/followers/sync' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/seek' ); + $request->set_param( 'collection', $sync_url ); + $request->set_param( 'item', 'https://example.org/actor/13' ); + + $response = rest_get_server()->dispatch( $request ); + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true', 20 ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * The advertised seekItem preserves the collection's filtering arguments. + * + * @covers \Activitypub\Rest\Collection::prepare_collection_response + */ + public function test_seek_item_preserves_query_arguments() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/followers' ); + $request->set_query_params( + array( + 'order' => 'asc', + 'per_page' => '5', + ) + ); + + $response = rest_get_server()->dispatch( $request )->get_data(); + + \parse_str( (string) \wp_parse_url( $response['seekItem'], PHP_URL_QUERY ), $params ); + \parse_str( (string) \wp_parse_url( \rawurldecode( $params['collection'] ), PHP_URL_QUERY ), $collection_params ); + + $this->assertSame( 'asc', $collection_params['order'] ); + $this->assertSame( '5', $collection_params['per_page'] ); + $this->assertArrayNotHasKey( 'page', $collection_params ); + } + + /** + * Seeking the outbox is owner-only. An unauthenticated request is asked to authenticate (401), + * identically for a public activity, a private one, and an unknown one — so the 401 discloses + * nothing about the outbox. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_seek_unauthenticated_returns_401() { + $public_id = 'https://example.org/outbox/public-activity'; + $hidden_id = 'https://example.org/outbox/hidden-activity'; + + $create = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'blog', + '_activitypub_activity_type' => 'Create', + ), + ); + + $public_post = self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); + + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + + foreach ( array( $public_id, $hidden_id, 'https://example.org/outbox/does-not-exist' ) as $item ) { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $item ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 401, $response->get_status(), "Unauthenticated seek of {$item} must ask to authenticate with 401." ); + } + + \wp_delete_post( $public_post, true ); + } + + /** + * An authenticated non-owner is refused with the uniform 404 — the same status as a missing + * item — for a public activity, a private one, and an unknown one, so it can infer nothing about + * another actor's outbox. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_seek_authenticated_non_owner_returns_404() { + $public_id = 'https://example.org/outbox/other-public-activity'; + $hidden_id = 'https://example.org/outbox/other-hidden-activity'; + + $create = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'blog', + '_activitypub_activity_type' => 'Create', + ), + ); + + $public_post = self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); + + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + + // A regular author cannot act as the blog actor, so seeking the blog outbox makes them a non-owner. + \wp_set_current_user( self::factory()->user->create( array( 'role' => 'author' ) ) ); + + foreach ( array( $public_id, $hidden_id, 'https://example.org/outbox/does-not-exist' ) as $item ) { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $item ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status(), "Authenticated non-owner seek of {$item} must return the uniform 404." ); + } + + \wp_set_current_user( 0 ); + \wp_delete_post( $public_post, true ); + } + + /** + * The outbox owner can seek their own activities, including a private-visibility one, confirming + * the non-owner refusal is an access gate and not a broken lookup. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_owner_can_seek_own_activities() { + $public_id = 'https://example.org/outbox/owner-public-activity'; + $hidden_id = 'https://example.org/outbox/owner-hidden-activity'; + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + $create = array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_author' => $user_id, + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'user', + '_activitypub_activity_type' => 'Create', + ), + ); + + self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); + + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + + \wp_set_current_user( $user_id ); + + foreach ( array( $public_id, $hidden_id ) as $item ) { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $request->set_param( 'item', $item ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status(), "Owner seek of {$item} must resolve to a page." ); + } + + // The owner still gets a 404 for an activity that is genuinely not in their outbox. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $request->set_param( 'item', 'https://example.org/outbox/never-existed' ); + + $response = rest_get_server()->dispatch( $request ); + + \wp_set_current_user( 0 ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * An anonymous seek into the owner-only inbox returns 401, not a misleading 404: the failure is a + * property of the request, not of any item, so surfacing it discloses no membership. + * + * @covers ::get_item + * @covers \Activitypub\Rest\Collection::maybe_seek_item + */ + public function test_inbox_seek_requires_authentication() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/inbox' ); + $request->set_param( 'item', 'https://example.org/activity/1' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } +}