Skip to content

Commit 8f0c9e2

Browse files
authored
Implement FEP-8fcf followers collection synchronization (#2297)
1 parent 8a233ed commit 8f0c9e2

25 files changed

+1526
-14
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Added support for FEP-8fcf follower synchronization, improving data consistency across servers with new sync headers, digest checks, and reconciliation tasks.

FEDERATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio
2424
- [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md)
2525
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md)
2626
- [FEP-3b86: Activity Intents](https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md)
27+
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
2728

2829
Partially supported FEPs
2930

includes/class-handler.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use Activitypub\Handler\Accept;
1111
use Activitypub\Handler\Announce;
12+
use Activitypub\Handler\Collection_Sync;
1213
use Activitypub\Handler\Create;
1314
use Activitypub\Handler\Delete;
1415
use Activitypub\Handler\Follow;
@@ -37,6 +38,7 @@ public static function init() {
3738
public static function register_handlers() {
3839
Accept::init();
3940
Announce::init();
41+
Collection_Sync::init();
4042
Create::init();
4143
Delete::init();
4244
Follow::init();

includes/class-http.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public static function post( $url, $body, $user_id ) {
5353
'body' => $body,
5454
'key_id' => \json_decode( $body )->actor . '#main-key',
5555
'private_key' => Actors::get_private_key( $user_id ),
56+
'user_id' => $user_id,
5657
);
5758

5859
$response = \wp_safe_remote_post( $url, $args );

includes/class-scheduler.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Activitypub\Collection\Outbox;
1515
use Activitypub\Collection\Remote_Actors;
1616
use Activitypub\Scheduler\Actor;
17+
use Activitypub\Scheduler\Collection_Sync;
1718
use Activitypub\Scheduler\Comment;
1819
use Activitypub\Scheduler\Post;
1920

@@ -60,6 +61,7 @@ public static function init() {
6061
public static function register_schedulers() {
6162
Post::init();
6263
Actor::init();
64+
Collection_Sync::init();
6365
Comment::init();
6466

6567
/**

includes/class-signature.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,97 @@ public static function generate_digest( $body ) {
465465
$digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
466466
return "SHA-256=$digest";
467467
}
468+
469+
/**
470+
* Compute the collection digest for a specific instance.
471+
*
472+
* Implements FEP-8fcf: Followers collection synchronization.
473+
* The digest is created by XORing together the individual SHA256 digests
474+
* of each follower's ID.
475+
*
476+
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
477+
*
478+
* @param array $collection The user ID whose followers to compute.
479+
*
480+
* @return string|false The hex-encoded digest, or false if no followers.
481+
*/
482+
public static function get_collection_digest( $collection ) {
483+
if ( empty( $collection ) || ! is_array( $collection ) ) {
484+
return false;
485+
}
486+
487+
// Initialize with zeros (64 hex chars = 32 bytes = 256 bits).
488+
$digest = str_repeat( '0', 64 );
489+
490+
foreach ( $collection as $item ) {
491+
// Compute SHA256 hash of the follower ID.
492+
$hash = hash( 'sha256', $item );
493+
494+
// XOR the hash with the running digest.
495+
$digest = self::xor_hex_strings( $digest, $hash );
496+
}
497+
498+
return $digest;
499+
}
500+
501+
/**
502+
* XOR two hexadecimal strings.
503+
*
504+
* Used for FEP-8fcf digest computation.
505+
*
506+
* @param string $hex1 First hex string.
507+
* @param string $hex2 Second hex string.
508+
*
509+
* @return string The XORed result as a hex string.
510+
*/
511+
public static function xor_hex_strings( $hex1, $hex2 ) {
512+
$result = '';
513+
514+
// Ensure both strings are the same length (should be 64 chars for SHA256).
515+
$length = \max( \strlen( $hex1 ), \strlen( $hex2 ) );
516+
$hex1 = \str_pad( $hex1, $length, '0', STR_PAD_LEFT );
517+
$hex2 = \str_pad( $hex2, $length, '0', STR_PAD_LEFT );
518+
519+
// XOR each pair of hex digits.
520+
for ( $i = 0; $i < $length; $i += 2 ) {
521+
$byte1 = \hexdec( \substr( $hex1, $i, 2 ) );
522+
$byte2 = \hexdec( \substr( $hex2, $i, 2 ) );
523+
$result .= \str_pad( \dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT );
524+
}
525+
526+
return $result;
527+
}
528+
529+
/**
530+
* Parse a Collection-Synchronization header (FEP-8fcf).
531+
*
532+
* Parses the signature-style format used by the Collection-Synchronization header.
533+
*
534+
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
535+
*
536+
* @param string $header The header value.
537+
*
538+
* @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure.
539+
*/
540+
public static function parse_collection_sync_header( $header ) {
541+
if ( empty( $header ) ) {
542+
return false;
543+
}
544+
545+
// Parse the signature-style format: key="value", key="value".
546+
$params = array();
547+
548+
if ( \preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) {
549+
foreach ( $matches as $match ) {
550+
$params[ $match[1] ] = $match[2];
551+
}
552+
}
553+
554+
// Validate required fields for FEP-8fcf.
555+
if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) {
556+
return false;
557+
}
558+
559+
return $params;
560+
}
468561
}

includes/collection/class-followers.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
namespace Activitypub\Collection;
99

10+
use Activitypub\Signature;
1011
use Activitypub\Tombstone;
1112

1213
use function Activitypub\get_remote_metadata_by_actor;
14+
use function Activitypub\get_rest_url_by_path;
1315

1416
/**
1517
* ActivityPub Followers Collection.
@@ -567,4 +569,108 @@ public static function remove_blocked_actors( $value, $type, $user_id ) {
567569

568570
self::remove( $actor_id, $user_id );
569571
}
572+
573+
/**
574+
* Compute the partial follower collection digest for a specific instance.
575+
*
576+
* Implements FEP-8fcf: Followers collection synchronization.
577+
* This is a convenience wrapper that filters followers by authority and then
578+
* computes the digest using the standard FEP-8fcf algorithm.
579+
*
580+
* The digest is created by XORing together the individual SHA256 digests
581+
* of each follower's ID.
582+
*
583+
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
584+
* @see Signature::get_collection_digest() for the core digest algorithm
585+
*
586+
* @param int $user_id The user ID whose followers to compute.
587+
* @param string $authority The URI authority (scheme + host) to filter by.
588+
*
589+
* @return string|false The hex-encoded digest, or false if no followers.
590+
*/
591+
public static function compute_partial_digest( $user_id, $authority ) {
592+
// Get followers filtered by authority.
593+
$followers = self::get_by_authority( $user_id, $authority );
594+
$follower_ids = \wp_list_pluck( $followers, 'guid' );
595+
596+
// Delegate to the core digest computation algorithm.
597+
return Signature::get_collection_digest( $follower_ids );
598+
}
599+
600+
/**
601+
* Get partial followers collection for a specific instance.
602+
*
603+
* Returns only followers whose ID shares the specified URI authority.
604+
* Used for FEP-8fcf synchronization.
605+
*
606+
* @param int $user_id The user ID whose followers to get.
607+
* @param string $authority The URI authority (scheme + host) to filter by.
608+
*
609+
* @return \WP_Post[] Array of WP_Post objects.
610+
*/
611+
public static function get_by_authority( $user_id, $authority ) {
612+
$posts = new \WP_Query(
613+
array(
614+
'post_type' => Remote_Actors::POST_TYPE,
615+
'posts_per_page' => -1,
616+
'orderby' => 'ID',
617+
'order' => 'DESC',
618+
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
619+
'meta_query' => array(
620+
'relation' => 'AND',
621+
array(
622+
'key' => self::FOLLOWER_META_KEY,
623+
'value' => $user_id,
624+
),
625+
array(
626+
'key' => '_activitypub_inbox',
627+
'compare' => 'LIKE',
628+
'value' => $authority,
629+
),
630+
),
631+
)
632+
);
633+
634+
return $posts->posts ?? array();
635+
}
636+
637+
/**
638+
* Generate the Collection-Synchronization header value for FEP-8fcf.
639+
*
640+
* @param int $user_id The user ID whose followers collection to sync.
641+
* @param string $authority The authority of the receiving instance.
642+
*
643+
* @return string|false The header value, or false if cannot generate.
644+
*/
645+
public static function generate_sync_header( $user_id, $authority ) {
646+
$followers = self::get_by_authority( $user_id, $authority );
647+
$followers = \wp_list_pluck( $followers, 'guid' );
648+
649+
// Compute the digest for this specific authority.
650+
$digest = Signature::get_collection_digest( $followers );
651+
652+
if ( ! $digest ) {
653+
return false;
654+
}
655+
656+
// Build the collection ID (followers collection URL).
657+
$collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) );
658+
659+
// Build the partial followers URL.
660+
$url = get_rest_url_by_path(
661+
sprintf(
662+
'actors/%d/followers/sync?authority=%s',
663+
$user_id,
664+
rawurlencode( $authority )
665+
)
666+
);
667+
668+
// Format as per FEP-8fcf (similar to HTTP Signatures format).
669+
return sprintf(
670+
'collectionId="%s", url="%s", digest="%s"',
671+
$collection_id,
672+
$url,
673+
$digest
674+
);
675+
}
570676
}

includes/collection/class-following.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,44 @@ public static function query_all( $user_id, $number = -1, $page = null, $args =
347347
return self::query( $user_id, $number, $page, $args );
348348
}
349349

350+
/**
351+
* Get partial followers collection for a specific instance.
352+
*
353+
* Returns only followers whose ID shares the specified URI authority.
354+
* Used for FEP-8fcf synchronization.
355+
*
356+
* @param int $user_id The user ID whose followers to get.
357+
* @param string $authority The URI authority (scheme + host) to filter by.
358+
* @param string $state The following state to filter by (accepted or pending). Default is accepted.
359+
*
360+
* @return array Array of follower URLs.
361+
*/
362+
public static function get_by_authority( $user_id, $authority, $state = self::FOLLOWING_META_KEY ) {
363+
$posts = new \WP_Query(
364+
array(
365+
'post_type' => Remote_Actors::POST_TYPE,
366+
'posts_per_page' => -1,
367+
'orderby' => 'ID',
368+
'order' => 'DESC',
369+
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
370+
'meta_query' => array(
371+
'relation' => 'AND',
372+
array(
373+
'key' => $state,
374+
'value' => $user_id,
375+
),
376+
array(
377+
'key' => '_activitypub_inbox',
378+
'compare' => 'LIKE',
379+
'value' => $authority,
380+
),
381+
),
382+
)
383+
);
384+
385+
return $posts->posts ?? array();
386+
}
387+
350388
/**
351389
* Get all followings of a given user.
352390
*

includes/collection/class-remote-actors.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ public static function normalize_identifier( $actor ) {
514514

515515
// If it's an email-like webfinger address, resolve it.
516516
if ( \filter_var( $actor, FILTER_VALIDATE_EMAIL ) ) {
517-
$resolved = \Activitypub\Webfinger::resolve( $actor );
517+
$resolved = Webfinger::resolve( $actor );
518518
return \is_wp_error( $resolved ) ? null : object_to_uri( $resolved );
519519
}
520520

includes/functions.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,3 +1811,20 @@ function extract_name_from_uri( $uri ) {
18111811

18121812
return $name;
18131813
}
1814+
1815+
/**
1816+
* Get the authority (scheme + host) from a URL.
1817+
*
1818+
* @param string $url The URL to parse.
1819+
*
1820+
* @return string|false The authority, or false on failure.
1821+
*/
1822+
function get_url_authority( $url ) {
1823+
$parsed = wp_parse_url( $url );
1824+
1825+
if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) {
1826+
return false;
1827+
}
1828+
1829+
return $parsed['scheme'] . '://' . $parsed['host'];
1830+
}

0 commit comments

Comments
 (0)