Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/add-seek-item-endpoint
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio
## Supported federation protocols and standards

- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
- [ActivityPub API: Actor Autocomplete](https://swicg.github.io/activitypub-api/autocomplete) (typeahead search over local and cached remote actors; requires the ActivityPub API to be enabled)
- [ActivityPub API: Basic Profile](https://swicg.github.io/activitypub-api/basicprofile) (Client-to-Server, partial; see [OAuth 2.0 for Client-to-Server](#oauth-20-for-client-to-server))
- [ActivityPub API: Seek Item](https://swicg.github.io/activitypub-api/seekitem) (followers, following, outbox, inbox, and liked collections)
- [ActivityPub API: Server-Sent Events](https://swicg.github.io/activitypub-api/sse) (partial, see below)
- [ActivityPub API: Actor Autocomplete](https://swicg.github.io/activitypub-api/autocomplete) (typeahead search over local and cached remote actors; requires the ActivityPub API to be enabled)
- [WebFinger](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/)
- [HTTP Signatures](https://swicg.github.io/activitypub-http-signature/)
- [NodeInfo](https://nodeinfo.diaspora.software/)
Expand Down
1 change: 1 addition & 0 deletions activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function rest_init() {
}
( new Rest\Outbox_Controller() )->register_routes();
( new Rest\Post_Controller() )->register_routes();
( new Rest\Seek_Controller() )->register_routes();
( new Rest\Replies_Controller() )->register_routes();
( new Rest\Webfinger_Controller() )->register_routes();

Expand Down
113 changes: 90 additions & 23 deletions includes/rest/class-actors-inbox-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public function register_routes() {
'minimum' => 1,
'maximum' => 100,
),
'item' => $this->get_seek_item_arg(),
),
'schema' => array( $this, 'get_collection_schema' ),
),
Expand Down Expand Up @@ -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 );

Expand All @@ -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 );
Expand Down Expand Up @@ -272,6 +255,90 @@ public function get_items( $request ) {
return $response;
}

/**
* Build the WP_Query arguments for the inbox collection.
*
* Shared by get_items() and get_item_index(), so the seek index is computed under the
* exact same query rules as the collection itself.
*
* @param \WP_REST_Request $request Full details about the request.
*
* @return array The WP_Query arguments.
*/
private function get_query_args( $request ) {
$args = array(
'posts_per_page' => $request->get_param( 'per_page' ),
'paged' => $request->get_param( 'page' ) ?? 1,
'post_type' => Inbox::POST_TYPE,
'post_status' => 'publish',
// Deterministic ordering: break post_date ties by ID, so pagination and seek agree.
'orderby' => array(
'date' => 'DESC',
'ID' => 'DESC',
),
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
'meta_query' => array(
array(
'key' => '_activitypub_user_id',
'value' => $request->get_param( 'user_id' ),
),
),
);

/**
* Filters WP_Query arguments when querying Inbox items via the REST API.
*
* Enables adding extra arguments or setting defaults for an inbox collection request.
*
* @param array $args Array of arguments for WP_Query.
* @param \WP_REST_Request $request The REST API request.
*/
return \apply_filters( 'activitypub_rest_inbox_query', $args, $request );
}

/**
* Get the position of an activity in the inbox, under the collection's own query rules.
*
* The inbox route requires authentication, so unlike the outbox this method needs no seek gate
* of its own: a non-owner is already refused before it runs — unauthenticated with 401, and an
* authenticated non-owner with a 403 that Seek_Controller collapses to the uniform 404 so the
* seek discloses no membership. Any new seek surface must preserve that 403 → 404 collapse.
*
* @since unreleased
*
* @param string $item The ActivityPub activity ID.
* @param \WP_REST_Request $request Full details about the request.
*
* @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found.
*/
public function get_item_index( $item, $request ) {
$inbox_item = Inbox::get_by_guid( $item );
if ( \is_wp_error( $inbox_item ) ) {
return $inbox_item;
}

$args = $this->get_query_args( $request );
$args['fields'] = 'ids';
$args['posts_per_page'] = 1;
unset( $args['paged'] );

// Confirm the item is part of this inbox before computing the index.
$membership = new \WP_Query( \array_merge( $args, array( 'post__in' => array( $inbox_item->ID ) ) ) );
if ( ! $membership->found_posts ) {
return false;
}

// Count the activities that sort before the item; that count is the item's zero-based index.
$preceding = $this->with_posts_where(
$this->get_preceding_by_date_where( $inbox_item->post_date, $inbox_item->ID ),
static function () use ( $args ) {
return new \WP_Query( $args );
}
);

return (int) $preceding->found_posts;
}

/**
* Prepares the item for the REST response.
*
Expand Down
59 changes: 59 additions & 0 deletions includes/rest/class-followers-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -233,6 +239,59 @@ static function ( $item ) use ( $context ) {
return $response;
}

/**
* Get the position of a follower in the collection, under the collection's own query rules.
*
* @since unreleased
*
* @param string $item The ActivityPub actor ID of the follower.
* @param \WP_REST_Request $request Full details about the request.
*
* @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found.
*/
public function get_item_index( $item, $request ) {
global $wpdb;

if ( ! $this->show_social_graph( $request ) ) {
return false;
}

$actor = Remote_Actors::get_by_uri( $item );
if ( \is_wp_error( $actor ) ) {
return $actor;
}

$user_id = $request->get_param( 'user_id' );
$order = $request->get_param( 'order' );
$args = array(
'fields' => 'ids',
'order' => \ucwords( $order ),
);

// Confirm membership through the collection's own query before computing the index.
$membership = Followers::query( $user_id, 1, null, \array_merge( $args, array( 'post__in' => array( $actor->ID ) ) ) );
if ( ! $membership['total'] ) {
return false;
}

// Posts sorting before the item: lower IDs for ascending order, higher IDs for descending.
if ( 'asc' === $order ) {
$where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID );
} else {
$where = $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $actor->ID );
}

// Count the followers that sort before the item; that count is the item's zero-based index.
$preceding = $this->with_posts_where(
$where,
static function () use ( $user_id, $args ) {
return Followers::query( $user_id, 1, null, $args );
}
);

return (int) $preceding['total'];
}

/**
* Retrieves partial followers list for FEP-8fcf synchronization.
*
Expand Down
59 changes: 59 additions & 0 deletions includes/rest/class-following-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand All @@ -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;
Expand Down Expand Up @@ -139,6 +145,59 @@ static function ( $item ) use ( $context ) {
return $response;
}

/**
* Get the position of a followed actor in the collection, under the collection's own query rules.
*
* @since unreleased
*
* @param string $item The ActivityPub actor ID of the followed actor.
* @param \WP_REST_Request $request Full details about the request.
*
* @return int|false|\WP_Error Zero-based index of the item, false or WP_Error when not found.
*/
public function get_item_index( $item, $request ) {
global $wpdb;

if ( ! $this->show_social_graph( $request ) ) {
return false;
}

$actor = Remote_Actors::get_by_uri( $item );
if ( \is_wp_error( $actor ) ) {
return $actor;
}

$user_id = $request->get_param( 'user_id' );
$order = $request->get_param( 'order' );
$args = array(
'fields' => 'ids',
'order' => \ucwords( $order ),
);

// Confirm membership through the collection's own query before computing the index.
$membership = Following::query( $user_id, 1, null, \array_merge( $args, array( 'post__in' => array( $actor->ID ) ) ) );
if ( ! $membership['total'] ) {
return false;
}

// Posts sorting before the item: lower IDs for ascending order, higher IDs for descending.
if ( 'asc' === $order ) {
$where = $wpdb->prepare( " AND {$wpdb->posts}.ID < %d", $actor->ID );
} else {
$where = $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $actor->ID );
}

// Count the followed actors that sort before the item; that count is the item's zero-based index.
$preceding = $this->with_posts_where(
$where,
static function () use ( $user_id, $args ) {
return Following::query( $user_id, 1, null, $args );
}
);

return (int) $preceding['total'];
}

/**
* Retrieves the following schema, conforming to JSON Schema.
*
Expand Down
22 changes: 22 additions & 0 deletions includes/rest/class-liked-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public function register_routes() {
'minimum' => 1,
'maximum' => 100,
),
'item' => $this->get_seek_item_arg(),
),
),
'schema' => array( $this, 'get_item_schema' ),
Expand All @@ -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.
Expand Down Expand Up @@ -113,6 +119,22 @@ public function get_items( $request ) {
return $response;
}

/**
* Get the position of an object in the liked collection.
*
* @since unreleased
*
* @param string $item The ActivityPub object ID of the liked object.
* @param \WP_REST_Request $request Full details about the request.
*
* @return int|false Zero-based index of the item, false when not found.
*/
public function get_item_index( $item, $request ) {
$index = \array_search( $item, $this->get_liked_object_ids( $request->get_param( 'user_id' ) ), true );

return false === $index ? false : (int) $index;
}

/**
* Get all currently liked object IDs for an actor.
*
Expand Down
Loading
Loading