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 ); } /** * Whether a route implements the seek extension. * * A route opts in by declaring an `item` argument on its readable handler, the same signal * prepare_collection_response() uses to advertise `seekItem`. Route patterns are matched the way * WP_REST_Server::match_request_to_handler() matches them, which cannot be called directly * because it is protected: the dispatch lands on the first registered pattern that matches, and * on that pattern's first handler serving GET, so only that one handler decides the answer. A * later pattern that happens to match too says nothing about where the dispatch actually goes. * * The seek route itself is excluded — it declares an `item` argument of its own, and a seek * dispatched to a seek would recurse — and the exclusion compares the registered pattern rather * than the requested route, because patterns match case-insensitively: a route of `/…/Seek` * resolves back to this endpoint and would slip past a comparison against the requested route. * * @since 9.4.0 * * @param string $route The route of the request about to be dispatched. * * @return string|false The registered pattern that declares a seek `item` argument, false otherwise. */ private function get_seekable_pattern( $route ) { foreach ( \rest_get_server()->get_routes( $this->namespace ) as $pattern => $handlers ) { if ( ! \preg_match( '@^' . $pattern . '$@i', $route ) ) { continue; } foreach ( $handlers as $handler ) { // Only a readable handler can serve a seek; the same route may also register writable ones. if ( empty( $handler['methods']['GET'] ) ) { continue; } // This is the handler the dispatch resolves to, so the seek route is where it stops. if ( '/' . $this->namespace . '/' . $this->rest_base === $pattern ) { return false; } return isset( $handler['args']['item'] ) ? $pattern : false; } } return false; } /** * Seek an item in a collection. * * Dispatches an internal request to the collection endpoint with the `item` parameter and * passes its redirect through. A missing-authentication failure surfaces as 401 to tell the * client to authenticate. Unknown collections, foreign URLs, items that are not part of the * collection, and authenticated-but-not-authorized requests all produce the same 404, so * collection membership is not leaked. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_REST_Response|\WP_Error Redirect on success, or 401/404 WP_Error on failure. */ public function get_item( $request ) { $not_found = new \WP_Error( 'activitypub_item_not_found', \__( 'The requested item could not be found in this collection.', 'activitypub' ), array( 'status' => 404 ) ); // A collection never dispatches a seek, so a seek reached from inside a seek can only be recursion. if ( self::$is_dispatching ) { return $not_found; } $collection = $request->get_param( 'collection' ); /* * `from_url()` reads a `?rest_route=` value without looking at the host, so a URL on any * host carrying one resolves to a local route. Our own collection URLs take that form on * plain-permalink installs, so match on the host rather than on the URL's shape. */ if ( ! is_same_domain( $collection ) ) { return $not_found; } // Resolves only URLs served by this site's REST API, so no remote request is ever made. $collection_request = \WP_REST_Request::from_url( $collection ); if ( ! $collection_request || ! \str_starts_with( $collection_request->get_route(), '/' . ACTIVITYPUB_REST_NAMESPACE . '/' ) ) { return $not_found; } /* * Only dispatch to routes that actually implement seek (they declare an `item` argument, the * same opt-in prepare_collection_response() checks). Without this, the signature-deferred * dispatch below could be pointed at any ActivityPub GET route rather than a real collection. */ $seekable_pattern = $this->get_seekable_pattern( $collection_request->get_route() ); if ( ! $seekable_pattern ) { return $not_found; } $collection_request->set_param( 'item', $request->get_param( 'item' ) ); // Carry the original headers over, so a Bearer token reaches the collection's own permission check. $collection_request->set_headers( $request->get_headers() ); /* * The outer request's signature was verified for the /seek route and cannot be replayed * against the inner route, so defer signature verification for the dispatch. Endpoints that * force signatures (FEP-8fcf's /followers/sync) are deliberately not deferred: their * mandatory verification must still run, and it will fail for a seek since the signature was * never computed over their route. get_item() maps that failure to the same 404 as any other * non-seekable collection, so no membership is leaked. */ $defer = static function ( $deferred, $inner_request, $force_signature ) { return ! $force_signature; }; // Latest priority, so a global defer filter (e.g. a local-dev __return_true) cannot reopen a forced route. \add_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX, 3 ); // In flight for the dispatch below, so any seek reached from within it is refused instead of recursing. self::$is_dispatching = true; $response = \rest_do_request( $collection_request ); self::$is_dispatching = false; \remove_filter( 'activitypub_defer_signature_verification', $defer, \PHP_INT_MAX ); /* * get_seekable_pattern() is a pre-filter, because the decision has to be made before the * signature-deferred dispatch, and core's own matcher is protected. This is the decision: * core's record of what it actually matched. */ if ( $response->get_matched_route() !== $seekable_pattern ) { return $not_found; } /* * 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, 401 ), true ) ) { return $response; } return $not_found; } }