From 05bdcfff7bfd104fa6abfa3d6e51255c3da4536c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:00 +0200 Subject: [PATCH 01/13] Add seek plumbing to the collection trait --- includes/rest/trait-collection.php | 118 ++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 5258389ee3..45a818aa98 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,27 @@ public function prepare_collection_response( $response, $request ) { $response = array( '@context' => $this->json_ld_context ) + $response; } + // Advertise the seek endpoint on Collections whose controller can resolve an item to a page. + if ( null === $page && \method_exists( $this, 'get_item_index' ) ) { + $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $response['id'] ), get_rest_url_by_path( 'seek' ) ); + + if ( \is_array( $response['@context'] ) && ! \in_array( $this->seek_item_context, $response['@context'], true ) ) { + $response['@context'][] = $this->seek_item_context; + } elseif ( \is_string( $response['@context'] ) ) { + $response['@context'] = array( $response['@context'], $this->seek_item_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'] ); + // 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'] ); + + $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 +112,91 @@ public function prepare_collection_response( $response, $request ) { return $response; } + /** + * Argument definition for the seek `item` parameter. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @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. Unknown, invisible, and unauthorized items all produce the same 404, + * so collection membership is not leaked. + * + * @see https://swicg.github.io/activitypub-api/seekitem + * + * @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, 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 ) || 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; + } + /** * Get the schema for an ActivityPub Collection. * @@ -150,6 +261,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', + ), ), ); From aa83e356c8ef1e7129971da3620786bc13a01068 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:01 +0200 Subject: [PATCH 02/13] Add a seek endpoint that dispatches to the collections internally --- activitypub.php | 1 + includes/rest/class-seek-controller.php | 131 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 includes/rest/class-seek-controller.php diff --git a/activitypub.php b/activitypub.php index b007145d90..f72f01adb8 100644 --- a/activitypub.php +++ b/activitypub.php @@ -71,6 +71,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-seek-controller.php b/includes/rest/class-seek-controller.php new file mode 100644 index 0000000000..d3eb7ddf01 --- /dev/null +++ b/includes/rest/class-seek-controller.php @@ -0,0 +1,131 @@ +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. Unknown collections, foreign URLs, items that are not part + * of the collection, and unauthorized 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 response on success, or WP_Error object 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 signature was verified for this request; the internal dispatch cannot carry it over. + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + $response = \rest_do_request( $collection_request ); + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + + if ( \in_array( $response->get_status(), array( 307, 308 ), true ) ) { + return $response; + } + + return $not_found; + } +} From 13e11a0480cba51061c0052019095ff90744d818 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:03 +0200 Subject: [PATCH 03/13] Resolve seek requests in the followers, following, outbox, inbox, and liked collections --- .../rest/class-actors-inbox-controller.php | 115 ++++++++--- includes/rest/class-followers-controller.php | 56 ++++++ includes/rest/class-following-controller.php | 56 ++++++ includes/rest/class-liked-controller.php | 20 ++ includes/rest/class-outbox-controller.php | 188 ++++++++++++------ 5 files changed, 353 insertions(+), 82 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index b834232f93..04751c6df2 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,92 @@ 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. + * + * @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 ) { + global $wpdb; + + $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; + } + + $where = $wpdb->prepare( + " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", + $inbox_item->post_date, + $inbox_item->post_date, + $inbox_item->ID + ); + + // Count the activities that sort before the item; that count is the item's zero-based index. + $preceding = $this->with_posts_where( + $where, + 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..99aa78661d 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,56 @@ static function ( $item ) use ( $context ) { return $response; } + /** + * Get the position of a follower in the collection, under the collection's own query rules. + * + * @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; + } + + 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..0ddcb8e7e5 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,56 @@ static function ( $item ) use ( $context ) { return $response; } + /** + * Get the position of a followed actor in the collection, under the collection's own query rules. + * + * @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; + } + + 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..36b4458dbc 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,20 @@ public function get_items( $request ) { return $response; } + /** + * Get the position of an object in the liked collection. + * + * @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..bcc6763d45 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,90 @@ 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. + * + * @return array The WP_Query arguments. + */ + private function get_query_args( $request ) { + $user_id = $request->get_param( 'user_id' ); + /** * Filters the activity types included in the outbox collection. * @@ -165,9 +249,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( @@ -218,71 +307,52 @@ 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, under the collection's own query rules. + * + * @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 ) { + global $wpdb; - $response['orderedItems'][] = $item; + $outbox_item = Outbox::get_by_guid( $item ); + if ( \is_wp_error( $outbox_item ) ) { + return $outbox_item; } - $response = $this->prepare_collection_response( $response, $request ); - if ( \is_wp_error( $response ) ) { - return $response; - } + $args = $this->get_query_args( $request ); + $args['fields'] = 'ids'; + $args['posts_per_page'] = 1; + unset( $args['paged'] ); - /** - * 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 ); + // 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; + } - /** - * 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 ); + $where = $wpdb->prepare( + " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", + $outbox_item->post_date, + $outbox_item->post_date, + $outbox_item->ID + ); - $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( + $where, + static function () use ( $args ) { + return new \WP_Query( $args ); + } + ); - return $response; + return (int) $preceding->found_posts; } /** From 2cf2236508898120696b82c2439c417a90a4b045 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 17:57:05 +0200 Subject: [PATCH 04/13] Add seek endpoint tests, changelog, and FEDERATION.md entry --- .github/changelog/add-seek-item-endpoint | 4 + FEDERATION.md | 1 + .../rest/class-test-seek-controller.php | 322 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .github/changelog/add-seek-item-endpoint create mode 100644 tests/phpunit/tests/includes/rest/class-test-seek-controller.php 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 d0c2b4ff78..3c2f9fb58a 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -7,6 +7,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio - [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) - [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: Server-Sent Events](https://swicg.github.io/activitypub-api/sse) (partial, see below) +- [ActivityPub API: Seek Item](https://swicg.github.io/activitypub-api/seekitem) (followers, following, outbox, inbox, and liked collections) - [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/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..0c8b98d706 --- /dev/null +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -0,0 +1,322 @@ +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' ), $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() ); + } + + /** + * The seek of an outbox activity respects the visibility rules for unauthenticated requests. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_collection_seek_respects_visibility() { + $public_id = 'https://example.org/outbox/public-activity'; + $private_id = 'https://example.org/outbox/private-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 ) ) ); + + $follow = $create; + $follow['meta_input']['_activitypub_activity_type'] = 'Follow'; + self::factory()->post->create( \array_merge( $follow, array( 'guid' => $private_id ) ) ); + + // The public activity is seekable anonymously. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $public_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 307, $response->get_status() ); + + // The non-public activity type is invisible to anonymous seeks. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $private_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + + \wp_delete_post( $public_post, true ); + } +} From 3ac11b5bc82c61be1cee9c79bf4ffdf9a90b626c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:13:08 +0200 Subject: [PATCH 05/13] Honor forced signatures on seek, preserve collection query args, add @since tags --- .../rest/class-actors-inbox-controller.php | 2 + includes/rest/class-followers-controller.php | 2 + includes/rest/class-following-controller.php | 2 + includes/rest/class-liked-controller.php | 2 + includes/rest/class-outbox-controller.php | 2 + includes/rest/class-seek-controller.php | 18 +++++-- includes/rest/trait-collection.php | 27 ++++++++--- .../rest/class-test-seek-controller.php | 47 ++++++++++++++++++- 8 files changed, 91 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 04751c6df2..46bbf3f1f4 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -299,6 +299,8 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the inbox, under the collection's own query rules. * + * @since unreleased + * * @param string $item The ActivityPub activity ID. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 99aa78661d..93dcb86347 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -242,6 +242,8 @@ static function ( $item ) use ( $context ) { /** * 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. * diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 0ddcb8e7e5..3d4a6124bd 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -148,6 +148,8 @@ static function ( $item ) use ( $context ) { /** * 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. * diff --git a/includes/rest/class-liked-controller.php b/includes/rest/class-liked-controller.php index 36b4458dbc..e50dfd6e12 100644 --- a/includes/rest/class-liked-controller.php +++ b/includes/rest/class-liked-controller.php @@ -122,6 +122,8 @@ public function get_items( $request ) { /** * 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. * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index bcc6763d45..4fbcaaa996 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -313,6 +313,8 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the outbox, under the collection's own query rules. * + * @since unreleased + * * @param string $item The ActivityPub activity ID. * @param \WP_REST_Request $request Full details about the request. * diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index d3eb7ddf01..1854405611 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -18,6 +18,8 @@ * collection and every current and future collection is reachable through one endpoint. * * @see https://swicg.github.io/activitypub-api/seekitem + * + * @since unreleased */ class Seek_Controller extends \WP_REST_Controller { use Verification; @@ -117,10 +119,20 @@ public function get_item( $request ) { // Carry the original headers over, so a Bearer token reaches the collection's own permission check. $collection_request->set_headers( $request->get_headers() ); - // The signature was verified for this request; the internal dispatch cannot carry it over. - \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + /* + * 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 ) { + return $force_signature ? $deferred : true; + }; + \add_filter( 'activitypub_defer_signature_verification', $defer, 10, 3 ); $response = \rest_do_request( $collection_request ); - \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); if ( \in_array( $response->get_status(), array( 307, 308 ), true ) ) { return $response; diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 45a818aa98..aae60d4aca 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -65,9 +65,22 @@ public function prepare_collection_response( $response, $request ) { $response = array( '@context' => $this->json_ld_context ) + $response; } - // Advertise the seek endpoint on Collections whose controller can resolve an item to a page. - if ( null === $page && \method_exists( $this, 'get_item_index' ) ) { - $response['seekItem'] = \add_query_arg( 'collection', \rawurlencode( $response['id'] ), get_rest_url_by_path( 'seek' ) ); + // 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'] ) ) { + $collection_id = \add_query_arg( \array_diff_key( $query_params, array( 'page' => '' ) ), $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' ) ); if ( \is_array( $response['@context'] ) && ! \in_array( $this->seek_item_context, $response['@context'], true ) ) { $response['@context'][] = $this->seek_item_context; @@ -81,10 +94,6 @@ public function prepare_collection_response( $response, $request ) { return $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'] ); - $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'] ); @@ -117,6 +126,8 @@ public function prepare_collection_response( $response, $request ) { * * @see https://swicg.github.io/activitypub-api/seekitem * + * @since unreleased + * * @return array The argument definition. */ public function get_seek_item_arg() { @@ -140,6 +151,8 @@ public function get_seek_item_arg() { * * @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). * diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index 0c8b98d706..e88fb2b9d1 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -181,7 +181,7 @@ public function test_collection_advertises_seek_item() { \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' ), $params['collection'] ); + $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'] ); } @@ -278,6 +278,51 @@ public function test_seek_endpoint_collection_without_seek_support() { $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 fails verification and collapses to the uniform 404. + * + * @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( 404, $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 ); + } + /** * The seek of an outbox activity respects the visibility rules for unauthenticated requests. * From 182e54de2456849be4f5a25151a6e1165c942e62 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:23:04 +0200 Subject: [PATCH 06/13] Simplify seek plumbing: dedupe the date cursor clause and collapse context handling --- .../rest/class-actors-inbox-controller.php | 11 +------ includes/rest/class-followers-controller.php | 1 + includes/rest/class-following-controller.php | 1 + includes/rest/class-outbox-controller.php | 11 +------ includes/rest/trait-collection.php | 33 ++++++++++++++++--- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 46bbf3f1f4..0dc403a300 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -307,8 +307,6 @@ private function get_query_args( $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; - $inbox_item = Inbox::get_by_guid( $item ); if ( \is_wp_error( $inbox_item ) ) { return $inbox_item; @@ -325,16 +323,9 @@ public function get_item_index( $item, $request ) { return false; } - $where = $wpdb->prepare( - " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", - $inbox_item->post_date, - $inbox_item->post_date, - $inbox_item->ID - ); - // Count the activities that sort before the item; that count is the item's zero-based index. $preceding = $this->with_posts_where( - $where, + $this->get_preceding_by_date_where( $inbox_item->post_date, $inbox_item->ID ), static function () use ( $args ) { return new \WP_Query( $args ); } diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 93dcb86347..1f6727e9f8 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -274,6 +274,7 @@ public function get_item_index( $item, $request ) { 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 { diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php index 3d4a6124bd..3ed45289db 100644 --- a/includes/rest/class-following-controller.php +++ b/includes/rest/class-following-controller.php @@ -180,6 +180,7 @@ public function get_item_index( $item, $request ) { 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 { diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 4fbcaaa996..ab21c56eee 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -321,8 +321,6 @@ private function get_query_args( $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; - $outbox_item = Outbox::get_by_guid( $item ); if ( \is_wp_error( $outbox_item ) ) { return $outbox_item; @@ -339,16 +337,9 @@ public function get_item_index( $item, $request ) { return false; } - $where = $wpdb->prepare( - " AND ( {$wpdb->posts}.post_date > %s OR ( {$wpdb->posts}.post_date = %s AND {$wpdb->posts}.ID > %d ) )", - $outbox_item->post_date, - $outbox_item->post_date, - $outbox_item->ID - ); - // Count the activities that sort before the item; that count is the item's zero-based index. $preceding = $this->with_posts_where( - $where, + $this->get_preceding_by_date_where( $outbox_item->post_date, $outbox_item->ID ), static function () use ( $args ) { return new \WP_Query( $args ); } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index aae60d4aca..6a3d77f4d3 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -77,15 +77,16 @@ public function prepare_collection_response( $response, $request ) { */ $attributes = $request->get_attributes(); if ( null === $page && isset( $attributes['args']['item'] ) ) { - $collection_id = \add_query_arg( \array_diff_key( $query_params, array( 'page' => '' ) ), $response['id'] ); + // 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' ) ); - if ( \is_array( $response['@context'] ) && ! \in_array( $this->seek_item_context, $response['@context'], true ) ) { - $response['@context'][] = $this->seek_item_context; - } elseif ( \is_string( $response['@context'] ) ) { - $response['@context'] = array( $response['@context'], $this->seek_item_context ); + $context = (array) $response['@context']; + if ( ! \in_array( $this->seek_item_context, $context, true ) ) { + $context[] = $this->seek_item_context; + $response['@context'] = $context; } } @@ -210,6 +211,28 @@ private function with_posts_where( $where, $callback ) { 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. * From a5c408e7766fa37f01857be45a00fd11644b3703 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:31:12 +0200 Subject: [PATCH 07/13] Cover the outbox content-visibility gate in the seek oracle tests --- .../rest/class-test-seek-controller.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index e88fb2b9d1..f69e346305 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -348,6 +348,12 @@ public function test_outbox_collection_seek_respects_visibility() { $follow['meta_input']['_activitypub_activity_type'] = 'Follow'; self::factory()->post->create( \array_merge( $follow, array( 'guid' => $private_id ) ) ); + // A public activity type that the author marked private: a public type, but hidden by visibility. + $hidden_id = 'https://example.org/outbox/hidden-activity'; + $hidden = $create; + $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); + // The public activity is seekable anonymously. $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); $request->set_param( 'item', $public_id ); @@ -362,6 +368,50 @@ public function test_outbox_collection_seek_respects_visibility() { $response = rest_get_server()->dispatch( $request ); $this->assertEquals( 404, $response->get_status() ); + // A private-visibility activity is not disclosed by the seek oracle to an anonymous request. + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'item', $hidden_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 404, $response->get_status() ); + \wp_delete_post( $public_post, true ); } + + /** + * The outbox owner can seek their own private-visibility activity, confirming the 404 above is a + * visibility gate and not a broken lookup. + * + * @covers \Activitypub\Rest\Outbox_Controller::get_item_index + */ + public function test_outbox_owner_can_seek_private_activity() { + $hidden_id = 'https://example.org/outbox/owner-hidden-activity'; + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + self::factory()->post->create( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'publish', + 'post_author' => $user_id, + 'guid' => $hidden_id, + 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), + 'meta_input' => array( + '_activitypub_activity_actor' => 'user', + '_activitypub_activity_type' => 'Create', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + ), + ) + ); + + \wp_set_current_user( $user_id ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $request->set_param( 'item', $hidden_id ); + + $response = rest_get_server()->dispatch( $request ); + + \wp_set_current_user( 0 ); + + $this->assertEquals( 307, $response->get_status() ); + } } From cbfc3488d2ece56ff9168b3dbcfd86d918968ad5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 18:49:45 +0200 Subject: [PATCH 08/13] Make outbox seek owner-only and surface auth failures instead of masking them as 404 --- includes/rest/class-outbox-controller.php | 13 +- includes/rest/class-seek-controller.php | 7 +- includes/rest/trait-collection.php | 19 ++- .../rest/class-test-seek-controller.php | 113 ++++++++++-------- 4 files changed, 98 insertions(+), 54 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index ab21c56eee..6cdab04e98 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -311,16 +311,25 @@ private function get_query_args( $request ) { } /** - * Get the position of an activity in the outbox, under the collection's own query rules. + * 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. * * @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. + * @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found or not permitted. */ public function get_item_index( $item, $request ) { + $owner = $this->verify_owner( $request ); + if ( true !== $owner ) { + return $owner; + } + $outbox_item = Outbox::get_by_guid( $item ); if ( \is_wp_error( $outbox_item ) ) { return $outbox_item; diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index 1854405611..701cd084de 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -134,7 +134,12 @@ public function get_item( $request ) { $response = \rest_do_request( $collection_request ); \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); - if ( \in_array( $response->get_status(), array( 307, 308 ), true ) ) { + /* + * A redirect is the sought page. An authentication or authorization failure is a property of + * the request, not the item, so it is surfaced as is (it discloses no membership). Everything + * else, including the collection's own item-not-found, collapses to a single 404. + */ + if ( \in_array( $response->get_status(), array( 307, 308, 401, 403 ), true ) ) { return $response; } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 6a3d77f4d3..972de42b58 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -167,7 +167,24 @@ public function maybe_seek_item( $request, $collection_id ) { $index = $this->get_item_index( $item, $request ); - if ( \is_wp_error( $index ) || false === $index ) { + if ( \is_wp_error( $index ) ) { + /* + * Surface an authentication or authorization failure as is: it is a property of the + * request, not the item, so it discloses no collection membership. Everything else + * (item absent, or hidden by the collection's visibility rules) collapses to a single + * 404 so the presence of a specific item can never be inferred. + */ + $data = $index->get_error_data(); + $status = \is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; + + if ( \in_array( $status, array( 401, 403 ), true ) ) { + 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' ), diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index f69e346305..4bb85611d2 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -283,7 +283,8 @@ public function test_seek_endpoint_collection_without_seek_support() { * * 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 fails verification and collapses to the uniform 404. + * 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 */ @@ -296,7 +297,7 @@ public function test_seek_endpoint_does_not_bypass_forced_signature() { $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + $this->assertEquals( 401, $response->get_status() ); } /** @@ -324,13 +325,15 @@ public function test_seek_item_preserves_query_arguments() { } /** - * The seek of an outbox activity respects the visibility rules for unauthenticated requests. + * Seeking the outbox is owner-only: a non-owner gets the same 403 for a public activity, a + * private-visibility activity, and an unknown one, so the seek discloses nothing about the + * outbox regardless of an activity's visibility. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ - public function test_outbox_collection_seek_respects_visibility() { - $public_id = 'https://example.org/outbox/public-activity'; - $private_id = 'https://example.org/outbox/private-activity'; + public function test_outbox_seek_is_owner_only() { + $public_id = 'https://example.org/outbox/public-activity'; + $hidden_id = 'https://example.org/outbox/hidden-activity'; $create = array( 'post_type' => Outbox::POST_TYPE, @@ -344,74 +347,84 @@ public function test_outbox_collection_seek_respects_visibility() { $public_post = self::factory()->post->create( \array_merge( $create, array( 'guid' => $public_id ) ) ); - $follow = $create; - $follow['meta_input']['_activitypub_activity_type'] = 'Follow'; - self::factory()->post->create( \array_merge( $follow, array( 'guid' => $private_id ) ) ); - - // A public activity type that the author marked private: a public type, but hidden by visibility. - $hidden_id = 'https://example.org/outbox/hidden-activity'; - $hidden = $create; + $hidden = $create; $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); - // The public activity is seekable anonymously. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); - $request->set_param( 'item', $public_id ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 307, $response->get_status() ); - - // The non-public activity type is invisible to anonymous seeks. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); - $request->set_param( 'item', $private_id ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + // A public activity, a private one, and an unknown one all return an identical 403 to a non-owner. + 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 ); - // A private-visibility activity is not disclosed by the seek oracle to an anonymous request. - $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); - $request->set_param( 'item', $hidden_id ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 404, $response->get_status() ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 403, $response->get_status(), "Non-owner seek of {$item} must be refused with 403." ); + } \wp_delete_post( $public_post, true ); } /** - * The outbox owner can seek their own private-visibility activity, confirming the 404 above is a - * visibility gate and not a broken lookup. + * The outbox owner can seek their own activities, including a private-visibility one, confirming + * the non-owner 403 is an access gate and not a broken lookup. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ - public function test_outbox_owner_can_seek_private_activity() { + 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' ) ); - self::factory()->post->create( - array( - 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'publish', - 'post_author' => $user_id, - 'guid' => $hidden_id, - 'post_content' => \wp_slash( \wp_json_encode( array( 'type' => 'Create' ) ) ), - 'meta_input' => array( - '_activitypub_activity_actor' => 'user', - '_activitypub_activity_type' => 'Create', - 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, - ), - ) + $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', $hidden_id ); + $request->set_param( 'item', 'https://example.org/outbox/never-existed' ); $response = rest_get_server()->dispatch( $request ); \wp_set_current_user( 0 ); - $this->assertEquals( 307, $response->get_status() ); + $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() ); } } From 58e2deebf59213e301d6303e0c7817fe5bd63de5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 19:00:23 +0200 Subject: [PATCH 09/13] Fix stale seek docblock and avoid re-deriving outbox ownership --- includes/rest/class-outbox-controller.php | 11 +++++++---- includes/rest/trait-collection.php | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 6cdab04e98..acd4c2d71b 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -232,11 +232,13 @@ public function get_items( $request ) { * 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 \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 ) { + private function get_query_args( $request, $is_owner = null ) { $user_id = $request->get_param( 'user_id' ); /** @@ -277,7 +279,7 @@ private function get_query_args( $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( @@ -335,7 +337,8 @@ public function get_item_index( $item, $request ) { return $outbox_item; } - $args = $this->get_query_args( $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'] ); diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index 972de42b58..edd36bc197 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -147,8 +147,9 @@ public function get_seek_item_arg() { * 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. Unknown, invisible, and unauthorized items all produce the same 404, - * so collection membership is not leaked. + * new items arrive. Unknown and invisible items both produce the same 404, so collection + * membership is not leaked; an authentication or authorization failure is surfaced as is, + * because it describes the request rather than any item and discloses no membership. * * @see https://swicg.github.io/activitypub-api/seekitem * @@ -157,7 +158,7 @@ public function get_seek_item_arg() { * @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, 404 error, or null when this is not a seek request. + * @return \WP_REST_Response|\WP_Error|null Redirect response, 401/403/404 error, or null when this is not a seek request. */ public function maybe_seek_item( $request, $collection_id ) { $item = $request->get_param( 'item' ); From bc41d584fb2027c6ece83cab3bbcc7ad4f579565 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 19:26:09 +0200 Subject: [PATCH 10/13] Note why the inbox seek index needs no owner gate of its own --- includes/rest/class-actors-inbox-controller.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 0dc403a300..548667b568 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -299,6 +299,9 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the inbox, under the collection's own query rules. * + * The inbox route is owner-only (it requires authentication), so unlike the outbox this method + * needs no seek gate of its own: a non-owner is already refused before it runs. + * * @since unreleased * * @param string $item The ActivityPub activity ID. From e3850df325ba10f221d233462e93b34050158ea5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 19:33:16 +0200 Subject: [PATCH 11/13] Return 401 for unauthenticated seeks and 404 for authenticated non-owners --- includes/rest/class-outbox-controller.php | 18 ++++-- includes/rest/class-seek-controller.php | 9 +-- includes/rest/trait-collection.php | 11 ++-- .../rest/class-test-seek-controller.php | 55 ++++++++++++++++--- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index acd4c2d71b..1300ea3247 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -318,18 +318,28 @@ private function get_query_args( $request, $is_owner = null ) { * 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 or WP_Error when not found or not permitted. + * @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 ) { - $owner = $this->verify_owner( $request ); - if ( true !== $owner ) { - return $owner; + if ( true !== $this->verify_owner( $request ) ) { + if ( ! \is_user_logged_in() ) { + return new \WP_Error( + 'activitypub_unauthorized', + \__( 'You need to authenticate to seek this collection.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + return false; } $outbox_item = Outbox::get_by_guid( $item ); diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index 701cd084de..c731e69731 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -135,11 +135,12 @@ public function get_item( $request ) { \remove_filter( 'activitypub_defer_signature_verification', $defer, 10 ); /* - * A redirect is the sought page. An authentication or authorization failure is a property of - * the request, not the item, so it is surfaced as is (it discloses no membership). Everything - * else, including the collection's own item-not-found, collapses to a single 404. + * 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, 403 ), true ) ) { + if ( \in_array( $response->get_status(), array( 307, 308, 401 ), true ) ) { return $response; } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index edd36bc197..e5e70fa47a 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -170,15 +170,16 @@ public function maybe_seek_item( $request, $collection_id ) { if ( \is_wp_error( $index ) ) { /* - * Surface an authentication or authorization failure as is: it is a property of the - * request, not the item, so it discloses no collection membership. Everything else - * (item absent, or hidden by the collection's visibility rules) collapses to a single - * 404 so the presence of a specific item can never be inferred. + * 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 ( \in_array( $status, array( 401, 403 ), true ) ) { + if ( 401 === $status ) { return $index; } diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index 4bb85611d2..c4c96fc183 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -325,13 +325,13 @@ public function test_seek_item_preserves_query_arguments() { } /** - * Seeking the outbox is owner-only: a non-owner gets the same 403 for a public activity, a - * private-visibility activity, and an unknown one, so the seek discloses nothing about the - * outbox regardless of an activity's visibility. + * 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_is_owner_only() { + public function test_outbox_seek_unauthenticated_returns_401() { $public_id = 'https://example.org/outbox/public-activity'; $hidden_id = 'https://example.org/outbox/hidden-activity'; @@ -351,21 +351,62 @@ public function test_outbox_seek_is_owner_only() { $hidden['meta_input']['activitypub_content_visibility'] = ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; self::factory()->post->create( \array_merge( $hidden, array( 'guid' => $hidden_id ) ) ); - // A public activity, a private one, and an unknown one all return an identical 403 to a non-owner. 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( 403, $response->get_status(), "Non-owner seek of {$item} must be refused with 403." ); + $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 403 is an access gate and not a broken lookup. + * the non-owner refusal is an access gate and not a broken lookup. * * @covers \Activitypub\Rest\Outbox_Controller::get_item_index */ From bfb1b0d092522632bb91b64d0f2a390af833f65f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Jul 2026 20:14:22 +0200 Subject: [PATCH 12/13] Flatten the outbox seek gate and document the inbox 403-to-404 collapse --- .../rest/class-actors-inbox-controller.php | 6 ++++-- includes/rest/class-outbox-controller.php | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 548667b568..ed6304faeb 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -299,8 +299,10 @@ private function get_query_args( $request ) { /** * Get the position of an activity in the inbox, under the collection's own query rules. * - * The inbox route is owner-only (it requires authentication), so unlike the outbox this method - * needs no seek gate of its own: a non-owner is already refused before it runs. + * 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 * diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php index 1300ea3247..c45637ba88 100644 --- a/includes/rest/class-outbox-controller.php +++ b/includes/rest/class-outbox-controller.php @@ -329,16 +329,17 @@ private function get_query_args( $request, $is_owner = null ) { * @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 ) { - if ( true !== $this->verify_owner( $request ) ) { - if ( ! \is_user_logged_in() ) { - return new \WP_Error( - 'activitypub_unauthorized', - \__( 'You need to authenticate to seek this collection.', 'activitypub' ), - array( 'status' => 401 ) - ); - } + // 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 ) + ); + } - // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + // An authenticated non-owner is refused with the uniform 404, disclosing no membership. + if ( true !== $this->verify_owner( $request ) ) { return false; } From 3d99d50d824f31713a79b47798c3947779c0f8c0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Jul 2026 11:59:29 +0200 Subject: [PATCH 13/13] Harden the seek defer filter against override and fix the response-code docblocks --- includes/rest/class-seek-controller.php | 17 +++++++------ includes/rest/trait-collection.php | 9 +++---- .../rest/class-test-seek-controller.php | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/includes/rest/class-seek-controller.php b/includes/rest/class-seek-controller.php index c731e69731..1c0f34a9b5 100644 --- a/includes/rest/class-seek-controller.php +++ b/includes/rest/class-seek-controller.php @@ -94,12 +94,13 @@ public function get_item_permissions_check( $request ) { * Seek an item in a collection. * * Dispatches an internal request to the collection endpoint with the `item` parameter and - * passes its redirect through. Unknown collections, foreign URLs, items that are not part - * of the collection, and unauthorized requests all produce the same 404, so collection - * membership is not leaked. + * 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 response on success, or WP_Error object on failure. + * @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( @@ -128,11 +129,13 @@ public function get_item( $request ) { * non-seekable collection, so no membership is leaked. */ $defer = static function ( $deferred, $inner_request, $force_signature ) { - return $force_signature ? $deferred : true; + // Decide deterministically: defer for the dispatch, except on forced-signature routes. + return ! $force_signature; }; - \add_filter( 'activitypub_defer_signature_verification', $defer, 10, 3 ); + // 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, 10 ); + \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 diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index e5e70fa47a..9e49afae46 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -147,9 +147,10 @@ public function get_seek_item_arg() { * 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. Unknown and invisible items both produce the same 404, so collection - * membership is not leaked; an authentication or authorization failure is surfaced as is, - * because it describes the request rather than any item and discloses no membership. + * 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 * @@ -158,7 +159,7 @@ public function get_seek_item_arg() { * @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/403/404 error, or null when this is not a seek request. + * @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' ); diff --git a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php index c4c96fc183..fa4917bec2 100644 --- a/tests/phpunit/tests/includes/rest/class-test-seek-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-seek-controller.php @@ -300,6 +300,30 @@ public function test_seek_endpoint_does_not_bypass_forced_signature() { $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. *