Skip to content
Merged
Show file tree
Hide file tree
Changes from 82 commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
7888ccf
Implement FEP-8fcf followers collection synchronization
pfefferle Oct 6, 2025
19439c3
Add FEP-8fcf to supported FEPs in FEDERATION.md
pfefferle Oct 6, 2025
0d8125a
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 6, 2025
d89ffba
Refactor and generalize collection synchronization logic
pfefferle Oct 6, 2025
00565cb
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 6, 2025
53a0600
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 7, 2025
d02675a
Update followers sync endpoint path to /followers/sync
pfefferle Oct 8, 2025
cdac645
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 8, 2025
adadf02
Remove unnecessary class name prefixes in method calls
pfefferle Oct 9, 2025
f19c792
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 10, 2025
06a8c1e
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 13, 2025
35301c5
Refactor Collection-Synchronization handling to new handler
pfefferle Oct 13, 2025
81eacf9
Add pagination and ordering to followers sync endpoint
pfefferle Oct 13, 2025
bff1ba6
Remove unused get_authority method
pfefferle Oct 13, 2025
7a78346
Fix followers collection type detection regex
pfefferle Oct 13, 2025
7786423
Update Playwright webServer command for rewrite structure
pfefferle Oct 13, 2025
1cc3217
Flush rewrite rules in Playwright webServer command
pfefferle Oct 13, 2025
ced40ba
Replace FEP-8fcf implementation doc with collection sync doc
pfefferle Oct 15, 2025
a05075f
Simplify webServer command in Playwright config
pfefferle Oct 15, 2025
79ba390
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 15, 2025
4ba9db6
Unindent tests in followers-controller.test.js
pfefferle Oct 15, 2025
d840498
Flush rewrite rules after setting permalink structure
pfefferle Oct 15, 2025
6e60479
Remove unused Collection trait from Inbox_Controller
pfefferle Oct 15, 2025
0e65a4c
Update rewrite structure command and remove testDir config
pfefferle Oct 15, 2025
6ac4ed9
Update rewrite structure command for test environment
pfefferle Oct 15, 2025
c1f5ba7
Update Playwright webServer command in config
pfefferle Oct 15, 2025
1c7b2f3
Set permalink structure in CI and remove theme activation
pfefferle Oct 15, 2025
da39b82
Fix CLI container name in Playwright workflow
pfefferle Oct 15, 2025
5f57952
Flush WordPress rewrite rules in Playwright workflow
pfefferle Oct 15, 2025
b2e9978
Set up pretty permalinks in global E2E setup
pfefferle Oct 15, 2025
17dd50a
Refactor E2E tests to use params for REST requests
pfefferle Oct 15, 2025
f885ea5
Add missing followers to local database
pfefferle Oct 15, 2025
04c05af
Add changelog
matticbot Oct 15, 2025
b46aed6
Update docs/collection-synchronization.md
pfefferle Oct 15, 2025
53ff24c
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 15, 2025
af7fdea
Add Collection-Synchronization header to signature components
pfefferle Oct 15, 2025
9b4b866
Refactor collection sync and update FEP-8fcf handling
pfefferle Oct 15, 2025
4e8c081
Filter out null values from followers and pending lists
pfefferle Oct 15, 2025
e28140d
Remove redundant collection-synchronization header checks
pfefferle Oct 16, 2025
5f78d3a
Refactor Followers sync header usage in HTTP class
pfefferle Oct 16, 2025
1278792
Move collection sync logic to Signature class
pfefferle Oct 16, 2025
c5df052
Move Collection-Synchronization header logic to filter
pfefferle Oct 16, 2025
799a3e3
Add user_id to signature test cases
pfefferle Oct 16, 2025
7861db7
Update REST API endpoints to use 'actors' instead of 'users'
pfefferle Oct 16, 2025
acc08c5
Adjust filter priorities and signature header handling
pfefferle Oct 16, 2025
960ae63
Improve header addition logic in maybe_add_headers
pfefferle Oct 16, 2025
5553b62
Remove redundant assignment in request body handling
pfefferle Oct 16, 2025
d1d924d
Clarify docblocks for Create activities header
pfefferle Oct 16, 2025
5caf4e9
Move get_authority to functions.php and refactor usage
pfefferle Oct 16, 2025
39b4b3c
Refactor digest computation to use Signature class
pfefferle Oct 16, 2025
bd3f0bf
Refactor followers collection ID validation logic
pfefferle Oct 16, 2025
3b91c81
Refactor collection sync to use action hook
pfefferle Oct 16, 2025
3e4490e
Remove HTTP_SIGNATURE_INPUT from signature check
pfefferle Oct 16, 2025
62a5f59
Remove outdated comment from signature test
pfefferle Oct 16, 2025
ca29f68
Clarify comment about filter priority in Signature class
pfefferle Oct 16, 2025
23cbd76
Refactor collection sync handling and scheduler
pfefferle Oct 16, 2025
ea2783b
Refactor followers and following collection methods
pfefferle Oct 16, 2025
5c34dfc
Use get_by_authority for partial followers retrieval
pfefferle Oct 16, 2025
af93122
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 16, 2025
3fc1b60
Refactor follower authority filtering logic
pfefferle Oct 16, 2025
930d587
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 17, 2025
e748f50
Refactor follower sync logic for authority filtering
pfefferle Oct 17, 2025
96c4c8c
Refactor followers handling to use WP_Post objects
pfefferle Oct 17, 2025
51b3e48
Refactor followers sync to use authority filtering
pfefferle Oct 17, 2025
e7e7fa4
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 17, 2025
248ba43
Fix followers reconciliation and add unit tests
pfefferle Oct 17, 2025
be501cf
Update includes/rest/class-followers-controller.php
pfefferle Oct 18, 2025
58b777f
Update includes/collection/class-followers.php
pfefferle Oct 18, 2025
d052dcd
Update includes/scheduler/class-collection-sync.php
pfefferle Oct 18, 2025
6b55dd8
Update includes/scheduler/class-collection-sync.php
pfefferle Oct 18, 2025
86de28a
Add comment clarifying header addition timing
pfefferle Oct 20, 2025
a86ddf0
Update authority argument description in REST controller
pfefferle Oct 20, 2025
d26cfc8
Update includes/handler/class-collection-sync.php
pfefferle Oct 20, 2025
adf620d
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 20, 2025
0630dc7
Send sync header to authority only once per day
pfefferle Oct 20, 2025
ef4a3e9
Add filter for collection sync header frequency
pfefferle Oct 20, 2025
3883c99
Add default value note to collection sync header filter
pfefferle Oct 20, 2025
86f3b5f
Update docblock for collection sync frequency filter
pfefferle Oct 20, 2025
c057799
Add caching and refactor sync frequency logic
pfefferle Oct 20, 2025
420efb1
Refactor follower digest computation to use Signature class
pfefferle Oct 20, 2025
249a664
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 20, 2025
b64e28c
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 20, 2025
9952740
Update includes/rest/class-followers-controller.php
pfefferle Oct 21, 2025
59d0a59
Rename compute_collection_digest to get_collection_digest
pfefferle Oct 21, 2025
abb32d8
Remove collection synchronization documentation
pfefferle Oct 21, 2025
d93d009
Remove port from get_url_authority output
pfefferle Oct 21, 2025
b3007f2
Fix user_id default and check in collection sync
pfefferle Oct 21, 2025
be08572
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 21, 2025
7e9c736
Update includes/rest/class-followers-controller.php
pfefferle Oct 21, 2025
29ce6d9
Update includes/collection/class-followers.php
pfefferle Oct 21, 2025
5d101d7
Update includes/scheduler/class-collection-sync.php
pfefferle Oct 21, 2025
e67745b
Update includes/handler/class-collection-sync.php
pfefferle Oct 21, 2025
9c319bd
Update includes/handler/class-collection-sync.php
pfefferle Oct 21, 2025
55954c7
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 21, 2025
8e559f0
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 21, 2025
e2b202d
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 22, 2025
d39c6a6
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 22, 2025
772e0b7
Merge branch 'trunk' into add/fep-8fcf
pfefferle Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/2297-from-description
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
223 changes: 223 additions & 0 deletions docs/collection-synchronization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# 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: `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
- 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::compute_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)
2 changes: 2 additions & 0 deletions includes/class-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions includes/class-http.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ 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,
);

$response = \wp_safe_remote_post( $url, $args );
Expand Down
2 changes: 2 additions & 0 deletions includes/class-scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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\Post;

Expand Down Expand Up @@ -60,6 +61,7 @@ public static function init() {
public static function register_schedulers() {
Post::init();
Actor::init();
Collection_Sync::init();
Comment::init();

/**
Expand Down
93 changes: 93 additions & 0 deletions includes/class-signature.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading