From 7888ccfc85ac413d3c13211e60a954a1dcb87623 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 6 Oct 2025 19:01:59 +0200 Subject: [PATCH 01/78] Implement FEP-8fcf followers collection synchronization Adds support for FEP-8fcf to synchronize followers collections across servers. This includes generating and processing Collection-Synchronization headers, computing partial follower digests, exposing a new /followers-sync REST endpoint, and scheduling async reconciliation jobs when mismatches are detected. New methods and traits are introduced for digest computation, header parsing, and reconciliation, with hooks for monitoring sync events. This improves data consistency and interoperability with other ActivityPub servers supporting FEP-8fcf. --- docs/fep-8fcf-implementation.md | 139 ++++++++++ includes/class-http.php | 12 + includes/class-scheduler.php | 2 + includes/collection/class-followers.php | 262 ++++++++++++++++++ .../rest/class-actors-inbox-controller.php | 4 + includes/rest/class-followers-controller.php | 72 +++++ includes/rest/class-inbox-controller.php | 5 + includes/rest/trait-followers-sync.php | 82 ++++++ includes/scheduler/class-follower.php | 137 +++++++++ 9 files changed, 715 insertions(+) create mode 100644 docs/fep-8fcf-implementation.md create mode 100644 includes/rest/trait-followers-sync.php create mode 100644 includes/scheduler/class-follower.php diff --git a/docs/fep-8fcf-implementation.md b/docs/fep-8fcf-implementation.md new file mode 100644 index 000000000..cc1e984db --- /dev/null +++ b/docs/fep-8fcf-implementation.md @@ -0,0 +1,139 @@ +# FEP-8fcf Implementation + +This is a prototype implementation of [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md). + +## Overview + +FEP-8fcf provides a mechanism for detecting and resolving discrepancies in follow relationships between ActivityPub instances. This helps ensure that follower lists stay synchronized even when there are software bugs, server crashes, or database rollbacks. + +## How It Works + +### 1. Outgoing Activities + +When sending Create activities to followers, the plugin automatically adds a `Collection-Synchronization` HTTP header that includes: + +- `collectionId`: The sender's followers collection URI +- `url`: URL to fetch the partial followers collection for that specific instance +- `digest`: A cryptographic digest (XOR'd SHA256 hashes) of followers from the receiving instance + +This is implemented in `includes/class-http.php`. + +### 2. Partial Followers Collection + +A new REST endpoint `/actors/{user_id}/followers-sync` provides partial followers collections filtered by instance authority. This endpoint only returns followers whose IDs match the requesting instance's domain. + +This is implemented in `includes/rest/class-followers-controller.php`. + +### 3. Incoming Activities + +When receiving activities with a `Collection-Synchronization` header, the plugin: + +1. Parses and validates the header parameters +2. Computes the local digest for comparison +3. If digests don't match, schedules an async reconciliation job + +This is implemented in `includes/rest/class-inbox-controller.php`. + +### 4. Reconciliation + +When a digest mismatch is detected, the plugin asynchronously: + +1. Fetches the authoritative partial followers collection from the remote server +2. Compares it with the local follower list +3. Removes followers that shouldn't exist locally +4. Logs followers that exist remotely but not locally (for review) + +This is implemented in `includes/scheduler/class-collection-sync.php`. + +## Components + +### Core Classes + +- **`Followers`** (`includes/collection/class-followers.php`) + - Computes partial follower digests using XOR'd SHA256 hashes + - Generates and parses Collection-Synchronization headers + - Filters followers by instance authority + - Validates header parameters + - New FEP-8fcf methods: `compute_partial_digest()`, `get_partial_followers()`, `generate_sync_header()`, `parse_sync_header()`, `validate_sync_header_params()`, `get_authority()` + +- **`Follower`** (`includes/scheduler/class-follower.php`) + - Handles async reconciliation when digest mismatches occur + - Removes out-of-sync followers + - Provides action hooks for monitoring sync events + +### Traits + +- **`Followers_Sync`** (`includes/rest/trait-followers-sync.php`) + - Reusable trait for inbox controllers + - Provides `process_followers_synchronization()` method + - Used by both `Inbox_Controller` and `Actors_Inbox_Controller` + +### Modified Classes + +- **`Http`** - Adds Collection-Synchronization header to outgoing Create activities +- **`Followers_Controller`** - Adds `/followers-sync` endpoint for partial collections +- **`Inbox_Controller`** - Uses `Followers_Sync` trait to process incoming headers +- **`Actors_Inbox_Controller`** - Uses `Followers_Sync` trait to process incoming headers +- **`Scheduler`** - Registers the Collection_Sync scheduler + +## Privacy Considerations + +FEP-8fcf is designed with privacy in mind: + +- Only followers from the requesting instance are included in partial collections +- Each instance only gets information about its own users +- No global follower list is exposed + +## Action Hooks + +The implementation provides several action hooks for monitoring and extending: + +```php +// Triggered when digest mismatch is detected +do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); + +// Triggered when a follower is removed during sync +do_action( 'activitypub_followers_sync_follower_removed', $user_id, $follower_url, $actor_url ); + +// Triggered when follower exists remotely but not locally +do_action( 'activitypub_followers_sync_follower_mismatch', $user_id, $follower_url, $actor_url ); + +// Triggered after reconciliation completes +do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $to_check ); +``` + +## Compatibility + +This implementation is compatible with: + +- Mastodon (v3.3.0+) +- Fedify (v0.8.0+) +- Tootik (v0.18.0+) +- Any other server that implements FEP-8fcf + +## Testing + +To test the implementation: + +1. Set up two WordPress instances with the ActivityPub plugin +2. Have users follow each other +3. Monitor the `Collection-Synchronization` headers in HTTP requests +4. Simulate a follower mismatch by manually removing a follower from the database +5. Send a Create activity and verify reconciliation occurs + +## Future Enhancements + +Potential improvements for the future: + +- Add admin UI to view synchronization logs +- Implement configurable sync frequency +- Add metrics/statistics for sync operations +- Support synchronization for Following collections +- Add option to disable FEP-8fcf support +- Implement exponential backoff for failed reconciliations + +## References + +- [FEP-8fcf Specification](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) +- [Mastodon Implementation](https://github.com/tootsuite/mastodon/pull/14510) +- [Fedify Documentation](https://fedify.dev/manual/send#followers-collection-synchronization) diff --git a/includes/class-http.php b/includes/class-http.php index 2f4f62ae9..1ae1df056 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -55,6 +55,18 @@ public static function post( $url, $body, $user_id ) { 'private_key' => Actors::get_private_key( $user_id ), ); + // FEP-8fcf: Add Collection-Synchronization header for Create activities. + $activity = \json_decode( $body ); + if ( $activity && isset( $activity->type ) && 'Create' === $activity->type ) { + $inbox_authority = Collection\Followers::get_authority( $url ); + if ( $inbox_authority ) { + $sync_header = Collection\Followers::generate_sync_header( $user_id, $inbox_authority ); + if ( $sync_header ) { + $args['headers']['Collection-Synchronization'] = $sync_header; + } + } + } + $response = \wp_safe_remote_post( $url, $args ); $code = \wp_remote_retrieve_response_code( $response ); diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 1cccdf066..a93ea0411 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -14,6 +14,7 @@ use Activitypub\Collection\Remote_Actors; use Activitypub\Scheduler\Actor; use Activitypub\Scheduler\Comment; +use Activitypub\Scheduler\Follower; use Activitypub\Scheduler\Post; /** @@ -58,6 +59,7 @@ public static function register_schedulers() { Post::init(); Actor::init(); Comment::init(); + Follower::init(); /** * Register additional schedulers. diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 64bc16447..06aa8589e 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -481,4 +481,266 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { self::remove( $actor_id, $user_id ); } + + /** + * Compute the partial follower collection digest for a specific instance. + * + * Implements FEP-8fcf: Followers collection synchronization. + * The digest is created by XORing together the individual SHA256 digests + * of each follower's ID. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param int $user_id The user ID whose followers to compute. + * @param string $authority The URI authority (scheme + host) to filter by. + * + * @return string The hex-encoded digest, or empty string if no followers. + */ + public static function compute_partial_digest( $user_id, $authority ) { + $followers = self::get_partial_followers( $user_id, $authority ); + + if ( empty( $followers ) ) { + return ''; + } + + // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). + $digest = str_repeat( '0', 64 ); + + foreach ( $followers as $follower_url ) { + // Compute SHA256 hash of the follower ID. + $hash = hash( 'sha256', $follower_url ); + + // XOR the hash with the running digest. + $digest = self::xor_hex_strings( $digest, $hash ); + } + + return $digest; + } + + /** + * Get partial followers collection for a specific instance. + * + * Returns only followers whose ID shares the specified URI authority. + * Used for FEP-8fcf synchronization. + * + * @param int $user_id The user ID whose followers to get. + * @param string $authority The URI authority (scheme + host) to filter by. + * + * @return array Array of follower URLs. + */ + public static function get_partial_followers( $user_id, $authority ) { + // Get all followers. + $followers = self::get_followers( $user_id ); + + if ( empty( $followers ) ) { + return array(); + } + + // Filter by authority. + $partial_followers = array(); + + foreach ( $followers as $follower ) { + $follower_url = is_string( $follower ) ? $follower : $follower->guid; + + if ( empty( $follower_url ) ) { + continue; + } + + // Parse the URL and check if authority matches. + $parsed = wp_parse_url( $follower_url ); + + if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { + continue; + } + + $follower_authority = $parsed['scheme'] . '://' . $parsed['host']; + + // Add port if it's not the default for the scheme. + if ( ! empty( $parsed['port'] ) ) { + $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { + $follower_authority .= ':' . $parsed['port']; + } + } + + if ( $follower_authority === $authority ) { + $partial_followers[] = $follower_url; + } + } + + // Sort for consistency. + sort( $partial_followers ); + + return $partial_followers; + } + + /** + * XOR two hexadecimal strings. + * + * Used for FEP-8fcf digest computation. + * + * @param string $hex1 First hex string. + * @param string $hex2 Second hex string. + * + * @return string The XORed result as a hex string. + */ + private static function xor_hex_strings( $hex1, $hex2 ) { + $result = ''; + + // Ensure both strings are the same length (should be 64 chars for SHA256). + $length = max( strlen( $hex1 ), strlen( $hex2 ) ); + $hex1 = str_pad( $hex1, $length, '0', STR_PAD_LEFT ); + $hex2 = str_pad( $hex2, $length, '0', STR_PAD_LEFT ); + + // XOR each pair of hex digits. + for ( $i = 0; $i < $length; $i += 2 ) { + $byte1 = hexdec( substr( $hex1, $i, 2 ) ); + $byte2 = hexdec( substr( $hex2, $i, 2 ) ); + $result .= str_pad( dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); + } + + return $result; + } + + /** + * Generate the Collection-Synchronization header value for FEP-8fcf. + * + * @param int $user_id The user ID whose followers collection to sync. + * @param string $authority The authority of the receiving instance. + * + * @return string|false The header value, or false if cannot generate. + */ + public static function generate_sync_header( $user_id, $authority ) { + // Compute the digest for this specific authority. + $digest = self::compute_partial_digest( $user_id, $authority ); + + if ( empty( $digest ) ) { + return false; + } + + // Build the collection ID (followers collection URL). + $collection_id = \Activitypub\get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) ); + + // Build the partial followers URL. + $url = \Activitypub\get_rest_url_by_path( + sprintf( + 'actors/%d/followers-sync?authority=%s', + $user_id, + rawurlencode( $authority ) + ) + ); + + // Format as per FEP-8fcf (similar to HTTP Signatures format). + return sprintf( + 'collectionId="%s", url="%s", digest="%s"', + $collection_id, + $url, + $digest + ); + } + + /** + * Parse the Collection-Synchronization header. + * + * @param string $header The header value. + * + * @return array|false Array with collectionId, url, and digest, or false on failure. + */ + public static function parse_sync_header( $header ) { + if ( empty( $header ) ) { + return false; + } + + // Parse the signature-style format. + $params = array(); + + if ( preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $params[ $match[1] ] = $match[2]; + } + } + + // Validate required fields. + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) { + return false; + } + + return $params; + } + + /** + * Validate Collection-Synchronization header parameters. + * + * @param array $params Parsed header parameters. + * @param string $actor_url The actor URL that sent the activity. + * + * @return bool True if valid, false otherwise. + */ + public static function validate_sync_header_params( $params, $actor_url ) { + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) ) { + return false; + } + + // Parse the actor URL to get the expected followers collection. + $expected_collection = $actor_url . '/followers'; + + // Check if collectionId matches the actor's followers collection. + if ( $params['collectionId'] !== $expected_collection ) { + return false; + } + + // Check if url has the same authority as collectionId (prevent SSRF). + $collection_parsed = wp_parse_url( $params['collectionId'] ); + $url_parsed = wp_parse_url( $params['url'] ); + + if ( ! $collection_parsed || ! $url_parsed ) { + return false; + } + + // Build authorities for comparison. + $collection_authority = $collection_parsed['scheme'] . '://' . $collection_parsed['host']; + $url_authority = $url_parsed['scheme'] . '://' . $url_parsed['host']; + + if ( ! empty( $collection_parsed['port'] ) ) { + $collection_authority .= ':' . $collection_parsed['port']; + } + + if ( ! empty( $url_parsed['port'] ) ) { + $url_authority .= ':' . $url_parsed['port']; + } + + return $collection_authority === $url_authority; + } + + /** + * Get the authority (scheme + host + port) from a URL. + * + * @param string $url The URL to parse. + * + * @return string|false The authority, or false on failure. + */ + public static function get_authority( $url ) { + $parsed = wp_parse_url( $url ); + + if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { + return false; + } + + $authority = $parsed['scheme'] . '://' . $parsed['host']; + + if ( ! empty( $parsed['port'] ) ) { + $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { + $authority .= ':' . $parsed['port']; + } + } + + return $authority; + } } diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index f4891430b..2c31b54a6 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -24,6 +24,7 @@ */ class Actors_Inbox_Controller extends Actors_Controller { use Collection; + use Followers_Sync; /** * Register routes. @@ -178,6 +179,9 @@ public function create_item( $request ) { */ do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity ); } else { + // FEP-8fcf: Process Collection-Synchronization header if present. + $this->process_followers_synchronization( $request, $data, $user_id ); + /** * ActivityPub inbox action. * diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 9e155a429..93a5f010a 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -74,6 +74,34 @@ public function register_routes() { 'schema' => array( $this, 'get_item_schema' ), ) ); + + // FEP-8fcf: Partial followers collection for synchronization. + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/followers-sync', + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the actor.', + 'type' => 'integer', + 'required' => true, + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_partial_followers' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'authority' => array( + 'description' => 'The URI authority to filter followers by.', + 'type' => 'string', + 'required' => true, + ), + ), + ), + ) + ); } /** @@ -131,6 +159,50 @@ function ( $item ) use ( $context ) { return $response; } + /** + * Retrieves partial followers list for FEP-8fcf synchronization. + * + * Returns only followers whose ID shares the specified URI authority. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_partial_followers( $request ) { + $user_id = $request->get_param( 'user_id' ); + $authority = $request->get_param( 'authority' ); + + // Validate authority format. + if ( ! preg_match( '#^https?://[^/]+$#', $authority ) ) { + return new \WP_Error( + 'invalid_authority', + \__( 'Invalid authority format.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Get partial followers filtered by authority. + $partial_followers = Followers::get_partial_followers( $user_id, $authority ); + + $response = array( + '@context' => get_context(), + 'id' => get_rest_url_by_path( + \sprintf( + 'actors/%d/followers-sync?authority=%s', + $user_id, + rawurlencode( $authority ) + ) + ), + 'type' => 'OrderedCollection', + 'totalItems' => count( $partial_followers ), + 'orderedItems' => $partial_followers, + ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + /** * Retrieves the followers schema, conforming to JSON Schema. * diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 9729cc962..59d159e39 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -24,6 +24,8 @@ * @see https://www.w3.org/TR/activitypub/#inbox */ class Inbox_Controller extends \WP_REST_Controller { + use Followers_Sync; + /** * The namespace of this controller's route. * @@ -167,6 +169,9 @@ public function create_item( $request ) { continue; } + // FEP-8fcf: Process Collection-Synchronization header if present. + $this->process_followers_synchronization( $request, $data, $user_id ); + /** * ActivityPub inbox action. * diff --git a/includes/rest/trait-followers-sync.php b/includes/rest/trait-followers-sync.php new file mode 100644 index 000000000..3a46e0071 --- /dev/null +++ b/includes/rest/trait-followers-sync.php @@ -0,0 +1,82 @@ +get_header( 'collection_synchronization' ); + + if ( empty( $sync_header ) ) { + return; + } + + // Parse the header. + $params = Followers::parse_sync_header( $sync_header ); + + if ( false === $params ) { + return; + } + + // Validate the header parameters. + $actor_url = isset( $data['actor'] ) ? $data['actor'] : null; + + if ( ! $actor_url ) { + return; + } + + if ( ! Followers::validate_sync_header_params( $params, $actor_url ) ) { + return; + } + + // Get our local authority. + $our_authority = Followers::get_authority( \home_url() ); + + if ( ! $our_authority ) { + return; + } + + // Compute our local digest for this actor's followers from our instance. + $local_digest = Followers::compute_partial_digest( $user_id, $our_authority ); + + // Compare digests. + if ( $local_digest === $params['digest'] ) { + // Digests match, no synchronization needed. + return; + } + + // Digests do not match, trigger reconciliation. + + /** + * Action triggered when Collection-Synchronization digest mismatch is detected. + * + * This allows for async processing of the reconciliation. + * + * @param int $user_id The local user ID. + * @param string $actor_url The remote actor URL. + * @param array $params The parsed Collection-Synchronization header parameters. + */ + \do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); + } +} diff --git a/includes/scheduler/class-follower.php b/includes/scheduler/class-follower.php new file mode 100644 index 000000000..b4bc5a38b --- /dev/null +++ b/includes/scheduler/class-follower.php @@ -0,0 +1,137 @@ +ID, $user_id ); + + /** + * Action triggered when a follower is removed due to synchronization. + * + * @param int $user_id The local user ID. + * @param string $follower_url The follower URL that was removed. + * @param string $actor_url The remote actor URL. + */ + \do_action( 'activitypub_followers_sync_follower_removed', $user_id, $follower_url, $actor_url ); + } + } + + /* + * For followers in remote but not local, we could send Undo Follow. + * However, this requires careful consideration as the follow may be pending. + * For now, just log these for potential manual review. + */ + foreach ( $to_check as $follower_url ) { + /** + * Action triggered when a follower exists remotely but not locally. + * + * This could indicate: + * - A pending follow request + * - A follow that was lost locally + * - An inconsistency that needs manual review + * + * @param int $user_id The local user ID. + * @param string $follower_url The follower URL. + * @param string $actor_url The remote actor URL. + */ + \do_action( 'activitypub_followers_sync_follower_mismatch', $user_id, $follower_url, $actor_url ); + } + + /** + * Action triggered after reconciliation is complete. + * + * @param int $user_id The local user ID. + * @param string $actor_url The remote actor URL. + * @param array $to_remove Followers that were removed. + * @param array $to_check Followers that need checking. + */ + \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $to_check ); + } +} From 19439c3d5ae28b9e9dd8352183267cb8982cbc46 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 6 Oct 2025 19:03:32 +0200 Subject: [PATCH 02/78] Add FEP-8fcf to supported FEPs in FEDERATION.md Included a reference to FEP-8fcf (Followers collection synchronization across servers) in the list of supported FEPs to reflect updated protocol support. --- FEDERATION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FEDERATION.md b/FEDERATION.md index adcbcb4c3..551c64ba8 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -24,6 +24,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio - [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md) - [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) - [FEP-3b86: Activity Intents](https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md) +- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) Partially supported FEPs From d89ffba852265b7f9344c6389b8fb84cea300295 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 6 Oct 2025 20:46:55 +0200 Subject: [PATCH 03/78] Refactor and generalize collection synchronization logic Moved FEP-8fcf Collection-Synchronization header parsing and validation from Followers class to Http class, and generalized synchronization handling into the Collection trait. Removed the Followers_Sync trait and updated controllers to use the new process_collection_synchronization method. This refactor enables support for synchronizing additional collection types beyond followers and centralizes related logic for easier maintenance. --- includes/class-http.php | 136 +++++++++++++++- includes/collection/class-followers.php | 132 +--------------- .../rest/class-actors-inbox-controller.php | 3 +- includes/rest/class-inbox-controller.php | 4 +- includes/rest/trait-collection.php | 147 ++++++++++++++++++ includes/rest/trait-followers-sync.php | 82 ---------- includes/scheduler/class-follower.php | 2 +- 7 files changed, 287 insertions(+), 219 deletions(-) delete mode 100644 includes/rest/trait-followers-sync.php diff --git a/includes/class-http.php b/includes/class-http.php index 1ae1df056..b97a58671 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -58,7 +58,7 @@ public static function post( $url, $body, $user_id ) { // FEP-8fcf: Add Collection-Synchronization header for Create activities. $activity = \json_decode( $body ); if ( $activity && isset( $activity->type ) && 'Create' === $activity->type ) { - $inbox_authority = Collection\Followers::get_authority( $url ); + $inbox_authority = self::get_authority( $url ); if ( $inbox_authority ) { $sync_header = Collection\Followers::generate_sync_header( $user_id, $inbox_authority ); if ( $sync_header ) { @@ -296,4 +296,138 @@ public static function get_remote_object( $url_or_object, $cached = true ) { return $data; } + + /** + * Parse a Collection-Synchronization header (FEP-8fcf). + * + * Parses the signature-style format used by the Collection-Synchronization header. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param string $header The header value. + * + * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure. + */ + public static function parse_collection_sync_header( $header ) { + if ( empty( $header ) ) { + return false; + } + + // Parse the signature-style format: key="value", key="value". + $params = array(); + + if ( preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $params[ $match[1] ] = $match[2]; + } + } + + // Validate required fields for FEP-8fcf. + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) { + return false; + } + + return $params; + } + + /** + * XOR two hexadecimal strings. + * + * Used for FEP-8fcf digest computation. + * + * @param string $hex1 First hex string. + * @param string $hex2 Second hex string. + * + * @return string The XORed result as a hex string. + */ + public static function xor_hex_strings( $hex1, $hex2 ) { + $result = ''; + + // Ensure both strings are the same length (should be 64 chars for SHA256). + $length = max( strlen( $hex1 ), strlen( $hex2 ) ); + $hex1 = str_pad( $hex1, $length, '0', STR_PAD_LEFT ); + $hex2 = str_pad( $hex2, $length, '0', STR_PAD_LEFT ); + + // XOR each pair of hex digits. + for ( $i = 0; $i < $length; $i += 2 ) { + $byte1 = hexdec( substr( $hex1, $i, 2 ) ); + $byte2 = hexdec( substr( $hex2, $i, 2 ) ); + $result .= str_pad( dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); + } + + return $result; + } + + /** + * Validate Collection-Synchronization header parameters. + * + * @param array $params Parsed header parameters. + * @param string $actor_url The actor URL that sent the activity. + * + * @return bool True if valid, false otherwise. + */ + public static function validate_collection_sync_header_params( $params, $actor_url ) { + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) ) { + return false; + } + + // Parse the actor URL to get the expected followers collection. + $expected_collection = $actor_url . '/followers'; + + // Check if collectionId matches the actor's followers collection. + if ( $params['collectionId'] !== $expected_collection ) { + return false; + } + + // Check if url has the same authority as collectionId (prevent SSRF). + $collection_parsed = wp_parse_url( $params['collectionId'] ); + $url_parsed = wp_parse_url( $params['url'] ); + + if ( ! $collection_parsed || ! $url_parsed ) { + return false; + } + + // Build authorities for comparison. + $collection_authority = $collection_parsed['scheme'] . '://' . $collection_parsed['host']; + $url_authority = $url_parsed['scheme'] . '://' . $url_parsed['host']; + + if ( ! empty( $collection_parsed['port'] ) ) { + $collection_authority .= ':' . $collection_parsed['port']; + } + + if ( ! empty( $url_parsed['port'] ) ) { + $url_authority .= ':' . $url_parsed['port']; + } + + return $collection_authority === $url_authority; + } + + /** + * Get the authority (scheme + host + port) from a URL. + * + * @param string $url The URL to parse. + * + * @return string|false The authority, or false on failure. + */ + public static function get_authority( $url ) { + $parsed = wp_parse_url( $url ); + + if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { + return false; + } + + $authority = $parsed['scheme'] . '://' . $parsed['host']; + + if ( ! empty( $parsed['port'] ) ) { + $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { + $authority .= ':' . $parsed['port']; + } + } + + return $authority; + } } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 06aa8589e..30e525488 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -511,7 +511,7 @@ public static function compute_partial_digest( $user_id, $authority ) { $hash = hash( 'sha256', $follower_url ); // XOR the hash with the running digest. - $digest = self::xor_hex_strings( $digest, $hash ); + $digest = \Activitypub\Http::xor_hex_strings( $digest, $hash ); } return $digest; @@ -577,34 +577,6 @@ public static function get_partial_followers( $user_id, $authority ) { return $partial_followers; } - /** - * XOR two hexadecimal strings. - * - * Used for FEP-8fcf digest computation. - * - * @param string $hex1 First hex string. - * @param string $hex2 Second hex string. - * - * @return string The XORed result as a hex string. - */ - private static function xor_hex_strings( $hex1, $hex2 ) { - $result = ''; - - // Ensure both strings are the same length (should be 64 chars for SHA256). - $length = max( strlen( $hex1 ), strlen( $hex2 ) ); - $hex1 = str_pad( $hex1, $length, '0', STR_PAD_LEFT ); - $hex2 = str_pad( $hex2, $length, '0', STR_PAD_LEFT ); - - // XOR each pair of hex digits. - for ( $i = 0; $i < $length; $i += 2 ) { - $byte1 = hexdec( substr( $hex1, $i, 2 ) ); - $byte2 = hexdec( substr( $hex2, $i, 2 ) ); - $result .= str_pad( dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); - } - - return $result; - } - /** * Generate the Collection-Synchronization header value for FEP-8fcf. * @@ -641,106 +613,4 @@ public static function generate_sync_header( $user_id, $authority ) { $digest ); } - - /** - * Parse the Collection-Synchronization header. - * - * @param string $header The header value. - * - * @return array|false Array with collectionId, url, and digest, or false on failure. - */ - public static function parse_sync_header( $header ) { - if ( empty( $header ) ) { - return false; - } - - // Parse the signature-style format. - $params = array(); - - if ( preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { - foreach ( $matches as $match ) { - $params[ $match[1] ] = $match[2]; - } - } - - // Validate required fields. - if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) { - return false; - } - - return $params; - } - - /** - * Validate Collection-Synchronization header parameters. - * - * @param array $params Parsed header parameters. - * @param string $actor_url The actor URL that sent the activity. - * - * @return bool True if valid, false otherwise. - */ - public static function validate_sync_header_params( $params, $actor_url ) { - if ( empty( $params['collectionId'] ) || empty( $params['url'] ) ) { - return false; - } - - // Parse the actor URL to get the expected followers collection. - $expected_collection = $actor_url . '/followers'; - - // Check if collectionId matches the actor's followers collection. - if ( $params['collectionId'] !== $expected_collection ) { - return false; - } - - // Check if url has the same authority as collectionId (prevent SSRF). - $collection_parsed = wp_parse_url( $params['collectionId'] ); - $url_parsed = wp_parse_url( $params['url'] ); - - if ( ! $collection_parsed || ! $url_parsed ) { - return false; - } - - // Build authorities for comparison. - $collection_authority = $collection_parsed['scheme'] . '://' . $collection_parsed['host']; - $url_authority = $url_parsed['scheme'] . '://' . $url_parsed['host']; - - if ( ! empty( $collection_parsed['port'] ) ) { - $collection_authority .= ':' . $collection_parsed['port']; - } - - if ( ! empty( $url_parsed['port'] ) ) { - $url_authority .= ':' . $url_parsed['port']; - } - - return $collection_authority === $url_authority; - } - - /** - * Get the authority (scheme + host + port) from a URL. - * - * @param string $url The URL to parse. - * - * @return string|false The authority, or false on failure. - */ - public static function get_authority( $url ) { - $parsed = wp_parse_url( $url ); - - if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { - return false; - } - - $authority = $parsed['scheme'] . '://' . $parsed['host']; - - if ( ! empty( $parsed['port'] ) ) { - $default_ports = array( - 'http' => 80, - 'https' => 443, - ); - if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { - $authority .= ':' . $parsed['port']; - } - } - - return $authority; - } } diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 2c31b54a6..13ec49009 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -24,7 +24,6 @@ */ class Actors_Inbox_Controller extends Actors_Controller { use Collection; - use Followers_Sync; /** * Register routes. @@ -180,7 +179,7 @@ public function create_item( $request ) { do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity ); } else { // FEP-8fcf: Process Collection-Synchronization header if present. - $this->process_followers_synchronization( $request, $data, $user_id ); + $this->process_collection_synchronization( $request, $data, $user_id ); /** * ActivityPub inbox action. diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 59d159e39..2266de8c2 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -24,7 +24,7 @@ * @see https://www.w3.org/TR/activitypub/#inbox */ class Inbox_Controller extends \WP_REST_Controller { - use Followers_Sync; + use Collection; /** * The namespace of this controller's route. @@ -170,7 +170,7 @@ public function create_item( $request ) { } // FEP-8fcf: Process Collection-Synchronization header if present. - $this->process_followers_synchronization( $request, $data, $user_id ); + $this->process_collection_synchronization( $request, $data, $user_id ); /** * ActivityPub inbox action. diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index e9e3f47aa..866492888 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -7,6 +7,9 @@ namespace Activitypub\Rest; +use Activitypub\Collection\Followers; +use Activitypub\Http; + /** * Collection Trait. * @@ -142,4 +145,148 @@ public function get_collection_schema( $item_schema = array() ) { return $collection_schema; } + + /** + * Process Collection-Synchronization header if present (FEP-8fcf). + * + * This method handles the FEP-8fcf Collection Synchronization protocol for any collection type. + * It detects the collection type from the URL and delegates to the appropriate handler. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param \WP_REST_Request $request The request object. + * @param array $data The activity data. + * @param int $user_id The local user ID receiving the activity. + */ + protected function process_collection_synchronization( $request, $data, $user_id ) { + // Get the Collection-Synchronization header. + $sync_header = $request->get_header( 'collection_synchronization' ); + + if ( empty( $sync_header ) ) { + return; + } + + // Parse the header using the generic HTTP parser. + $params = \Activitypub\Http::parse_collection_sync_header( $sync_header ); + + if ( false === $params ) { + return; + } + + // Ensure we have a URL parameter to determine collection type. + if ( ! isset( $params['url'] ) ) { + return; + } + + // Determine the collection type from the URL. + $collection_type = $this->detect_collection_type( $params['url'] ); + + if ( ! $collection_type ) { + // Unknown or unsupported collection type. + return; + } + + // Get the actor URL for validation. + $actor_url = isset( $data['actor'] ) ? $data['actor'] : null; + + if ( ! $actor_url ) { + return; + } + + /** + * Filters whether collection synchronization should be processed for a specific collection type. + * + * Allows collection handlers to implement their own synchronization logic. + * Return true to indicate that synchronization was handled, false to skip. + * + * @param bool $handled Whether the synchronization was handled. + * @param string $type The collection type (e.g., 'followers', 'following', 'liked'). + * @param array $params The parsed Collection-Synchronization header parameters. + * @param int $user_id The local user ID. + * @param string $actor The remote actor URL. + * @param \WP_REST_Request $request The request object. + * @param array $data The activity data. + */ + $handled = \apply_filters( + 'activitypub_collection_synchronization', + false, + $collection_type, + $params, + $user_id, + $actor_url, + $request, + $data + ); + + // If no handler processed it, use the default followers handler. + if ( ! $handled && 'followers' === $collection_type ) { + $this->process_followers_collection_sync( $params, $user_id, $actor_url ); + } + } + + /** + * Detect the collection type from a URL. + * + * @param string $url The collection URL. + * @return string|false The collection type (e.g., 'followers', 'following', 'liked') or false if unknown. + */ + protected function detect_collection_type( $url ) { + // Check for followers collection. + if ( preg_match( '#/followers(?:-sync)?(?:\?|$)#', $url ) ) { + return 'followers'; + } + + /** + * Filters the collection type detection. + * + * Allows plugins to register custom collection types for synchronization. + * + * @param string|false $type The detected collection type, or false if unknown. + * @param string $url The collection URL. + */ + return \apply_filters( 'activitypub_detect_collection_type', false, $url ); + } + + /** + * Process followers collection synchronization. + * + * @param array $params The parsed Collection-Synchronization header parameters. + * @param int $user_id The local user ID. + * @param string $actor_url The remote actor URL. + */ + protected function process_followers_collection_sync( $params, $user_id, $actor_url ) { + // Validate the header parameters. + if ( ! Http::validate_collection_sync_header_params( $params, $actor_url ) ) { + return; + } + + // Get our local authority. + $our_authority = Http::get_authority( \home_url() ); + + if ( ! $our_authority ) { + return; + } + + // Compute our local digest for this actor's followers from our instance. + $local_digest = Followers::compute_partial_digest( $user_id, $our_authority ); + + // Compare digests. + if ( $local_digest === $params['digest'] ) { + // Digests match, no synchronization needed. + return; + } + + // Digests do not match, trigger reconciliation. + + /** + * Action triggered when Collection-Synchronization digest mismatch is detected for followers. + * + * This allows for async processing of the reconciliation. + * + * @param int $user_id The local user ID. + * @param string $actor_url The remote actor URL. + * @param array $params The parsed Collection-Synchronization header parameters. + */ + \do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); + } } diff --git a/includes/rest/trait-followers-sync.php b/includes/rest/trait-followers-sync.php deleted file mode 100644 index 3a46e0071..000000000 --- a/includes/rest/trait-followers-sync.php +++ /dev/null @@ -1,82 +0,0 @@ -get_header( 'collection_synchronization' ); - - if ( empty( $sync_header ) ) { - return; - } - - // Parse the header. - $params = Followers::parse_sync_header( $sync_header ); - - if ( false === $params ) { - return; - } - - // Validate the header parameters. - $actor_url = isset( $data['actor'] ) ? $data['actor'] : null; - - if ( ! $actor_url ) { - return; - } - - if ( ! Followers::validate_sync_header_params( $params, $actor_url ) ) { - return; - } - - // Get our local authority. - $our_authority = Followers::get_authority( \home_url() ); - - if ( ! $our_authority ) { - return; - } - - // Compute our local digest for this actor's followers from our instance. - $local_digest = Followers::compute_partial_digest( $user_id, $our_authority ); - - // Compare digests. - if ( $local_digest === $params['digest'] ) { - // Digests match, no synchronization needed. - return; - } - - // Digests do not match, trigger reconciliation. - - /** - * Action triggered when Collection-Synchronization digest mismatch is detected. - * - * This allows for async processing of the reconciliation. - * - * @param int $user_id The local user ID. - * @param string $actor_url The remote actor URL. - * @param array $params The parsed Collection-Synchronization header parameters. - */ - \do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); - } -} diff --git a/includes/scheduler/class-follower.php b/includes/scheduler/class-follower.php index b4bc5a38b..a4bc107b5 100644 --- a/includes/scheduler/class-follower.php +++ b/includes/scheduler/class-follower.php @@ -70,7 +70,7 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { $remote_followers = $data['orderedItems']; // Get our authority. - $our_authority = Followers::get_authority( \home_url() ); + $our_authority = Http::get_authority( \home_url() ); if ( ! $our_authority ) { return; From d02675a34663e07c340801285cafc7be113cb393 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 8 Oct 2025 14:02:07 +0200 Subject: [PATCH 04/78] Update followers sync endpoint path to /followers/sync Changed the REST endpoint path from /followers-sync to /followers/sync in documentation, URL generation, and route registration to ensure consistency and clarity in the API. --- docs/fep-8fcf-implementation.md | 4 ++-- includes/collection/class-followers.php | 2 +- includes/rest/class-followers-controller.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/fep-8fcf-implementation.md b/docs/fep-8fcf-implementation.md index cc1e984db..3cb82cde0 100644 --- a/docs/fep-8fcf-implementation.md +++ b/docs/fep-8fcf-implementation.md @@ -20,7 +20,7 @@ This is implemented in `includes/class-http.php`. ### 2. Partial Followers Collection -A new REST endpoint `/actors/{user_id}/followers-sync` provides partial followers collections filtered by instance authority. This endpoint only returns followers whose IDs match the requesting instance's domain. +A new REST endpoint `/actors/{user_id}/followers/sync` provides partial followers collections filtered by instance authority. This endpoint only returns followers whose IDs match the requesting instance's domain. This is implemented in `includes/rest/class-followers-controller.php`. @@ -71,7 +71,7 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. ### Modified Classes - **`Http`** - Adds Collection-Synchronization header to outgoing Create activities -- **`Followers_Controller`** - Adds `/followers-sync` endpoint for partial collections +- **`Followers_Controller`** - Adds `/followers/sync` endpoint for partial collections - **`Inbox_Controller`** - Uses `Followers_Sync` trait to process incoming headers - **`Actors_Inbox_Controller`** - Uses `Followers_Sync` trait to process incoming headers - **`Scheduler`** - Registers the Collection_Sync scheduler diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 30e525488..43cd7d5de 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -599,7 +599,7 @@ public static function generate_sync_header( $user_id, $authority ) { // Build the partial followers URL. $url = \Activitypub\get_rest_url_by_path( sprintf( - 'actors/%d/followers-sync?authority=%s', + 'actors/%d/followers/sync?authority=%s', $user_id, rawurlencode( $authority ) ) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 6c58f8ff9..35a2c2d71 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -79,7 +79,7 @@ public function register_routes() { // FEP-8fcf: Partial followers collection for synchronization. \register_rest_route( $this->namespace, - '/' . $this->rest_base . '/followers-sync', + '/' . $this->rest_base . '/followers/sync', array( 'args' => array( 'user_id' => array( @@ -191,7 +191,7 @@ public function get_partial_followers( $request ) { '@context' => get_context(), 'id' => get_rest_url_by_path( \sprintf( - 'actors/%d/followers-sync?authority=%s', + 'actors/%d/followers/sync?authority=%s', $user_id, rawurlencode( $authority ) ) From adadf02d823140705bbf62b7d4ea5d87a45a9de3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 12:05:04 +0200 Subject: [PATCH 05/78] Remove unnecessary class name prefixes in method calls Refactored code to remove redundant '\Activitypub\' class name prefixes when calling static methods within the same namespace. This improves code readability and consistency. --- includes/collection/class-followers.php | 8 +++++--- includes/collection/class-remote-actors.php | 2 +- includes/rest/trait-collection.php | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 43cd7d5de..2dcfbbe22 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -7,9 +7,11 @@ namespace Activitypub\Collection; +use Activitypub\Http; use Activitypub\Tombstone; use function Activitypub\get_remote_metadata_by_actor; +use function Activitypub\get_rest_url_by_path; /** * ActivityPub Followers Collection. @@ -511,7 +513,7 @@ public static function compute_partial_digest( $user_id, $authority ) { $hash = hash( 'sha256', $follower_url ); // XOR the hash with the running digest. - $digest = \Activitypub\Http::xor_hex_strings( $digest, $hash ); + $digest = Http::xor_hex_strings( $digest, $hash ); } return $digest; @@ -594,10 +596,10 @@ public static function generate_sync_header( $user_id, $authority ) { } // Build the collection ID (followers collection URL). - $collection_id = \Activitypub\get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) ); + $collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) ); // Build the partial followers URL. - $url = \Activitypub\get_rest_url_by_path( + $url = get_rest_url_by_path( sprintf( 'actors/%d/followers/sync?authority=%s', $user_id, diff --git a/includes/collection/class-remote-actors.php b/includes/collection/class-remote-actors.php index 4d4e823e9..09441ce0f 100644 --- a/includes/collection/class-remote-actors.php +++ b/includes/collection/class-remote-actors.php @@ -515,7 +515,7 @@ public static function normalize_identifier( $actor ) { // If it's an email-like webfinger address, resolve it. if ( \filter_var( $actor, FILTER_VALIDATE_EMAIL ) ) { - $resolved = \Activitypub\Webfinger::resolve( $actor ); + $resolved = Webfinger::resolve( $actor ); return \is_wp_error( $resolved ) ? null : object_to_uri( $resolved ); } diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index be5834a1b..53f85f649 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -169,7 +169,7 @@ protected function process_collection_synchronization( $request, $data, $user_id } // Parse the header using the generic HTTP parser. - $params = \Activitypub\Http::parse_collection_sync_header( $sync_header ); + $params = Http::parse_collection_sync_header( $sync_header ); if ( false === $params ) { return; From 35301c50277913ec3d6f729b7fff7f5bc0df9009 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 13 Oct 2025 19:58:55 +0200 Subject: [PATCH 06/78] Refactor Collection-Synchronization handling to new handler Moved Collection-Synchronization (FEP-8fcf) logic from REST controllers and trait to a dedicated handler class (Collection_Sync). Removed related code from trait-collection.php, class-http.php, and REST controllers, and registered the new handler in class-handler.php. This improves separation of concerns and centralizes collection sync logic. --- includes/class-handler.php | 2 + includes/class-http.php | 44 ---- includes/handler/class-collection-sync.php | 221 ++++++++++++++++++ .../rest/class-actors-inbox-controller.php | 3 - includes/rest/class-followers-controller.php | 1 - includes/rest/class-inbox-controller.php | 3 - includes/rest/trait-collection.php | 147 ------------ 7 files changed, 223 insertions(+), 198 deletions(-) create mode 100644 includes/handler/class-collection-sync.php diff --git a/includes/class-handler.php b/includes/class-handler.php index 2ef9250fd..4db0baad3 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -9,6 +9,7 @@ use Activitypub\Handler\Accept; use Activitypub\Handler\Announce; +use Activitypub\Handler\Collection_Sync; use Activitypub\Handler\Create; use Activitypub\Handler\Delete; use Activitypub\Handler\Follow; @@ -37,6 +38,7 @@ public static function init() { public static function register_handlers() { Accept::init(); Announce::init(); + Collection_Sync::init(); Create::init(); Delete::init(); Follow::init(); diff --git a/includes/class-http.php b/includes/class-http.php index b97a58671..a19cc67af 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -358,50 +358,6 @@ public static function xor_hex_strings( $hex1, $hex2 ) { return $result; } - /** - * Validate Collection-Synchronization header parameters. - * - * @param array $params Parsed header parameters. - * @param string $actor_url The actor URL that sent the activity. - * - * @return bool True if valid, false otherwise. - */ - public static function validate_collection_sync_header_params( $params, $actor_url ) { - if ( empty( $params['collectionId'] ) || empty( $params['url'] ) ) { - return false; - } - - // Parse the actor URL to get the expected followers collection. - $expected_collection = $actor_url . '/followers'; - - // Check if collectionId matches the actor's followers collection. - if ( $params['collectionId'] !== $expected_collection ) { - return false; - } - - // Check if url has the same authority as collectionId (prevent SSRF). - $collection_parsed = wp_parse_url( $params['collectionId'] ); - $url_parsed = wp_parse_url( $params['url'] ); - - if ( ! $collection_parsed || ! $url_parsed ) { - return false; - } - - // Build authorities for comparison. - $collection_authority = $collection_parsed['scheme'] . '://' . $collection_parsed['host']; - $url_authority = $url_parsed['scheme'] . '://' . $url_parsed['host']; - - if ( ! empty( $collection_parsed['port'] ) ) { - $collection_authority .= ':' . $collection_parsed['port']; - } - - if ( ! empty( $url_parsed['port'] ) ) { - $url_authority .= ':' . $url_parsed['port']; - } - - return $collection_authority === $url_authority; - } - /** * Get the authority (scheme + host + port) from a URL. * diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php new file mode 100644 index 000000000..31269a97f --- /dev/null +++ b/includes/handler/class-collection-sync.php @@ -0,0 +1,221 @@ + 80, + 'https' => 443, + ); + if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { + $authority .= ':' . $parsed['port']; + } + } + + return $authority; + } +} diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php index 038daafdd..baa584834 100644 --- a/includes/rest/class-actors-inbox-controller.php +++ b/includes/rest/class-actors-inbox-controller.php @@ -176,9 +176,6 @@ public function create_item( $request ) { */ do_action( 'activitypub_rest_inbox_disallowed', $data, $user_id, $type, $activity ); } else { - // FEP-8fcf: Process Collection-Synchronization header if present. - $this->process_collection_synchronization( $request, $data, $user_id ); - /** * ActivityPub inbox action. * diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index d8870b668..5d87b7b60 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -192,7 +192,6 @@ public function get_partial_followers( $request ) { $partial_followers = Followers::get_partial_followers( $user_id, $authority ); $response = array( - '@context' => get_context(), 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/followers/sync?authority=%s', diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 2266de8c2..21ca77dc4 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -169,9 +169,6 @@ public function create_item( $request ) { continue; } - // FEP-8fcf: Process Collection-Synchronization header if present. - $this->process_collection_synchronization( $request, $data, $user_id ); - /** * ActivityPub inbox action. * diff --git a/includes/rest/trait-collection.php b/includes/rest/trait-collection.php index f96457312..27c0b2742 100644 --- a/includes/rest/trait-collection.php +++ b/includes/rest/trait-collection.php @@ -7,9 +7,6 @@ namespace Activitypub\Rest; -use Activitypub\Collection\Followers; -use Activitypub\Http; - /** * Collection Trait. * @@ -162,148 +159,4 @@ public function get_collection_schema( $item_schema = array() ) { return $collection_schema; } - - /** - * Process Collection-Synchronization header if present (FEP-8fcf). - * - * This method handles the FEP-8fcf Collection Synchronization protocol for any collection type. - * It detects the collection type from the URL and delegates to the appropriate handler. - * - * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md - * - * @param \WP_REST_Request $request The request object. - * @param array $data The activity data. - * @param int $user_id The local user ID receiving the activity. - */ - protected function process_collection_synchronization( $request, $data, $user_id ) { - // Get the Collection-Synchronization header. - $sync_header = $request->get_header( 'collection_synchronization' ); - - if ( empty( $sync_header ) ) { - return; - } - - // Parse the header using the generic HTTP parser. - $params = Http::parse_collection_sync_header( $sync_header ); - - if ( false === $params ) { - return; - } - - // Ensure we have a URL parameter to determine collection type. - if ( ! isset( $params['url'] ) ) { - return; - } - - // Determine the collection type from the URL. - $collection_type = $this->detect_collection_type( $params['url'] ); - - if ( ! $collection_type ) { - // Unknown or unsupported collection type. - return; - } - - // Get the actor URL for validation. - $actor_url = isset( $data['actor'] ) ? $data['actor'] : null; - - if ( ! $actor_url ) { - return; - } - - /** - * Filters whether collection synchronization should be processed for a specific collection type. - * - * Allows collection handlers to implement their own synchronization logic. - * Return true to indicate that synchronization was handled, false to skip. - * - * @param bool $handled Whether the synchronization was handled. - * @param string $type The collection type (e.g., 'followers', 'following', 'liked'). - * @param array $params The parsed Collection-Synchronization header parameters. - * @param int $user_id The local user ID. - * @param string $actor The remote actor URL. - * @param \WP_REST_Request $request The request object. - * @param array $data The activity data. - */ - $handled = \apply_filters( - 'activitypub_collection_synchronization', - false, - $collection_type, - $params, - $user_id, - $actor_url, - $request, - $data - ); - - // If no handler processed it, use the default followers handler. - if ( ! $handled && 'followers' === $collection_type ) { - $this->process_followers_collection_sync( $params, $user_id, $actor_url ); - } - } - - /** - * Detect the collection type from a URL. - * - * @param string $url The collection URL. - * @return string|false The collection type (e.g., 'followers', 'following', 'liked') or false if unknown. - */ - protected function detect_collection_type( $url ) { - // Check for followers collection. - if ( preg_match( '#/followers(?:-sync)?(?:\?|$)#', $url ) ) { - return 'followers'; - } - - /** - * Filters the collection type detection. - * - * Allows plugins to register custom collection types for synchronization. - * - * @param string|false $type The detected collection type, or false if unknown. - * @param string $url The collection URL. - */ - return \apply_filters( 'activitypub_detect_collection_type', false, $url ); - } - - /** - * Process followers collection synchronization. - * - * @param array $params The parsed Collection-Synchronization header parameters. - * @param int $user_id The local user ID. - * @param string $actor_url The remote actor URL. - */ - protected function process_followers_collection_sync( $params, $user_id, $actor_url ) { - // Validate the header parameters. - if ( ! Http::validate_collection_sync_header_params( $params, $actor_url ) ) { - return; - } - - // Get our local authority. - $our_authority = Http::get_authority( \home_url() ); - - if ( ! $our_authority ) { - return; - } - - // Compute our local digest for this actor's followers from our instance. - $local_digest = Followers::compute_partial_digest( $user_id, $our_authority ); - - // Compare digests. - if ( $local_digest === $params['digest'] ) { - // Digests match, no synchronization needed. - return; - } - - // Digests do not match, trigger reconciliation. - - /** - * Action triggered when Collection-Synchronization digest mismatch is detected for followers. - * - * This allows for async processing of the reconciliation. - * - * @param int $user_id The local user ID. - * @param string $actor_url The remote actor URL. - * @param array $params The parsed Collection-Synchronization header parameters. - */ - \do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); - } } From 81eacf9000c3729ff75bf21927ae1b0aad6c6070 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 13 Oct 2025 20:43:42 +0200 Subject: [PATCH 07/78] Add pagination and ordering to followers sync endpoint Introduces 'page', 'per_page', and 'order' parameters to the followers sync REST endpoint for improved pagination and sorting. Updates the controller to handle these parameters and enhances test coverage with extensive e2e tests for pagination, authority filtering, error handling, and response consistency. Also updates Playwright config to ensure proper rewrite structure for test environment. --- includes/rest/class-followers-controller.php | 24 ++ .../rest/followers-controller.test.js | 276 ++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 5d87b7b60..04c53776e 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -99,6 +99,24 @@ public function register_routes() { 'type' => 'string', 'required' => true, ), + 'page' => array( + 'description' => 'Current page of the collection.', + 'type' => 'integer', + 'minimum' => 1, + // No default so we can differentiate between Collection and CollectionPage requests. + ), + 'per_page' => array( + 'description' => 'Maximum number of items to be returned in result set.', + 'type' => 'integer', + 'default' => 20, + 'minimum' => 1, + ), + 'order' => array( + 'description' => 'Order sort attribute ascending or descending.', + 'type' => 'string', + 'default' => 'desc', + 'enum' => array( 'asc', 'desc' ), + ), ), ), ) @@ -204,6 +222,12 @@ public function get_partial_followers( $request ) { 'orderedItems' => $partial_followers, ); + $response = $this->prepare_collection_response( $response, $request ); + + if ( \is_wp_error( $response ) ) { + return $response; + } + $response = \rest_ensure_response( $response ); $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); diff --git a/tests/e2e/specs/includes/rest/followers-controller.test.js b/tests/e2e/specs/includes/rest/followers-controller.test.js index bb80fd553..3655c23ce 100644 --- a/tests/e2e/specs/includes/rest/followers-controller.test.js +++ b/tests/e2e/specs/includes/rest/followers-controller.test.js @@ -125,4 +125,280 @@ test.describe( 'ActivityPub Followers Endpoint', () => { // Verify proper typing expect( data.type ).toBe( 'OrderedCollection' ); } ); + + test.describe( 'Followers Collection Endpoint', () => { + test( 'should return Collection-Synchronization header on followers collection request', async ( { + requestUtils, + } ) => { + await requestUtils.setupRest(); + + try { + // Request followers collection with proper headers + const response = await requestUtils.rest( { + path: '/activitypub/1.0/actors/1/followers', + } ); + + // Check if response has expected structure + expect( response ).toHaveProperty( '@context' ); + expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( response ).toHaveProperty( 'id' ); + } catch ( error ) { + // Log error for debugging + console.error( 'Followers collection request failed:', error.message ); + throw error; + } + } ); + + test( 'should include proper pagination links in followers collection', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + try { + const response = await requestUtils.rest( { + path: '/activitypub/1.0/actors/1/followers', + } ); + + // Collection should have first and last links + expect( response ).toHaveProperty( 'id' ); + expect( response.id ).toContain( '/activitypub/1.0/actors/1/followers' ); + } catch ( error ) { + console.error( 'Pagination test failed:', error.message ); + throw error; + } + } ); + } ); + + test.describe( 'Partial Followers Sync Endpoint', () => { + test( 'should accept authority parameter for partial followers', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + } ); + + // Should return an OrderedCollection + expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( response ).toHaveProperty( 'orderedItems' ); + + // Verify the collection ID includes the authority parameter + expect( response.id ).toContain( 'authority=' ); + expect( response.id ).toContain( encodeURIComponent( testAuthority ) ); + + // orderedItems should be an array + expect( Array.isArray( response.orderedItems ) ).toBe( true ); + } ); + + test( 'should reject invalid authority format', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + // Test with invalid authority (no protocol) + const invalidAuthority = 'example.com'; + + try { + await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ invalidAuthority }`, + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 400 Bad Request for invalid authority (or 404 if endpoint doesn't exist) + expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); + } + } ); + + test( 'should require authority parameter', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + try { + await requestUtils.rest( { + path: '/activitypub/1.0/actors/1/followers/sync', + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 400 when authority is missing (or 404 if endpoint doesn't exist) + expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); + } + } ); + + test( 'should return empty collection for authority with no followers', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + // Use an authority that definitely has no followers + const testAuthority = 'https://non-existent-instance.test'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + } ); + + expect( response.type ).toBe( 'OrderedCollection' ); + expect( response.totalItems ).toBe( 0 ); + expect( response.orderedItems ).toEqual( [] ); + } ); + } ); + + test.describe( 'Collection Response Format', () => { + test( 'should return valid ActivityStreams OrderedCollection', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://mastodon.social'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + } ); + + // Validate ActivityStreams OrderedCollection structure + expect( response ).toHaveProperty( '@context' ); + expect( response ).toHaveProperty( 'id' ); + expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( typeof response.totalItems ).toBe( 'number' ); + expect( response ).toHaveProperty( 'orderedItems' ); + expect( Array.isArray( response.orderedItems ) ).toBe( true ); + } ); + + test( 'should return proper Content-Type header', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + } ); + + // If we got data back, the content type was acceptable + expect( response ).toBeDefined(); + expect( response ).toHaveProperty( 'type' ); + } ); + } ); + + test.describe( 'Multiple Authorities', () => { + test( 'should handle different authority formats correctly', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const authorities = [ + 'https://mastodon.social', + 'https://mastodon.social:443', + 'http://localhost:3000', + 'https://subdomain.example.com', + ]; + + for ( const authority of authorities ) { + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority ) }`, + } ); + + expect( response.type ).toBe( 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( Array.isArray( response.orderedItems ) ).toBe( true ); + } + } ); + } ); + + test.describe( 'Error Handling', () => { + test( 'should return 404 for non-existent actor', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + try { + await requestUtils.rest( { + path: `/activitypub/1.0/actors/99999/followers/sync?authority=${ encodeURIComponent( + testAuthority + ) }`, + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 404 or 400 for non-existent user + expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); + } + } ); + + test( 'should handle malformed authority gracefully', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const malformedAuthorities = [ + 'not-a-url', + 'ftp://invalid-protocol.com', + 'https://', + '://no-protocol.com', + ]; + + for ( const authority of malformedAuthorities ) { + try { + await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority ) }`, + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 400 for invalid authority format + expect( error.status || error.code ).toBe( 400 ); + } + } + } ); + } ); + + test.describe( 'Response Consistency', () => { + test( 'should return consistent results for same authority', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + // Make two requests to the same endpoint + const response1 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + } ); + + const response2 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + } ); + + // Results should be consistent + expect( response1.totalItems ).toBe( response2.totalItems ); + expect( response1.orderedItems ).toEqual( response2.orderedItems ); + } ); + + test( 'should filter followers correctly by authority', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const authority1 = 'https://mastodon.social'; + const authority2 = 'https://pixelfed.social'; + + const response1 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority1 ) }`, + } ); + + const response2 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority2 ) }`, + } ); + + // Both should return valid collections (even if empty) + expect( response1.type ).toBe( 'OrderedCollection' ); + expect( response2.type ).toBe( 'OrderedCollection' ); + + // Each should have their own totalItems count + expect( typeof response1.totalItems ).toBe( 'number' ); + expect( typeof response2.totalItems ).toBe( 'number' ); + + // If there are followers, they should all match the authority + if ( response1.orderedItems.length > 0 ) { + response1.orderedItems.forEach( ( follower ) => { + expect( typeof follower ).toBe( 'string' ); + expect( follower ).toContain( 'https://' ); + } ); + } + + if ( response2.orderedItems.length > 0 ) { + response2.orderedItems.forEach( ( follower ) => { + expect( typeof follower ).toBe( 'string' ); + expect( follower ).toContain( 'https://' ); + } ); + } + } ); + } ); } ); From bff1ba6382bc348f9f5d5d8e96e7e60ac49d41b9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 13 Oct 2025 20:45:02 +0200 Subject: [PATCH 08/78] Remove unused get_authority method Deleted the static get_authority method from Collection_Sync as it is no longer used in the codebase. --- includes/handler/class-collection-sync.php | 29 ---------------------- 1 file changed, 29 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 31269a97f..4774ca1ba 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -189,33 +189,4 @@ public static function validate_collection_sync_header_params( $params, $actor_u return $collection_authority === $url_authority; } - - /** - * Get the authority (scheme + host + port) from a URL. - * - * @param string $url The URL to parse. - * - * @return string|false The authority, or false on failure. - */ - public static function get_authority( $url ) { - $parsed = wp_parse_url( $url ); - - if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { - return false; - } - - $authority = $parsed['scheme'] . '://' . $parsed['host']; - - if ( ! empty( $parsed['port'] ) ) { - $default_ports = array( - 'http' => 80, - 'https' => 443, - ); - if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { - $authority .= ':' . $parsed['port']; - } - } - - return $authority; - } } From 7a78346691717c3cf4d44c47943a9c09419e02c4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 13 Oct 2025 20:48:47 +0200 Subject: [PATCH 09/78] Fix followers collection type detection regex Corrects the regular expression in detect_collection_type to match '/followers/sync' instead of '/followers-sync', ensuring accurate detection of followers collections. --- includes/handler/class-collection-sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 4774ca1ba..a97e9c475 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -88,7 +88,7 @@ public static function handle_collection_synchronization( $data, $user_id ) { */ protected static function detect_collection_type( $url ) { // Check for followers collection. - if ( preg_match( '#/followers(?:-sync)?(?:\?|$)#', $url ) ) { + if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $url ) ) { return 'followers'; } From 778642383cd24bbeff97a005c534361ad5a0b532 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 13 Oct 2025 21:01:17 +0200 Subject: [PATCH 10/78] Update Playwright webServer command for rewrite structure The webServer command now runs an additional command to set the WordPress permalink structure to '/postname' after starting the environment. This ensures tests run with the correct permalink settings. --- tests/e2e/playwright.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index fd367b749..5609f6650 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -18,7 +18,7 @@ const config = defineConfig( { testDir: './specs', webServer: { ...baseConfig.webServer, - command: 'npm run env-start', + command: "npm run env-start && npm wp-env run tests-cli -- wp rewrite structure '/postname'", }, } ); From 1cc3217e9399b094544f67a5b8094897a1316403 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 13 Oct 2025 21:08:35 +0200 Subject: [PATCH 11/78] Flush rewrite rules in Playwright webServer command Added a 'wp rewrite flush' command to the Playwright webServer setup to ensure rewrite rules are properly applied before running tests. --- tests/e2e/playwright.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index 5609f6650..415bae482 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -18,7 +18,8 @@ const config = defineConfig( { testDir: './specs', webServer: { ...baseConfig.webServer, - command: "npm run env-start && npm wp-env run tests-cli -- wp rewrite structure '/postname'", + command: + "npm run env-start && npm wp-env run tests-cli -- wp rewrite structure '/postname' && npm wp-env run tests-cli -- wp rewrite flush", }, } ); From ced40ba7d9c8480693c91d5bf7c607e0f6b24de5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 11:21:44 +0200 Subject: [PATCH 12/78] Replace FEP-8fcf implementation doc with collection sync doc Removed the old 'fep-8fcf-implementation.md' and added a new, more comprehensive 'collection-synchronization.md' documentation. The new doc provides detailed information on the FEP-8fcf prototype, including implementation details, REST endpoints, privacy considerations, action hooks, compatibility, testing, and future enhancements. --- docs/collection-synchronization.md | 215 +++++++++++++++++++++++++++++ docs/fep-8fcf-implementation.md | 139 ------------------- 2 files changed, 215 insertions(+), 139 deletions(-) create mode 100644 docs/collection-synchronization.md delete mode 100644 docs/fep-8fcf-implementation.md diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md new file mode 100644 index 000000000..63969aa6e --- /dev/null +++ b/docs/collection-synchronization.md @@ -0,0 +1,215 @@ +# Collection Synchronization + +This is a prototype implementation of [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md). + +## Overview + +FEP-8fcf provides a mechanism for detecting and resolving discrepancies in follow relationships between ActivityPub instances. This helps ensure that follower lists stay synchronized even when there are software bugs, server crashes, or database rollbacks. + +## How It Works + +### 1. Outgoing Activities + +When sending Create activities to followers, the plugin automatically adds a `Collection-Synchronization` HTTP header that includes: + +- `collectionId`: The sender's followers collection URI +- `url`: URL to fetch the partial followers collection for that specific instance (e.g., `/actors/{id}/followers/sync?authority=https://example.com`) +- `digest`: A cryptographic digest (XOR'd SHA256 hashes) of followers from the receiving instance + +The header is added during HTTP delivery in `Http::post()` when sending to inboxes. + +This is implemented in `includes/class-http.php`. + +### 2. Partial Followers Collection + +A new REST endpoint `/actors/{user_id}/followers/sync` provides partial followers collections filtered by instance authority. This endpoint only returns followers whose IDs match the requesting instance's domain. + +This is implemented in `includes/rest/class-followers-controller.php`. + +### 3. Incoming Activities + +When receiving Create activities with a `Collection-Synchronization` header, the plugin: + +1. Detects the collection type from the URL (e.g., followers, following) +2. Validates the header parameters against the actor's collection +3. Computes the local digest for comparison +4. If digests don't match, fires the `activitypub_followers_sync_mismatch` action for async reconciliation + +This is implemented in `includes/handler/class-collection-sync.php`. + +### 4. Reconciliation + +When a digest mismatch is detected, the plugin triggers a scheduled reconciliation job that: + +1. Fetches the authoritative partial followers collection from the remote server +2. Compares it with the local follower list +3. Removes followers that shouldn't exist locally +4. Reports followers that exist remotely but not locally (for review) + +The reconciliation is handled asynchronously via WordPress's cron system. + +This is implemented in `includes/scheduler/class-follower.php`. + +## Components + +### Core Classes + +- **`Http`** (`includes/class-http.php`) + - Adds `Collection-Synchronization` header to outgoing Create activities + - Generates sync headers with digest, collectionId, and URL + - Methods: `post()` with sync header generation + +- **`Collection_Sync`** (`includes/handler/class-collection-sync.php`) + - Handles incoming activities with Collection-Synchronization headers + - Detects collection type from URLs (followers, following, etc.) + - Validates header parameters against actor collections + - Triggers reconciliation on digest mismatch + - Methods: `handle_collection_synchronization()`, `detect_collection_type()`, `process_followers_collection_sync()`, `validate_collection_sync_header_params()` + +- **`Followers`** (`includes/collection/class-followers.php`) + - Computes partial follower digests using XOR'd SHA256 hashes + - Filters followers by instance authority + - Methods: `compute_partial_digest()`, `get_partial_followers()` + +- **`Followers_Controller`** (`includes/rest/class-followers-controller.php`) + - Adds `/actors/{id}/followers/sync` REST endpoint for partial collections + - Filters followers by authority parameter + - Returns ActivityStreams OrderedCollection with only matching followers + - Methods: `get_partial_followers()` + +- **`Follower`** (`includes/scheduler/class-follower.php`) + - Handles async reconciliation when digest mismatches occur + - Fetches authoritative partial followers from remote server + - Removes out-of-sync followers + - Reports mismatches via action hooks + - Methods: `reconcile_followers_collection()` + +- **`Scheduler`** (`includes/class-scheduler.php`) + - Registers the follower reconciliation scheduled action + - Initializes the Collection_Sync handler + +## Privacy Considerations + +FEP-8fcf is designed with privacy in mind: + +- Only followers from the requesting instance are included in partial collections +- Each instance only gets information about its own users +- No global follower list is exposed + +## Action Hooks + +The implementation provides several action hooks for monitoring and extending: + +```php +// Triggered when digest mismatch is detected +do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); + +// Triggered when a follower is removed during sync +do_action( 'activitypub_followers_sync_follower_removed', $user_id, $follower_url, $actor_url ); + +// Triggered when follower exists remotely but not locally +do_action( 'activitypub_followers_sync_follower_mismatch', $user_id, $follower_url, $actor_url ); + +// Triggered after reconciliation completes +do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $to_check ); +``` + +## REST API Endpoints + +### Partial Followers Collection + +``` +GET /wp-json/activitypub/1.0/actors/{user_id}/followers/sync?authority={authority} +``` + +**Parameters:** +- `user_id` (required): The local actor's user ID +- `authority` (required): URI authority to filter followers (e.g., `https://mastodon.social`) +- `page` (optional): Page number for pagination +- `per_page` (optional): Items per page (default: 20) + +**Response:** ActivityStreams OrderedCollection with filtered followers + +**Example:** +```bash +curl -H "Accept: application/activity+json" \ + "https://example.com/wp-json/activitypub/1.0/actors/1/followers/sync?authority=https://mastodon.social" +``` + +## Compatibility + +This implementation is compatible with: + +- Mastodon (v3.3.0+) +- Fedify (v0.8.0+) +- Tootik (v0.18.0+) +- Any other server that implements FEP-8fcf + +## Testing + +### Manual Testing + +To test the implementation: + +1. Set up two WordPress instances with the ActivityPub plugin +2. Have users follow each other +3. Monitor the `Collection-Synchronization` headers in HTTP requests +4. Simulate a follower mismatch by manually removing a follower from the database +5. Send a Create activity and verify reconciliation occurs + +### Automated Tests + +The implementation includes: +- **Unit tests** (`tests/phpunit/tests/includes/class-test-http.php`) - Tests header generation +- **E2E tests** (`tests/e2e/specs/includes/rest/followers-controller.test.js`) - Tests the sync endpoint +- **Integration tests** - Tests full reconciliation flow + +Run tests with: +```bash +# PHP unit tests +vendor/bin/phpunit + +# E2E tests +npm run test:e2e +``` + +## Configuration + +The FEP-8fcf implementation is enabled by default. There are no configuration options currently available. + +## Debugging + +To debug synchronization issues: + +1. Enable WordPress debug logging: + ```php + define( 'WP_DEBUG', true ); + define( 'WP_DEBUG_LOG', true ); + ``` + +2. Monitor action hooks: + ```php + add_action( 'activitypub_followers_sync_mismatch', function( $user_id, $actor_url, $params ) { + error_log( "Sync mismatch for user $user_id from $actor_url" ); + }, 10, 3 ); + ``` + +3. Check scheduled actions in WordPress admin under Tools > Scheduled Actions + +## Future Enhancements + +Potential improvements for the future: + +- Add admin UI to view synchronization logs +- Implement configurable sync frequency +- Add metrics/statistics for sync operations +- Support synchronization for Following collections +- Add option to disable FEP-8fcf support +- Implement exponential backoff for failed reconciliations +- Add support for other collection types (liked, outbox, etc.) + +## References + +- [FEP-8fcf Specification](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) +- [Mastodon Implementation](https://github.com/tootsuite/mastodon/pull/14510) +- [Fedify Documentation](https://fedify.dev/manual/send#followers-collection-synchronization) diff --git a/docs/fep-8fcf-implementation.md b/docs/fep-8fcf-implementation.md deleted file mode 100644 index 3cb82cde0..000000000 --- a/docs/fep-8fcf-implementation.md +++ /dev/null @@ -1,139 +0,0 @@ -# FEP-8fcf Implementation - -This is a prototype implementation of [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md). - -## Overview - -FEP-8fcf provides a mechanism for detecting and resolving discrepancies in follow relationships between ActivityPub instances. This helps ensure that follower lists stay synchronized even when there are software bugs, server crashes, or database rollbacks. - -## How It Works - -### 1. Outgoing Activities - -When sending Create activities to followers, the plugin automatically adds a `Collection-Synchronization` HTTP header that includes: - -- `collectionId`: The sender's followers collection URI -- `url`: URL to fetch the partial followers collection for that specific instance -- `digest`: A cryptographic digest (XOR'd SHA256 hashes) of followers from the receiving instance - -This is implemented in `includes/class-http.php`. - -### 2. Partial Followers Collection - -A new REST endpoint `/actors/{user_id}/followers/sync` provides partial followers collections filtered by instance authority. This endpoint only returns followers whose IDs match the requesting instance's domain. - -This is implemented in `includes/rest/class-followers-controller.php`. - -### 3. Incoming Activities - -When receiving activities with a `Collection-Synchronization` header, the plugin: - -1. Parses and validates the header parameters -2. Computes the local digest for comparison -3. If digests don't match, schedules an async reconciliation job - -This is implemented in `includes/rest/class-inbox-controller.php`. - -### 4. Reconciliation - -When a digest mismatch is detected, the plugin asynchronously: - -1. Fetches the authoritative partial followers collection from the remote server -2. Compares it with the local follower list -3. Removes followers that shouldn't exist locally -4. Logs followers that exist remotely but not locally (for review) - -This is implemented in `includes/scheduler/class-collection-sync.php`. - -## Components - -### Core Classes - -- **`Followers`** (`includes/collection/class-followers.php`) - - Computes partial follower digests using XOR'd SHA256 hashes - - Generates and parses Collection-Synchronization headers - - Filters followers by instance authority - - Validates header parameters - - New FEP-8fcf methods: `compute_partial_digest()`, `get_partial_followers()`, `generate_sync_header()`, `parse_sync_header()`, `validate_sync_header_params()`, `get_authority()` - -- **`Follower`** (`includes/scheduler/class-follower.php`) - - Handles async reconciliation when digest mismatches occur - - Removes out-of-sync followers - - Provides action hooks for monitoring sync events - -### Traits - -- **`Followers_Sync`** (`includes/rest/trait-followers-sync.php`) - - Reusable trait for inbox controllers - - Provides `process_followers_synchronization()` method - - Used by both `Inbox_Controller` and `Actors_Inbox_Controller` - -### Modified Classes - -- **`Http`** - Adds Collection-Synchronization header to outgoing Create activities -- **`Followers_Controller`** - Adds `/followers/sync` endpoint for partial collections -- **`Inbox_Controller`** - Uses `Followers_Sync` trait to process incoming headers -- **`Actors_Inbox_Controller`** - Uses `Followers_Sync` trait to process incoming headers -- **`Scheduler`** - Registers the Collection_Sync scheduler - -## Privacy Considerations - -FEP-8fcf is designed with privacy in mind: - -- Only followers from the requesting instance are included in partial collections -- Each instance only gets information about its own users -- No global follower list is exposed - -## Action Hooks - -The implementation provides several action hooks for monitoring and extending: - -```php -// Triggered when digest mismatch is detected -do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); - -// Triggered when a follower is removed during sync -do_action( 'activitypub_followers_sync_follower_removed', $user_id, $follower_url, $actor_url ); - -// Triggered when follower exists remotely but not locally -do_action( 'activitypub_followers_sync_follower_mismatch', $user_id, $follower_url, $actor_url ); - -// Triggered after reconciliation completes -do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $to_check ); -``` - -## Compatibility - -This implementation is compatible with: - -- Mastodon (v3.3.0+) -- Fedify (v0.8.0+) -- Tootik (v0.18.0+) -- Any other server that implements FEP-8fcf - -## Testing - -To test the implementation: - -1. Set up two WordPress instances with the ActivityPub plugin -2. Have users follow each other -3. Monitor the `Collection-Synchronization` headers in HTTP requests -4. Simulate a follower mismatch by manually removing a follower from the database -5. Send a Create activity and verify reconciliation occurs - -## Future Enhancements - -Potential improvements for the future: - -- Add admin UI to view synchronization logs -- Implement configurable sync frequency -- Add metrics/statistics for sync operations -- Support synchronization for Following collections -- Add option to disable FEP-8fcf support -- Implement exponential backoff for failed reconciliations - -## References - -- [FEP-8fcf Specification](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) -- [Mastodon Implementation](https://github.com/tootsuite/mastodon/pull/14510) -- [Fedify Documentation](https://fedify.dev/manual/send#followers-collection-synchronization) From a05075f0853bd6da5bf8983eb8ad650205450fec Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 12:09:29 +0200 Subject: [PATCH 13/78] Simplify webServer command in Playwright config Removed additional WordPress CLI commands from the webServer command, leaving only 'npm run env-start' for environment setup. --- tests/e2e/playwright.config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index 415bae482..fd367b749 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -18,8 +18,7 @@ const config = defineConfig( { testDir: './specs', webServer: { ...baseConfig.webServer, - command: - "npm run env-start && npm wp-env run tests-cli -- wp rewrite structure '/postname' && npm wp-env run tests-cli -- wp rewrite flush", + command: 'npm run env-start', }, } ); From 4ba9db6eeae88c6063bd044b14b71fc91687779b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 12:33:45 +0200 Subject: [PATCH 14/78] Unindent tests in followers-controller.test.js Moved test cases out of nested blocks in the 'Partial Followers Sync Endpoint' and 'Collection Response Format' suites for improved readability and consistency. No logic changes were made. From d8404980d9a83e641026ce503494bb4aa9e34354 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 12:39:58 +0200 Subject: [PATCH 15/78] Flush rewrite rules after setting permalink structure Updated the afterStart lifecycle script to flush rewrite rules after setting the permalink structure, ensuring changes take effect immediately. --- .wp-env.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.wp-env.json b/.wp-env.json index 4b3352b32..8b0854f0e 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -12,6 +12,6 @@ } }, "lifecycleScripts": { - "afterStart": "npx wp-env run cli wp rewrite structure /%year%/%monthnum%/%postname%/" + "afterStart": "npx wp-env run cli wp rewrite structure /%year%/%monthnum%/%postname%/ && npx wp-env run cli wp rewrite flush" } } From 6e60479ea7aef69b6bc228112ac6286b07c82d80 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 12:46:10 +0200 Subject: [PATCH 16/78] Remove unused Collection trait from Inbox_Controller The Collection trait was removed from the Inbox_Controller class as it is no longer used. This helps clean up the code and avoid unnecessary dependencies. --- includes/rest/class-inbox-controller.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 21ca77dc4..9729cc962 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -24,8 +24,6 @@ * @see https://www.w3.org/TR/activitypub/#inbox */ class Inbox_Controller extends \WP_REST_Controller { - use Collection; - /** * The namespace of this controller's route. * From 0e65a4c5b324f9c2564389912b9b62c82c9344bf Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 13:00:16 +0200 Subject: [PATCH 17/78] Update rewrite structure command and remove testDir config Changed the 'afterStart' lifecycle script in .wp-env.json to use 'tests-cli' for setting the rewrite structure instead of 'cli' and removed the 'testDir' property from the Playwright config. This aligns the environment setup and test configuration with updated project requirements. --- .wp-env.json | 2 +- tests/e2e/playwright.config.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.wp-env.json b/.wp-env.json index 8b0854f0e..0a5b68766 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -12,6 +12,6 @@ } }, "lifecycleScripts": { - "afterStart": "npx wp-env run cli wp rewrite structure /%year%/%monthnum%/%postname%/ && npx wp-env run cli wp rewrite flush" + "afterStart": "npx wp-env run cli wp rewrite structure /%year%/%monthnum%/%postname%/ && npx wp-env run tests-cli wp rewrite structure /%year%/%monthnum%/%postname%/" } } diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index fd367b749..2d171af17 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -15,7 +15,6 @@ process.env.STORAGE_STATE_PATH ??= path.join( process.env.WP_ARTIFACTS_PATH, 'st const config = defineConfig( { ...baseConfig, globalSetup: require.resolve( './config/global-setup.js' ), - testDir: './specs', webServer: { ...baseConfig.webServer, command: 'npm run env-start', From 6ac4ed926e0a7d6d832bb485e62badfd52e68163 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 13:04:00 +0200 Subject: [PATCH 18/78] Update rewrite structure command for test environment Removed duplicate rewrite structure command from .wp-env.json lifecycleScripts and added it to the Playwright webServer start command to ensure correct permalink structure during E2E tests. --- .wp-env.json | 2 +- tests/e2e/playwright.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.wp-env.json b/.wp-env.json index 0a5b68766..4b3352b32 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -12,6 +12,6 @@ } }, "lifecycleScripts": { - "afterStart": "npx wp-env run cli wp rewrite structure /%year%/%monthnum%/%postname%/ && npx wp-env run tests-cli wp rewrite structure /%year%/%monthnum%/%postname%/" + "afterStart": "npx wp-env run cli wp rewrite structure /%year%/%monthnum%/%postname%/" } } diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index 2d171af17..caa91f459 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -17,7 +17,7 @@ const config = defineConfig( { globalSetup: require.resolve( './config/global-setup.js' ), webServer: { ...baseConfig.webServer, - command: 'npm run env-start', + command: 'npm run env-start && npx wp-env run tests-cli wp rewrite structure "/%year%/%monthnum%/%postname%/"', }, } ); From c1f5ba7c739afd7d080fdfe150eef09c83aef0a5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 13:07:31 +0200 Subject: [PATCH 19/78] Update Playwright webServer command in config Removed the 'wp rewrite structure' command from the Playwright webServer configuration, leaving only 'npm run env-start'. This simplifies the server startup process for E2E tests. --- tests/e2e/playwright.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index caa91f459..2d171af17 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -17,7 +17,7 @@ const config = defineConfig( { globalSetup: require.resolve( './config/global-setup.js' ), webServer: { ...baseConfig.webServer, - command: 'npm run env-start && npx wp-env run tests-cli wp rewrite structure "/%year%/%monthnum%/%postname%/"', + command: 'npm run env-start', }, } ); From 1c7b2f358b914a9c4a14c8eb486ace6d74e87686 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 13:16:40 +0200 Subject: [PATCH 20/78] Set permalink structure in CI and remove theme activation Adds a step in the Playwright GitHub Actions workflow to set the WordPress permalink structure before running tests. Removes the activation of the 'twentytwentyfour' theme from the global test setup script. --- .github/workflows/playwright.yml | 3 +++ tests/e2e/config/global-setup.js | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9f48a5f6e..952e8a9a7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -54,6 +54,9 @@ jobs: - name: Start WordPress environment run: npm run env-start + - name: Set rewrite structure + run: npx wp-env run test-cli wp rewrite structure /%year%/%monthnum%/%postname%/ + - name: Run Playwright tests run: npm run test:e2e env: diff --git a/tests/e2e/config/global-setup.js b/tests/e2e/config/global-setup.js index 5112a96d5..bbab9ff9a 100644 --- a/tests/e2e/config/global-setup.js +++ b/tests/e2e/config/global-setup.js @@ -31,7 +31,6 @@ async function globalSetup( config ) { // Reset the test environment before running the tests. await Promise.all( [ - requestUtils.activateTheme( 'twentytwentyfour' ), requestUtils.deleteAllPosts(), requestUtils.deleteAllBlocks(), requestUtils.resetPreferences(), From da39b82f4b9419d4a2afdd151ddfbe4f5dbb6856 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 13:19:58 +0200 Subject: [PATCH 21/78] Fix CLI container name in Playwright workflow Corrects the container name from 'test-cli' to 'tests-cli' in the rewrite structure step to ensure the workflow runs the command in the correct environment. --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 952e8a9a7..c3c26f220 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -55,7 +55,7 @@ jobs: run: npm run env-start - name: Set rewrite structure - run: npx wp-env run test-cli wp rewrite structure /%year%/%monthnum%/%postname%/ + run: npx wp-env run tests-cli wp rewrite structure /%year%/%monthnum%/%postname%/ - name: Run Playwright tests run: npm run test:e2e From 5f57952acb0f259c7d376b90015a5624f7581a37 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 14:45:50 +0200 Subject: [PATCH 22/78] Flush WordPress rewrite rules in Playwright workflow Added a step to flush WordPress rewrite rules after setting the rewrite structure in the Playwright GitHub Actions workflow. This ensures permalink changes take effect before running end-to-end tests. --- .github/workflows/playwright.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c3c26f220..ba4b7e249 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -52,10 +52,10 @@ jobs: run: npx playwright install --with-deps chromium - name: Start WordPress environment - run: npm run env-start - - - name: Set rewrite structure - run: npx wp-env run tests-cli wp rewrite structure /%year%/%monthnum%/%postname%/ + run: | + npm run env-start + npx wp-env run tests-cli wp rewrite structure /%year%/%monthnum%/%postname%/ + npx wp-env run tests-cli wp rewrite flush - name: Run Playwright tests run: npm run test:e2e From b2e9978a106a49d7c15f931c289a09eb609cc12f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 14:49:23 +0200 Subject: [PATCH 23/78] Set up pretty permalinks in global E2E setup Adds commands to configure and flush WordPress pretty permalinks before any REST API calls in the global E2E test setup. This ensures consistent permalink structure for tests. --- tests/e2e/config/global-setup.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/e2e/config/global-setup.js b/tests/e2e/config/global-setup.js index bbab9ff9a..563934734 100644 --- a/tests/e2e/config/global-setup.js +++ b/tests/e2e/config/global-setup.js @@ -2,12 +2,16 @@ * External dependencies */ import { request } from '@playwright/test'; +import { exec } from 'child_process'; +import { promisify } from 'util'; /** * WordPress dependencies */ import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; +const execAsync = promisify( exec ); + /** * Global setup for ActivityPub E2E tests. * @@ -15,6 +19,14 @@ import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; * @returns {Promise} */ async function globalSetup( config ) { + // Set up pretty permalinks before any REST API calls + try { + await execAsync( 'npx wp-env run tests-cli wp rewrite structure "/%year%/%monthnum%/%postname%/"' ); + await execAsync( 'npx wp-env run tests-cli wp rewrite flush' ); + } catch ( error ) { + console.error( 'Failed to set up permalinks:', error ); + } + const { storageState, baseURL } = config.projects[ 0 ].use; const storageStatePath = typeof storageState === 'string' ? storageState : undefined; From 17dd50a4cedaeacfef368aadc34f85405ad94072 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 14:54:40 +0200 Subject: [PATCH 24/78] Refactor E2E tests to use params for REST requests Updated Playwright E2E tests to pass query parameters using the 'params' option instead of manually encoding them in the path. Also removed redundant permalink setup steps from the workflow and global setup script, as they are no longer required for the test environment. --- .github/workflows/playwright.yml | 5 +-- tests/e2e/config/global-setup.js | 12 ------ .../rest/followers-controller.test.js | 40 +++++++++++-------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index ba4b7e249..9f48a5f6e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -52,10 +52,7 @@ jobs: run: npx playwright install --with-deps chromium - name: Start WordPress environment - run: | - npm run env-start - npx wp-env run tests-cli wp rewrite structure /%year%/%monthnum%/%postname%/ - npx wp-env run tests-cli wp rewrite flush + run: npm run env-start - name: Run Playwright tests run: npm run test:e2e diff --git a/tests/e2e/config/global-setup.js b/tests/e2e/config/global-setup.js index 563934734..bbab9ff9a 100644 --- a/tests/e2e/config/global-setup.js +++ b/tests/e2e/config/global-setup.js @@ -2,16 +2,12 @@ * External dependencies */ import { request } from '@playwright/test'; -import { exec } from 'child_process'; -import { promisify } from 'util'; /** * WordPress dependencies */ import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; -const execAsync = promisify( exec ); - /** * Global setup for ActivityPub E2E tests. * @@ -19,14 +15,6 @@ const execAsync = promisify( exec ); * @returns {Promise} */ async function globalSetup( config ) { - // Set up pretty permalinks before any REST API calls - try { - await execAsync( 'npx wp-env run tests-cli wp rewrite structure "/%year%/%monthnum%/%postname%/"' ); - await execAsync( 'npx wp-env run tests-cli wp rewrite flush' ); - } catch ( error ) { - console.error( 'Failed to set up permalinks:', error ); - } - const { storageState, baseURL } = config.projects[ 0 ].use; const storageStatePath = typeof storageState === 'string' ? storageState : undefined; diff --git a/tests/e2e/specs/includes/rest/followers-controller.test.js b/tests/e2e/specs/includes/rest/followers-controller.test.js index 3655c23ce..a29ed7bb1 100644 --- a/tests/e2e/specs/includes/rest/followers-controller.test.js +++ b/tests/e2e/specs/includes/rest/followers-controller.test.js @@ -175,10 +175,9 @@ test.describe( 'ActivityPub Followers Endpoint', () => { const testAuthority = 'https://example.com'; const response = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, } ); - - // Should return an OrderedCollection expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); expect( response ).toHaveProperty( 'totalItems' ); expect( response ).toHaveProperty( 'orderedItems' ); @@ -199,7 +198,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { try { await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ invalidAuthority }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: invalidAuthority }, } ); // If no error is thrown, fail the test expect( false ).toBe( true ); @@ -231,7 +231,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { const testAuthority = 'https://non-existent-instance.test'; const response = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, } ); expect( response.type ).toBe( 'OrderedCollection' ); @@ -247,7 +248,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { const testAuthority = 'https://mastodon.social'; const response = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, } ); // Validate ActivityStreams OrderedCollection structure @@ -266,7 +268,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { const testAuthority = 'https://example.com'; const response = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, } ); // If we got data back, the content type was acceptable @@ -288,7 +291,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { for ( const authority of authorities ) { const response = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority }, } ); expect( response.type ).toBe( 'OrderedCollection' ); @@ -306,9 +310,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { try { await requestUtils.rest( { - path: `/activitypub/1.0/actors/99999/followers/sync?authority=${ encodeURIComponent( - testAuthority - ) }`, + path: `/activitypub/1.0/actors/99999/followers/sync`, + params: { authority: testAuthority }, } ); // If no error is thrown, fail the test expect( false ).toBe( true ); @@ -331,7 +334,8 @@ test.describe( 'ActivityPub Followers Endpoint', () => { for ( const authority of malformedAuthorities ) { try { await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority }, } ); // If no error is thrown, fail the test expect( false ).toBe( true ); @@ -351,11 +355,13 @@ test.describe( 'ActivityPub Followers Endpoint', () => { // Make two requests to the same endpoint const response1 = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, } ); const response2 = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( testAuthority ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, } ); // Results should be consistent @@ -370,11 +376,13 @@ test.describe( 'ActivityPub Followers Endpoint', () => { const authority2 = 'https://pixelfed.social'; const response1 = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority1 ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: authority1 }, } ); const response2 = await requestUtils.rest( { - path: `/activitypub/1.0/actors/1/followers/sync?authority=${ encodeURIComponent( authority2 ) }`, + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: authority2 }, } ); // Both should return valid collections (even if empty) From f885ea5b9c6953ab97b4767d6e793bc522a2f1e4 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 16:49:19 +0200 Subject: [PATCH 25/78] Add missing followers to local database Calls Followers::add_follower for each remote follower not found locally, ensuring local records are updated to match remote state. --- includes/scheduler/class-follower.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/scheduler/class-follower.php b/includes/scheduler/class-follower.php index a4bc107b5..5a4073323 100644 --- a/includes/scheduler/class-follower.php +++ b/includes/scheduler/class-follower.php @@ -109,6 +109,8 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { * For now, just log these for potential manual review. */ foreach ( $to_check as $follower_url ) { + Followers::add_follower( $user_id, $follower_url ); + /** * Action triggered when a follower exists remotely but not locally. * From 04c05af18c43482b375efd84e95b70a43a6cb8d0 Mon Sep 17 00:00:00 2001 From: Automattic Bot Date: Wed, 15 Oct 2025 16:52:05 +0200 Subject: [PATCH 26/78] Add changelog --- .github/changelog/2297-from-description | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/2297-from-description diff --git a/.github/changelog/2297-from-description b/.github/changelog/2297-from-description new file mode 100644 index 000000000..6f807b475 --- /dev/null +++ b/.github/changelog/2297-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added support for FEP-8fcf follower synchronization, improving data consistency across servers with new sync headers, digest checks, and reconciliation tasks. From b46aed6fbba4b39e4a5b1ca8830c1dcaa7a5377a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 16:58:40 +0200 Subject: [PATCH 27/78] Update docs/collection-synchronization.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/collection-synchronization.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md index 63969aa6e..f1ead28b6 100644 --- a/docs/collection-synchronization.md +++ b/docs/collection-synchronization.md @@ -82,12 +82,10 @@ This is implemented in `includes/scheduler/class-follower.php`. - Fetches authoritative partial followers from remote server - Removes out-of-sync followers - Reports mismatches via action hooks - - Methods: `reconcile_followers_collection()` + - Methods: `reconcile_followers()` - **`Scheduler`** (`includes/class-scheduler.php`) - Registers the follower reconciliation scheduled action - - Initializes the Collection_Sync handler - ## Privacy Considerations FEP-8fcf is designed with privacy in mind: From af7fdea351062b3baa29e6ffc24294d957c40074 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 21:56:58 +0200 Subject: [PATCH 28/78] Add Collection-Synchronization header to signature components Updated HTTP message signature and draft signature classes to include the Collection-Synchronization header in the signed components when present. Also removed unnecessary early returns in Followers class digest computation methods. --- includes/collection/class-followers.php | 8 ------- .../class-http-message-signature.php | 7 ++++++- .../signature/class-http-signature-draft.php | 21 ++++++++++++++----- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 2dcfbbe22..3ad6d83ea 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -501,10 +501,6 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { public static function compute_partial_digest( $user_id, $authority ) { $followers = self::get_partial_followers( $user_id, $authority ); - if ( empty( $followers ) ) { - return ''; - } - // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). $digest = str_repeat( '0', 64 ); @@ -591,10 +587,6 @@ public static function generate_sync_header( $user_id, $authority ) { // Compute the digest for this specific authority. $digest = self::compute_partial_digest( $user_id, $authority ); - if ( empty( $digest ) ) { - return false; - } - // Build the collection ID (followers collection URL). $collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) ); diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php index 956df92db..34142e3ed 100644 --- a/includes/signature/class-http-message-signature.php +++ b/includes/signature/class-http-message-signature.php @@ -91,11 +91,16 @@ class Http_Message_Signature implements Http_Signature { */ public function sign( $args, $url ) { // Standard components to sign. - $components = array( + $components = array( '"@method"' => \strtoupper( $args['method'] ), '"@target-uri"' => $url, '"@authority"' => \wp_parse_url( $url, PHP_URL_HOST ), ); + + if ( isset( $args['headers']['Collection-Synchronization'] ) ) { + $components['"collection-synchronization"'] = $args['headers']['Collection-Synchronization']; + } + $identifiers = \array_keys( $components ); // Add digest if provided. diff --git a/includes/signature/class-http-signature-draft.php b/includes/signature/class-http-signature-draft.php index 7c934ace8..54f5118e3 100644 --- a/includes/signature/class-http-signature-draft.php +++ b/includes/signature/class-http-signature-draft.php @@ -49,16 +49,27 @@ public function sign( $args, $url ) { $http_method = \strtolower( $args['method'] ); $date = $args['headers']['Date']; + $signed_parts = array( + sprintf( '(request-target): %s %s', $http_method, $path ), + sprintf( 'host: %s', $host ), + sprintf( 'date: %s', $date ), + ); + $headers_list = array( '(request-target)', 'host', 'date' ); + if ( isset( $args['body'] ) ) { $args['headers']['Digest'] = $this->generate_digest( $args['body'] ); + $signed_parts[] = sprintf( 'digest: %s', $args['headers']['Digest'] ); + $headers_list[] = 'digest'; + } - $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: {$args['headers']['Digest']}"; - $headers_list = '(request-target) host date digest'; - } else { - $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date"; - $headers_list = '(request-target) host date'; + if ( isset( $args['headers']['Collection-Synchronization'] ) ) { + $signed_parts[] = sprintf( 'collection-synchronization: %s', $args['headers']['Collection-Synchronization'] ); + $headers_list[] = 'collection-synchronization'; } + $signed_string = implode( "\n", $signed_parts ); + $headers_list = implode( ' ', $headers_list ); + $signature = null; \openssl_sign( $signed_string, $signature, $args['private_key'], \OPENSSL_ALGO_SHA256 ); $signature = \base64_encode( $signature ); From 9b4b8662e0b2d6981ec66a99274e8036199e3f39 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 22:19:44 +0200 Subject: [PATCH 29/78] Refactor collection sync and update FEP-8fcf handling Replaces the Follower scheduler with a new Collection_Sync scheduler for improved reconciliation of follower state per FEP-8fcf. Updates the documentation, handler, and signature verification to require the Collection-Synchronization header to be signed. Adds local following snapshot logic, updates action hooks, and enhances test coverage for signature headers. --- docs/collection-synchronization.md | 41 ++-- includes/class-scheduler.php | 4 +- includes/collection/class-following.php | 42 ++++ includes/handler/class-collection-sync.php | 141 +++++++++++- includes/scheduler/class-collection-sync.php | 204 ++++++++++++++++++ includes/scheduler/class-follower.php | 139 ------------ .../class-http-message-signature.php | 22 ++ .../signature/class-http-signature-draft.php | 10 + .../tests/includes/class-test-signature.php | 3 +- 9 files changed, 437 insertions(+), 169 deletions(-) create mode 100644 includes/scheduler/class-collection-sync.php delete mode 100644 includes/scheduler/class-follower.php diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md index f1ead28b6..0ed803a77 100644 --- a/docs/collection-synchronization.md +++ b/docs/collection-synchronization.md @@ -16,7 +16,7 @@ When sending Create activities to followers, the plugin automatically adds a `Co - `url`: URL to fetch the partial followers collection for that specific instance (e.g., `/actors/{id}/followers/sync?authority=https://example.com`) - `digest`: A cryptographic digest (XOR'd SHA256 hashes) of followers from the receiving instance -The header is added during HTTP delivery in `Http::post()` when sending to inboxes. +The header is added during HTTP delivery in `Http::post()` when sending to inboxes and is automatically covered by the HTTP signature to meet the FEP requirement for authenticity. This is implemented in `includes/class-http.php`. @@ -42,13 +42,14 @@ This is implemented in `includes/handler/class-collection-sync.php`. When a digest mismatch is detected, the plugin triggers a scheduled reconciliation job that: 1. Fetches the authoritative partial followers collection from the remote server -2. Compares it with the local follower list -3. Removes followers that shouldn't exist locally -4. Reports followers that exist remotely but not locally (for review) +2. Compares it with the local *following* relationships for that remote actor +3. Removes local follow records that the remote server no longer recognises +4. Promotes pending follow requests that the remote server already lists as accepted +5. Issues Undo Follow activities for any unexpected entries reported by the remote server The reconciliation is handled asynchronously via WordPress's cron system. -This is implemented in `includes/scheduler/class-follower.php`. +This is implemented in `includes/scheduler/class-collection-sync.php`. ## Components @@ -67,21 +68,26 @@ This is implemented in `includes/scheduler/class-follower.php`. - Methods: `handle_collection_synchronization()`, `detect_collection_type()`, `process_followers_collection_sync()`, `validate_collection_sync_header_params()` - **`Followers`** (`includes/collection/class-followers.php`) - - Computes partial follower digests using XOR'd SHA256 hashes - - Filters followers by instance authority + - Computes partial follower digests for outgoing deliveries using XOR'd SHA256 hashes + - Filters followers by instance authority when building partial collections - Methods: `compute_partial_digest()`, `get_partial_followers()` +- **`Following`** (`includes/collection/class-following.php`) + - Exposes local following state for reconciliation and digest calculations + - Maps local user IDs to ActivityPub actor URLs for comparison + - Methods: `get_local_followers_snapshot()` + - **`Followers_Controller`** (`includes/rest/class-followers-controller.php`) - Adds `/actors/{id}/followers/sync` REST endpoint for partial collections - Filters followers by authority parameter - Returns ActivityStreams OrderedCollection with only matching followers - Methods: `get_partial_followers()` -- **`Follower`** (`includes/scheduler/class-follower.php`) +- **`Collection_Sync`** (`includes/scheduler/class-collection-sync.php`) - Handles async reconciliation when digest mismatches occur - - Fetches authoritative partial followers from remote server - - Removes out-of-sync followers - - Reports mismatches via action hooks + - Fetches authoritative partial followers from the remote server + - Removes stale local follow relationships, promotes pending accepts, and cleans up unexpected entries + - Reports changes via action hooks - Methods: `reconcile_followers()` - **`Scheduler`** (`includes/class-scheduler.php`) @@ -102,14 +108,17 @@ The implementation provides several action hooks for monitoring and extending: // Triggered when digest mismatch is detected do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); -// Triggered when a follower is removed during sync -do_action( 'activitypub_followers_sync_follower_removed', $user_id, $follower_url, $actor_url ); +// Triggered when a local follow record is removed during sync +do_action( 'activitypub_followers_sync_follower_removed', $local_user_id, $local_actor_uri, $actor_url ); + +// Triggered when a pending follow is auto-accepted during sync +do_action( 'activitypub_followers_sync_follow_request_accepted', $local_user_id, $local_actor_uri, $actor_url ); -// Triggered when follower exists remotely but not locally -do_action( 'activitypub_followers_sync_follower_mismatch', $user_id, $follower_url, $actor_url ); +// Triggered when an unexpected remote entry requires an Undo Follow +do_action( 'activitypub_followers_sync_follower_mismatch', $local_user_id, $local_actor_uri, $actor_url ); // Triggered after reconciliation completes -do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $to_check ); +do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $removed_actor_uris, $undo_actor_uris ); ``` ## REST API Endpoints diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 8da9242b1..9cabc206f 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -14,8 +14,8 @@ use Activitypub\Collection\Outbox; use Activitypub\Collection\Remote_Actors; use Activitypub\Scheduler\Actor; +use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; -use Activitypub\Scheduler\Follower; use Activitypub\Scheduler\Post; /** @@ -61,8 +61,8 @@ public static function init() { public static function register_schedulers() { Post::init(); Actor::init(); + Collection_Sync::init(); Comment::init(); - Follower::init(); /** * Register additional schedulers. diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php index 0f28701ad..32d5926a2 100644 --- a/includes/collection/class-following.php +++ b/includes/collection/class-following.php @@ -376,6 +376,48 @@ public static function get_all_with_count( $user_id, $number = -1, $page = null, return \compact( 'following', 'total' ); } + /** + * Retrieve a snapshot of local followers information for a remote actor. + * + * @param string $actor_url The remote actor URL. + * + * @return array|\WP_Error { + * Snapshot data or WP_Error when the remote actor is unknown. + * + * @type array $followers Map of local actor URLs to user IDs for accepted follows. + * @type array $pending Map of local actor URLs to user IDs for pending follows. + * @type \WP_Post $remote_post Remote actor post object. + * } + */ + public static function get_local_followers_snapshot( $actor_url ) { + $post = Remote_Actors::fetch_by_uri( $actor_url ); + + if ( \is_wp_error( $post ) ) { + return $post; + } + + $accepted = \get_post_meta( $post->ID, self::FOLLOWING_META_KEY, false ); + $pending = \get_post_meta( $post->ID, self::PENDING_META_KEY, false ); + + return array( + 'followers' => \array_map( + function ( $user_id ) { + $actor = Actors::get_by_id( $user_id ); + return ! \is_wp_error( $actor ) ? $actor->get_id() : null; + }, + $accepted + ), + 'pending' => \array_map( + function ( $user_id ) { + $actor = Actors::get_by_id( $user_id ); + return ! \is_wp_error( $actor ) ? $actor->get_id() : null; + }, + $pending + ), + 'remote_post' => $post, + ); + } + /** * Get all followings of a given user. * diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index a97e9c475..dad138384 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -7,7 +7,8 @@ namespace Activitypub\Handler; -use Activitypub\Collection\Followers; +use Activitypub\Collection\Following; +use Activitypub\Collection\Remote_Actors; use Activitypub\Http; /** @@ -123,12 +124,21 @@ protected static function process_followers_collection_sync( $params, $user_id, return; } - // Compute our local digest for this actor's followers from our instance. - $local_digest = Followers::compute_partial_digest( $user_id, $our_authority ); + $local_actor_urls = self::get_local_actor_urls_for_remote( $actor_url, $our_authority ); - // Compare digests. - if ( $local_digest === $params['digest'] ) { - // Digests match, no synchronization needed. + if ( \is_wp_error( $local_actor_urls ) ) { + return; + } + + $remote_digest = strtolower( trim( $params['digest'] ) ); + + if ( 64 !== strlen( $remote_digest ) || preg_match( '/[^0-9a-f]/', $remote_digest ) ) { + return; + } + + $local_digest = self::compute_digest_from_actor_urls( $local_actor_urls ); + + if ( \hash_equals( $local_digest, $remote_digest ) ) { return; } @@ -159,12 +169,18 @@ public static function validate_collection_sync_header_params( $params, $actor_u return false; } - // Parse the actor URL to get the expected followers collection. - $expected_collection = $actor_url . '/followers'; + $expected_collection = self::get_followers_collection_id( $actor_url ); - // Check if collectionId matches the actor's followers collection. - if ( $params['collectionId'] !== $expected_collection ) { - return false; + if ( $expected_collection ) { + if ( self::normalize_collection_url( $params['collectionId'] ) !== self::normalize_collection_url( $expected_collection ) ) { + return false; + } + } else { + $default_collection = rtrim( $actor_url, '/' ) . '/followers'; + + if ( self::normalize_collection_url( $params['collectionId'] ) !== self::normalize_collection_url( $default_collection ) ) { + return false; + } } // Check if url has the same authority as collectionId (prevent SSRF). @@ -189,4 +205,107 @@ public static function validate_collection_sync_header_params( $params, $actor_u return $collection_authority === $url_authority; } + + /** + * Retrieve local actor URLs that follow the remote actor and share the given authority. + * + * @param string $actor_url The remote actor URL. + * @param string $authority The authority to filter by. + * + * @return array|\WP_Error Array of actor URLs or WP_Error on failure. + */ + protected static function get_local_actor_urls_for_remote( $actor_url, $authority ) { + $snapshot = Following::get_local_followers_snapshot( $actor_url ); + + if ( \is_wp_error( $snapshot ) ) { + return $snapshot; + } + + $actor_urls = array_keys( $snapshot['followers'] ); + $actor_urls = self::filter_actor_urls_by_authority( $actor_urls, $authority ); + sort( $actor_urls ); + + return $actor_urls; + } + + /** + * Filter actor URLs by authority. + * + * @param array $actor_urls Array of actor URLs. + * @param string $authority Authority to match. + * + * @return array Filtered list of actor URLs. + */ + protected static function filter_actor_urls_by_authority( array $actor_urls, $authority ) { + $matched = array(); + + foreach ( $actor_urls as $actor_uri ) { + $actor_authority = Http::get_authority( $actor_uri ); + + if ( $actor_authority && $actor_authority === $authority ) { + $matched[] = $actor_uri; + } + } + + return $matched; + } + + /** + * Compute the partial collection digest from a list of actor URLs. + * + * @param array $actor_urls Actor URLs to include in the digest. + * + * @return string The computed digest. + */ + protected static function compute_digest_from_actor_urls( array $actor_urls ) { + $digest = str_repeat( '0', 64 ); + + foreach ( $actor_urls as $actor_uri ) { + $digest = Http::xor_hex_strings( $digest, hash( 'sha256', $actor_uri ) ); + } + + return $digest; + } + + /** + * Retrieve the followers collection ID for the remote actor if known. + * + * @param string $actor_url The remote actor URL. + * + * @return string|null The followers collection ID or null if unavailable. + */ + protected static function get_followers_collection_id( $actor_url ) { + $post = Remote_Actors::get_by_uri( $actor_url ); + + if ( \is_wp_error( $post ) ) { + $post = Remote_Actors::fetch_by_uri( $actor_url ); + + if ( \is_wp_error( $post ) ) { + return null; + } + } + + $actor = Remote_Actors::get_actor( $post ); + + if ( \is_wp_error( $actor ) ) { + return null; + } + + return $actor->get_followers(); + } + + /** + * Normalize a collection URL for comparison. + * + * @param string $url The URL to normalize. + * + * @return string Normalized URL without trailing slash. + */ + protected static function normalize_collection_url( $url ) { + if ( ! \is_string( $url ) ) { + return ''; + } + + return rtrim( $url, '/' ); + } } diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php new file mode 100644 index 000000000..9f7aed14d --- /dev/null +++ b/includes/scheduler/class-collection-sync.php @@ -0,0 +1,204 @@ + $user_id ) { + $actor_authority = Http::get_authority( $actor_uri ); + + if ( $actor_authority && $actor_authority === $authority ) { + $filtered[ $actor_uri ] = $user_id; + } + } + + return $filtered; + } +} diff --git a/includes/scheduler/class-follower.php b/includes/scheduler/class-follower.php deleted file mode 100644 index 5a4073323..000000000 --- a/includes/scheduler/class-follower.php +++ /dev/null @@ -1,139 +0,0 @@ -ID, $user_id ); - - /** - * Action triggered when a follower is removed due to synchronization. - * - * @param int $user_id The local user ID. - * @param string $follower_url The follower URL that was removed. - * @param string $actor_url The remote actor URL. - */ - \do_action( 'activitypub_followers_sync_follower_removed', $user_id, $follower_url, $actor_url ); - } - } - - /* - * For followers in remote but not local, we could send Undo Follow. - * However, this requires careful consideration as the follow may be pending. - * For now, just log these for potential manual review. - */ - foreach ( $to_check as $follower_url ) { - Followers::add_follower( $user_id, $follower_url ); - - /** - * Action triggered when a follower exists remotely but not locally. - * - * This could indicate: - * - A pending follow request - * - A follow that was lost locally - * - An inconsistency that needs manual review - * - * @param int $user_id The local user ID. - * @param string $follower_url The follower URL. - * @param string $actor_url The remote actor URL. - */ - \do_action( 'activitypub_followers_sync_follower_mismatch', $user_id, $follower_url, $actor_url ); - } - - /** - * Action triggered after reconciliation is complete. - * - * @param int $user_id The local user ID. - * @param string $actor_url The remote actor URL. - * @param array $to_remove Followers that were removed. - * @param array $to_check Followers that need checking. - */ - \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $to_check ); - } -} diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php index 34142e3ed..0d9450eba 100644 --- a/includes/signature/class-http-message-signature.php +++ b/includes/signature/class-http-message-signature.php @@ -143,6 +143,28 @@ public function verify( array $headers, $body = null ) { return $parsed; } + $errors = new \WP_Error(); + + $collection_header_present = isset( $headers['collection-synchronization'] ) || isset( $headers['collection_synchronization'] ); + if ( $collection_header_present ) { + $covered = false; + + foreach ( $parsed as $data ) { + foreach ( $data['components'] as $component ) { + $normalized = strtolower( trim( $component, '"' ) ); + + if ( 'collection-synchronization' === $normalized ) { + $covered = true; + break 2; + } + } + } + + if ( ! $covered ) { + $errors->add( 'activitypub_signature', 'Collection-Synchronization header must be signed.' ); + } + } + $errors = new \WP_Error(); foreach ( $parsed as $data ) { $result = $this->verify_signature_label( $data, $headers, $body ); diff --git a/includes/signature/class-http-signature-draft.php b/includes/signature/class-http-signature-draft.php index 54f5118e3..6f3282613 100644 --- a/includes/signature/class-http-signature-draft.php +++ b/includes/signature/class-http-signature-draft.php @@ -103,6 +103,16 @@ public function verify( array $headers, $body = null ) { return new \WP_Error( 'activitypub_signature', 'No Key ID present.' ); } + $collection_header_present = isset( $headers['collection-synchronization'] ) || isset( $headers['collection_synchronization'] ); + + if ( $collection_header_present ) { + $lower_headers = \array_map( 'strtolower', $parsed['headers'] ); + + if ( ! in_array( 'collection-synchronization', $lower_headers, true ) ) { + return new \WP_Error( 'activitypub_signature', 'Collection-Synchronization header must be signed.' ); + } + } + $public_key = Remote_Actors::get_public_key( $parsed['keyId'] ); if ( \is_wp_error( $public_key ) ) { return $public_key; diff --git a/tests/phpunit/tests/includes/class-test-signature.php b/tests/phpunit/tests/includes/class-test-signature.php index 7b0e9d138..6f64a46f3 100644 --- a/tests/phpunit/tests/includes/class-test-signature.php +++ b/tests/phpunit/tests/includes/class-test-signature.php @@ -679,7 +679,8 @@ public function test_rfc9421_is_unsupported() { $test = function ( $args ) { $this->assertFalse( isset( $args['headers']['Signature-Input'] ) ); - $this->assertStringContainsString( 'headers="(request-target) host date digest"', $args['headers']['Signature'] ); + // FEP-8fcf: Collection-Synchronization header is added for Create activities. + $this->assertStringContainsString( 'headers="(request-target) host date digest collection-synchronization"', $args['headers']['Signature'] ); return $args; }; From 4e8c081e3597d44c71491546e81f69f775e7beb5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Oct 2025 22:40:37 +0200 Subject: [PATCH 30/78] Filter out null values from followers and pending lists Wraps the mapped followers and pending arrays with array_filter to remove null values, ensuring only valid actor IDs are returned. This prevents potential issues caused by invalid or errored actor lookups. --- includes/collection/class-following.php | 28 ++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php index 32d5926a2..4a6cf2897 100644 --- a/includes/collection/class-following.php +++ b/includes/collection/class-following.php @@ -400,19 +400,23 @@ public static function get_local_followers_snapshot( $actor_url ) { $pending = \get_post_meta( $post->ID, self::PENDING_META_KEY, false ); return array( - 'followers' => \array_map( - function ( $user_id ) { - $actor = Actors::get_by_id( $user_id ); - return ! \is_wp_error( $actor ) ? $actor->get_id() : null; - }, - $accepted + 'followers' => array_filter( + \array_map( + function ( $user_id ) { + $actor = Actors::get_by_id( $user_id ); + return ! \is_wp_error( $actor ) ? $actor->get_id() : null; + }, + $accepted + ) ), - 'pending' => \array_map( - function ( $user_id ) { - $actor = Actors::get_by_id( $user_id ); - return ! \is_wp_error( $actor ) ? $actor->get_id() : null; - }, - $pending + 'pending' => array_filter( + \array_map( + function ( $user_id ) { + $actor = Actors::get_by_id( $user_id ); + return ! \is_wp_error( $actor ) ? $actor->get_id() : null; + }, + $pending + ) ), 'remote_post' => $post, ); From e28140d03324b54ec0efe6d0bbe1dc80e16482da Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 10:11:14 +0200 Subject: [PATCH 31/78] Remove redundant collection-synchronization header checks Moved the check for the 'collection-synchronization' header being part of the signature from the signature verification classes to the collection sync handler, as required by FEP. This streamlines the verification process and avoids duplicate checks. --- includes/handler/class-collection-sync.php | 7 ++++++ .../class-http-message-signature.php | 22 ------------------- .../signature/class-http-signature-draft.php | 10 --------- 3 files changed, 7 insertions(+), 32 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index dad138384..31a067899 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -42,6 +42,13 @@ public static function handle_collection_synchronization( $data, $user_id ) { return; } + // Check if sync-header is part of signature (required by FEP). + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $signature = $_SERVER['HTTP_SIGNATURE'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if ( false === \stripos( \wp_unslash( $signature ), 'collection-synchronization' ) ) { + return; + } + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput $sync_header = \wp_unslash( $_SERVER['HTTP_COLLECTION_SYNCHRONIZATION'] ); diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php index 0d9450eba..34142e3ed 100644 --- a/includes/signature/class-http-message-signature.php +++ b/includes/signature/class-http-message-signature.php @@ -143,28 +143,6 @@ public function verify( array $headers, $body = null ) { return $parsed; } - $errors = new \WP_Error(); - - $collection_header_present = isset( $headers['collection-synchronization'] ) || isset( $headers['collection_synchronization'] ); - if ( $collection_header_present ) { - $covered = false; - - foreach ( $parsed as $data ) { - foreach ( $data['components'] as $component ) { - $normalized = strtolower( trim( $component, '"' ) ); - - if ( 'collection-synchronization' === $normalized ) { - $covered = true; - break 2; - } - } - } - - if ( ! $covered ) { - $errors->add( 'activitypub_signature', 'Collection-Synchronization header must be signed.' ); - } - } - $errors = new \WP_Error(); foreach ( $parsed as $data ) { $result = $this->verify_signature_label( $data, $headers, $body ); diff --git a/includes/signature/class-http-signature-draft.php b/includes/signature/class-http-signature-draft.php index 6f3282613..54f5118e3 100644 --- a/includes/signature/class-http-signature-draft.php +++ b/includes/signature/class-http-signature-draft.php @@ -103,16 +103,6 @@ public function verify( array $headers, $body = null ) { return new \WP_Error( 'activitypub_signature', 'No Key ID present.' ); } - $collection_header_present = isset( $headers['collection-synchronization'] ) || isset( $headers['collection_synchronization'] ); - - if ( $collection_header_present ) { - $lower_headers = \array_map( 'strtolower', $parsed['headers'] ); - - if ( ! in_array( 'collection-synchronization', $lower_headers, true ) ) { - return new \WP_Error( 'activitypub_signature', 'Collection-Synchronization header must be signed.' ); - } - } - $public_key = Remote_Actors::get_public_key( $parsed['keyId'] ); if ( \is_wp_error( $public_key ) ) { return $public_key; From 5f78d3a6d7c94d5558c96856afcf12bf93c2ada7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 10:15:15 +0200 Subject: [PATCH 32/78] Refactor Followers sync header usage in HTTP class Replaces usage of Collection\Followers with direct Followers import for generating the synchronization header in the HTTP class. This streamlines the code and improves clarity by using the imported Followers class directly. --- includes/class-http.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/includes/class-http.php b/includes/class-http.php index a19cc67af..302320228 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -8,6 +8,7 @@ namespace Activitypub; use Activitypub\Collection\Actors; +use Activitypub\Collection\Followers; /** * ActivityPub HTTP Class @@ -59,11 +60,9 @@ public static function post( $url, $body, $user_id ) { $activity = \json_decode( $body ); if ( $activity && isset( $activity->type ) && 'Create' === $activity->type ) { $inbox_authority = self::get_authority( $url ); - if ( $inbox_authority ) { - $sync_header = Collection\Followers::generate_sync_header( $user_id, $inbox_authority ); - if ( $sync_header ) { - $args['headers']['Collection-Synchronization'] = $sync_header; - } + $sync_header = Followers::generate_sync_header( $user_id, $inbox_authority ); + if ( $sync_header ) { + $args['headers']['Collection-Synchronization'] = $sync_header; } } From 1278792c620ed9e891425a15e74fcc0171a29f60 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 10:26:34 +0200 Subject: [PATCH 33/78] Move collection sync logic to Signature class Refactored FEP-8fcf collection synchronization logic by moving parse_collection_sync_header and xor_hex_strings from Http to Signature, and added compute_collection_digest to Signature. Updated Followers and Collection_Sync classes to use the new Signature methods for digest computation and header parsing. This centralizes collection sync logic and improves code organization. --- includes/class-http.php | 61 -------------- includes/class-signature.php | 93 ++++++++++++++++++++++ includes/collection/class-followers.php | 11 ++- includes/handler/class-collection-sync.php | 5 +- 4 files changed, 104 insertions(+), 66 deletions(-) diff --git a/includes/class-http.php b/includes/class-http.php index 302320228..eac6966aa 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -296,67 +296,6 @@ public static function get_remote_object( $url_or_object, $cached = true ) { return $data; } - /** - * Parse a Collection-Synchronization header (FEP-8fcf). - * - * Parses the signature-style format used by the Collection-Synchronization header. - * - * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md - * - * @param string $header The header value. - * - * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure. - */ - public static function parse_collection_sync_header( $header ) { - if ( empty( $header ) ) { - return false; - } - - // Parse the signature-style format: key="value", key="value". - $params = array(); - - if ( preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { - foreach ( $matches as $match ) { - $params[ $match[1] ] = $match[2]; - } - } - - // Validate required fields for FEP-8fcf. - if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) { - return false; - } - - return $params; - } - - /** - * XOR two hexadecimal strings. - * - * Used for FEP-8fcf digest computation. - * - * @param string $hex1 First hex string. - * @param string $hex2 Second hex string. - * - * @return string The XORed result as a hex string. - */ - public static function xor_hex_strings( $hex1, $hex2 ) { - $result = ''; - - // Ensure both strings are the same length (should be 64 chars for SHA256). - $length = max( strlen( $hex1 ), strlen( $hex2 ) ); - $hex1 = str_pad( $hex1, $length, '0', STR_PAD_LEFT ); - $hex2 = str_pad( $hex2, $length, '0', STR_PAD_LEFT ); - - // XOR each pair of hex digits. - for ( $i = 0; $i < $length; $i += 2 ) { - $byte1 = hexdec( substr( $hex1, $i, 2 ) ); - $byte2 = hexdec( substr( $hex2, $i, 2 ) ); - $result .= str_pad( dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); - } - - return $result; - } - /** * Get the authority (scheme + host + port) from a URL. * diff --git a/includes/class-signature.php b/includes/class-signature.php index 2087f31be..57c7a76d2 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -465,4 +465,97 @@ public static function generate_digest( $body ) { $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode return "SHA-256=$digest"; } + + /** + * Compute the collection digest for a specific instance. + * + * Implements FEP-8fcf: Followers collection synchronization. + * The digest is created by XORing together the individual SHA256 digests + * of each follower's ID. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param array $collection The user ID whose followers to compute. + * + * @return string|false The hex-encoded digest, or false if no followers. + */ + public static function compute_collection_digest( $collection ) { + if ( empty( $collection ) || ! is_array( $collection ) ) { + return false; + } + + // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). + $digest = str_repeat( '0', 64 ); + + foreach ( $collection as $item ) { + // Compute SHA256 hash of the follower ID. + $hash = hash( 'sha256', $item ); + + // XOR the hash with the running digest. + $digest = self::xor_hex_strings( $digest, $hash ); + } + + return $digest; + } + + /** + * XOR two hexadecimal strings. + * + * Used for FEP-8fcf digest computation. + * + * @param string $hex1 First hex string. + * @param string $hex2 Second hex string. + * + * @return string The XORed result as a hex string. + */ + public static function xor_hex_strings( $hex1, $hex2 ) { + $result = ''; + + // Ensure both strings are the same length (should be 64 chars for SHA256). + $length = max( strlen( $hex1 ), strlen( $hex2 ) ); + $hex1 = str_pad( $hex1, $length, '0', STR_PAD_LEFT ); + $hex2 = str_pad( $hex2, $length, '0', STR_PAD_LEFT ); + + // XOR each pair of hex digits. + for ( $i = 0; $i < $length; $i += 2 ) { + $byte1 = hexdec( substr( $hex1, $i, 2 ) ); + $byte2 = hexdec( substr( $hex2, $i, 2 ) ); + $result .= str_pad( dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); + } + + return $result; + } + + /** + * Parse a Collection-Synchronization header (FEP-8fcf). + * + * Parses the signature-style format used by the Collection-Synchronization header. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param string $header The header value. + * + * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure. + */ + public static function parse_collection_sync_header( $header ) { + if ( empty( $header ) ) { + return false; + } + + // Parse the signature-style format: key="value", key="value". + $params = array(); + + if ( preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $params[ $match[1] ] = $match[2]; + } + } + + // Validate required fields for FEP-8fcf. + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) { + return false; + } + + return $params; + } } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 3ad6d83ea..fde2a077c 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -7,7 +7,7 @@ namespace Activitypub\Collection; -use Activitypub\Http; +use Activitypub\Signature; use Activitypub\Tombstone; use function Activitypub\get_remote_metadata_by_actor; @@ -509,7 +509,7 @@ public static function compute_partial_digest( $user_id, $authority ) { $hash = hash( 'sha256', $follower_url ); // XOR the hash with the running digest. - $digest = Http::xor_hex_strings( $digest, $hash ); + $digest = Signature::xor_hex_strings( $digest, $hash ); } return $digest; @@ -584,8 +584,13 @@ public static function get_partial_followers( $user_id, $authority ) { * @return string|false The header value, or false if cannot generate. */ public static function generate_sync_header( $user_id, $authority ) { + $followers = self::get_partial_followers( $user_id, $authority ); // Compute the digest for this specific authority. - $digest = self::compute_partial_digest( $user_id, $authority ); + $digest = Signature::compute_collection_digest( $followers ); + + if ( ! $digest ) { + return false; + } // Build the collection ID (followers collection URL). $collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) ); diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 31a067899..31beb9ed8 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -10,6 +10,7 @@ use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Actors; use Activitypub\Http; +use Activitypub\Signature; /** * Collection Sync Handler. @@ -53,7 +54,7 @@ public static function handle_collection_synchronization( $data, $user_id ) { $sync_header = \wp_unslash( $_SERVER['HTTP_COLLECTION_SYNCHRONIZATION'] ); // Parse the header using the generic HTTP parser. - $params = Http::parse_collection_sync_header( $sync_header ); + $params = Signature::parse_collection_sync_header( $sync_header ); if ( false === $params ) { return; @@ -268,7 +269,7 @@ protected static function compute_digest_from_actor_urls( array $actor_urls ) { $digest = str_repeat( '0', 64 ); foreach ( $actor_urls as $actor_uri ) { - $digest = Http::xor_hex_strings( $digest, hash( 'sha256', $actor_uri ) ); + $digest = Signature::xor_hex_strings( $digest, hash( 'sha256', $actor_uri ) ); } return $digest; From c5df052cc4ed7255ffe1e46350aa7ac369149f78 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 11:05:54 +0200 Subject: [PATCH 34/78] Move Collection-Synchronization header logic to filter Refactored the addition of the Collection-Synchronization header for Create activities (FEP-8fcf) from Http to Collection_Sync via a new http_request_args filter. This centralizes header logic, improves maintainability, and ensures the header is added consistently. Also adjusted the filter priority in Signature for better compatibility. --- includes/class-http.php | 12 +-------- includes/class-signature.php | 2 +- includes/handler/class-collection-sync.php | 31 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/includes/class-http.php b/includes/class-http.php index eac6966aa..34cb4285b 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -8,7 +8,6 @@ namespace Activitypub; use Activitypub\Collection\Actors; -use Activitypub\Collection\Followers; /** * ActivityPub HTTP Class @@ -54,18 +53,9 @@ public static function post( $url, $body, $user_id ) { 'body' => $body, 'key_id' => \json_decode( $body )->actor . '#main-key', 'private_key' => Actors::get_private_key( $user_id ), + 'user_id' => $user_id, ); - // FEP-8fcf: Add Collection-Synchronization header for Create activities. - $activity = \json_decode( $body ); - if ( $activity && isset( $activity->type ) && 'Create' === $activity->type ) { - $inbox_authority = self::get_authority( $url ); - $sync_header = Followers::generate_sync_header( $user_id, $inbox_authority ); - if ( $sync_header ) { - $args['headers']['Collection-Synchronization'] = $sync_header; - } - } - $response = \wp_safe_remote_post( $url, $args ); $code = \wp_remote_retrieve_response_code( $response ); diff --git a/includes/class-signature.php b/includes/class-signature.php index 57c7a76d2..fbb50c1a5 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -24,7 +24,7 @@ class Signature { * Initialize the class. */ public static function init() { - \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 0, 2 ); // Ahead of all other filters, so signature is set. + \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 5, 2 ); // Ahead of most other filters, so signature is set. \add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 ); } diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 31beb9ed8..39f5e7880 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -7,6 +7,7 @@ namespace Activitypub\Handler; +use Activitypub\Collection\Followers; use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Actors; use Activitypub\Http; @@ -25,6 +26,8 @@ class Collection_Sync { */ public static function init() { \add_action( 'activitypub_inbox_create', array( self::class, 'handle_collection_synchronization' ), 10, 2 ); + + \add_filter( 'http_request_args', array( self::class, 'maybe_add_headers' ), 1, 2 ); } /** @@ -89,6 +92,34 @@ public static function handle_collection_synchronization( $data, $user_id ) { } } + /** + * Add Collection-Synchronization header to Create activities (FEP-8fcf). + * + * This method adds the Collection-Synchronization header to outgoing Create activities. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param array $args The HTTP request arguments. + * @param string $url The request URL. + * + * @return array Modified HTTP request arguments. + */ + public static function maybe_add_headers( $args, $url ) { + // FEP-8fcf: Add Collection-Synchronization header for Create activities. + $activity = \json_decode( $args['body'] ?? '' ); + if ( ! $activity || ! isset( $activity->type ) || 'Create' !== $activity->type ) { + return $args; + } + + $inbox_authority = Http::get_authority( $url ); + $sync_header = Followers::generate_sync_header( $args['user_id'], $inbox_authority ); + if ( $sync_header ) { + $args['headers']['Collection-Synchronization'] = $sync_header; + } + + return $args; + } + /** * Detect the collection type from a URL. * From 799a3e3a9e06ce15be56c8401796627ffa38f134 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 11:08:45 +0200 Subject: [PATCH 35/78] Add user_id to signature test cases Included the 'user_id' parameter in relevant test cases within Test_Signature to ensure proper context for signature generation. Also updated a signature assertion to remove 'collection-synchronization' from the expected headers. --- tests/phpunit/tests/includes/class-test-signature.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/includes/class-test-signature.php b/tests/phpunit/tests/includes/class-test-signature.php index 6f64a46f3..ebf28dfda 100644 --- a/tests/phpunit/tests/includes/class-test-signature.php +++ b/tests/phpunit/tests/includes/class-test-signature.php @@ -324,6 +324,7 @@ function () use ( $keys ) { 'body' => '{"type":"Create","actor":"https://example.org/author/admin","object":{"type":"Note","content":"Test content."}}', 'key_id' => 'https://example.org/author/admin#main-key', 'private_key' => Actors::get_private_key( 1 ), + 'user_id' => 1, 'headers' => array( 'Content-Type' => 'application/activity+json', 'Date' => \gmdate( 'D, d M Y H:i:s T' ), @@ -402,6 +403,7 @@ function () use ( $keys ) { ), 'key_id' => 'https://example.org/author/admin#main-key', 'private_key' => \openssl_pkey_get_private( $keys['private_key'] ), + 'user_id' => 1, ), 'https://example.org/wp-json/activitypub/1.0/inbox' ); @@ -680,7 +682,7 @@ public function test_rfc9421_is_unsupported() { $test = function ( $args ) { $this->assertFalse( isset( $args['headers']['Signature-Input'] ) ); // FEP-8fcf: Collection-Synchronization header is added for Create activities. - $this->assertStringContainsString( 'headers="(request-target) host date digest collection-synchronization"', $args['headers']['Signature'] ); + $this->assertStringContainsString( 'headers="(request-target) host date digest"', $args['headers']['Signature'] ); return $args; }; From 7861db74023eff30f519b1db41468f6712eb0ad2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 11:24:04 +0200 Subject: [PATCH 36/78] Update REST API endpoints to use 'actors' instead of 'users' Refactored all relevant test files to use '/actors/' in REST API endpoints instead of '/users/' for consistency with the API specification. Also updated class-signature.php to use fully qualified PHP function names for better clarity and to avoid namespace issues. --- includes/class-signature.php | 14 +++++++------- .../specs/includes/rest/actors-controller.test.js | 2 +- .../includes/rest/following-controller.test.js | 2 +- .../specs/includes/rest/inbox-controller.test.js | 2 +- .../specs/includes/rest/outbox-controller.test.js | 2 +- .../specs/includes/rest/replies-controller.test.js | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/includes/class-signature.php b/includes/class-signature.php index fbb50c1a5..bfe8db3fe 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -512,15 +512,15 @@ public static function xor_hex_strings( $hex1, $hex2 ) { $result = ''; // Ensure both strings are the same length (should be 64 chars for SHA256). - $length = max( strlen( $hex1 ), strlen( $hex2 ) ); - $hex1 = str_pad( $hex1, $length, '0', STR_PAD_LEFT ); - $hex2 = str_pad( $hex2, $length, '0', STR_PAD_LEFT ); + $length = \max( \strlen( $hex1 ), \strlen( $hex2 ) ); + $hex1 = \str_pad( $hex1, $length, '0', STR_PAD_LEFT ); + $hex2 = \str_pad( $hex2, $length, '0', STR_PAD_LEFT ); // XOR each pair of hex digits. for ( $i = 0; $i < $length; $i += 2 ) { - $byte1 = hexdec( substr( $hex1, $i, 2 ) ); - $byte2 = hexdec( substr( $hex2, $i, 2 ) ); - $result .= str_pad( dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); + $byte1 = \hexdec( \substr( $hex1, $i, 2 ) ); + $byte2 = \hexdec( \substr( $hex2, $i, 2 ) ); + $result .= \str_pad( \dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); } return $result; @@ -545,7 +545,7 @@ public static function parse_collection_sync_header( $header ) { // Parse the signature-style format: key="value", key="value". $params = array(); - if ( preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { + if ( \preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $params[ $match[1] ] = $match[2]; } diff --git a/tests/e2e/specs/includes/rest/actors-controller.test.js b/tests/e2e/specs/includes/rest/actors-controller.test.js index a59a46d96..38ccd8898 100644 --- a/tests/e2e/specs/includes/rest/actors-controller.test.js +++ b/tests/e2e/specs/includes/rest/actors-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Actors REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - actorEndpoint = `/activitypub/1.0/users/${ testUserId }`; + actorEndpoint = `/activitypub/1.0/actors/${ testUserId }`; } ); test( 'should return 200 status code for actor endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/following-controller.test.js b/tests/e2e/specs/includes/rest/following-controller.test.js index c317553f2..78d241203 100644 --- a/tests/e2e/specs/includes/rest/following-controller.test.js +++ b/tests/e2e/specs/includes/rest/following-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Following Collection REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - followingEndpoint = `/activitypub/1.0/users/${ testUserId }/following`; + followingEndpoint = `/activitypub/1.0/actors/${ testUserId }/following`; } ); test( 'should return 200 status code for following endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/inbox-controller.test.js b/tests/e2e/specs/includes/rest/inbox-controller.test.js index 20229640c..bfdad540b 100644 --- a/tests/e2e/specs/includes/rest/inbox-controller.test.js +++ b/tests/e2e/specs/includes/rest/inbox-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Inbox REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - inboxEndpoint = `/activitypub/1.0/users/${ testUserId }/inbox`; + inboxEndpoint = `/activitypub/1.0/actors/${ testUserId }/inbox`; } ); test( 'should return 200 status code for inbox GET endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/outbox-controller.test.js b/tests/e2e/specs/includes/rest/outbox-controller.test.js index 57ca932d7..f57e46b02 100644 --- a/tests/e2e/specs/includes/rest/outbox-controller.test.js +++ b/tests/e2e/specs/includes/rest/outbox-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Outbox REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - outboxEndpoint = `/activitypub/1.0/users/${ testUserId }/outbox`; + outboxEndpoint = `/activitypub/1.0/actors/${ testUserId }/outbox`; } ); test( 'should return 200 status code for outbox endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/replies-controller.test.js b/tests/e2e/specs/includes/rest/replies-controller.test.js index 88ab52738..499bf0ee9 100644 --- a/tests/e2e/specs/includes/rest/replies-controller.test.js +++ b/tests/e2e/specs/includes/rest/replies-controller.test.js @@ -12,7 +12,7 @@ test.describe( 'ActivityPub Replies Collection REST API', () => { // Use the default test user and a sample post testUserId = 1; testPostId = 1; // Assuming a post exists - repliesEndpoint = `/activitypub/1.0/users/${ testUserId }/posts/${ testPostId }/replies`; + repliesEndpoint = `/activitypub/1.0/actors/${ testUserId }/posts/${ testPostId }/replies`; } ); test( 'should return 200 status code for replies endpoint', async ( { requestUtils } ) => { From acc08c59d358c37a58f831852cf7e8e5500dbbf9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 11:36:51 +0200 Subject: [PATCH 37/78] Adjust filter priorities and signature header handling Set 'http_request_args' filter in Signature to priority 0 and in Collection_Sync to -1 for better execution order. Update Collection_Sync to check 'HTTP_SIGNATURE_INPUT' header and always unslash signature input for improved FEP compliance. --- includes/class-signature.php | 2 +- includes/handler/class-collection-sync.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/class-signature.php b/includes/class-signature.php index bfe8db3fe..8abee7bc3 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -24,7 +24,7 @@ class Signature { * Initialize the class. */ public static function init() { - \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 5, 2 ); // Ahead of most other filters, so signature is set. + \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 0, 2 ); // Ahead of most other filters, so signature is set. \add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 ); } diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 39f5e7880..0ec74736a 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -27,7 +27,7 @@ class Collection_Sync { public static function init() { \add_action( 'activitypub_inbox_create', array( self::class, 'handle_collection_synchronization' ), 10, 2 ); - \add_filter( 'http_request_args', array( self::class, 'maybe_add_headers' ), 1, 2 ); + \add_filter( 'http_request_args', array( self::class, 'maybe_add_headers' ), -1, 2 ); } /** @@ -47,9 +47,9 @@ public static function handle_collection_synchronization( $data, $user_id ) { } // Check if sync-header is part of signature (required by FEP). - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $signature = $_SERVER['HTTP_SIGNATURE'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - if ( false === \stripos( \wp_unslash( $signature ), 'collection-synchronization' ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $signature = \wp_unslash( $_SERVER['HTTP_SIGNATURE_INPUT'] ?? $_SERVER['HTTP_SIGNATURE'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '' ); + if ( false === \stripos( $signature, 'collection-synchronization' ) ) { return; } From 960ae632a244bdfb963ec334cec713cf2cad5898 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 11:48:27 +0200 Subject: [PATCH 38/78] Improve header addition logic in maybe_add_headers Refactors maybe_add_headers to handle both JSON string and array bodies, ensuring the Collection-Synchronization header is only added for 'Create' activities. This improves robustness when processing different body formats. --- includes/handler/class-collection-sync.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 0ec74736a..586439b81 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -105,9 +105,21 @@ public static function handle_collection_synchronization( $data, $user_id ) { * @return array Modified HTTP request arguments. */ public static function maybe_add_headers( $args, $url ) { - // FEP-8fcf: Add Collection-Synchronization header for Create activities. - $activity = \json_decode( $args['body'] ?? '' ); - if ( ! $activity || ! isset( $activity->type ) || 'Create' !== $activity->type ) { + if ( empty( $args['body'] ) ) { + return $args; + } + + if ( ! is_array( $args['body'] ) ) { + $body = \json_decode( $args['body'], true ); + if ( null === $body ) { + return $args; + } + $args['body'] = $body; + } else { + $body = $args['body']; + } + + if ( ! isset( $body['type'] ) || 'Create' !== $body['type'] ) { return $args; } From 5553b62a0c97623999048eccad628aec95d5bf34 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 11:55:37 +0200 Subject: [PATCH 39/78] Remove redundant assignment in request body handling Eliminates an unnecessary assignment of the 'body' parameter in the Collection_Sync class, as the value is already present in the arguments. This streamlines the code and avoids redundant operations. --- includes/handler/class-collection-sync.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 586439b81..30abd1e6a 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -114,7 +114,6 @@ public static function maybe_add_headers( $args, $url ) { if ( null === $body ) { return $args; } - $args['body'] = $body; } else { $body = $args['body']; } From d1d924d653fc7268f0d8af7b519bad7ad8b42f3c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 12:17:02 +0200 Subject: [PATCH 40/78] Clarify docblocks for Create activities header Updated docblocks to consistently refer to `Create` activities in code comments, improving clarity and accuracy. --- includes/handler/class-collection-sync.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 30abd1e6a..d2511ef4e 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -93,9 +93,9 @@ public static function handle_collection_synchronization( $data, $user_id ) { } /** - * Add Collection-Synchronization header to Create activities (FEP-8fcf). + * Add Collection-Synchronization header to `Create` activities (FEP-8fcf). * - * This method adds the Collection-Synchronization header to outgoing Create activities. + * This method adds the Collection-Synchronization header to outgoing `Create` activities. * * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md * From 5caf4e93b081deda907f72eb7fdabaca8c577db3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 12:25:03 +0200 Subject: [PATCH 41/78] Move get_authority to functions.php and refactor usage The get_authority method was removed from Http and reimplemented as get_url_authority in functions.php. All usages in collection sync handlers and scheduler were updated to use the new function, and related validation logic was refactored for consistency. --- includes/class-http.php | 29 ----------------- includes/functions.php | 30 ++++++++++++++++++ includes/handler/class-collection-sync.php | 33 ++++++-------------- includes/scheduler/class-collection-sync.php | 8 +++-- 4 files changed, 44 insertions(+), 56 deletions(-) diff --git a/includes/class-http.php b/includes/class-http.php index 34cb4285b..9e2d64cfd 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -285,33 +285,4 @@ public static function get_remote_object( $url_or_object, $cached = true ) { return $data; } - - /** - * Get the authority (scheme + host + port) from a URL. - * - * @param string $url The URL to parse. - * - * @return string|false The authority, or false on failure. - */ - public static function get_authority( $url ) { - $parsed = wp_parse_url( $url ); - - if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { - return false; - } - - $authority = $parsed['scheme'] . '://' . $parsed['host']; - - if ( ! empty( $parsed['port'] ) ) { - $default_ports = array( - 'http' => 80, - 'https' => 443, - ); - if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { - $authority .= ':' . $parsed['port']; - } - } - - return $authority; - } } diff --git a/includes/functions.php b/includes/functions.php index e46da9639..b7131799c 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1790,3 +1790,33 @@ function extract_name_from_uri( $uri ) { return $name; } + +/** + * Get the authority (scheme + host + port) from a URL. + * + * @param string $url The URL to parse. + * + * @return string|false The authority, or false on failure. + */ +function get_url_authority( $url ) { + $parsed = wp_parse_url( $url ); + + if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { + return false; + } + + $authority = $parsed['scheme'] . '://' . $parsed['host']; + + if ( ! empty( $parsed['port'] ) ) { + $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + + if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { + $authority .= ':' . $parsed['port']; + } + } + + return $authority; +} diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index d2511ef4e..d446b4622 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -10,9 +10,10 @@ use Activitypub\Collection\Followers; use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Actors; -use Activitypub\Http; use Activitypub\Signature; +use function Activitypub\get_url_authority; + /** * Collection Sync Handler. * @@ -122,7 +123,7 @@ public static function maybe_add_headers( $args, $url ) { return $args; } - $inbox_authority = Http::get_authority( $url ); + $inbox_authority = get_url_authority( $url ); $sync_header = Followers::generate_sync_header( $args['user_id'], $inbox_authority ); if ( $sync_header ) { $args['headers']['Collection-Synchronization'] = $sync_header; @@ -163,12 +164,12 @@ protected static function detect_collection_type( $url ) { */ protected static function process_followers_collection_sync( $params, $user_id, $actor_url ) { // Validate the header parameters. - if ( ! self::validate_collection_sync_header_params( $params, $actor_url ) ) { + if ( ! self::validate_header_params( $params, $actor_url ) ) { return; } // Get our local authority. - $our_authority = Http::get_authority( \home_url() ); + $our_authority = get_url_authority( \home_url() ); if ( ! $our_authority ) { return; @@ -214,7 +215,7 @@ protected static function process_followers_collection_sync( $params, $user_id, * * @return bool True if valid, false otherwise. */ - public static function validate_collection_sync_header_params( $params, $actor_url ) { + public static function validate_header_params( $params, $actor_url ) { if ( empty( $params['collectionId'] ) || empty( $params['url'] ) ) { return false; } @@ -233,25 +234,9 @@ public static function validate_collection_sync_header_params( $params, $actor_u } } - // Check if url has the same authority as collectionId (prevent SSRF). - $collection_parsed = wp_parse_url( $params['collectionId'] ); - $url_parsed = wp_parse_url( $params['url'] ); - - if ( ! $collection_parsed || ! $url_parsed ) { - return false; - } - // Build authorities for comparison. - $collection_authority = $collection_parsed['scheme'] . '://' . $collection_parsed['host']; - $url_authority = $url_parsed['scheme'] . '://' . $url_parsed['host']; - - if ( ! empty( $collection_parsed['port'] ) ) { - $collection_authority .= ':' . $collection_parsed['port']; - } - - if ( ! empty( $url_parsed['port'] ) ) { - $url_authority .= ':' . $url_parsed['port']; - } + $collection_authority = get_url_authority( $params['collectionId'] ); + $url_authority = get_url_authority( $params['url'] ); return $collection_authority === $url_authority; } @@ -290,7 +275,7 @@ protected static function filter_actor_urls_by_authority( array $actor_urls, $au $matched = array(); foreach ( $actor_urls as $actor_uri ) { - $actor_authority = Http::get_authority( $actor_uri ); + $actor_authority = get_url_authority( $actor_uri ); if ( $actor_authority && $actor_authority === $authority ) { $matched[] = $actor_uri; diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index 9f7aed14d..c1cf2a6ff 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -14,6 +14,8 @@ use Activitypub\Collection\Following; use Activitypub\Http; +use function Activitypub\get_url_authority; + /** * Collection_Sync class. */ @@ -71,7 +73,7 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { $remote_followers = $data['orderedItems']; // Get our authority. - $our_authority = Http::get_authority( \home_url() ); + $our_authority = get_url_authority( \home_url() ); if ( ! $our_authority ) { return; @@ -170,7 +172,7 @@ protected static function filter_actor_list_by_authority( array $actors, $author continue; } - $actor_authority = Http::get_authority( $actor_uri ); + $actor_authority = get_url_authority( $actor_uri ); if ( $actor_authority && $actor_authority === $authority ) { $matched[] = $actor_uri; @@ -192,7 +194,7 @@ protected static function filter_actor_map_by_authority( array $map, $authority $filtered = array(); foreach ( $map as $actor_uri => $user_id ) { - $actor_authority = Http::get_authority( $actor_uri ); + $actor_authority = get_url_authority( $actor_uri ); if ( $actor_authority && $actor_authority === $authority ) { $filtered[ $actor_uri ] = $user_id; From 39b4b3c9912b28340d7fc2af849fc68cb1aba5e6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 12:29:22 +0200 Subject: [PATCH 42/78] Refactor digest computation to use Signature class Replaces the local compute_digest_from_actor_urls method with Signature::compute_collection_digest and removes the redundant method. This centralizes digest logic in the Signature class for better maintainability. --- includes/handler/class-collection-sync.php | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index d446b4622..1fa1b20c2 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -187,7 +187,7 @@ protected static function process_followers_collection_sync( $params, $user_id, return; } - $local_digest = self::compute_digest_from_actor_urls( $local_actor_urls ); + $local_digest = Signature::compute_collection_digest( $local_actor_urls ); if ( \hash_equals( $local_digest, $remote_digest ) ) { return; @@ -285,23 +285,6 @@ protected static function filter_actor_urls_by_authority( array $actor_urls, $au return $matched; } - /** - * Compute the partial collection digest from a list of actor URLs. - * - * @param array $actor_urls Actor URLs to include in the digest. - * - * @return string The computed digest. - */ - protected static function compute_digest_from_actor_urls( array $actor_urls ) { - $digest = str_repeat( '0', 64 ); - - foreach ( $actor_urls as $actor_uri ) { - $digest = Signature::xor_hex_strings( $digest, hash( 'sha256', $actor_uri ) ); - } - - return $digest; - } - /** * Retrieve the followers collection ID for the remote actor if known. * From bd3f0bfb96cbcae4be3257a8c1e693eac422cc3e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 12:36:09 +0200 Subject: [PATCH 43/78] Refactor followers collection ID validation logic Simplifies the validation of the followers collection ID by removing redundant normalization and default handling. Updates get_followers_collection_id to return WP_Error on failure and uses trailingslashit for comparison. Removes the unused normalize_collection_url method. --- includes/handler/class-collection-sync.php | 41 +++++----------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 1fa1b20c2..0466b0a14 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -222,16 +222,12 @@ public static function validate_header_params( $params, $actor_url ) { $expected_collection = self::get_followers_collection_id( $actor_url ); - if ( $expected_collection ) { - if ( self::normalize_collection_url( $params['collectionId'] ) !== self::normalize_collection_url( $expected_collection ) ) { - return false; - } - } else { - $default_collection = rtrim( $actor_url, '/' ) . '/followers'; + if ( \is_wp_error( $expected_collection ) ) { + return false; + } - if ( self::normalize_collection_url( $params['collectionId'] ) !== self::normalize_collection_url( $default_collection ) ) { - return false; - } + if ( trailingslashit( $params['collectionId'] ) !== trailingslashit( $expected_collection ) ) { + return false; } // Build authorities for comparison. @@ -290,40 +286,21 @@ protected static function filter_actor_urls_by_authority( array $actor_urls, $au * * @param string $actor_url The remote actor URL. * - * @return string|null The followers collection ID or null if unavailable. + * @return string|\WP_Error The followers collection ID or null if unavailable. */ protected static function get_followers_collection_id( $actor_url ) { - $post = Remote_Actors::get_by_uri( $actor_url ); + $post = Remote_Actors::fetch_by_uri( $actor_url ); if ( \is_wp_error( $post ) ) { - $post = Remote_Actors::fetch_by_uri( $actor_url ); - - if ( \is_wp_error( $post ) ) { - return null; - } + return $post; } $actor = Remote_Actors::get_actor( $post ); if ( \is_wp_error( $actor ) ) { - return null; + return $actor; } return $actor->get_followers(); } - - /** - * Normalize a collection URL for comparison. - * - * @param string $url The URL to normalize. - * - * @return string Normalized URL without trailing slash. - */ - protected static function normalize_collection_url( $url ) { - if ( ! \is_string( $url ) ) { - return ''; - } - - return rtrim( $url, '/' ); - } } From 3b91c810216becbd645002fe68cc661e82828c1b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 12:52:54 +0200 Subject: [PATCH 44/78] Refactor collection sync to use action hook Replaces direct processing of followers collection sync with a generic 'activitypub_collection_sync' action hook, allowing for async processing and extensibility. Removes specific methods for detecting collection type and processing followers sync, simplifying the handler logic. --- includes/handler/class-collection-sync.php | 148 +++------------------ 1 file changed, 20 insertions(+), 128 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 0466b0a14..5c9686a1f 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -8,7 +8,6 @@ namespace Activitypub\Handler; use Activitypub\Collection\Followers; -use Activitypub\Collection\Following; use Activitypub\Collection\Remote_Actors; use Activitypub\Signature; @@ -69,8 +68,11 @@ public static function handle_collection_synchronization( $data, $user_id ) { return; } - // Determine the collection type from the URL. - $collection_type = self::detect_collection_type( $params['url'] ); + // Check for followers collection. + $collection_type = null; + if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) { + $collection_type = 'followers'; + } if ( ! $collection_type ) { // Unknown or unsupported collection type. @@ -84,13 +86,22 @@ public static function handle_collection_synchronization( $data, $user_id ) { return; } - switch ( $collection_type ) { - case 'followers': - self::process_followers_collection_sync( $params, $user_id, $actor_url ); - break; - default: - break; + // Validate the header parameters. + if ( ! self::validate_header_params( $params, $actor_url ) ) { + return; } + + /** + * Action triggered Collection Sync. + * + * This allows for async processing of the reconciliation. + * + * @param string $collection_type The collection type (e.g., 'followers', 'following', 'liked'). + * @param int $user_id The local user ID. + * @param string $actor_url The remote actor URL. + * @param array $params The parsed Collection-Synchronization header parameters. + */ + \do_action( 'activitypub_collection_sync', $collection_type, $user_id, $actor_url, $params ); } /** @@ -132,81 +143,6 @@ public static function maybe_add_headers( $args, $url ) { return $args; } - /** - * Detect the collection type from a URL. - * - * @param string $url The collection URL. - * @return string|false The collection type (e.g., 'followers', 'following', 'liked') or false if unknown. - */ - protected static function detect_collection_type( $url ) { - // Check for followers collection. - if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $url ) ) { - return 'followers'; - } - - /** - * Filters the collection type detection. - * - * Allows plugins to register custom collection types for synchronization. - * - * @param string|false $type The detected collection type, or false if unknown. - * @param string $url The collection URL. - */ - return \apply_filters( 'activitypub_detect_collection_type', false, $url ); - } - - /** - * Process followers collection synchronization. - * - * @param array $params The parsed Collection-Synchronization header parameters. - * @param int $user_id The local user ID. - * @param string $actor_url The remote actor URL. - */ - protected static function process_followers_collection_sync( $params, $user_id, $actor_url ) { - // Validate the header parameters. - if ( ! self::validate_header_params( $params, $actor_url ) ) { - return; - } - - // Get our local authority. - $our_authority = get_url_authority( \home_url() ); - - if ( ! $our_authority ) { - return; - } - - $local_actor_urls = self::get_local_actor_urls_for_remote( $actor_url, $our_authority ); - - if ( \is_wp_error( $local_actor_urls ) ) { - return; - } - - $remote_digest = strtolower( trim( $params['digest'] ) ); - - if ( 64 !== strlen( $remote_digest ) || preg_match( '/[^0-9a-f]/', $remote_digest ) ) { - return; - } - - $local_digest = Signature::compute_collection_digest( $local_actor_urls ); - - if ( \hash_equals( $local_digest, $remote_digest ) ) { - return; - } - - // Digests do not match, trigger reconciliation. - - /** - * Action triggered when Collection-Synchronization digest mismatch is detected for followers. - * - * This allows for async processing of the reconciliation. - * - * @param int $user_id The local user ID. - * @param string $actor_url The remote actor URL. - * @param array $params The parsed Collection-Synchronization header parameters. - */ - \do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); - } - /** * Validate Collection-Synchronization header parameters. * @@ -237,50 +173,6 @@ public static function validate_header_params( $params, $actor_url ) { return $collection_authority === $url_authority; } - /** - * Retrieve local actor URLs that follow the remote actor and share the given authority. - * - * @param string $actor_url The remote actor URL. - * @param string $authority The authority to filter by. - * - * @return array|\WP_Error Array of actor URLs or WP_Error on failure. - */ - protected static function get_local_actor_urls_for_remote( $actor_url, $authority ) { - $snapshot = Following::get_local_followers_snapshot( $actor_url ); - - if ( \is_wp_error( $snapshot ) ) { - return $snapshot; - } - - $actor_urls = array_keys( $snapshot['followers'] ); - $actor_urls = self::filter_actor_urls_by_authority( $actor_urls, $authority ); - sort( $actor_urls ); - - return $actor_urls; - } - - /** - * Filter actor URLs by authority. - * - * @param array $actor_urls Array of actor URLs. - * @param string $authority Authority to match. - * - * @return array Filtered list of actor URLs. - */ - protected static function filter_actor_urls_by_authority( array $actor_urls, $authority ) { - $matched = array(); - - foreach ( $actor_urls as $actor_uri ) { - $actor_authority = get_url_authority( $actor_uri ); - - if ( $actor_authority && $actor_authority === $authority ) { - $matched[] = $actor_uri; - } - } - - return $matched; - } - /** * Retrieve the followers collection ID for the remote actor if known. * From 3e4490e9456921bc1490c3e6a743f480f65acbd1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 13:05:00 +0200 Subject: [PATCH 45/78] Remove HTTP_SIGNATURE_INPUT from signature check Updated the signature extraction logic in Collection_Sync to exclude the HTTP_SIGNATURE_INPUT header, now only considering HTTP_SIGNATURE and HTTP_AUTHORIZATION. This aligns with FEP requirements and simplifies the header handling. --- includes/handler/class-collection-sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 5c9686a1f..da4e6e59f 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -48,7 +48,7 @@ public static function handle_collection_synchronization( $data, $user_id ) { // Check if sync-header is part of signature (required by FEP). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $signature = \wp_unslash( $_SERVER['HTTP_SIGNATURE_INPUT'] ?? $_SERVER['HTTP_SIGNATURE'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '' ); + $signature = \wp_unslash( $_SERVER['HTTP_SIGNATURE'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '' ); if ( false === \stripos( $signature, 'collection-synchronization' ) ) { return; } From 62a5f59088b8e9e9fbdfcfbca9e81d2df1bc1505 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 13:11:19 +0200 Subject: [PATCH 46/78] Remove outdated comment from signature test Deleted a comment referencing the Collection-Synchronization header in the Create activities test, as it is no longer relevant to the test logic. --- tests/phpunit/tests/includes/class-test-signature.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/includes/class-test-signature.php b/tests/phpunit/tests/includes/class-test-signature.php index ebf28dfda..36f98d3f3 100644 --- a/tests/phpunit/tests/includes/class-test-signature.php +++ b/tests/phpunit/tests/includes/class-test-signature.php @@ -681,7 +681,6 @@ public function test_rfc9421_is_unsupported() { $test = function ( $args ) { $this->assertFalse( isset( $args['headers']['Signature-Input'] ) ); - // FEP-8fcf: Collection-Synchronization header is added for Create activities. $this->assertStringContainsString( 'headers="(request-target) host date digest"', $args['headers']['Signature'] ); return $args; From ca29f6845f26caa67f3507f278fbf1c91805ee93 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 13:12:19 +0200 Subject: [PATCH 47/78] Clarify comment about filter priority in Signature class Updated the comment to specify that the 'http_request_args' filter runs ahead of all other filters, clarifying the intent for setting the signature early. --- includes/class-signature.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-signature.php b/includes/class-signature.php index 8abee7bc3..aaeea0711 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -24,7 +24,7 @@ class Signature { * Initialize the class. */ public static function init() { - \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 0, 2 ); // Ahead of most other filters, so signature is set. + \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 0, 2 ); // Ahead of all other filters, so signature is set. \add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 ); } From 23cbd76a5b88792e432e7ab15ea1340667de16a1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 16:58:38 +0200 Subject: [PATCH 48/78] Refactor collection sync handling and scheduler Removes redundant URL parameter check and the get_followers_collection_id method from Collection_Sync handler, simplifying followers collection retrieval. Updates the scheduler to generalize reconciliation scheduling for different collection types and removes unused filtering methods. --- includes/handler/class-collection-sync.php | 42 +++++---------- includes/scheduler/class-collection-sync.php | 55 ++------------------ 2 files changed, 17 insertions(+), 80 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index da4e6e59f..b5f85ea48 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -63,11 +63,6 @@ public static function handle_collection_synchronization( $data, $user_id ) { return; } - // Ensure we have a URL parameter to determine collection type. - if ( ! isset( $params['url'] ) ) { - return; - } - // Check for followers collection. $collection_type = null; if ( preg_match( '#/followers(?:/sync)?(?:\?|$)#', $params['url'] ) ) { @@ -156,7 +151,19 @@ public static function validate_header_params( $params, $actor_url ) { return false; } - $expected_collection = self::get_followers_collection_id( $actor_url ); + $post = Remote_Actors::fetch_by_uri( $actor_url ); + + if ( \is_wp_error( $post ) ) { + return false; + } + + $actor = Remote_Actors::get_actor( $post ); + + if ( \is_wp_error( $actor ) ) { + return false; + } + + $expected_collection = $actor->get_followers(); if ( \is_wp_error( $expected_collection ) ) { return false; @@ -172,27 +179,4 @@ public static function validate_header_params( $params, $actor_url ) { return $collection_authority === $url_authority; } - - /** - * Retrieve the followers collection ID for the remote actor if known. - * - * @param string $actor_url The remote actor URL. - * - * @return string|\WP_Error The followers collection ID or null if unavailable. - */ - protected static function get_followers_collection_id( $actor_url ) { - $post = Remote_Actors::fetch_by_uri( $actor_url ); - - if ( \is_wp_error( $post ) ) { - return $post; - } - - $actor = Remote_Actors::get_actor( $post ); - - if ( \is_wp_error( $actor ) ) { - return $actor; - } - - return $actor->get_followers(); - } } diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index c1cf2a6ff..e7e97b4e8 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -24,22 +24,23 @@ class Collection_Sync { * Initialize the scheduler. */ public static function init() { - \add_action( 'activitypub_followers_sync_mismatch', array( self::class, 'schedule_reconciliation' ), 10, 3 ); + \add_action( 'activitypub_collection_sync', array( self::class, 'schedule_reconciliation' ), 10, 4 ); \add_action( 'activitypub_followers_sync_reconcile', array( self::class, 'reconcile_followers' ), 10, 3 ); } /** * Schedule a reconciliation job. * + * @param string $type The collection type (e.g., 'followers'). * @param int $user_id The local user ID. * @param string $actor_url The remote actor URL. * @param array $params The Collection-Synchronization header parameters. */ - public static function schedule_reconciliation( $user_id, $actor_url, $params ) { + public static function schedule_reconciliation( $type, $user_id, $actor_url, $params ) { // Schedule async processing to avoid blocking the inbox. \wp_schedule_single_event( time() + 60, // Process in 1 minute. - 'activitypub_followers_sync_reconcile', + "activitypub_{$type}_sync_reconcile", array( $user_id, $actor_url, $params ) ); } @@ -155,52 +156,4 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { */ \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $remote_unknown ); } - - /** - * Filter a list of actor URIs by authority. - * - * @param array $actors Actor URIs. - * @param string $authority Authority to match. - * - * @return array Filtered actor URIs. - */ - protected static function filter_actor_list_by_authority( array $actors, $authority ) { - $matched = array(); - - foreach ( $actors as $actor_uri ) { - if ( ! is_string( $actor_uri ) ) { - continue; - } - - $actor_authority = get_url_authority( $actor_uri ); - - if ( $actor_authority && $actor_authority === $authority ) { - $matched[] = $actor_uri; - } - } - - return $matched; - } - - /** - * Filter a map of actor URIs keyed to user IDs by authority. - * - * @param array $map Map of actor URIs to user IDs. - * @param string $authority Authority to match. - * - * @return array Filtered map with matching entries. - */ - protected static function filter_actor_map_by_authority( array $map, $authority ) { - $filtered = array(); - - foreach ( $map as $actor_uri => $user_id ) { - $actor_authority = get_url_authority( $actor_uri ); - - if ( $actor_authority && $actor_authority === $authority ) { - $filtered[ $actor_uri ] = $user_id; - } - } - - return $filtered; - } } From ea2783bd3df2dd1220064d6473eb0853c6eb26a1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 17:16:19 +0200 Subject: [PATCH 49/78] Refactor followers and following collection methods Renamed Followers::get_partial_followers to get_by_authority and updated its implementation to use get_url_authority for filtering. Updated all usages accordingly. In Following, renamed get_local_followers_snapshot to get_local_followers and changed the returned key from 'followers' to 'accepted' for clarity. --- includes/collection/class-followers.php | 58 ++++++------------------- includes/collection/class-following.php | 4 +- 2 files changed, 15 insertions(+), 47 deletions(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index fde2a077c..8e2a40075 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -12,6 +12,7 @@ use function Activitypub\get_remote_metadata_by_actor; use function Activitypub\get_rest_url_by_path; +use function Activitypub\get_url_authority; /** * ActivityPub Followers Collection. @@ -499,7 +500,7 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { * @return string The hex-encoded digest, or empty string if no followers. */ public static function compute_partial_digest( $user_id, $authority ) { - $followers = self::get_partial_followers( $user_id, $authority ); + $followers = self::get_by_authority( $user_id, $authority ); // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). $digest = str_repeat( '0', 64 ); @@ -526,53 +527,20 @@ public static function compute_partial_digest( $user_id, $authority ) { * * @return array Array of follower URLs. */ - public static function get_partial_followers( $user_id, $authority ) { - // Get all followers. + public static function get_by_authority( $user_id, $authority ) { $followers = self::get_followers( $user_id ); - if ( empty( $followers ) ) { - return array(); - } - - // Filter by authority. - $partial_followers = array(); - - foreach ( $followers as $follower ) { - $follower_url = is_string( $follower ) ? $follower : $follower->guid; - - if ( empty( $follower_url ) ) { - continue; - } - - // Parse the URL and check if authority matches. - $parsed = wp_parse_url( $follower_url ); - - if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { - continue; - } - - $follower_authority = $parsed['scheme'] . '://' . $parsed['host']; - - // Add port if it's not the default for the scheme. - if ( ! empty( $parsed['port'] ) ) { - $default_ports = array( - 'http' => 80, - 'https' => 443, - ); - if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { - $follower_authority .= ':' . $parsed['port']; + $authority_followers = array_map( + function ( $post ) use ( $authority ) { + if ( get_url_authority( $post->guid ) === $authority ) { + return $post->guid; } - } - - if ( $follower_authority === $authority ) { - $partial_followers[] = $follower_url; - } - } - - // Sort for consistency. - sort( $partial_followers ); + return null; + }, + $followers + ); - return $partial_followers; + return array_filter( $authority_followers ); } /** @@ -584,7 +552,7 @@ public static function get_partial_followers( $user_id, $authority ) { * @return string|false The header value, or false if cannot generate. */ public static function generate_sync_header( $user_id, $authority ) { - $followers = self::get_partial_followers( $user_id, $authority ); + $followers = self::get_by_authority( $user_id, $authority ); // Compute the digest for this specific authority. $digest = Signature::compute_collection_digest( $followers ); diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php index 4a6cf2897..6ed11c707 100644 --- a/includes/collection/class-following.php +++ b/includes/collection/class-following.php @@ -389,7 +389,7 @@ public static function get_all_with_count( $user_id, $number = -1, $page = null, * @type \WP_Post $remote_post Remote actor post object. * } */ - public static function get_local_followers_snapshot( $actor_url ) { + public static function get_local_followers( $actor_url ) { $post = Remote_Actors::fetch_by_uri( $actor_url ); if ( \is_wp_error( $post ) ) { @@ -400,7 +400,7 @@ public static function get_local_followers_snapshot( $actor_url ) { $pending = \get_post_meta( $post->ID, self::PENDING_META_KEY, false ); return array( - 'followers' => array_filter( + 'accepted' => array_filter( \array_map( function ( $user_id ) { $actor = Actors::get_by_id( $user_id ); From 5c34dfc2e4a9375df1f11f9a1174330e6e5f3c30 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 17:51:41 +0200 Subject: [PATCH 50/78] Use get_by_authority for partial followers retrieval Replaces the call to Followers::get_partial_followers with Followers::get_by_authority when fetching partial followers filtered by authority. This ensures the correct method is used for retrieving followers based on authority. --- includes/rest/class-followers-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 04c53776e..7c0184016 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -207,7 +207,7 @@ public function get_partial_followers( $request ) { } // Get partial followers filtered by authority. - $partial_followers = Followers::get_partial_followers( $user_id, $authority ); + $partial_followers = Followers::get_by_authority( $user_id, $authority ); $response = array( 'id' => get_rest_url_by_path( From 3fc1b603b0b4ccc14cbbd00271efd419e608fc99 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 16 Oct 2025 18:25:11 +0200 Subject: [PATCH 51/78] Refactor follower authority filtering logic Replaced get_by_authority with get_id_by_authority to improve follower filtering by authority using WP_Query and meta_query. Updated usages in Followers and Followers_Controller classes for consistency and better performance. --- includes/collection/class-followers.php | 44 +++++++++++++------- includes/rest/class-followers-controller.php | 2 +- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 8e2a40075..b2140ec6e 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -12,7 +12,6 @@ use function Activitypub\get_remote_metadata_by_actor; use function Activitypub\get_rest_url_by_path; -use function Activitypub\get_url_authority; /** * ActivityPub Followers Collection. @@ -500,7 +499,7 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { * @return string The hex-encoded digest, or empty string if no followers. */ public static function compute_partial_digest( $user_id, $authority ) { - $followers = self::get_by_authority( $user_id, $authority ); + $followers = self::get_id_by_authority( $user_id, $authority ); // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). $digest = str_repeat( '0', 64 ); @@ -527,20 +526,35 @@ public static function compute_partial_digest( $user_id, $authority ) { * * @return array Array of follower URLs. */ - public static function get_by_authority( $user_id, $authority ) { - $followers = self::get_followers( $user_id ); - - $authority_followers = array_map( - function ( $post ) use ( $authority ) { - if ( get_url_authority( $post->guid ) === $authority ) { - return $post->guid; - } - return null; - }, - $followers + public static function get_id_by_authority( $user_id, $authority ) { + $posts = new \WP_Query( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'DESC', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => self::FOLLOWER_META_KEY, + 'value' => $user_id, + ), + array( + 'key' => '_activitypub_inbox', + 'compare' => 'LIKE', + 'value' => $authority, + ), + ), + ) ); - return array_filter( $authority_followers ); + return array_map( + function ( $post ) { + return $post->guid; + }, + $posts->posts + ); } /** @@ -552,7 +566,7 @@ function ( $post ) use ( $authority ) { * @return string|false The header value, or false if cannot generate. */ public static function generate_sync_header( $user_id, $authority ) { - $followers = self::get_by_authority( $user_id, $authority ); + $followers = self::get_id_by_authority( $user_id, $authority ); // Compute the digest for this specific authority. $digest = Signature::compute_collection_digest( $followers ); diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 7c0184016..ae175d3da 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -207,7 +207,7 @@ public function get_partial_followers( $request ) { } // Get partial followers filtered by authority. - $partial_followers = Followers::get_by_authority( $user_id, $authority ); + $partial_followers = Followers::get_id_by_authority( $user_id, $authority ); $response = array( 'id' => get_rest_url_by_path( From e748f50db8efb9435e1c3df41a65b82e03ba8528 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 17:19:34 +0200 Subject: [PATCH 52/78] Refactor follower sync logic for authority filtering Simplifies the follower synchronization process by directly iterating and filtering remote followers by authority, removing complex diff and intersection logic. The commit also updates the reconciliation action to remove unused parameters. --- includes/scheduler/class-collection-sync.php | 67 +++----------------- 1 file changed, 8 insertions(+), 59 deletions(-) diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index e7e97b4e8..8c2a84bd2 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -10,7 +10,6 @@ namespace Activitypub\Scheduler; -use Activitypub\Collection\Actors; use Activitypub\Collection\Following; use Activitypub\Http; @@ -76,30 +75,15 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { // Get our authority. $our_authority = get_url_authority( \home_url() ); - if ( ! $our_authority ) { - return; - } - - $remote_followers = self::filter_actor_list_by_authority( $remote_followers, $our_authority ); - sort( $remote_followers ); - - $snapshot = Following::get_local_followers_snapshot( $actor_url ); - - if ( \is_wp_error( $snapshot ) ) { - return; - } - - $accepted_followers = self::filter_actor_map_by_authority( $snapshot['followers'], $our_authority ); - $pending_followers = self::filter_actor_map_by_authority( $snapshot['pending'], $our_authority ); - - $remote_followers = array_values( array_unique( $remote_followers ) ); - $local_followers = array_keys( $accepted_followers ); + foreach ( $remote_followers as $actor_uri ) { + if ( get_url_authority( $actor_uri ) !== $our_authority ) { + continue; + } - $to_remove = array_diff( $local_followers, $remote_followers ); - $pending_to_accept = array_intersect( $remote_followers, array_keys( $pending_followers ) ); - $remote_unknown = array_diff( $remote_followers, $local_followers, array_keys( $pending_followers ) ); + if ( in_array( $actor_uri, $accepted_followers, true ) ) { + continue; + } - foreach ( $to_remove as $actor_uri ) { $user_to_remove = $accepted_followers[ $actor_uri ]; Following::unfollow( $snapshot['remote_post'], $user_to_remove ); @@ -113,47 +97,12 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { \do_action( 'activitypub_followers_sync_follower_removed', $user_to_remove, $actor_uri, $actor_url ); } - foreach ( $pending_to_accept as $actor_uri ) { - $user_to_accept = $pending_followers[ $actor_uri ]; - Following::accept( $snapshot['remote_post'], $user_to_accept ); - - /** - * Action triggered when a pending follow is auto-accepted during synchronization. - * - * @param int $user_id The local user ID whose follow was accepted. - * @param string $actor_uri The local actor URI. - * @param string $remote_actor The remote actor URL. - */ - \do_action( 'activitypub_followers_sync_follow_request_accepted', $user_to_accept, $actor_uri, $actor_url ); - } - - foreach ( $remote_unknown as $actor_uri ) { - $local_user_id = Actors::get_id_by_resource( $actor_uri ); - - if ( \is_wp_error( $local_user_id ) ) { - continue; - } - - Following::unfollow( $snapshot['remote_post'], $local_user_id ); - - /** - * Action triggered when an unexpected follow entry is reconciled with an Undo. - * - * @param int $user_id The local user ID whose unexpected follow was undone. - * @param string $actor_uri The local actor URI. - * @param string $remote_actor The remote actor URL. - */ - \do_action( 'activitypub_followers_sync_follower_mismatch', $local_user_id, $actor_uri, $actor_url ); - } - /** * Action triggered after reconciliation is complete. * * @param int $user_id The local user ID that triggered the reconciliation. * @param string $actor_url The remote actor URL. - * @param array $to_remove Local actor URIs removed from the follow list. - * @param array $remote_unknown Local actor URIs that required Undo operations. */ - \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $to_remove, $remote_unknown ); + \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url ); } } From 96c4c8c60536366e07727f7304112d322769dedd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 17:42:25 +0200 Subject: [PATCH 53/78] Refactor followers handling to use WP_Post objects Updated methods to work with WP_Post objects instead of follower URLs, and adjusted mapping to extract GUIDs where needed. This improves type consistency and prepares for future enhancements involving follower data. --- includes/collection/class-followers.php | 20 +++++++++++--------- includes/rest/class-followers-controller.php | 12 +++++++++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index b2140ec6e..f1577999c 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -504,9 +504,9 @@ public static function compute_partial_digest( $user_id, $authority ) { // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). $digest = str_repeat( '0', 64 ); - foreach ( $followers as $follower_url ) { + foreach ( $followers as $follower ) { // Compute SHA256 hash of the follower ID. - $hash = hash( 'sha256', $follower_url ); + $hash = hash( 'sha256', $follower->guid ); // XOR the hash with the running digest. $digest = Signature::xor_hex_strings( $digest, $hash ); @@ -524,7 +524,7 @@ public static function compute_partial_digest( $user_id, $authority ) { * @param int $user_id The user ID whose followers to get. * @param string $authority The URI authority (scheme + host) to filter by. * - * @return array Array of follower URLs. + * @return \WP_Post[] Array of WP_Post objects. */ public static function get_id_by_authority( $user_id, $authority ) { $posts = new \WP_Query( @@ -549,12 +549,7 @@ public static function get_id_by_authority( $user_id, $authority ) { ) ); - return array_map( - function ( $post ) { - return $post->guid; - }, - $posts->posts - ); + return $posts->posts ?? array(); } /** @@ -567,6 +562,13 @@ function ( $post ) { */ public static function generate_sync_header( $user_id, $authority ) { $followers = self::get_id_by_authority( $user_id, $authority ); + $followers = array_map( + function ( $post ) { + return $post->guid; + }, + $followers + ); + // Compute the digest for this specific authority. $digest = Signature::compute_collection_digest( $followers ); diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index ae175d3da..6f0c26c7c 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -207,7 +207,13 @@ public function get_partial_followers( $request ) { } // Get partial followers filtered by authority. - $partial_followers = Followers::get_id_by_authority( $user_id, $authority ); + $followers = Followers::get_id_by_authority( $user_id, $authority ); + $followers = array_map( + function ( $post ) { + return $post->guid; + }, + $followers + ); $response = array( 'id' => get_rest_url_by_path( @@ -218,8 +224,8 @@ public function get_partial_followers( $request ) { ) ), 'type' => 'OrderedCollection', - 'totalItems' => count( $partial_followers ), - 'orderedItems' => $partial_followers, + 'totalItems' => count( $followers ), + 'orderedItems' => $followers, ); $response = $this->prepare_collection_response( $response, $request ); From 51b3e4848b8296db83091fb09a79fbcd78c366e2 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 18:27:32 +0200 Subject: [PATCH 54/78] Refactor followers sync to use authority filtering Updated collection synchronization logic and documentation to use authority-based filtering for followers and following relationships. Replaced get_id_by_authority() with get_by_authority() in Followers and Following classes, and refactored reconciliation logic to handle accepted and pending follows according to remote authoritative lists. Updated REST controller and documentation to reflect these changes and clarified action hooks. --- docs/collection-synchronization.md | 53 +++++++-------- includes/collection/class-followers.php | 6 +- includes/collection/class-following.php | 68 +++++++++----------- includes/rest/class-followers-controller.php | 2 +- includes/scheduler/class-collection-sync.php | 50 +++++++------- 5 files changed, 83 insertions(+), 96 deletions(-) diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md index 0ed803a77..dc3a629ef 100644 --- a/docs/collection-synchronization.md +++ b/docs/collection-synchronization.md @@ -41,13 +41,15 @@ This is implemented in `includes/handler/class-collection-sync.php`. When a digest mismatch is detected, the plugin triggers a scheduled reconciliation job that: -1. Fetches the authoritative partial followers collection from the remote server -2. Compares it with the local *following* relationships for that remote actor -3. Removes local follow records that the remote server no longer recognises -4. Promotes pending follow requests that the remote server already lists as accepted -5. Issues Undo Follow activities for any unexpected entries reported by the remote server +1. Fetches the authoritative partial followers collection from the remote server (using the URL from the Collection-Synchronization header) +2. Compares it with the local *following* relationships for that remote actor (filtered by authority) +3. For **accepted** following relationships: + - If the remote actor is NOT in the remote followers list, reject the local follow (remote server no longer recognizes it) +4. For **pending** following relationships: + - If the remote actor IS in the remote followers list, accept the pending follow (remote server already accepted it) + - If the remote actor is NOT in the remote followers list, reject the pending follow (remote server doesn't recognize it) -The reconciliation is handled asynchronously via WordPress's cron system. +The reconciliation is handled asynchronously via WordPress's cron system to avoid blocking inbox processing. This is implemented in `includes/scheduler/class-collection-sync.php`. @@ -74,8 +76,9 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. - **`Following`** (`includes/collection/class-following.php`) - Exposes local following state for reconciliation and digest calculations - - Maps local user IDs to ActivityPub actor URLs for comparison - - Methods: `get_local_followers_snapshot()` + - Filters following relationships by authority (instance domain) + - Handles accept/reject operations for follow relationships + - Methods: `get_by_authority()`, `accept()`, `reject()` - **`Followers_Controller`** (`includes/rest/class-followers-controller.php`) - Adds `/actors/{id}/followers/sync` REST endpoint for partial collections @@ -85,10 +88,13 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. - **`Collection_Sync`** (`includes/scheduler/class-collection-sync.php`) - Handles async reconciliation when digest mismatches occur - - Fetches authoritative partial followers from the remote server - - Removes stale local follow relationships, promotes pending accepts, and cleans up unexpected entries - - Reports changes via action hooks - - Methods: `reconcile_followers()` + - Fetches authoritative partial followers from the remote server using the sync URL + - Compares remote followers with local following relationships filtered by home authority + - Rejects accepted follows not recognized by remote server + - Accepts pending follows already in remote followers list + - Rejects pending follows not in remote followers list + - Reports completion via action hooks + - Methods: `reconcile_followers()`, `schedule_reconciliation()` - **`Scheduler`** (`includes/class-scheduler.php`) - Registers the follower reconciliation scheduled action @@ -102,25 +108,20 @@ FEP-8fcf is designed with privacy in mind: ## Action Hooks -The implementation provides several action hooks for monitoring and extending: +The implementation provides action hooks for monitoring and extending: ```php -// Triggered when digest mismatch is detected -do_action( 'activitypub_followers_sync_mismatch', $user_id, $actor_url, $params ); +// Triggered when a digest mismatch is detected and reconciliation is scheduled +// Fired in includes/handler/class-collection-sync.php +do_action( 'activitypub_collection_sync', $collection_type, $user_id, $actor_url, $params ); -// Triggered when a local follow record is removed during sync -do_action( 'activitypub_followers_sync_follower_removed', $local_user_id, $local_actor_uri, $actor_url ); - -// Triggered when a pending follow is auto-accepted during sync -do_action( 'activitypub_followers_sync_follow_request_accepted', $local_user_id, $local_actor_uri, $actor_url ); - -// Triggered when an unexpected remote entry requires an Undo Follow -do_action( 'activitypub_followers_sync_follower_mismatch', $local_user_id, $local_actor_uri, $actor_url ); - -// Triggered after reconciliation completes -do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url, $removed_actor_uris, $undo_actor_uris ); +// Triggered after reconciliation completes successfully +// Fired in includes/scheduler/class-collection-sync.php +do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url ); ``` +**Note:** The `Following::accept()` and `Following::reject()` methods trigger their own action hooks for tracking individual follow state changes. + ## REST API Endpoints ### Partial Followers Collection diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index f1577999c..07874a427 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -499,7 +499,7 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { * @return string The hex-encoded digest, or empty string if no followers. */ public static function compute_partial_digest( $user_id, $authority ) { - $followers = self::get_id_by_authority( $user_id, $authority ); + $followers = self::get_by_authority( $user_id, $authority ); // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). $digest = str_repeat( '0', 64 ); @@ -526,7 +526,7 @@ public static function compute_partial_digest( $user_id, $authority ) { * * @return \WP_Post[] Array of WP_Post objects. */ - public static function get_id_by_authority( $user_id, $authority ) { + public static function get_by_authority( $user_id, $authority ) { $posts = new \WP_Query( array( 'post_type' => Remote_Actors::POST_TYPE, @@ -561,7 +561,7 @@ public static function get_id_by_authority( $user_id, $authority ) { * @return string|false The header value, or false if cannot generate. */ public static function generate_sync_header( $user_id, $authority ) { - $followers = self::get_id_by_authority( $user_id, $authority ); + $followers = self::get_by_authority( $user_id, $authority ); $followers = array_map( function ( $post ) { return $post->guid; diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php index 6ed11c707..a989c4036 100644 --- a/includes/collection/class-following.php +++ b/includes/collection/class-following.php @@ -377,49 +377,41 @@ public static function get_all_with_count( $user_id, $number = -1, $page = null, } /** - * Retrieve a snapshot of local followers information for a remote actor. + * Get partial followers collection for a specific instance. * - * @param string $actor_url The remote actor URL. + * Returns only followers whose ID shares the specified URI authority. + * Used for FEP-8fcf synchronization. * - * @return array|\WP_Error { - * Snapshot data or WP_Error when the remote actor is unknown. + * @param int $user_id The user ID whose followers to get. + * @param string $authority The URI authority (scheme + host) to filter by. + * @param string $state The following state to filter by (accepted or pending). Default is accepted. * - * @type array $followers Map of local actor URLs to user IDs for accepted follows. - * @type array $pending Map of local actor URLs to user IDs for pending follows. - * @type \WP_Post $remote_post Remote actor post object. - * } + * @return array Array of follower URLs. */ - public static function get_local_followers( $actor_url ) { - $post = Remote_Actors::fetch_by_uri( $actor_url ); - - if ( \is_wp_error( $post ) ) { - return $post; - } - - $accepted = \get_post_meta( $post->ID, self::FOLLOWING_META_KEY, false ); - $pending = \get_post_meta( $post->ID, self::PENDING_META_KEY, false ); - - return array( - 'accepted' => array_filter( - \array_map( - function ( $user_id ) { - $actor = Actors::get_by_id( $user_id ); - return ! \is_wp_error( $actor ) ? $actor->get_id() : null; - }, - $accepted - ) - ), - 'pending' => array_filter( - \array_map( - function ( $user_id ) { - $actor = Actors::get_by_id( $user_id ); - return ! \is_wp_error( $actor ) ? $actor->get_id() : null; - }, - $pending - ) - ), - 'remote_post' => $post, + public static function get_by_authority( $user_id, $authority, $state = self::FOLLOWING_META_KEY ) { + $posts = new \WP_Query( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'DESC', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => $state, + 'value' => $user_id, + ), + array( + 'key' => '_activitypub_inbox', + 'compare' => 'LIKE', + 'value' => $authority, + ), + ), + ) ); + + return $posts->posts ?? array(); } /** diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 6f0c26c7c..e03a3d12d 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -207,7 +207,7 @@ public function get_partial_followers( $request ) { } // Get partial followers filtered by authority. - $followers = Followers::get_id_by_authority( $user_id, $authority ); + $followers = Followers::get_by_authority( $user_id, $authority ); $followers = array_map( function ( $post ) { return $post->guid; diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index 8c2a84bd2..afc56421a 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -57,44 +57,38 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { } // Fetch the authoritative partial followers collection. - $response = Http::get( $params['url'], 300 ); // Cache for 5 minutes. + $data = Http::get_remote_object( $params['url'], 300 ); // Cache for 5 minutes. - if ( \is_wp_error( $response ) ) { - return; - } - - $body = \wp_remote_retrieve_body( $response ); - $data = \json_decode( $body, true ); - - if ( empty( $data['orderedItems'] ) || ! \is_array( $data['orderedItems'] ) ) { + if ( \is_wp_error( $data ) || empty( $data['orderedItems'] ) || ! \is_array( $data['orderedItems'] ) ) { return; } $remote_followers = $data['orderedItems']; // Get our authority. - $our_authority = get_url_authority( \home_url() ); - - foreach ( $remote_followers as $actor_uri ) { - if ( get_url_authority( $actor_uri ) !== $our_authority ) { - continue; - } - - if ( in_array( $actor_uri, $accepted_followers, true ) ) { - continue; + $home_authority = get_url_authority( \home_url() ); + + $accepted = Following::get_by_authority( $user_id, $home_authority ); + foreach ( $accepted as $following ) { + $key = array_search( $following->guid, $remote_followers, true ); + if ( false === $key ) { + Following::reject( $following, $user_id ); + } else { + unset( $remote_followers[ $key ] ); } + } - $user_to_remove = $accepted_followers[ $actor_uri ]; - Following::unfollow( $snapshot['remote_post'], $user_to_remove ); + $remote_followers = array_values( $remote_followers ); // Reindex. - /** - * Action triggered when a follow is removed due to synchronization. - * - * @param int $user_id The local user ID whose follow was undone. - * @param string $actor_uri The local actor URI. - * @param string $remote_actor The remote actor URL. - */ - \do_action( 'activitypub_followers_sync_follower_removed', $user_to_remove, $actor_uri, $actor_url ); + $pending = Following::get_by_authority( $user_id, $home_authority, Following::PENDING ); + foreach ( $pending as $following ) { + $key = array_search( $following->guid, $remote_followers, true ); + if ( false === $key ) { + Following::reject( $following, $user_id ); + } else { + Following::accept( $following, $user_id ); + unset( $remote_followers[ $key ] ); + } } /** From 248ba43262e7fa0c22134a77d9edab67859f3388 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 18:45:21 +0200 Subject: [PATCH 55/78] Fix followers reconciliation and add unit tests Corrects the check for 'orderedItems' in followers reconciliation and updates the pending meta key usage. Adds comprehensive PHPUnit tests for the Collection_Sync scheduler, covering scheduling, reconciliation logic, and edge cases. --- includes/scheduler/class-collection-sync.php | 4 +- .../scheduler/class-test-collection-sync.php | 534 ++++++++++++++++++ 2 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index afc56421a..aee11c8a1 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -59,7 +59,7 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { // Fetch the authoritative partial followers collection. $data = Http::get_remote_object( $params['url'], 300 ); // Cache for 5 minutes. - if ( \is_wp_error( $data ) || empty( $data['orderedItems'] ) || ! \is_array( $data['orderedItems'] ) ) { + if ( \is_wp_error( $data ) || ! isset( $data['orderedItems'] ) || ! \is_array( $data['orderedItems'] ) ) { return; } @@ -80,7 +80,7 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { $remote_followers = array_values( $remote_followers ); // Reindex. - $pending = Following::get_by_authority( $user_id, $home_authority, Following::PENDING ); + $pending = Following::get_by_authority( $user_id, $home_authority, Following::PENDING_META_KEY ); foreach ( $pending as $following ) { $key = array_search( $following->guid, $remote_followers, true ); if ( false === $key ) { diff --git a/tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php b/tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php new file mode 100644 index 000000000..bbf514474 --- /dev/null +++ b/tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php @@ -0,0 +1,534 @@ +user->create( + array( + 'user_login' => 'test_user', + 'user_email' => 'test@example.com', + ) + ); + } + + /** + * Set up each test. + */ + public function set_up() { + parent::set_up(); + _delete_all_posts(); + } + + /** + * Helper: Create a remote actor post. + * + * @param string $actor_url The actor URL. + * @return int The post ID. + */ + protected function create_remote_actor( $actor_url ) { + $post_id = self::factory()->post->create( + array( + 'post_title' => $actor_url, + 'post_status' => 'publish', + 'post_type' => Remote_Actors::POST_TYPE, + 'guid' => $actor_url, + ) + ); + + // Set the inbox meta. The inbox should contain the home authority + // because get_by_authority filters by inbox containing the authority. + $home_authority = get_url_authority( \home_url() ); + \add_post_meta( $post_id, '_activitypub_inbox', $home_authority . '/inbox' ); + + return $post_id; + } + + /** + * Helper: Add an accepted follow. + * + * @param int $post_id The remote actor post ID. + * @param int $user_id The local user ID. + */ + protected function add_accepted_follow( $post_id, $user_id ) { + \add_post_meta( $post_id, Following::FOLLOWING_META_KEY, $user_id ); + } + + /** + * Helper: Add a pending follow. + * + * @param int $post_id The remote actor post ID. + * @param int $user_id The local user ID. + */ + protected function add_pending_follow( $post_id, $user_id ) { + \add_post_meta( $post_id, Following::PENDING_META_KEY, $user_id ); + } + + /** + * Test schedule_reconciliation schedules the correct action. + * + * @covers ::schedule_reconciliation + */ + public function test_schedule_reconciliation() { + $user_id = self::$user_id; + $actor_url = 'https://example.com/users/test'; + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + 'collectionId' => 'https://example.com/users/test/followers', + 'digest' => 'abcdef123456', + ); + + // Clear any existing scheduled events. + wp_clear_scheduled_hook( 'activitypub_followers_sync_reconcile', array( $user_id, $actor_url, $params ) ); + + // Schedule the reconciliation. + Collection_Sync::schedule_reconciliation( 'followers', $user_id, $actor_url, $params ); + + // Check that the event was scheduled. + $scheduled = wp_next_scheduled( 'activitypub_followers_sync_reconcile', array( $user_id, $actor_url, $params ) ); + + $this->assertNotFalse( $scheduled, 'Event should be scheduled' ); + $this->assertGreaterThan( time(), $scheduled, 'Event should be scheduled in the future' ); + $this->assertLessThanOrEqual( time() + 61, $scheduled, 'Event should be scheduled within ~60 seconds' ); + + // Clean up. + wp_clear_scheduled_hook( 'activitypub_followers_sync_reconcile', array( $user_id, $actor_url, $params ) ); + } + + /** + * Test reconcile_followers with empty URL parameter. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_empty_url() { + $params = array(); // No URL. + + // Should return early without errors. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // No assertions needed - just verify no errors. + $this->assertTrue( true ); + } + + /** + * Test reconcile_followers with invalid remote response. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_invalid_remote_response() { + $params = array( + 'url' => 'https://example.com/invalid', + ); + + // Mock Http::get_remote_object to return an error. + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/invalid' === $url_or_object ) { + return new \WP_Error( 'http_request_failed', 'Request failed' ); + } + return $preempt; + }, + 10, + 2 + ); + + // Should return early without errors. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // No assertions needed - just verify no errors. + $this->assertTrue( true ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers rejects accepted follows not in remote list. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_rejects_stale_accepted() { + $home_authority = get_url_authority( \home_url() ); + + // Create remote actors. + $alice_id = $this->create_remote_actor( self::$remote_actors[0] ); // example.com/alice. + $bob_id = $this->create_remote_actor( self::$remote_actors[1] ); // example.com/bob. + + // Add both as accepted follows. + $this->add_accepted_follow( $alice_id, self::$user_id ); + $this->add_accepted_follow( $bob_id, self::$user_id ); + + // Verify they're accepted. + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 2, $accepted ); + + // Mock remote response with only Alice (Bob is missing). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array( + self::$remote_actors[0], // Only Alice. + ), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify Bob was rejected (no longer accepted). + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 1, $accepted, 'Bob should have been rejected' ); + $this->assertEquals( self::$remote_actors[0], $accepted[0]->guid, 'Only Alice should remain' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers accepts pending follows in remote list. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_accepts_pending_in_remote() { + $home_authority = get_url_authority( \home_url() ); + + // Create remote actor Charlie. + $charlie_id = $this->create_remote_actor( self::$remote_actors[2] ); // example.com/charlie. + + // Add as pending follow. + $this->add_pending_follow( $charlie_id, self::$user_id ); + + // Verify it's pending. + $pending = Following::get_by_authority( self::$user_id, $home_authority, Following::PENDING_META_KEY ); + $this->assertCount( 1, $pending ); + + // Mock remote response with Charlie (already accepted on remote). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array( + self::$remote_actors[2], // Charlie. + ), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify Charlie was accepted. + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 1, $accepted, 'Charlie should be accepted' ); + $this->assertEquals( self::$remote_actors[2], $accepted[0]->guid ); + + // Verify Charlie is no longer pending. + $pending = Following::get_by_authority( self::$user_id, $home_authority, Following::PENDING_META_KEY ); + $this->assertCount( 0, $pending, 'Charlie should not be pending' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers rejects pending follows not in remote list. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_rejects_pending_not_in_remote() { + // Create remote actor Dave (mastodon.social). + $dave_id = $this->create_remote_actor( self::$remote_actors[3] ); // mastodon.social/dave. + + // Add as pending follow. + $this->add_pending_follow( $dave_id, self::$user_id ); + + // Mock remote response with empty list (Dave not included). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array(), // Empty. + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify Dave was rejected (not accepted). + $accepted = Following::get_by_authority( self::$user_id, 'https://mastodon.social' ); + $this->assertCount( 0, $accepted, 'Dave should not be accepted' ); + + // Verify Dave is no longer pending. + $pending = Following::get_by_authority( self::$user_id, 'https://mastodon.social', Following::PENDING_META_KEY ); + $this->assertCount( 0, $pending, 'Dave should not be pending' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers handles mixed scenario correctly. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_mixed_scenario() { + $home_authority = get_url_authority( \home_url() ); + + // Create remote actors from example.com. + $alice_id = $this->create_remote_actor( self::$remote_actors[0] ); // Alice. + $bob_id = $this->create_remote_actor( self::$remote_actors[1] ); // Bob. + $charlie_id = $this->create_remote_actor( self::$remote_actors[2] ); // Charlie. + + // Alice: accepted, in remote (should stay accepted). + $this->add_accepted_follow( $alice_id, self::$user_id ); + + // Bob: accepted, NOT in remote (should be rejected). + $this->add_accepted_follow( $bob_id, self::$user_id ); + + // Charlie: pending, in remote (should be accepted). + $this->add_pending_follow( $charlie_id, self::$user_id ); + + // Mock remote response with Alice and Charlie only. + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array( + self::$remote_actors[0], // Alice. + self::$remote_actors[2], // Charlie. + ), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify final state. + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 2, $accepted, 'Should have 2 accepted follows' ); + + $accepted_guids = array_map( + function ( $post ) { + return $post->guid; + }, + $accepted + ); + sort( $accepted_guids ); + + $expected = array( self::$remote_actors[0], self::$remote_actors[2] ); + sort( $expected ); + + $this->assertEquals( $expected, $accepted_guids, 'Alice and Charlie should be accepted' ); + + // Verify no pending follows remain. + $pending = Following::get_by_authority( self::$user_id, $home_authority, Following::PENDING_META_KEY ); + $this->assertCount( 0, $pending, 'No pending follows should remain' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers fires action hook on completion. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_fires_action_hook() { + $action_fired = false; + $hook_user_id = null; + $hook_actor = null; + + add_action( + 'activitypub_followers_sync_reconciled', + function ( $user_id, $actor_url ) use ( &$action_fired, &$hook_user_id, &$hook_actor ) { + $action_fired = true; + $hook_user_id = $user_id; + $hook_actor = $actor_url; + }, + 10, + 2 + ); + + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array(), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify action was fired with correct parameters. + $this->assertTrue( $action_fired, 'Action hook should fire' ); + $this->assertEquals( self::$user_id, $hook_user_id ); + $this->assertEquals( 'https://example.com/users/test', $hook_actor ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + remove_all_actions( 'activitypub_followers_sync_reconciled' ); + } + + /** + * Test reconcile_followers only processes followers from home authority. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_filters_by_authority() { + $home_authority = get_url_authority( \home_url() ); + + // Create actors with different inbox authorities. + // Alice has home authority inbox (will be processed). + $alice_id = $this->create_remote_actor( self::$remote_actors[0] ); // Has home authority inbox. + + // Dave has different authority inbox (won't be processed). + $dave_id = self::factory()->post->create( + array( + 'post_title' => self::$remote_actors[3], + 'post_status' => 'publish', + 'post_type' => Remote_Actors::POST_TYPE, + 'guid' => self::$remote_actors[3], + ) + ); + \add_post_meta( $dave_id, '_activitypub_inbox', 'https://mastodon.social/inbox' ); + + // Add both as accepted follows. + $this->add_accepted_follow( $alice_id, self::$user_id ); + $this->add_accepted_follow( $dave_id, self::$user_id ); + + // Mock remote response with empty list (should only affect home authority followers). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array(), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify only home authority followers were processed. + // Alice should be rejected (from home authority, not in remote list). + $home_accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 0, $home_accepted, 'Home authority followers should be processed' ); + + // Dave should remain (different authority, not processed). + $mastodon_accepted = Following::get_by_authority( self::$user_id, 'https://mastodon.social' ); + $this->assertCount( 1, $mastodon_accepted, 'Other authority followers should not be affected' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } +} From be501cfe679eaa07f4cc48e219a6083a107731ea Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 18 Oct 2025 09:28:27 +0200 Subject: [PATCH 56/78] Update includes/rest/class-followers-controller.php Co-authored-by: Konstantin Obenland --- includes/rest/class-followers-controller.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index e03a3d12d..5833166b0 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -208,12 +208,7 @@ public function get_partial_followers( $request ) { // Get partial followers filtered by authority. $followers = Followers::get_by_authority( $user_id, $authority ); - $followers = array_map( - function ( $post ) { - return $post->guid; - }, - $followers - ); + $followers = \wp_list_pluck( $followers, 'guid' ); $response = array( 'id' => get_rest_url_by_path( From 58b777f013f5e272ace8727432a5dd2e56e07a89 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 18 Oct 2025 09:28:37 +0200 Subject: [PATCH 57/78] Update includes/collection/class-followers.php Co-authored-by: Konstantin Obenland --- includes/collection/class-followers.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 07874a427..0fbc3f131 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -562,12 +562,7 @@ public static function get_by_authority( $user_id, $authority ) { */ public static function generate_sync_header( $user_id, $authority ) { $followers = self::get_by_authority( $user_id, $authority ); - $followers = array_map( - function ( $post ) { - return $post->guid; - }, - $followers - ); + $followers = \wp_list_pluck( $followers, 'guid' ); // Compute the digest for this specific authority. $digest = Signature::compute_collection_digest( $followers ); From d052dcdbb62c3d84855aaf9836325eb14b42f25b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 18 Oct 2025 09:28:51 +0200 Subject: [PATCH 58/78] Update includes/scheduler/class-collection-sync.php Co-authored-by: Konstantin Obenland --- includes/scheduler/class-collection-sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index aee11c8a1..e700f75a8 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -38,7 +38,7 @@ public static function init() { public static function schedule_reconciliation( $type, $user_id, $actor_url, $params ) { // Schedule async processing to avoid blocking the inbox. \wp_schedule_single_event( - time() + 60, // Process in 1 minute. + time() + MINUTE_IN_SECONDS, "activitypub_{$type}_sync_reconcile", array( $user_id, $actor_url, $params ) ); From 6b55dd82f79d1804384128a49b26c27b1930ef36 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Sat, 18 Oct 2025 09:29:03 +0200 Subject: [PATCH 59/78] Update includes/scheduler/class-collection-sync.php Co-authored-by: Konstantin Obenland --- includes/scheduler/class-collection-sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index e700f75a8..1cd2921e5 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -57,7 +57,7 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { } // Fetch the authoritative partial followers collection. - $data = Http::get_remote_object( $params['url'], 300 ); // Cache for 5 minutes. + $data = Http::get_remote_object( $params['url'], 5 * MINUTE_IN_SECONDS ); if ( \is_wp_error( $data ) || ! isset( $data['orderedItems'] ) || ! \is_array( $data['orderedItems'] ) ) { return; From 86de28a4a65ba7b5a1793cbd46c92123daf4a6c3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:22:23 +0200 Subject: [PATCH 60/78] Add comment clarifying header addition timing Added a comment to explain that the Collection-Synchronization header must be included before signing, as it needs to be part of the signature. --- includes/handler/class-collection-sync.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index b5f85ea48..0305cfda5 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -27,6 +27,7 @@ class Collection_Sync { public static function init() { \add_action( 'activitypub_inbox_create', array( self::class, 'handle_collection_synchronization' ), 10, 2 ); + // The Collection-Synchronization header needs to be part of the signature, so it must be added before signing. \add_filter( 'http_request_args', array( self::class, 'maybe_add_headers' ), -1, 2 ); } From a86ddf02ee10853de74ec1d09d705e3a3115bca9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:24:21 +0200 Subject: [PATCH 61/78] Update authority argument description in REST controller Changed the description for the 'authority' argument in the followers REST controller to clarify that it refers to the host used for filtering followers. --- includes/rest/class-followers-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 5833166b0..afdc9aba9 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -95,7 +95,7 @@ public function register_routes() { 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), 'args' => array( 'authority' => array( - 'description' => 'The URI authority to filter followers by.', + 'description' => 'The host to filter followers by.', 'type' => 'string', 'required' => true, ), From d26cfc8ea09820d0ca33e356100445650cc757d6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:24:37 +0200 Subject: [PATCH 62/78] Update includes/handler/class-collection-sync.php Co-authored-by: Konstantin Obenland --- includes/handler/class-collection-sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 0305cfda5..37021690e 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -76,7 +76,7 @@ public static function handle_collection_synchronization( $data, $user_id ) { } // Get the actor URL for validation. - $actor_url = isset( $data['actor'] ) ? $data['actor'] : null; + $actor_url = $data['actor'] ?? false; if ( ! $actor_url ) { return; From 0630dc7eb4ff947f934e5bc16d68cf3dd074c446 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:37:07 +0200 Subject: [PATCH 63/78] Send sync header to authority only once per day Adds logic to prevent sending the Collection-Synchronization header to the same authority more than once per day per user. Uses a transient to track when the header was last sent, improving efficiency and reducing redundant requests. --- includes/handler/class-collection-sync.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 37021690e..2ef201390 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -130,10 +130,26 @@ public static function maybe_add_headers( $args, $url ) { return $args; } + // Only send header if we haven't sent one to this authority in the last day. $inbox_authority = get_url_authority( $url ); - $sync_header = Followers::generate_sync_header( $args['user_id'], $inbox_authority ); + $user_id = $args['user_id'] ?? 0; + + if ( ! $user_id || ! $inbox_authority ) { + return $args; + } + + // Check if we've already sent a sync header to this authority today. + $transient_key = 'activitypub_sync_sent_' . $user_id . '_' . md5( $inbox_authority ); + if ( false !== \get_transient( $transient_key ) ) { + return $args; + } + + $sync_header = Followers::generate_sync_header( $user_id, $inbox_authority ); if ( $sync_header ) { $args['headers']['Collection-Synchronization'] = $sync_header; + + // Store that we've sent the header (expires in 1 week). + \set_transient( $transient_key, time(), WEEK_IN_SECONDS ); } return $args; From ef4a3e9a6a2d60ab7c0a49a4385ccf96fd56b33d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:41:45 +0200 Subject: [PATCH 64/78] Add filter for collection sync header frequency Introduces the 'activitypub_collection_sync_frequency' filter to allow customization of how often the Collection-Synchronization header is sent to a given authority. This provides greater flexibility for developers to adjust synchronization intervals. --- includes/handler/class-collection-sync.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 2ef201390..1a1baf180 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -148,8 +148,15 @@ public static function maybe_add_headers( $args, $url ) { if ( $sync_header ) { $args['headers']['Collection-Synchronization'] = $sync_header; - // Store that we've sent the header (expires in 1 week). - \set_transient( $transient_key, time(), WEEK_IN_SECONDS ); + /** + * Filter the frequency of Collection-Synchronization headers sent to a given authority. + * + * @param int $frequency The frequency in seconds. + * @param int $user_id The local user ID. + * @param string $inbox_authority The inbox authority. + */ + $frequency = \apply_filters( 'activitypub_collection_sync_frequency', WEEK_IN_SECONDS, $user_id, $inbox_authority ); + \set_transient( $transient_key, time(), $frequency ); } return $args; From 3883c99cea65f44731c7d45d8683b79735b3a4e0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:41:59 +0200 Subject: [PATCH 65/78] Add default value note to collection sync header filter Updated the docblock for the collection synchronization header frequency filter to specify that the default value is one week. --- includes/handler/class-collection-sync.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 1a1baf180..7aa0e07d6 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -151,6 +151,8 @@ public static function maybe_add_headers( $args, $url ) { /** * Filter the frequency of Collection-Synchronization headers sent to a given authority. * + * Default is one week. + * * @param int $frequency The frequency in seconds. * @param int $user_id The local user ID. * @param string $inbox_authority The inbox authority. From 86f3b5f98475cfcb56f2232b5529642cc98e4b5b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:42:17 +0200 Subject: [PATCH 66/78] Update docblock for collection sync frequency filter Consolidated the default value note for the frequency parameter into the @param line in the docblock for clarity. --- includes/handler/class-collection-sync.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 7aa0e07d6..6d0e6f49f 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -151,9 +151,7 @@ public static function maybe_add_headers( $args, $url ) { /** * Filter the frequency of Collection-Synchronization headers sent to a given authority. * - * Default is one week. - * - * @param int $frequency The frequency in seconds. + * @param int $frequency The frequency in seconds. Default is one week. * @param int $user_id The local user ID. * @param string $inbox_authority The inbox authority. */ From c057799c1557941974ed6008744257177dfd0e80 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 10:48:38 +0200 Subject: [PATCH 67/78] Add caching and refactor sync frequency logic Introduces a transient cache to prevent duplicate collection syncs per user and actor. Refactors frequency logic into a new get_frequency() method for consistency and maintainability. --- includes/handler/class-collection-sync.php | 35 ++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 6d0e6f49f..fdd59169b 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -87,6 +87,14 @@ public static function handle_collection_synchronization( $data, $user_id ) { return; } + $cache_key = 'activitypub_collection_sync_received_' . $user_id . '_' . md5( $actor_url ); + if ( false === \get_transient( $cache_key ) ) { + $frequency = self::get_frequency(); + \set_transient( $cache_key, time(), $frequency ); + } else { + return; + } + /** * Action triggered Collection Sync. * @@ -139,7 +147,7 @@ public static function maybe_add_headers( $args, $url ) { } // Check if we've already sent a sync header to this authority today. - $transient_key = 'activitypub_sync_sent_' . $user_id . '_' . md5( $inbox_authority ); + $transient_key = 'activitypub_collection_sync_sent_' . $user_id . '_' . md5( $inbox_authority ); if ( false !== \get_transient( $transient_key ) ) { return $args; } @@ -148,14 +156,7 @@ public static function maybe_add_headers( $args, $url ) { if ( $sync_header ) { $args['headers']['Collection-Synchronization'] = $sync_header; - /** - * Filter the frequency of Collection-Synchronization headers sent to a given authority. - * - * @param int $frequency The frequency in seconds. Default is one week. - * @param int $user_id The local user ID. - * @param string $inbox_authority The inbox authority. - */ - $frequency = \apply_filters( 'activitypub_collection_sync_frequency', WEEK_IN_SECONDS, $user_id, $inbox_authority ); + $frequency = self::get_frequency(); \set_transient( $transient_key, time(), $frequency ); } @@ -203,4 +204,20 @@ public static function validate_header_params( $params, $actor_url ) { return $collection_authority === $url_authority; } + + /** + * Get the frequency for Collection-Synchronization headers. + * + * @return int Frequency in seconds. + */ + private static function get_frequency() { + /** + * Filter the frequency of Collection-Synchronization headers sent to a given authority. + * + * @param int $frequency The frequency in seconds. Default is one week. + * @param int $user_id The local user ID. + * @param string $inbox_authority The inbox authority. + */ + return \apply_filters( 'activitypub_collection_sync_frequency', WEEK_IN_SECONDS ); + } } From 420efb1234b288cb5c16ceb7406ea3338c3dc38f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 20 Oct 2025 18:54:30 +0200 Subject: [PATCH 68/78] Refactor follower digest computation to use Signature class Updated Followers::compute_partial_digest() to delegate digest computation to Signature::compute_collection_digest(), improving code reuse and consistency. Updated documentation in both the code and collection-synchronization.md to reflect this change and clarify method responsibilities. --- docs/collection-synchronization.md | 12 ++++++------ includes/collection/class-followers.php | 26 +++++++++++-------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md index dc3a629ef..b16cf0698 100644 --- a/docs/collection-synchronization.md +++ b/docs/collection-synchronization.md @@ -57,10 +57,10 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. ### Core Classes -- **`Http`** (`includes/class-http.php`) - - Adds `Collection-Synchronization` header to outgoing Create activities - - Generates sync headers with digest, collectionId, and URL - - Methods: `post()` with sync header generation +- **`Signature`** (`includes/class-signature.php`) + - Provides core digest computation algorithm using XOR'd SHA256 hashes + - Order-independent digest for efficient collection comparison + - Methods: `compute_collection_digest()`, `xor_hex_strings()`, `parse_collection_sync_header()` - **`Collection_Sync`** (`includes/handler/class-collection-sync.php`) - Handles incoming activities with Collection-Synchronization headers @@ -70,9 +70,9 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. - Methods: `handle_collection_synchronization()`, `detect_collection_type()`, `process_followers_collection_sync()`, `validate_collection_sync_header_params()` - **`Followers`** (`includes/collection/class-followers.php`) - - Computes partial follower digests for outgoing deliveries using XOR'd SHA256 hashes + - Computes partial follower digests for outgoing deliveries using XOR'd SHA256 hashes (delegates to `Signature::compute_collection_digest()`) - Filters followers by instance authority when building partial collections - - Methods: `compute_partial_digest()`, `get_partial_followers()` + - Methods: `compute_partial_digest()` (convenience wrapper), `generate_sync_header()`, `get_by_authority()` - **`Following`** (`includes/collection/class-following.php`) - Exposes local following state for reconciliation and digest calculations diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 0fbc3f131..d84920af7 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -488,31 +488,27 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { * Compute the partial follower collection digest for a specific instance. * * Implements FEP-8fcf: Followers collection synchronization. + * This is a convenience wrapper that filters followers by authority and then + * computes the digest using the standard FEP-8fcf algorithm. + * * The digest is created by XORing together the individual SHA256 digests * of each follower's ID. * * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * @see Signature::compute_collection_digest() for the core digest algorithm * - * @param int $user_id The user ID whose followers to compute. + * @param int $user_id The user ID whose followers to compute. * @param string $authority The URI authority (scheme + host) to filter by. * - * @return string The hex-encoded digest, or empty string if no followers. + * @return string|false The hex-encoded digest, or false if no followers. */ public static function compute_partial_digest( $user_id, $authority ) { - $followers = self::get_by_authority( $user_id, $authority ); - - // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). - $digest = str_repeat( '0', 64 ); - - foreach ( $followers as $follower ) { - // Compute SHA256 hash of the follower ID. - $hash = hash( 'sha256', $follower->guid ); - - // XOR the hash with the running digest. - $digest = Signature::xor_hex_strings( $digest, $hash ); - } + // Get followers filtered by authority. + $followers = self::get_by_authority( $user_id, $authority ); + $follower_ids = \wp_list_pluck( $followers, 'guid' ); - return $digest; + // Delegate to the core digest computation algorithm. + return Signature::compute_collection_digest( $follower_ids ); } /** From 995274016c398911ef44724876dbc034d8cd3212 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 08:52:47 +0200 Subject: [PATCH 69/78] Update includes/rest/class-followers-controller.php Co-authored-by: Konstantin Obenland --- includes/rest/class-followers-controller.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 856bf09ec..98eb15bf4 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -97,6 +97,8 @@ public function register_routes() { 'authority' => array( 'description' => 'The host to filter followers by.', 'type' => 'string', + 'format' => 'uri', + 'pattern' => '^https?://[^/]+$', 'required' => true, ), 'page' => array( From 59d0a59e524bc82e0825909712a525586190e71a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 08:53:55 +0200 Subject: [PATCH 70/78] Rename compute_collection_digest to get_collection_digest Refactored the Signature class method compute_collection_digest to get_collection_digest for clarity and consistency. Updated all references and documentation to use the new method name. --- docs/collection-synchronization.md | 4 ++-- includes/class-signature.php | 2 +- includes/collection/class-followers.php | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md index b16cf0698..99a406971 100644 --- a/docs/collection-synchronization.md +++ b/docs/collection-synchronization.md @@ -60,7 +60,7 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. - **`Signature`** (`includes/class-signature.php`) - Provides core digest computation algorithm using XOR'd SHA256 hashes - Order-independent digest for efficient collection comparison - - Methods: `compute_collection_digest()`, `xor_hex_strings()`, `parse_collection_sync_header()` + - Methods: `get_collection_digest()`, `xor_hex_strings()`, `parse_collection_sync_header()` - **`Collection_Sync`** (`includes/handler/class-collection-sync.php`) - Handles incoming activities with Collection-Synchronization headers @@ -70,7 +70,7 @@ This is implemented in `includes/scheduler/class-collection-sync.php`. - Methods: `handle_collection_synchronization()`, `detect_collection_type()`, `process_followers_collection_sync()`, `validate_collection_sync_header_params()` - **`Followers`** (`includes/collection/class-followers.php`) - - Computes partial follower digests for outgoing deliveries using XOR'd SHA256 hashes (delegates to `Signature::compute_collection_digest()`) + - Computes partial follower digests for outgoing deliveries using XOR'd SHA256 hashes (delegates to `Signature::get_collection_digest()`) - Filters followers by instance authority when building partial collections - Methods: `compute_partial_digest()` (convenience wrapper), `generate_sync_header()`, `get_by_authority()` diff --git a/includes/class-signature.php b/includes/class-signature.php index aaeea0711..c3759a653 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -479,7 +479,7 @@ public static function generate_digest( $body ) { * * @return string|false The hex-encoded digest, or false if no followers. */ - public static function compute_collection_digest( $collection ) { + public static function get_collection_digest( $collection ) { if ( empty( $collection ) || ! is_array( $collection ) ) { return false; } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index b21fa5846..2e690fc99 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -581,7 +581,7 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { * of each follower's ID. * * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md - * @see Signature::compute_collection_digest() for the core digest algorithm + * @see Signature::get_collection_digest() for the core digest algorithm * * @param int $user_id The user ID whose followers to compute. * @param string $authority The URI authority (scheme + host) to filter by. @@ -594,7 +594,7 @@ public static function compute_partial_digest( $user_id, $authority ) { $follower_ids = \wp_list_pluck( $followers, 'guid' ); // Delegate to the core digest computation algorithm. - return Signature::compute_collection_digest( $follower_ids ); + return Signature::get_collection_digest( $follower_ids ); } /** @@ -647,7 +647,7 @@ public static function generate_sync_header( $user_id, $authority ) { $followers = \wp_list_pluck( $followers, 'guid' ); // Compute the digest for this specific authority. - $digest = Signature::compute_collection_digest( $followers ); + $digest = Signature::get_collection_digest( $followers ); if ( ! $digest ) { return false; From abb32d82d98881feec1ca14d2cd039441b5782b5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 08:54:51 +0200 Subject: [PATCH 71/78] Remove collection synchronization documentation Deleted docs/collection-synchronization.md, which described the FEP-8fcf followers collection synchronization implementation, its components, API, and testing instructions. --- docs/collection-synchronization.md | 223 ----------------------------- 1 file changed, 223 deletions(-) delete mode 100644 docs/collection-synchronization.md diff --git a/docs/collection-synchronization.md b/docs/collection-synchronization.md deleted file mode 100644 index 99a406971..000000000 --- a/docs/collection-synchronization.md +++ /dev/null @@ -1,223 +0,0 @@ -# Collection Synchronization - -This is a prototype implementation of [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md). - -## Overview - -FEP-8fcf provides a mechanism for detecting and resolving discrepancies in follow relationships between ActivityPub instances. This helps ensure that follower lists stay synchronized even when there are software bugs, server crashes, or database rollbacks. - -## How It Works - -### 1. Outgoing Activities - -When sending Create activities to followers, the plugin automatically adds a `Collection-Synchronization` HTTP header that includes: - -- `collectionId`: The sender's followers collection URI -- `url`: URL to fetch the partial followers collection for that specific instance (e.g., `/actors/{id}/followers/sync?authority=https://example.com`) -- `digest`: A cryptographic digest (XOR'd SHA256 hashes) of followers from the receiving instance - -The header is added during HTTP delivery in `Http::post()` when sending to inboxes and is automatically covered by the HTTP signature to meet the FEP requirement for authenticity. - -This is implemented in `includes/class-http.php`. - -### 2. Partial Followers Collection - -A new REST endpoint `/actors/{user_id}/followers/sync` provides partial followers collections filtered by instance authority. This endpoint only returns followers whose IDs match the requesting instance's domain. - -This is implemented in `includes/rest/class-followers-controller.php`. - -### 3. Incoming Activities - -When receiving Create activities with a `Collection-Synchronization` header, the plugin: - -1. Detects the collection type from the URL (e.g., followers, following) -2. Validates the header parameters against the actor's collection -3. Computes the local digest for comparison -4. If digests don't match, fires the `activitypub_followers_sync_mismatch` action for async reconciliation - -This is implemented in `includes/handler/class-collection-sync.php`. - -### 4. Reconciliation - -When a digest mismatch is detected, the plugin triggers a scheduled reconciliation job that: - -1. Fetches the authoritative partial followers collection from the remote server (using the URL from the Collection-Synchronization header) -2. Compares it with the local *following* relationships for that remote actor (filtered by authority) -3. For **accepted** following relationships: - - If the remote actor is NOT in the remote followers list, reject the local follow (remote server no longer recognizes it) -4. For **pending** following relationships: - - If the remote actor IS in the remote followers list, accept the pending follow (remote server already accepted it) - - If the remote actor is NOT in the remote followers list, reject the pending follow (remote server doesn't recognize it) - -The reconciliation is handled asynchronously via WordPress's cron system to avoid blocking inbox processing. - -This is implemented in `includes/scheduler/class-collection-sync.php`. - -## Components - -### Core Classes - -- **`Signature`** (`includes/class-signature.php`) - - Provides core digest computation algorithm using XOR'd SHA256 hashes - - Order-independent digest for efficient collection comparison - - Methods: `get_collection_digest()`, `xor_hex_strings()`, `parse_collection_sync_header()` - -- **`Collection_Sync`** (`includes/handler/class-collection-sync.php`) - - Handles incoming activities with Collection-Synchronization headers - - Detects collection type from URLs (followers, following, etc.) - - Validates header parameters against actor collections - - Triggers reconciliation on digest mismatch - - Methods: `handle_collection_synchronization()`, `detect_collection_type()`, `process_followers_collection_sync()`, `validate_collection_sync_header_params()` - -- **`Followers`** (`includes/collection/class-followers.php`) - - Computes partial follower digests for outgoing deliveries using XOR'd SHA256 hashes (delegates to `Signature::get_collection_digest()`) - - Filters followers by instance authority when building partial collections - - Methods: `compute_partial_digest()` (convenience wrapper), `generate_sync_header()`, `get_by_authority()` - -- **`Following`** (`includes/collection/class-following.php`) - - Exposes local following state for reconciliation and digest calculations - - Filters following relationships by authority (instance domain) - - Handles accept/reject operations for follow relationships - - Methods: `get_by_authority()`, `accept()`, `reject()` - -- **`Followers_Controller`** (`includes/rest/class-followers-controller.php`) - - Adds `/actors/{id}/followers/sync` REST endpoint for partial collections - - Filters followers by authority parameter - - Returns ActivityStreams OrderedCollection with only matching followers - - Methods: `get_partial_followers()` - -- **`Collection_Sync`** (`includes/scheduler/class-collection-sync.php`) - - Handles async reconciliation when digest mismatches occur - - Fetches authoritative partial followers from the remote server using the sync URL - - Compares remote followers with local following relationships filtered by home authority - - Rejects accepted follows not recognized by remote server - - Accepts pending follows already in remote followers list - - Rejects pending follows not in remote followers list - - Reports completion via action hooks - - Methods: `reconcile_followers()`, `schedule_reconciliation()` - -- **`Scheduler`** (`includes/class-scheduler.php`) - - Registers the follower reconciliation scheduled action -## Privacy Considerations - -FEP-8fcf is designed with privacy in mind: - -- Only followers from the requesting instance are included in partial collections -- Each instance only gets information about its own users -- No global follower list is exposed - -## Action Hooks - -The implementation provides action hooks for monitoring and extending: - -```php -// Triggered when a digest mismatch is detected and reconciliation is scheduled -// Fired in includes/handler/class-collection-sync.php -do_action( 'activitypub_collection_sync', $collection_type, $user_id, $actor_url, $params ); - -// Triggered after reconciliation completes successfully -// Fired in includes/scheduler/class-collection-sync.php -do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url ); -``` - -**Note:** The `Following::accept()` and `Following::reject()` methods trigger their own action hooks for tracking individual follow state changes. - -## REST API Endpoints - -### Partial Followers Collection - -``` -GET /wp-json/activitypub/1.0/actors/{user_id}/followers/sync?authority={authority} -``` - -**Parameters:** -- `user_id` (required): The local actor's user ID -- `authority` (required): URI authority to filter followers (e.g., `https://mastodon.social`) -- `page` (optional): Page number for pagination -- `per_page` (optional): Items per page (default: 20) - -**Response:** ActivityStreams OrderedCollection with filtered followers - -**Example:** -```bash -curl -H "Accept: application/activity+json" \ - "https://example.com/wp-json/activitypub/1.0/actors/1/followers/sync?authority=https://mastodon.social" -``` - -## Compatibility - -This implementation is compatible with: - -- Mastodon (v3.3.0+) -- Fedify (v0.8.0+) -- Tootik (v0.18.0+) -- Any other server that implements FEP-8fcf - -## Testing - -### Manual Testing - -To test the implementation: - -1. Set up two WordPress instances with the ActivityPub plugin -2. Have users follow each other -3. Monitor the `Collection-Synchronization` headers in HTTP requests -4. Simulate a follower mismatch by manually removing a follower from the database -5. Send a Create activity and verify reconciliation occurs - -### Automated Tests - -The implementation includes: -- **Unit tests** (`tests/phpunit/tests/includes/class-test-http.php`) - Tests header generation -- **E2E tests** (`tests/e2e/specs/includes/rest/followers-controller.test.js`) - Tests the sync endpoint -- **Integration tests** - Tests full reconciliation flow - -Run tests with: -```bash -# PHP unit tests -vendor/bin/phpunit - -# E2E tests -npm run test:e2e -``` - -## Configuration - -The FEP-8fcf implementation is enabled by default. There are no configuration options currently available. - -## Debugging - -To debug synchronization issues: - -1. Enable WordPress debug logging: - ```php - define( 'WP_DEBUG', true ); - define( 'WP_DEBUG_LOG', true ); - ``` - -2. Monitor action hooks: - ```php - add_action( 'activitypub_followers_sync_mismatch', function( $user_id, $actor_url, $params ) { - error_log( "Sync mismatch for user $user_id from $actor_url" ); - }, 10, 3 ); - ``` - -3. Check scheduled actions in WordPress admin under Tools > Scheduled Actions - -## Future Enhancements - -Potential improvements for the future: - -- Add admin UI to view synchronization logs -- Implement configurable sync frequency -- Add metrics/statistics for sync operations -- Support synchronization for Following collections -- Add option to disable FEP-8fcf support -- Implement exponential backoff for failed reconciliations -- Add support for other collection types (liked, outbox, etc.) - -## References - -- [FEP-8fcf Specification](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) -- [Mastodon Implementation](https://github.com/tootsuite/mastodon/pull/14510) -- [Fedify Documentation](https://fedify.dev/manual/send#followers-collection-synchronization) From d93d00976fa3beaa6134ac44a87a2046f45f7da5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 08:56:13 +0200 Subject: [PATCH 72/78] Remove port from get_url_authority output The get_url_authority function now returns only the scheme and host, omitting the port even if specified. This simplifies the function and matches the updated docblock. --- includes/functions.php | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/includes/functions.php b/includes/functions.php index 4e05db86c..c1627e913 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1792,7 +1792,7 @@ function extract_name_from_uri( $uri ) { } /** - * Get the authority (scheme + host + port) from a URL. + * Get the authority (scheme + host) from a URL. * * @param string $url The URL to parse. * @@ -1805,18 +1805,5 @@ function get_url_authority( $url ) { return false; } - $authority = $parsed['scheme'] . '://' . $parsed['host']; - - if ( ! empty( $parsed['port'] ) ) { - $default_ports = array( - 'http' => 80, - 'https' => 443, - ); - - if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $default_ports[ $parsed['scheme'] ] !== $parsed['port'] ) { - $authority .= ':' . $parsed['port']; - } - } - - return $authority; + return $parsed['scheme'] . '://' . $parsed['host']; } From b3007f289c29af2b14c740098cd5be759b36f0e6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 08:58:17 +0200 Subject: [PATCH 73/78] Fix user_id default and check in collection sync Changed the default value of user_id from 0 to false and updated the conditional check to use strict comparison. This ensures that user_id is properly validated before proceeding. --- includes/handler/class-collection-sync.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index fdd59169b..554b8f21d 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -140,9 +140,9 @@ public static function maybe_add_headers( $args, $url ) { // Only send header if we haven't sent one to this authority in the last day. $inbox_authority = get_url_authority( $url ); - $user_id = $args['user_id'] ?? 0; + $user_id = $args['user_id'] ?? false; - if ( ! $user_id || ! $inbox_authority ) { + if ( false === $user_id || ! $inbox_authority ) { return $args; } From 7e9c736d34972921579b56758e1721dc207ec33a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 16:13:12 +0200 Subject: [PATCH 74/78] Update includes/rest/class-followers-controller.php Co-authored-by: Konstantin Obenland --- includes/rest/class-followers-controller.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index 98eb15bf4..4aab6a8a9 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -199,15 +199,6 @@ public function get_partial_followers( $request ) { $user_id = $request->get_param( 'user_id' ); $authority = $request->get_param( 'authority' ); - // Validate authority format. - if ( ! preg_match( '#^https?://[^/]+$#', $authority ) ) { - return new \WP_Error( - 'invalid_authority', - \__( 'Invalid authority format.', 'activitypub' ), - array( 'status' => 400 ) - ); - } - // Get partial followers filtered by authority. $followers = Followers::get_by_authority( $user_id, $authority ); $followers = \wp_list_pluck( $followers, 'guid' ); From 29ce6d9b17cc04a53a3f09785645c2c70b72b15e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 16:13:31 +0200 Subject: [PATCH 75/78] Update includes/collection/class-followers.php Co-authored-by: Konstantin Obenland --- includes/collection/class-followers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 2e690fc99..3dcf83355 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -603,7 +603,7 @@ public static function compute_partial_digest( $user_id, $authority ) { * Returns only followers whose ID shares the specified URI authority. * Used for FEP-8fcf synchronization. * - * @param int $user_id The user ID whose followers to get. + * @param int $user_id The user ID whose followers to get. * @param string $authority The URI authority (scheme + host) to filter by. * * @return \WP_Post[] Array of WP_Post objects. From 5d101d7f4202c73c1f208f1fe79c814af76be8b0 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 16:13:40 +0200 Subject: [PATCH 76/78] Update includes/scheduler/class-collection-sync.php Co-authored-by: Konstantin Obenland --- includes/scheduler/class-collection-sync.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php index 1cd2921e5..3b946539d 100644 --- a/includes/scheduler/class-collection-sync.php +++ b/includes/scheduler/class-collection-sync.php @@ -94,8 +94,8 @@ public static function reconcile_followers( $user_id, $actor_url, $params ) { /** * Action triggered after reconciliation is complete. * - * @param int $user_id The local user ID that triggered the reconciliation. - * @param string $actor_url The remote actor URL. + * @param int $user_id The local user ID that triggered the reconciliation. + * @param string $actor_url The remote actor URL. */ \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url ); } From e67745b5f3ec8d618d322eb1fb85a02abb6a5f6d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 16:13:50 +0200 Subject: [PATCH 77/78] Update includes/handler/class-collection-sync.php Co-authored-by: Konstantin Obenland --- includes/handler/class-collection-sync.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index 554b8f21d..d066d3d06 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -212,7 +212,7 @@ public static function validate_header_params( $params, $actor_url ) { */ private static function get_frequency() { /** - * Filter the frequency of Collection-Synchronization headers sent to a given authority. + * Filter the frequency of Collection-Synchronization headers sent to a given authority. * * @param int $frequency The frequency in seconds. Default is one week. * @param int $user_id The local user ID. From 9c319bdc985f0bb75f7b12c76c78a9bb0a8a52e1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 21 Oct 2025 16:14:00 +0200 Subject: [PATCH 78/78] Update includes/handler/class-collection-sync.php Co-authored-by: Konstantin Obenland --- includes/handler/class-collection-sync.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php index d066d3d06..54e95ef1f 100644 --- a/includes/handler/class-collection-sync.php +++ b/includes/handler/class-collection-sync.php @@ -115,8 +115,8 @@ public static function handle_collection_synchronization( $data, $user_id ) { * * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md * - * @param array $args The HTTP request arguments. - * @param string $url The request URL. + * @param array $args The HTTP request arguments. + * @param string $url The request URL. * * @return array Modified HTTP request arguments. */