Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
05bdcff
Add seek plumbing to the collection trait
pfefferle Jul 16, 2026
aa83e35
Add a seek endpoint that dispatches to the collections internally
pfefferle Jul 16, 2026
13e11a0
Resolve seek requests in the followers, following, outbox, inbox, and…
pfefferle Jul 16, 2026
2cf2236
Add seek endpoint tests, changelog, and FEDERATION.md entry
pfefferle Jul 16, 2026
3ac11b5
Honor forced signatures on seek, preserve collection query args, add …
pfefferle Jul 16, 2026
182e54d
Simplify seek plumbing: dedupe the date cursor clause and collapse co…
pfefferle Jul 16, 2026
a5c408e
Cover the outbox content-visibility gate in the seek oracle tests
pfefferle Jul 16, 2026
cbfc348
Make outbox seek owner-only and surface auth failures instead of mask…
pfefferle Jul 16, 2026
58e2dee
Fix stale seek docblock and avoid re-deriving outbox ownership
pfefferle Jul 16, 2026
bc41d58
Note why the inbox seek index needs no owner gate of its own
pfefferle Jul 16, 2026
e3850df
Return 401 for unauthenticated seeks and 404 for authenticated non-ow…
pfefferle Jul 16, 2026
bfb1b0d
Flatten the outbox seek gate and document the inbox 403-to-404 collapse
pfefferle Jul 16, 2026
a928fbb
Merge branch 'trunk' into add/seek-item-endpoint
pfefferle Jul 17, 2026
3d99d50
Harden the seek defer filter against override and fix the response-co…
pfefferle Jul 17, 2026
a305f50
Merge branch 'trunk' into add/seek-item-endpoint
pfefferle Jul 19, 2026
f9faec0
Merge branch 'trunk' into add/seek-item-endpoint
pfefferle Jul 23, 2026
06c80d3
Harden the seek endpoint against a HEAD membership oracle
pfefferle Jul 23, 2026
a439138
fix a fatal in the seek route check
pfefferle Jul 23, 2026
5df4314
ignore the security-scan folder
pfefferle Jul 23, 2026
e47eb72
Silence the intentional unused-parameter warning on can_advertise_seek
pfefferle Jul 23, 2026
b47f87d
Merge branch 'trunk' into add/seek-item-endpoint
pfefferle Jul 23, 2026
38acb63
stop seek from dispatching to itself
pfefferle Jul 24, 2026
b4f51ef
limit the delete signature exception to inboxes
pfefferle Jul 24, 2026
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: security

Ensure that only account deletions delivered to your site can skip the usual sender verification.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/build/**/*.map
/coverage/
/node_modules/
/security-scan/
/vendor/
_site
.idea/
Expand Down
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
13 changes: 13 additions & 0 deletions includes/handler/class-delete.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ public static function skip_inbox_storage( $skip, $data ) {
* arrives.
*
* @since 8.2.0 The `$force_signature` parameter is now respected.
* @since unreleased Limited to POST deliveries to an inbox route.
*
* @param bool $defer Whether to defer signature verification.
* @param \WP_REST_Request $request The request object.
Expand All @@ -317,6 +318,18 @@ public static function defer_signature_verification( $defer, $request, $force_si
return $defer;
}

// Deliveries are POSTs; on any other method the body is not an activity we accept, so it must not waive verification.
if ( 'POST' !== $request->get_method() ) {
return $defer;
}

$route = $request->get_route();

// The carve-out is for inbox deliveries only: both the shared inbox and the per-actor inboxes end in `/inbox`.
if ( ! \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE ) || ! \str_ends_with( $route, '/inbox' ) ) {
return $defer;
}

$json = $request->get_json_params();

if ( isset( $json['type'] ) && 'Delete' === $json['type'] ) {
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
Loading
Loading