get_param( 'page' ); $per_page = \max( 1, \absint( $request->get_param( 'per_page' ) ) ); $max_pages = \max( 1, \ceil( $response['totalItems'] / $per_page ) ); if ( $page > $max_pages ) { return new \WP_Error( 'rest_post_invalid_page_number', 'The page number requested is larger than the number of pages available.', array( 'status' => 400 ) ); } // Set the JSON-LD context if not already set. if ( empty( $response['@context'] ) ) { // Ensure the context is the first element in the response. $response = array( '@context' => $this->json_ld_context ) + $response; } $query_params = $this->get_collection_query_params( $request ); 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( $query_params, $response['id'] ); $response['first'] = \add_query_arg( 'page', 1, $response['id'] ); $response['last'] = \add_query_arg( 'page', $max_pages, $response['id'] ); /* * Advertise the seek endpoint on the Collection when the route offered an `item` argument, * which is how a controller opts in. It is advertised from here so the collection handed to * the seek endpoint is the id above, with the same filtering arguments and no page, and so a * collection with nothing to seek — empty, or with its items withheld — never offers it. */ $attributes = $request->get_attributes(); if ( null === $page && isset( $attributes['args']['item'] ) ) { // 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( $response['id'] ), get_rest_url_by_path( 'seek' ) ); $context = (array) $response['@context']; if ( ! \in_array( $this->seek_item_context, $context, true ) ) { $context[] = $this->seek_item_context; $response['@context'] = $context; } } // If this is a Collection request, return early. if ( null === $page ) { // No items in Collections, only links to CollectionPages. unset( $response['items'], $response['orderedItems'] ); return $response; } // Still here, so this is a Page request. Append the type. $response['type'] .= 'Page'; $response['partOf'] = \remove_query_arg( 'page', $response['id'] ); if ( $max_pages > $page ) { $response['next'] = \add_query_arg( 'page', $page + 1, $response['partOf'] ); } if ( $page > 1 ) { $response['prev'] = \add_query_arg( 'page', $page - 1, $response['partOf'] ); } return $response; } /** * Collect the query arguments that belong in a collection's id and page links. * * @since 9.4.0 * * @param \WP_REST_Request $request The collection request. * * @return array The request's item-selecting query arguments. */ private function get_collection_query_params( $request ) { /* * Only the arguments that select which items a page holds belong in these links. Copying the * query string wholesale carries unregistered parameters along, and `rest_route` is a public * query var, so a link carrying one resolves to that route instead of to the collection page. * The seek `item` is absent by the same rule: it picks a page, it does not describe one. */ $allowed = array( 'page', 'per_page', 'order', 'context', 'type' ); /** * Filters the query arguments carried into a collection's id and page links. * * Arguments that are not listed are dropped, so a parameter the collection does not know * about cannot change where one of its links resolves. * * @param string[] $allowed The query argument names to carry over. * @param \WP_REST_Request $request The collection request. */ $allowed = \apply_filters( 'activitypub_rest_collection_query_args', $allowed, $request ); return \array_intersect_key( $request->get_query_params(), \array_flip( $allowed ) ); } /** * Argument definition for the seek `item` parameter. * * @see https://swicg.github.io/activitypub-api/seekitem * * @since 9.4.0 * * @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', ); } /** * Resolve the zero-based position of an item under the collection's own query and visibility rules. * * Collections with a seekable order override this; the default has nothing to seek. * * @param string $item The ActivityPub ID of the item. * @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 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed return false; } /** * 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. An unknown item and one hidden by the collection's visibility rules answer * with the same 404, so collection membership is not leaked. * * @see https://swicg.github.io/activitypub-api/seekitem * * @since 9.4.0 * * @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 ); /* * A failed lookup, an absent item and one hidden by the collection's visibility rules all * collapse to a single 404, so the presence of a specific item can never be inferred, per * the seekItem spec. A request that has to authenticate at all is refused by the route's * permission callback, before any item is resolved. */ 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 = $this->get_collection_query_params( $request ); $query_params['page'] = $page; $response = new \WP_REST_Response( null, 307 ); $response->header( 'Location', \add_query_arg( $query_params, $collection_id ) ); return $response; } /** * Run a callback with an additional WHERE clause appended to WP_Query's SQL. * * Used by get_item_index() implementations to count the items that sort before the sought * item, reusing the collection's own query (and thereby its visibility rules) unchanged. * * @param string $where Prepared SQL to append to the WHERE clause. * @param callable $callback Callback executing the query. * * @return mixed The callback return value. */ private function with_posts_where( $where, $callback ) { $filter = static function ( $sql ) use ( $where ) { return $sql . $where; }; \add_filter( 'posts_where', $filter ); $result = $callback(); \remove_filter( 'posts_where', $filter ); return $result; } /** * Build a WHERE clause matching the posts that sort before a cursor in ID order. * * Mirrors an `orderby` of ID, so the count of matching posts is the cursor's zero-based index. * Shared by the ID-ordered collections (followers, following). * * @since 9.4.0 * * @param int $id The cursor post's ID. * @param string $order The collection's sort order, `asc` or `desc`. * * @return string Prepared SQL to append to a WHERE clause. */ private function get_preceding_by_id_where( $id, $order ) { global $wpdb; // Lower IDs sort before the cursor in ascending order, higher IDs in descending order. $comparison = 'asc' === $order ? '<' : '>'; return $wpdb->prepare( " AND {$wpdb->posts}.ID {$comparison} %d", $id ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * 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). * * This assumes the collection's default newest-first date ordering. A site that reorders the * outbox/inbox through the `activitypub_rest_outbox_query` / `activitypub_rest_inbox_query` * filter makes this index disagree with the paginated order, so seek only resolves correctly * under the default ordering. * * @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. * * Returns a schema definition for ActivityPub (Ordered)Collection and (Ordered)CollectionPage * that controllers can use to compose their full schema by passing in their item schema. * * @param array $item_schema Optional. The schema for the items in the collection. Default empty array. * * @return array The collection schema. */ public function get_collection_schema( $item_schema = array() ) { $collection_schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'collection', 'type' => 'object', 'properties' => array( '@context' => array( 'description' => 'The JSON-LD context of the OrderedCollection.', 'type' => array( 'string', 'array', 'object' ), ), 'id' => array( 'description' => 'The unique identifier for the OrderedCollection.', 'type' => 'string', 'format' => 'uri', ), 'type' => array( 'description' => 'The type of the object. Either OrderedCollection or OrderedCollectionPage.', 'type' => 'string', 'enum' => array( 'Collection', 'CollectionPage', 'OrderedCollection', 'OrderedCollectionPage' ), ), 'totalItems' => array( 'description' => 'The total number of items in the collection.', 'type' => 'integer', 'minimum' => 0, ), 'orderedItems' => array( 'description' => 'The ordered items in the collection.', 'type' => 'array', ), 'first' => array( 'description' => 'Link to the first page of the collection.', 'type' => 'string', 'format' => 'uri', ), 'last' => array( 'description' => 'Link to the last page of the collection.', 'type' => 'string', 'format' => 'uri', ), 'next' => array( 'description' => 'Link to the next page of the collection.', 'type' => 'string', 'format' => 'uri', ), 'prev' => array( 'description' => 'Link to the previous page of the collection.', 'type' => 'string', 'format' => 'uri', ), 'partOf' => array( 'description' => 'The OrderedCollection to which this OrderedCollectionPage belongs.', 'type' => 'string', 'format' => 'uri', ), 'seekItem' => array( 'description' => 'Endpoint to resolve the collection page containing a given item.', 'type' => 'string', 'format' => 'uri', ), ), ); // Add the orderedItems property based on the provided item schema. if ( ! empty( $item_schema ) ) { $collection_schema['properties']['orderedItems']['items'] = $item_schema; } return $collection_schema; } }