Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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 get_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;
}
}
106 changes: 106 additions & 0 deletions includes/collection/class-followers.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

namespace Activitypub\Collection;

use Activitypub\Signature;
use Activitypub\Tombstone;

use function Activitypub\get_remote_metadata_by_actor;
use function Activitypub\get_rest_url_by_path;

/**
* ActivityPub Followers Collection.
Expand Down Expand Up @@ -567,4 +569,108 @@ 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.
* 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::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.
*
* @return string|false The hex-encoded digest, or false if no followers.
*/
public static function compute_partial_digest( $user_id, $authority ) {
// Get followers filtered by authority.
$followers = self::get_by_authority( $user_id, $authority );
$follower_ids = \wp_list_pluck( $followers, 'guid' );

// Delegate to the core digest computation algorithm.
return Signature::get_collection_digest( $follower_ids );
}

/**
* 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 \WP_Post[] Array of WP_Post objects.
*/
public static function get_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 $posts->posts ?? array();
}

/**
* 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 ) {
$followers = self::get_by_authority( $user_id, $authority );
$followers = \wp_list_pluck( $followers, 'guid' );

// Compute the digest for this specific authority.
$digest = Signature::get_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 ) );

// Build the partial followers URL.
$url = 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
);
}
}
38 changes: 38 additions & 0 deletions includes/collection/class-following.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,44 @@ public static function query_all( $user_id, $number = -1, $page = null, $args =
return self::query( $user_id, $number, $page, $args );
}

/**
* 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.
* @param string $state The following state to filter by (accepted or pending). Default is accepted.
*
* @return array Array of follower URLs.
*/
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();
}

/**
* Get all followings of a given user.
*
Expand Down
2 changes: 1 addition & 1 deletion includes/collection/class-remote-actors.php
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,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 );
}

Expand Down
17 changes: 17 additions & 0 deletions includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1811,3 +1811,20 @@ function extract_name_from_uri( $uri ) {

return $name;
}

/**
* Get the authority (scheme + host) 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;
}

return $parsed['scheme'] . '://' . $parsed['host'];
}
Loading
Loading