Add FEP-8fcf followers collection synchronization

Sender: followers-only Create deliveries carry a signed
Collection-Synchronization header scoped to the authority of each inbox,
and the partial followers collection is served to authenticated instances
at /users/{username}/followers_synchronization.

Receiver: a signed Collection-Synchronization header whose digest differs
from our copy queues FollowersSyncPipeline, which fetches the partial
collection as the instance actor and reconciles followers, pending follow
requests and unknown follows.

Adds profiles.followers_url for the collectionId check.
pull/7364/head
Daniel Supernault 5 days ago
parent c6d98fb72d
commit dedfa67b70
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1

@ -9,7 +9,9 @@ use App\Models\FeatureAuthorization;
use App\Models\Profile;
use App\Models\Status;
use App\Services\AccountService;
use App\Services\ActivityPubSignedFetchService;
use App\Services\FeaturedCollectionService;
use App\Services\FollowersSyncService;
use App\Services\InstanceService;
use App\Util\Lexer\Nickname;
use App\Util\Site\Nodeinfo;
@ -308,6 +310,43 @@ class FederationController extends Controller
return response()->json($obj)->header('Content-Type', 'application/activity+json');
}
/**
* FEP-8fcf: partial followers collection of a local actor, limited to the
* followers hosted by the instance that signed the request.
*/
public function userFollowersSynchronization(Request $request, $username): JsonResponse
{
abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);
abort_if(! FollowersSyncService::enabled(), 404);
$profile = Profile::whereNull('domain')
->whereNull('status')
->whereUsername($username)
->first();
abort_if(! $profile, 404);
$signer = ActivityPubSignedFetchService::verify($request);
abort_if(! $signer, 401);
$authority = FollowersSyncService::authority($signer->remote_url);
abort_if(! $authority, 401);
$items = FollowersSyncService::partialFollowers($profile, $authority);
$res = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $profile->permalink('/followers_synchronization'),
'type' => 'OrderedCollection',
'totalItems' => count($items),
'orderedItems' => $items,
];
return response()
->json($res, 200, [], JSON_UNESCAPED_SLASHES)
->header('Content-Type', 'application/activity+json')
->header('Cache-Control', 'private, no-store');
}
public function userFeatureAuthorization(Request $request, $username, $id): JsonResponse
{
abort_if(! (bool) config_cache('federation.activitypub.enabled'), 404);

@ -0,0 +1,93 @@
<?php
namespace App\Jobs\FollowPipeline;
use App\Models\Profile;
use App\Services\FollowersSyncService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* FEP-8fcf: reconcile our local copy of a remote actor's followers with the
* partial followers collection served by the authoritative server.
*/
class FollowersSyncPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profileId;
protected $collectionId;
protected $url;
protected $digest;
public $timeout = 300;
public $tries = 1;
public $maxExceptions = 1;
/**
* Create a new job instance.
*
* @param int|string $profileId Remote profile that sent the Collection-Synchronization header
* @param string $collectionId `collectionId` header parameter
* @param string $url `url` header parameter
* @param string $digest `digest` header parameter
*/
public function __construct($profileId, string $collectionId, string $url, string $digest)
{
$this->profileId = $profileId;
$this->collectionId = $collectionId;
$this->url = $url;
$this->digest = $digest;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("ap:followers-sync:pid:{$this->profileId}"))->shared()->dontRelease()];
}
/**
* Execute the job.
*/
public function handle(): void
{
$sender = Profile::whereNotNull('domain')
->whereNull('status')
->find($this->profileId);
if (! $sender) {
return;
}
try {
FollowersSyncService::synchronize(
$sender,
$this->collectionId,
$this->url,
$this->digest
);
} catch (Throwable $e) {
Log::warning('FollowersSync: synchronization failed', [
'profile_id' => $sender->id,
'url' => $this->url,
'exception' => $e::class,
'error' => $e->getMessage(),
]);
}
}
}

@ -3,6 +3,7 @@
namespace App\Jobs\InboxPipeline;
use App\Models\Profile;
use App\Services\FollowersSyncService;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\HttpSignature;
use Illuminate\Bus\Queueable;
@ -76,6 +77,9 @@ class InboxValidator implements ShouldQueue
}
}
// FEP-8fcf: compare the sender's followers digest with our copy
FollowersSyncService::handleInboundHeaders($headers);
if (isset($payload['type']) && in_array($payload['type'], ['Follow', 'Accept'])) {
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('follow');
} else {

@ -3,6 +3,7 @@
namespace App\Jobs\InboxPipeline;
use App\Models\Profile;
use App\Services\FollowersSyncService;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\HttpSignature;
use Illuminate\Bus\Queueable;
@ -62,6 +63,9 @@ class InboxWorker implements ShouldQueue
}
}
// FEP-8fcf: compare the sender's followers digest with our copy
FollowersSyncService::handleInboundHeaders($headers);
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('shared');
return;

@ -0,0 +1,129 @@
<?php
namespace App\Jobs\ProfilePipeline;
use App\Models\Profile;
use App\Services\InstanceService;
use App\Util\ActivityPub\Helpers;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
/**
* Discover (or refresh) the actor that owns an HTTP signature keyId.
*
* Signed GET requests are verified inside a web request, where fetching an
* unknown key would let anyone pin a worker on a slow remote host. Unknown
* signers are resolved here instead, so their next request can be verified.
*/
class SigningActorDiscoveryPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $keyId;
public $timeout = 60;
public $tries = 1;
public $maxExceptions = 1;
public function __construct(string $keyId)
{
$this->keyId = $keyId;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping('ap:signing-actor:'.hash('sha256', $this->keyId)))->shared()->dontRelease()];
}
/**
* Execute the job.
*/
public function handle(): void
{
$keyId = Helpers::validateUrl($this->keyId);
if (! $keyId) {
return;
}
$host = strtolower((string) parse_url($keyId, PHP_URL_HOST));
if (
$host === ''
|| Helpers::isLocalDomain($host)
|| in_array($host, InstanceService::getBannedDomains())
) {
return;
}
$known = Profile::whereKeyId($keyId)
->whereNotNull('domain')
->first();
if ($known) {
if ($known->remote_url) {
// Refreshes the stored public key when the profile is stale.
Helpers::getOrFetchRemoteProfile($known->remote_url);
}
return;
}
$document = Helpers::fetchFromUrl(explode('#', $keyId, 2)[0]);
if (! is_array($document)) {
return;
}
$actorUrl = self::ownerOf($document, $keyId);
if (
! $actorUrl
|| strtolower((string) parse_url($actorUrl, PHP_URL_HOST)) !== $host
) {
return;
}
Helpers::profileFirstOrNew($actorUrl);
}
/**
* The keyId may dereference to the actor itself or to a standalone key
* document that points at its owner.
*
* @param array<string, mixed> $document
*/
protected static function ownerOf(array $document, string $keyId): ?string
{
$key = $document['publicKey'] ?? null;
if (is_array($key) && array_is_list($key)) {
$key = collect($key)->first(
fn ($item) => is_array($item) && ($item['id'] ?? null) === $keyId,
$key[0] ?? null
);
}
$owner = (is_array($key) ? ($key['owner'] ?? null) : null)
?? $document['owner']
?? $document['id']
?? null;
if (is_array($owner)) {
$owner = $owner['id'] ?? null;
}
return is_string($owner) && $owner !== '' ? $owner : null;
}
}

@ -114,6 +114,17 @@ class StatusActivityPubDeliver implements ShouldQueue
$activity = FractalService::item($status, $activitypubObject);
ActivityPubDeliveryService::pool($profile, $audience, $activity);
/*
* FEP-8fcf: followers-only posts rely on the receiving server's copy
* of our followers collection for access control, so they carry a
* Collection-Synchronization header that lets it detect drift.
*/
ActivityPubDeliveryService::pool(
$profile,
$audience,
$activity,
null,
$status->scope === 'private'
);
}
}

@ -23,6 +23,7 @@ use Illuminate\Support\Facades\Storage;
* @property string|null $name
* @property string|null $domain
* @property string|null $remote_url
* @property string|null $followers_url
* @property bool $is_private
* @property int $status_count
* @property int $following_count

@ -183,6 +183,7 @@ class ActivityPubDeliveryService
* @param array<int, string> $audience Inbox URLs
* @param array<string, mixed> $activity ActivityPub activity
* @param \Closure|null $onError fn(Throwable|Response $reason, int $index): void
* @param bool $synchronizeFollowers Attach a signed FEP-8fcf Collection-Synchronization header to every request
* @return array{total: int, skipped: int, duplicate: int, invalid: int, sent: int, delivered: int, rejected: int, failed: int}
*
* @throws JsonException
@ -191,7 +192,8 @@ class ActivityPubDeliveryService
Profile $profile,
array $audience,
array $activity,
?\Closure $onError = null
?\Closure $onError = null,
bool $synchronizeFollowers = false
): array {
$result = [
'total' => count($audience),
@ -227,6 +229,25 @@ class ActivityPubDeliveryService
*/
$payload = self::serializePayload($activity);
/*
* FEP-8fcf: the digest of each partial followers collection is
* looked up once, the header itself differs per destination since
* it is scoped to the authority of the receiving inbox.
*/
$syncDigests = null;
if ($synchronizeFollowers && FollowersSyncService::enabled()) {
try {
$syncDigests = FollowersSyncService::outboundDigests($profile);
} catch (Throwable $e) {
Log::warning('Unable to compute followers synchronization digests', [
'profile_id' => $profile->id,
'exception' => $e::class,
'error' => $e->getMessage(),
]);
}
}
/*
* Prepare and sign every delivery before starting the HTTP pool.
*
@ -284,10 +305,21 @@ class ActivityPubDeliveryService
$seen[$url] = true;
$extraHeaders = [];
if ($syncDigests !== null) {
$syncHeader = FollowersSyncService::header($profile, $url, $syncDigests);
if ($syncHeader !== null) {
$extraHeaders[FollowersSyncService::HEADER] = $syncHeader;
}
}
$headers = self::signedHeaders(
$profile,
$url,
$payload
$payload,
$extraHeaders
);
$deliveries[] = [
@ -581,12 +613,16 @@ class ActivityPubDeliveryService
/**
* Generate signed HTTP headers for the exact serialized payload.
*
* Extra headers become part of the signed header set.
*
* @param array<string, string> $extraHeaders
* @return array<string, string>
*/
private static function signedHeaders(
Profile $profile,
string $url,
string $payload
string $payload,
array $extraHeaders = []
): array {
$headers = HttpSignature::sign(
$profile,
@ -595,7 +631,7 @@ class ActivityPubDeliveryService
[
'Content-Type' => self::CONTENT_TYPE,
'User-Agent' => self::userAgent(),
]
] + $extraHeaders
);
if (empty($headers)) {

@ -0,0 +1,175 @@
<?php
namespace App\Services;
use App\Jobs\ProfilePipeline\SigningActorDiscoveryPipeline;
use App\Models\Profile;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\HttpSignature;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Throwable;
/**
* Verifies draft-cavage HTTP signatures on incoming GET requests.
*
* No remote request is ever made while verifying: a signer we have not seen
* before is discovered in the background and verified on its next attempt.
*/
class ActivityPubSignedFetchService
{
const DISCOVERY_KEY = 'pf:services:ap-signed-fetch:discover:';
const DISCOVERY_TTL = 600;
/**
* Without these in the signed set a signature could be replayed against
* another path or host.
*/
private const REQUIRED_SIGNED_HEADERS = [
'(request-target)',
'host',
'date',
];
private const SUPPORTED_ALGORITHMS = [
'rsa-sha256',
'hs2019',
];
/**
* Return the remote profile that signed the request, or null when the
* request is unsigned, invalid, or signed by an actor we cannot verify
* yet.
*/
public static function verify(Request $request): ?Profile
{
$signature = $request->header('Signature');
if (! is_string($signature) || trim($signature) === '') {
return null;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if (
isset($signatureData['error'])
|| ! isset($signatureData['keyId'], $signatureData['headers'], $signatureData['signature'])
) {
return null;
}
if (
isset($signatureData['algorithm'])
&& ! in_array(strtolower($signatureData['algorithm']), self::SUPPORTED_ALGORITHMS, true)
) {
return null;
}
$signed = preg_split('/\s+/', strtolower(trim($signatureData['headers']))) ?: [];
foreach (self::REQUIRED_SIGNED_HEADERS as $required) {
if (! in_array($required, $signed, true)) {
return null;
}
}
if (! self::hasFreshDate($request->header('Date'))) {
return null;
}
$keyId = Helpers::validateUrl($signatureData['keyId']);
if (! $keyId) {
return null;
}
$keyHost = strtolower((string) parse_url($keyId, PHP_URL_HOST));
if (
$keyHost === ''
|| Helpers::isLocalDomain($keyHost)
|| in_array($keyHost, InstanceService::getBannedDomains())
) {
return null;
}
$signer = Profile::whereKeyId($keyId)
->whereNotNull('domain')
->first();
if (! $signer) {
self::discover($keyId);
return null;
}
if ($signer->status !== null || empty($signer->public_key)) {
return null;
}
// Rebind: the profile bound to this keyId must live on the keyId host.
if (strtolower((string) parse_url((string) $signer->remote_url, PHP_URL_HOST)) !== $keyHost) {
return null;
}
$publicKey = openssl_pkey_get_public($signer->public_key);
if (! $publicKey) {
return null;
}
try {
[$verified] = HttpSignature::verify(
$publicKey,
$signatureData,
$request->headers->all(),
$request->getRequestUri(),
'',
'get'
);
} catch (Throwable) {
return null;
}
if ($verified !== 1) {
// The signer may have rotated its key since we last fetched it.
if (Helpers::needsFetch($signer)) {
self::discover($keyId);
}
return null;
}
return $signer;
}
protected static function discover(string $keyId): void
{
if (! Cache::add(self::DISCOVERY_KEY.hash('sha256', $keyId), 1, self::DISCOVERY_TTL)) {
return;
}
SigningActorDiscoveryPipeline::dispatch($keyId)->onQueue('low');
}
/**
* Not older than 12 hours and not more than an hour in the future.
*/
protected static function hasFreshDate(mixed $date): bool
{
if (! is_string($date) || trim($date) === '') {
return false;
}
try {
$parsed = Carbon::parse($date);
} catch (Throwable) {
return false;
}
return $parsed->gt(now()->subHours(12))
&& $parsed->lt(now()->addHour());
}
}

@ -0,0 +1,959 @@
<?php
namespace App\Services;
use App\Http\Controllers\FollowerController;
use App\Jobs\FollowPipeline\FollowersSyncPipeline;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\Jobs\FollowPipeline\UnfollowPipeline;
use App\Models\Follower;
use App\Models\FollowRequest;
use App\Models\Profile;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\HttpSignature;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* FEP-8fcf: Followers collection synchronization across servers.
*
* https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
*
* Sender side: outboundDigests() / header() / partialFollowers()
* Receiver side: handleInboundHeaders() / synchronize()
*
* Everything that compares actor ids works on "authorities" (scheme + host
* + non default port), as the FEP defines the partial collection by URI
* scheme and authority, not by hostname alone.
*/
class FollowersSyncService
{
const HEADER = 'Collection-Synchronization';
const EMPTY_DIGEST = '0000000000000000000000000000000000000000000000000000000000000000';
const DIGEST_CACHE_KEY = 'pf:services:followers-sync:digests:v1:';
const DIGEST_CACHE_TTL = 21600;
const COOLDOWN_KEY = 'pf:services:followers-sync:cooldown:';
const FOLLOWERS_URL_MISS_KEY = 'pf:services:followers-sync:no-followers-url:';
/**
* Follows younger than this are never removed by a synchronization, so
* an Accept that is still in flight cannot be undone by a list that was
* generated moments earlier.
*/
const REMOVAL_GRACE_MINUTES = 10;
const MAX_ITEMS = 100000;
const MAX_UNDO_PER_RUN = 100;
const COLLECTION_TYPES = [
'Collection',
'OrderedCollection',
'CollectionPage',
'OrderedCollectionPage',
];
public static function enabled(): bool
{
return (bool) config('federation.activitypub.followers_sync.enabled', true);
}
/**
* URI scheme and authority of a URL, normalized: lowercase scheme and
* host, default ports dropped. Null for anything that is not http(s).
*/
public static function authority(mixed $url): ?string
{
if (! is_string($url) || trim($url) === '') {
return null;
}
$parts = parse_url(trim($url));
if (! is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
return null;
}
if (isset($parts['user']) || isset($parts['pass'])) {
return null;
}
$scheme = strtolower($parts['scheme']);
if (! in_array($scheme, ['http', 'https'], true)) {
return null;
}
$authority = $scheme.'://'.strtolower($parts['host']);
if (isset($parts['port'])) {
$port = (int) $parts['port'];
$default = $scheme === 'https' ? 443 : 80;
if ($port !== $default) {
$authority .= ':'.$port;
}
}
return $authority;
}
/**
* Authority of this instance, taken from the URL generator so it always
* agrees with the actor ids produced by Profile::permalink().
*/
public static function localAuthority(): ?string
{
return self::authority(url('/'));
}
/**
* Partial follower collection digest: the SHA256 digests of every
* follower id XORed together, hex encoded.
*
* @param iterable<int, mixed> $ids
*/
public static function digest(iterable $ids): string
{
$acc = str_repeat("\0", 32);
foreach ($ids as $id) {
if (! is_string($id) || $id === '') {
continue;
}
$acc ^= hash('sha256', $id, true);
}
return bin2hex($acc);
}
/**
* Parse a Collection-Synchronization header value. It reuses the
* `signature` parameter syntax of draft-cavage HTTP signatures.
*
* @return array{collectionId: string, url: string, digest: string}|null
*/
public static function parseHeader(mixed $value): ?array
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || strlen($value) > 2048) {
return null;
}
preg_match_all(
'/(?:^|,)\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"/',
$value,
$matches,
PREG_SET_ORDER
);
$params = [];
foreach ($matches as $match) {
if (isset($params[$match[1]])) {
return null;
}
$params[$match[1]] = $match[2];
}
foreach (['collectionId', 'url', 'digest'] as $required) {
if (! isset($params[$required]) || $params[$required] === '') {
return null;
}
}
$digest = strtolower($params['digest']);
if (! preg_match('/^[0-9a-f]{64}$/', $digest)) {
return null;
}
return [
'collectionId' => $params['collectionId'],
'url' => $params['url'],
'digest' => $digest,
];
}
/*
|--------------------------------------------------------------------------
| Local actor ids
|--------------------------------------------------------------------------
|
| Digests are computed over actor ids exactly as the remote server knows
| them. These two methods are the only place that turns a local profile
| into an actor id and back, so a change of the actor URL scheme only has
| to be reflected here. Reconciliation always compares profile ids, never
| URL strings, so a remote that still lists an older URL form of a local
| actor can at worst cause a redundant fetch, never a removal.
|
*/
public static function localActorId(Profile $profile): string
{
return $profile->permalink();
}
/**
* The `/users/{segment}` segment of a local actor id, or null when the
* URL is not a recognized local actor id.
*/
public static function localActorSegment(mixed $uri): ?string
{
$local = self::localAuthority();
if (! $local || self::authority($uri) !== $local) {
return null;
}
$parts = parse_url(trim($uri));
if (isset($parts['query']) || isset($parts['fragment'])) {
return null;
}
if (! preg_match('#^/users/([A-Za-z0-9_.\-]{1,64})/?$#', $parts['path'] ?? '', $match)) {
return null;
}
return $match[1];
}
public static function resolveLocalActor(mixed $uri): ?Profile
{
$segment = self::localActorSegment($uri);
if ($segment === null) {
return null;
}
return self::resolveLocalActorSegments([$segment])->first();
}
/**
* Resolve many `/users/{segment}` segments at once.
*
* @param array<int, string> $segments
* @return Collection<int, Profile> Keyed by profile id
*/
public static function resolveLocalActorSegments(array $segments): Collection
{
$columns = ['id', 'user_id', 'username', 'domain', 'remote_url', 'status'];
$segments = array_values(array_unique(array_map('strval', $segments)));
$resolved = collect();
foreach (array_chunk($segments, 500) as $chunk) {
$profiles = Profile::whereNull('domain')
->whereIn('username', $chunk)
->get($columns);
$matched = $profiles
->map(fn (Profile $profile) => strtolower($profile->username))
->all();
// Id based actor URLs (/users/{id}), accepted alongside usernames.
$ids = array_values(array_filter(
$chunk,
fn (string $segment) => ctype_digit($segment) && ! in_array(strtolower($segment), $matched, true)
));
if (! empty($ids)) {
$profiles = $profiles->concat(
Profile::whereNull('domain')->whereIn('id', $ids)->get($columns)
);
}
foreach ($profiles as $profile) {
$resolved->put($profile->id, $profile);
}
}
return $resolved;
}
/**
* Digest of every partial followers collection of a local profile,
* keyed by authority. Computed with a single pass over the remote
* followers and cached, so a delivery to many inboxes costs one lookup.
*
* @return array<string, string>
*/
public static function outboundDigests(Profile $profile): array
{
if ($profile->domain !== null) {
return [];
}
return Cache::remember(
self::DIGEST_CACHE_KEY.$profile->id,
self::DIGEST_CACHE_TTL,
function () use ($profile) {
$acc = [];
self::remoteFollowersQuery($profile->id)
->select('id', 'remote_url')
->chunkById(5000, function ($rows) use (&$acc) {
foreach ($rows as $row) {
$authority = self::authority($row->remote_url);
if (! $authority) {
continue;
}
$acc[$authority] = ($acc[$authority] ?? str_repeat("\0", 32))
^ hash('sha256', $row->remote_url, true);
}
}, 'id');
return array_map('bin2hex', $acc);
}
);
}
public static function forgetOutboundDigests(mixed $profileId): void
{
Cache::forget(self::DIGEST_CACHE_KEY.$profileId);
}
/**
* Collection-Synchronization header value for a delivery of $profile to
* $inboxUrl, or null when no header should be sent.
*
* @param array<string, string>|null $digests Result of outboundDigests(), to avoid a cache lookup per inbox
*/
public static function header(Profile $profile, string $inboxUrl, ?array $digests = null): ?string
{
if (! self::enabled() || $profile->domain !== null) {
return null;
}
$authority = self::authority($inboxUrl);
if (! $authority || $authority === self::localAuthority()) {
return null;
}
$digests ??= self::outboundDigests($profile);
return sprintf(
'collectionId="%s", url="%s", digest="%s"',
$profile->permalink('/followers'),
$profile->permalink('/followers_synchronization'),
$digests[$authority] ?? self::EMPTY_DIGEST
);
}
/**
* Partial followers collection of a local profile for one remote
* instance: the ids of its followers that share $authority.
*
* @return array<int, string>
*/
public static function partialFollowers(Profile $profile, string $authority): array
{
$host = parse_url($authority, PHP_URL_HOST);
if (! $host || $profile->domain !== null) {
return [];
}
return self::remoteFollowersQuery($profile->id)
->where('domain', strtolower($host))
->pluck('remote_url')
->filter(fn ($url) => self::authority($url) === $authority)
->unique()
->sort()
->values()
->all();
}
/**
* Remote followers of a local profile. Built on the profiles table so
* duplicate follower rows cannot cancel each other out of the XOR.
*/
private static function remoteFollowersQuery(mixed $profileId)
{
return DB::table('profiles')
->whereNotNull('domain')
->whereNotNull('remote_url')
->whereNull('deleted_at')
->whereIn('id', function ($query) use ($profileId) {
$query->select('profile_id')
->from('followers')
->where('following_id', $profileId);
});
}
/**
* Inspect the headers of a delivery whose HTTP signature has already
* been verified, and queue a synchronization when the sender's digest
* disagrees with our local copy of its followers collection.
*
* Never throws: a synchronization problem must not block the inbox.
*
* @param array<string, mixed> $headers Request headers as returned by $request->headers->all()
*/
public static function handleInboundHeaders(mixed $headers): void
{
try {
self::processInboundHeaders($headers);
} catch (Throwable $e) {
Log::debug('FollowersSync: unable to process Collection-Synchronization header', [
'exception' => $e::class,
'error' => $e->getMessage(),
]);
}
}
private static function processInboundHeaders(mixed $headers): void
{
if (! self::enabled() || ! is_array($headers)) {
return;
}
$headers = array_change_key_case($headers, CASE_LOWER);
$raw = self::singleHeaderValue($headers[strtolower(self::HEADER)] ?? null);
$signature = self::singleHeaderValue($headers['signature'] ?? null);
if ($raw === null || $signature === null) {
return;
}
$signatureData = HttpSignature::parseSignatureHeader($signature);
if (isset($signatureData['error']) || ! isset($signatureData['keyId'], $signatureData['headers'])) {
return;
}
// The FEP only considers a *signed* Collection-Synchronization header.
$signed = preg_split('/\s+/', strtolower(trim($signatureData['headers']))) ?: [];
if (! in_array(strtolower(self::HEADER), $signed, true)) {
return;
}
$params = self::parseHeader($raw);
if (! $params) {
return;
}
/*
* The sender is whoever signed the request, not payload.actor: the
* user inbox only requires both to share a host.
*/
$keyId = Helpers::validateUrl($signatureData['keyId']);
if (! $keyId) {
return;
}
$sender = Profile::whereKeyId($keyId)
->whereNotNull('domain')
->first();
if (! $sender || $sender->status !== null) {
return;
}
if (! self::senderMatches($sender, $params['collectionId'], $params['url'])) {
return;
}
/*
* Reject a wrong collectionId right away when we know the sender's
* followers collection. When we don't know it yet, the queued job
* resolves and checks it, keeping remote fetches out of this path.
*/
if ($sender->followers_url && $sender->followers_url !== $params['collectionId']) {
return;
}
if (hash_equals($params['digest'], self::localFollowerDigest($sender))) {
return;
}
$cooldown = max(60, (int) config('federation.activitypub.followers_sync.cooldown', 900));
if (! Cache::add(self::COOLDOWN_KEY.$sender->id, 1, $cooldown)) {
return;
}
FollowersSyncPipeline::dispatch(
$sender->id,
$params['collectionId'],
$params['url'],
$params['digest']
)->onQueue('follow');
}
/**
* Both the collection and the synchronization URL must live on the
* sender's own authority, so an instance cannot be tricked into
* requesting the followers of a third party.
*/
public static function senderMatches(Profile $sender, string $collectionId, string $url): bool
{
$authority = self::authority($sender->remote_url);
return $authority !== null
&& $authority !== self::localAuthority()
&& self::authority($collectionId) === $authority
&& self::authority($url) === $authority;
}
/**
* Local profiles that follow a remote profile, according to our database.
*
* @return Collection<int, Profile>
*/
public static function localFollowerProfiles(Profile $remote): Collection
{
return Profile::whereNull('domain')
->whereIn('id', function ($query) use ($remote) {
$query->select('profile_id')
->from('followers')
->where('following_id', $remote->id);
})
->get(['id', 'user_id', 'username', 'domain', 'remote_url', 'status']);
}
public static function localFollowerDigest(Profile $remote): string
{
return self::digest(
self::localFollowerProfiles($remote)
->map(fn (Profile $profile) => self::localActorId($profile))
);
}
/**
* Followers collection id advertised by a remote actor document. Only
* accepted when it lives on the actor's own authority.
*
* @param array<string, mixed> $actor
*/
public static function followersUrlFromActor(array $actor): ?string
{
$followers = $actor['followers'] ?? null;
if (is_array($followers)) {
$followers = $followers['id'] ?? null;
}
if (! is_string($followers) || $followers === '' || strlen($followers) > 255) {
return null;
}
$authority = self::authority($followers);
if (! $authority || $authority !== self::authority($actor['id'] ?? null)) {
return null;
}
return $followers;
}
/**
* Followers collection id of a remote profile, fetching the actor once
* to backfill profiles that were ingested before the column existed.
*
* May perform a remote request: only call this from a queued job.
*/
public static function followersUrlFor(Profile $remote): ?string
{
if ($remote->followers_url) {
return $remote->followers_url;
}
if (! $remote->remote_url || Cache::has(self::FOLLOWERS_URL_MISS_KEY.$remote->id)) {
return null;
}
$actor = Helpers::fetchProfileFromUrl($remote->remote_url);
$url = is_array($actor) && ($actor['id'] ?? null) === $remote->remote_url
? self::followersUrlFromActor($actor)
: null;
if (! $url) {
Cache::put(self::FOLLOWERS_URL_MISS_KEY.$remote->id, 1, 86400);
return null;
}
DB::table('profiles')
->where('id', $remote->id)
->update(['followers_url' => $url]);
$remote->followers_url = $url;
$remote->syncOriginalAttribute('followers_url');
return $url;
}
/**
* Fetch the partial followers collection from the authoritative server
* and reconcile our local copy with it.
*
* @return array{status: string, removed: int, accepted: int, undone: int}
*/
public static function synchronize(Profile $sender, string $collectionId, string $url, string $expectedDigest): array
{
$result = [
'status' => 'skipped',
'removed' => 0,
'accepted' => 0,
'undone' => 0,
];
$expectedDigest = strtolower($expectedDigest);
if (
! self::enabled()
|| $sender->domain === null
|| $sender->status !== null
|| ! preg_match('/^[0-9a-f]{64}$/', $expectedDigest)
|| ! self::senderMatches($sender, $collectionId, $url)
) {
return $result;
}
if (self::followersUrlFor($sender) !== $collectionId) {
$result['status'] = 'collection_mismatch';
return $result;
}
if (hash_equals($expectedDigest, self::localFollowerDigest($sender))) {
$result['status'] = 'in_sync';
return $result;
}
$collection = self::fetchCollection($url, (string) self::authority($sender->remote_url));
/*
* A failed or malformed response is never treated as an empty list.
*/
if ($collection === null) {
$result['status'] = 'fetch_failed';
return $result;
}
/*
* Removing followers is destructive, so it only happens when the
* fetched list is complete, hashes to the digest the sender signed,
* and contains no local URL we are unable to interpret.
*/
$canRemove = $collection['complete']
&& hash_equals($expectedDigest, self::digest($collection['items']));
$segments = [];
foreach (array_unique($collection['items']) as $uri) {
$segment = self::localActorSegment($uri);
/*
* A partial collection only holds actors of this instance. An
* entry we cannot map to a local actor id means the response is
* not what we think it is, so nothing gets removed based on it.
*/
if ($segment === null) {
$canRemove = false;
continue;
}
$segments[] = $segment;
}
$listed = self::resolveLocalActorSegments($segments);
$known = self::localFollowerProfiles($sender)->keyBy('id');
if ($canRemove) {
$stale = $known->keys()->diff($listed->keys())->values();
if ($stale->isNotEmpty()) {
$removable = Follower::whereFollowingId($sender->id)
->whereIn('profile_id', $stale->all())
->where(function ($query) {
$query->whereNull('created_at')
->orWhere('created_at', '<', now()->subMinutes(self::REMOVAL_GRACE_MINUTES));
})
->pluck('profile_id')
->unique();
foreach ($removable as $profileId) {
self::removeLocalFollower($profileId, $sender);
$result['removed']++;
}
}
}
foreach ($listed as $profileId => $profile) {
if ($known->has($profileId)) {
continue;
}
$pending = FollowRequest::whereFollowerId($profileId)
->whereFollowingId($sender->id)
->whereIsRejected(false)
->first();
if ($pending) {
self::acceptPendingFollow($pending, $profile, $sender);
$result['accepted']++;
continue;
}
// Bounded, so a remote cannot turn its own list into a delivery flood.
if ($result['undone'] < self::MAX_UNDO_PER_RUN && self::sendUndoFollow($profileId, $sender)) {
$result['undone']++;
}
}
$result['status'] = $canRemove ? 'synchronized' : 'synchronized_without_removals';
if ($result['removed'] || $result['accepted'] || $result['undone']) {
Log::info('FollowersSync: reconciled followers of remote actor', [
'profile_id' => $sender->id,
'actor' => $sender->remote_url,
] + $result);
}
return $result;
}
/**
* Fetch a (possibly paged) collection with a request signed by the
* instance actor. Every page has to stay on the sender's authority.
*
* @return array{items: array<int, string>, complete: bool}|null
*/
public static function fetchCollection(string $url, string $authority): ?array
{
$maxPages = max(1, (int) config('federation.activitypub.followers_sync.max_pages', 10));
$document = self::fetchDocument($url, $authority);
if (! self::isCollection($document)) {
return null;
}
$items = [];
$pages = 0;
$seen = [$url => true];
$page = self::hasItems($document)
? $document
: ($document['first'] ?? null);
while ($page !== null) {
if (is_string($page)) {
if (isset($seen[$page])) {
return null;
}
if (++$pages > $maxPages) {
return ['items' => $items, 'complete' => false];
}
$seen[$page] = true;
$page = self::fetchDocument($page, $authority);
}
if (! self::isCollection($page)) {
return null;
}
$pageItems = $page['orderedItems'] ?? $page['items'] ?? [];
if (is_string($pageItems)) {
$pageItems = [$pageItems];
}
if (! is_array($pageItems)) {
return null;
}
if (! array_is_list($pageItems)) {
$pageItems = [$pageItems];
}
foreach ($pageItems as $item) {
if (is_array($item)) {
$item = $item['id'] ?? null;
}
if (! is_string($item) || $item === '') {
return null;
}
$items[] = $item;
}
if (count($items) > self::MAX_ITEMS) {
return null;
}
$page = $page['next'] ?? null;
if ($page !== null && ! is_string($page) && ! is_array($page)) {
return null;
}
}
return ['items' => $items, 'complete' => true];
}
private static function fetchDocument(string $url, string $authority): ?array
{
if (self::authority($url) !== $authority) {
return null;
}
$document = ActivityPubFetchService::fetchRequest($url, true);
return is_array($document) ? $document : null;
}
private static function isCollection(mixed $document): bool
{
if (! is_array($document)) {
return false;
}
$types = $document['type'] ?? null;
foreach ((array) $types as $type) {
if (is_string($type) && in_array($type, self::COLLECTION_TYPES, true)) {
return true;
}
}
return false;
}
private static function hasItems(array $document): bool
{
return array_key_exists('orderedItems', $document)
|| array_key_exists('items', $document);
}
/**
* The remote server does not list this local follower. No Undo is sent:
* the remote already agrees the relationship does not exist.
*/
private static function removeLocalFollower(mixed $profileId, Profile $remote): void
{
Follower::whereProfileId($profileId)
->whereFollowingId($remote->id)
->delete();
app(StoryIndexService::class)->removeFollowing((int) $profileId, (int) $remote->id);
UnfollowPipeline::dispatch($profileId, $remote->id)->onQueue('high');
RelationshipService::refresh($profileId, $remote->id);
self::forgetFollowCaches($profileId, $remote->id);
}
/**
* The remote server lists a local profile whose follow request is still
* pending here: treat it as accepted, as the Accept handler would.
*/
private static function acceptPendingFollow(FollowRequest $request, Profile $local, Profile $remote): void
{
$follower = Follower::firstOrCreate([
'profile_id' => $local->id,
'following_id' => $remote->id,
]);
FollowPipeline::dispatch($follower)->onQueue('high');
RelationshipService::refresh($local->id, $remote->id);
self::forgetFollowCaches($local->id, $remote->id);
$request->delete();
}
/**
* The remote server lists a local profile that does not follow it here.
*/
private static function sendUndoFollow(mixed $profileId, Profile $remote): bool
{
if (! config('federation.activitypub.remoteFollow')) {
return false;
}
$local = Profile::whereNull('domain')
->whereNull('status')
->find($profileId);
if (! $local || empty($local->private_key)) {
return false;
}
try {
(new FollowerController)->sendUndoFollow($local, $remote);
return true;
} catch (Throwable $e) {
Log::debug('FollowersSync: unable to deliver Undo Follow', [
'profile_id' => $local->id,
'target_id' => $remote->id,
'error' => $e->getMessage(),
]);
return false;
}
}
private static function forgetFollowCaches(mixed ...$profileIds): void
{
foreach ($profileIds as $id) {
Cache::forget('profile:follower_count:'.$id);
Cache::forget('profile:following_count:'.$id);
Cache::forget('profile:following:'.$id);
Cache::forget('profile:followers:'.$id);
AccountService::del($id);
}
}
/**
* A header that was sent more than once is ambiguous: ignore it.
*/
private static function singleHeaderValue(mixed $value): ?string
{
if (is_array($value)) {
if (count($value) !== 1) {
return null;
}
$value = reset($value);
}
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
}

@ -61,6 +61,8 @@ class RelationshipService
{
Cache::forget('pf:services:follower:audience:'.$aid);
Cache::forget('pf:services:follower:audience:'.$tid);
FollowersSyncService::forgetOutboundDigests($aid);
FollowersSyncService::forgetOutboundDigests($tid);
self::delete($tid, $aid);
self::delete($aid, $tid);
self::get($tid, $aid);
@ -72,6 +74,8 @@ class RelationshipService
{
Cache::forget('pf:services:follower:audience:'.$aid);
Cache::forget('pf:services:follower:audience:'.$tid);
FollowersSyncService::forgetOutboundDigests($aid);
FollowersSyncService::forgetOutboundDigests($tid);
self::delete($tid, $aid);
self::delete($aid, $tid);
}

@ -18,6 +18,7 @@ use App\Services\Account\AccountStatService;
use App\Services\ActivityPubDeliveryService;
use App\Services\ActivityPubFetchService;
use App\Services\DomainService;
use App\Services\FollowersSyncService;
use App\Services\InstanceService;
use App\Services\MediaPathService;
use App\Services\NetworkTimelineService;
@ -1801,6 +1802,7 @@ class Helpers
'sharedInbox' => $res['endpoints']['sharedInbox'] ?? null,
'inbox_url' => $res['inbox'],
'outbox_url' => $res['outbox'] ?? null,
'followers_url' => FollowersSyncService::followersUrlFromActor($res),
'public_key' => $res['publicKey']['publicKeyPem'],
'indexable' => isset($res['indexable']) ? (bool) $res['indexable'] : false,
'moved_to_profile_id' => $movedToPid,

@ -34,6 +34,24 @@ return [
],
'authorized_fetch' => env('AUTHORIZED_FETCH', false),
/*
* FEP-8fcf: Followers collection synchronization across servers
*
* When enabled, followers-only posts are delivered with a signed
* Collection-Synchronization header, the partial followers
* collection is served to authenticated remote instances, and
* incoming Collection-Synchronization headers are reconciled.
*/
'followers_sync' => [
'enabled' => env('AP_FOLLOWERS_SYNC', true),
// Minimum seconds between two synchronizations of the same remote actor
'cooldown' => env('AP_FOLLOWERS_SYNC_COOLDOWN', 900),
// Maximum collection pages fetched from a remote server per synchronization
'max_pages' => env('AP_FOLLOWERS_SYNC_MAX_PAGES', 10),
],
],
'atom' => [

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasColumn('profiles', 'followers_url')) {
return;
}
Schema::table('profiles', function (Blueprint $table) {
$table->string('followers_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasColumn('profiles', 'followers_url')) {
return;
}
Schema::table('profiles', function (Blueprint $table) {
$table->dropColumn('followers_url');
});
}
};

@ -44,6 +44,7 @@ $middleware = ['auth:sanctum,api'];
Route::post('/f/inbox', [FederationController::class, 'sharedInbox']);
Route::post('/users/{username}/inbox', [FederationController::class, 'userInbox']);
Route::get('/users/{username}/followers_synchronization', [FederationController::class, 'userFollowersSynchronization']);
Route::get('i/actor', [InstanceActorController::class, 'profile']);
Route::post('i/actor/inbox', [InstanceActorController::class, 'inbox']);
Route::get('i/actor/outbox', [InstanceActorController::class, 'outbox']);

@ -0,0 +1,574 @@
<?php
use App\Jobs\FollowPipeline\FollowersSyncPipeline;
use App\Jobs\FollowPipeline\UnfollowPipeline;
use App\Jobs\ProfilePipeline\SigningActorDiscoveryPipeline;
use App\Models\Follower;
use App\Models\FollowRequest;
use App\Models\InstanceActor;
use App\Models\Profile;
use App\Models\User;
use App\Services\ActivityPubDeliveryService;
use App\Services\FollowersSyncService;
use App\Services\RelationshipService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| FEP-8fcf: Followers collection synchronization across servers
|--------------------------------------------------------------------------
*/
beforeEach(function () {
Redis::spy();
Queue::fake();
config([
'instance.enable_cc' => false,
'federation.activitypub.enabled' => true,
'federation.activitypub.remoteFollow' => true,
'federation.activitypub.followers_sync.enabled' => true,
]);
});
function fsyncLocalProfile(): Profile
{
$user = User::factory()->create();
$user->refresh();
return $user->profile;
}
function fsyncRemoteProfile(string $domain, string $username, array $attributes = []): Profile
{
$actor = "https://{$domain}/users/{$username}";
return Profile::factory()->remote()->create(array_merge([
'domain' => $domain,
'username' => "@{$username}@{$domain}",
'remote_url' => $actor,
'inbox_url' => "{$actor}/inbox",
'sharedInbox' => "https://{$domain}/inbox",
'followers_url' => "{$actor}/followers",
], $attributes));
}
/**
* Insert a follow without going through FollowerObserver, optionally aged so
* it falls outside the removal grace period.
*/
function fsyncFollow(Profile $actor, Profile $target, int $ageInMinutes = 120): void
{
DB::table('followers')->insert([
'profile_id' => $actor->id,
'following_id' => $target->id,
'created_at' => now()->subMinutes($ageInMinutes),
'updated_at' => now()->subMinutes($ageInMinutes),
]);
}
/**
* Seed the DNS and banned-domain caches so URL validation passes without a
* network lookup. Call after factories, the lazy refresh can flush the cache.
*/
function fsyncSeedHosts(array $hosts): void
{
foreach ($hosts as $host) {
Cache::put('helpers:url:public-ips:' . hash('xxh128', $host), ['203.0.113.40'], 3600);
}
Cache::put('instances:banned:domains', [], 1209600);
}
function fsyncKeyPair(): array
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $private);
return [$private, openssl_pkey_get_details($key)['key']];
}
function fsyncInProduction(callable $fn): mixed
{
$app = app();
$previous = $app['env'];
$app['env'] = 'production';
try {
return $fn();
} finally {
$app['env'] = $previous;
}
}
function fsyncSignedGetHeaders(string $privateKey, string $keyId, string $path, string $host = 'pixelfed.test'): array
{
$date = now()->toRfc7231String();
openssl_sign(
"(request-target): get {$path}\nhost: {$host}\ndate: {$date}",
$signature,
$privateKey,
OPENSSL_ALGO_SHA256
);
return [
'Accept' => 'application/activity+json',
'Date' => $date,
'Signature' => sprintf(
'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date",signature="%s"',
$keyId,
base64_encode($signature)
),
];
}
function fsyncInboundHeaders(Profile $sender, array $params, bool $signed = true): array
{
$list = '(request-target) host date digest' . ($signed ? ' collection-synchronization' : '');
return [
'signature' => [
sprintf('keyId="%s",algorithm="rsa-sha256",headers="%s",signature="dGVzdA=="', $sender->key_id, $list),
],
'collection-synchronization' => [
sprintf('collectionId="%s", url="%s", digest="%s"', $params['collectionId'], $params['url'], $params['digest']),
],
];
}
function fsyncFakeCollection(string $url, array $items): void
{
[$private] = fsyncKeyPair();
Cache::forever(InstanceActor::PKI_PRIVATE, $private);
Http::fake([
$url => Http::response(json_encode([
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $url,
'type' => 'OrderedCollection',
'orderedItems' => $items,
]), 200, ['Content-Type' => 'application/activity+json']),
]);
}
describe('sender', function () {
it('signs a Collection-Synchronization header scoped to each destination', function () {
Http::fake();
$profile = fsyncLocalProfile();
$alice = fsyncRemoteProfile('remote1.example', 'alice');
$bob = fsyncRemoteProfile('remote1.example', 'bob');
$carol = fsyncRemoteProfile('remote2.example', 'carol');
foreach ([$alice, $bob, $carol] as $follower) {
fsyncFollow($follower, $profile);
}
fsyncSeedHosts(['remote1.example', 'remote2.example', 'remote3.example']);
fsyncInProduction(fn() => ActivityPubDeliveryService::pool(
$profile,
[
'https://remote1.example/inbox',
'https://remote2.example/inbox',
'https://remote3.example/inbox',
],
['id' => $profile->permalink('#create'), 'type' => 'Create', 'actor' => $profile->permalink()],
null,
true
));
$expected = [
'https://remote1.example/inbox' => FollowersSyncService::digest([$alice->remote_url, $bob->remote_url]),
'https://remote2.example/inbox' => FollowersSyncService::digest([$carol->remote_url]),
'https://remote3.example/inbox' => FollowersSyncService::EMPTY_DIGEST,
];
Http::assertSentCount(3);
foreach (Http::recorded() as [$request]) {
$params = FollowersSyncService::parseHeader($request->header('Collection-Synchronization')[0] ?? null);
// Read the signed header list straight from the Signature header:
// HttpSignature::parseSignatureHeader() also DNS-validates the
// keyId, which is this instance and is not seeded in the cache.
preg_match('/headers="([^"]*)"/', $request->header('Signature')[0] ?? '', $signed);
expect($params)->not->toBeNull();
expect($params['collectionId'])->toBe($profile->permalink('/followers'));
expect($params['url'])->toBe($profile->permalink('/followers_synchronization'));
expect($params['digest'])->toBe($expected[$request->url()]);
expect(explode(' ', $signed[1] ?? ''))->toContain('collection-synchronization');
}
});
it('does not send the header unless asked to', function () {
Http::fake();
$profile = fsyncLocalProfile();
fsyncFollow(fsyncRemoteProfile('remote1.example', 'alice'), $profile);
fsyncSeedHosts(['remote1.example']);
fsyncInProduction(fn() => ActivityPubDeliveryService::pool(
$profile,
['https://remote1.example/inbox'],
['id' => $profile->permalink('#create'), 'type' => 'Create', 'actor' => $profile->permalink()]
));
Http::assertSent(fn($request) => empty($request->header('Collection-Synchronization')));
});
it('drops the cached digests when a relationship changes', function () {
$profile = fsyncLocalProfile();
$alice = fsyncRemoteProfile('remote1.example', 'alice');
expect(FollowersSyncService::outboundDigests($profile))->toBe([]);
fsyncFollow($alice, $profile);
RelationshipService::forget($alice->id, $profile->id);
expect(FollowersSyncService::outboundDigests($profile))->toBe([
'https://remote1.example' => FollowersSyncService::digest([$alice->remote_url]),
]);
});
});
describe('partial followers endpoint', function () {
it('rejects unsigned requests', function () {
$profile = fsyncLocalProfile();
$this->get("/users/{$profile->username}/followers_synchronization", ['Accept' => 'application/activity+json'])
->assertStatus(401);
});
it('queues discovery of an unknown signer instead of fetching it inline', function () {
Http::fake();
$profile = fsyncLocalProfile();
[$private] = fsyncKeyPair();
fsyncSeedHosts(['remote1.example']);
$path = "/users/{$profile->username}/followers_synchronization";
$this->get($path, fsyncSignedGetHeaders($private, 'https://remote1.example/actor#main-key', $path))
->assertStatus(401);
Http::assertNothingSent();
Queue::assertPushed(SigningActorDiscoveryPipeline::class);
});
it('only lists the followers hosted by the instance that signed the request', function () {
$profile = fsyncLocalProfile();
[$private, $public] = fsyncKeyPair();
fsyncRemoteProfile('remote1.example', 'actor', [
'remote_url' => 'https://remote1.example/actor',
'key_id' => 'https://remote1.example/actor#main-key',
'public_key' => $public,
]);
$alice = fsyncRemoteProfile('remote1.example', 'alice');
$bob = fsyncRemoteProfile('remote1.example', 'bob');
$carol = fsyncRemoteProfile('remote2.example', 'carol');
foreach ([$alice, $bob, $carol] as $follower) {
fsyncFollow($follower, $profile);
}
fsyncSeedHosts(['remote1.example']);
$path = "/users/{$profile->username}/followers_synchronization";
$response = $this->get($path, fsyncSignedGetHeaders($private, 'https://remote1.example/actor#main-key', $path))
->assertOk()
->assertJsonPath('type', 'OrderedCollection')
->assertJsonPath('id', $profile->permalink('/followers_synchronization'));
expect($response->json('orderedItems'))->toBe([$alice->remote_url, $bob->remote_url]);
});
it('rejects a signature made for another path', function () {
$profile = fsyncLocalProfile();
[$private, $public] = fsyncKeyPair();
fsyncRemoteProfile('remote1.example', 'actor', [
'remote_url' => 'https://remote1.example/actor',
'key_id' => 'https://remote1.example/actor#main-key',
'public_key' => $public,
]);
fsyncSeedHosts(['remote1.example']);
$headers = fsyncSignedGetHeaders($private, 'https://remote1.example/actor#main-key', "/users/{$profile->username}/followers");
$this->get("/users/{$profile->username}/followers_synchronization", $headers)
->assertStatus(401);
});
});
describe('inbound header', function () {
it('queues a synchronization when the digests differ', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice', ['key_id' => 'https://remote1.example/users/alice#main-key']);
fsyncFollow($local, $sender);
fsyncSeedHosts(['remote1.example']);
FollowersSyncService::handleInboundHeaders(fsyncInboundHeaders($sender, [
'collectionId' => $sender->followers_url,
'url' => $sender->remote_url . '/followers_synchronization',
'digest' => FollowersSyncService::EMPTY_DIGEST,
]));
Queue::assertPushed(FollowersSyncPipeline::class, 1);
});
it('stays quiet when the digests agree', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice', ['key_id' => 'https://remote1.example/users/alice#main-key']);
fsyncFollow($local, $sender);
fsyncSeedHosts(['remote1.example']);
FollowersSyncService::handleInboundHeaders(fsyncInboundHeaders($sender, [
'collectionId' => $sender->followers_url,
'url' => $sender->remote_url . '/followers_synchronization',
'digest' => FollowersSyncService::digest([$local->permalink()]),
]));
Queue::assertNotPushed(FollowersSyncPipeline::class);
});
it('ignores a header that is not covered by the signature', function () {
$sender = fsyncRemoteProfile('remote1.example', 'alice', ['key_id' => 'https://remote1.example/users/alice#main-key']);
fsyncFollow(fsyncLocalProfile(), $sender);
fsyncSeedHosts(['remote1.example']);
FollowersSyncService::handleInboundHeaders(fsyncInboundHeaders($sender, [
'collectionId' => $sender->followers_url,
'url' => $sender->remote_url . '/followers_synchronization',
'digest' => FollowersSyncService::EMPTY_DIGEST,
], false));
Queue::assertNotPushed(FollowersSyncPipeline::class);
});
it('ignores a collection or url that does not belong to the sender', function () {
$sender = fsyncRemoteProfile('remote1.example', 'alice', ['key_id' => 'https://remote1.example/users/alice#main-key']);
fsyncFollow(fsyncLocalProfile(), $sender);
fsyncSeedHosts(['remote1.example']);
FollowersSyncService::handleInboundHeaders(fsyncInboundHeaders($sender, [
'collectionId' => 'https://remote1.example/users/mallory/followers',
'url' => $sender->remote_url . '/followers_synchronization',
'digest' => FollowersSyncService::EMPTY_DIGEST,
]));
FollowersSyncService::handleInboundHeaders(fsyncInboundHeaders($sender, [
'collectionId' => $sender->followers_url,
'url' => 'https://victim.example/users/bob/followers_synchronization',
'digest' => FollowersSyncService::EMPTY_DIGEST,
]));
Queue::assertNotPushed(FollowersSyncPipeline::class);
});
it('synchronizes the same actor once per cooldown window', function () {
$sender = fsyncRemoteProfile('remote1.example', 'alice', ['key_id' => 'https://remote1.example/users/alice#main-key']);
fsyncFollow(fsyncLocalProfile(), $sender);
fsyncSeedHosts(['remote1.example']);
$headers = fsyncInboundHeaders($sender, [
'collectionId' => $sender->followers_url,
'url' => $sender->remote_url . '/followers_synchronization',
'digest' => FollowersSyncService::EMPTY_DIGEST,
]);
FollowersSyncService::handleInboundHeaders($headers);
FollowersSyncService::handleInboundHeaders($headers);
Queue::assertPushed(FollowersSyncPipeline::class, 1);
});
});
describe('synchronization', function () {
it('removes local followers the authoritative server does not list', function () {
$kept = fsyncLocalProfile();
$stale = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($kept, $sender);
fsyncFollow($stale, $sender);
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, [$kept->permalink()]);
$result = FollowersSyncService::synchronize(
$sender,
$sender->followers_url,
$url,
FollowersSyncService::digest([$kept->permalink()])
);
expect($result['status'])->toBe('synchronized');
expect($result['removed'])->toBe(1);
expect(Follower::whereProfileId($stale->id)->whereFollowingId($sender->id)->exists())->toBeFalse();
expect(Follower::whereProfileId($kept->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
Queue::assertPushed(UnfollowPipeline::class, 1);
});
it('removes nothing when the list does not hash to the signed digest', function () {
$kept = fsyncLocalProfile();
$stale = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($kept, $sender);
fsyncFollow($stale, $sender);
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, [$kept->permalink()]);
$result = FollowersSyncService::synchronize(
$sender,
$sender->followers_url,
$url,
FollowersSyncService::digest(['https://pixelfed.test/users/somebody-else'])
);
expect($result['status'])->toBe('synchronized_without_removals');
expect(Follower::whereProfileId($stale->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
});
it('never treats a failed fetch as an empty collection', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($local, $sender);
[$private] = fsyncKeyPair();
Cache::forever(InstanceActor::PKI_PRIVATE, $private);
fsyncSeedHosts(['remote1.example']);
Http::fake(['*' => Http::response('', 500)]);
$result = FollowersSyncService::synchronize(
$sender,
$sender->followers_url,
$sender->remote_url . '/followers_synchronization',
FollowersSyncService::EMPTY_DIGEST
);
expect($result['status'])->toBe('fetch_failed');
expect(Follower::whereProfileId($local->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
});
it('keeps a follow that is younger than the grace period', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($local, $sender, 1);
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, []);
$result = FollowersSyncService::synchronize($sender, $sender->followers_url, $url, FollowersSyncService::EMPTY_DIGEST);
expect($result['removed'])->toBe(0);
expect(Follower::whereProfileId($local->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
});
it('keeps a follower that is listed under its id based actor url', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($local, $sender);
$listedAs = url('users/' . $local->id);
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, [$listedAs]);
$result = FollowersSyncService::synchronize($sender, $sender->followers_url, $url, FollowersSyncService::digest([$listedAs]));
expect($result['status'])->toBe('synchronized');
expect($result['removed'])->toBe(0);
expect(Follower::whereProfileId($local->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
});
it('removes nothing when the list holds a local url it cannot interpret', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($local, $sender);
$listedAs = url('@' . $local->username);
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, [$listedAs]);
$result = FollowersSyncService::synchronize($sender, $sender->followers_url, $url, FollowersSyncService::digest([$listedAs]));
expect($result['status'])->toBe('synchronized_without_removals');
expect(Follower::whereProfileId($local->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
});
it('accepts a pending follow request the authoritative server already lists', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
FollowRequest::create([
'follower_id' => $local->id,
'following_id' => $sender->id,
]);
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, [$local->permalink()]);
$result = FollowersSyncService::synchronize($sender, $sender->followers_url, $url, FollowersSyncService::digest([$local->permalink()]));
expect($result['accepted'])->toBe(1);
expect(Follower::whereProfileId($local->id)->whereFollowingId($sender->id)->exists())->toBeTrue();
expect(FollowRequest::whereFollowerId($local->id)->whereFollowingId($sender->id)->exists())->toBeFalse();
});
it('undoes a follow the authoritative server lists but we do not know', function () {
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
$url = $sender->remote_url . '/followers_synchronization';
fsyncSeedHosts(['remote1.example']);
fsyncFakeCollection($url, [$local->permalink()]);
$result = FollowersSyncService::synchronize($sender, $sender->followers_url, $url, FollowersSyncService::digest([$local->permalink()]));
expect($result['undone'])->toBe(1);
expect(Follower::whereProfileId($local->id)->whereFollowingId($sender->id)->exists())->toBeFalse();
});
it('does not fetch anything for a collection that is not the sender\'s followers collection', function () {
Http::fake();
$local = fsyncLocalProfile();
$sender = fsyncRemoteProfile('remote1.example', 'alice');
fsyncFollow($local, $sender);
fsyncSeedHosts(['remote1.example']);
$result = FollowersSyncService::synchronize(
$sender,
'https://remote1.example/users/mallory/followers',
$sender->remote_url . '/followers_synchronization',
FollowersSyncService::EMPTY_DIGEST
);
expect($result['status'])->toBe('collection_mismatch');
Http::assertNothingSent();
});
});

@ -0,0 +1,74 @@
<?php
use App\Services\FollowersSyncService;
/*
|--------------------------------------------------------------------------
| FEP-8fcf primitives
|--------------------------------------------------------------------------
*/
it('matches the digest test vector of the FEP', function () {
expect(FollowersSyncService::digest([
'https://testing.example.org/users/1',
'https://testing.example.org/users/2',
]))->toBe('c33f48cd341ef046a206b8a72ec97af65079f9a3a9b90eef79c5920dce45c61f');
});
it('hashes an empty collection to zeros', function () {
expect(FollowersSyncService::digest([]))->toBe(FollowersSyncService::EMPTY_DIGEST);
});
it('does not depend on the order of the followers', function () {
expect(FollowersSyncService::digest(['a', 'b', 'c']))
->toBe(FollowersSyncService::digest(['c', 'a', 'b']));
});
it('normalizes authorities', function () {
expect(FollowersSyncService::authority('HTTPS://Example.ORG:443/users/1'))->toBe('https://example.org');
expect(FollowersSyncService::authority('https://example.org:8443/users/1'))->toBe('https://example.org:8443');
expect(FollowersSyncService::authority('http://example.org/users/1'))->toBe('http://example.org');
expect(FollowersSyncService::authority('https://testing.example.org/users/1'))->not->toBe('https://example.org');
});
it('rejects urls that have no usable authority', function () {
expect(FollowersSyncService::authority('ftp://example.org/x'))->toBeNull();
expect(FollowersSyncService::authority('https://user:pass@example.org/x'))->toBeNull();
expect(FollowersSyncService::authority('not a url'))->toBeNull();
expect(FollowersSyncService::authority(null))->toBeNull();
});
it('parses the example header of the FEP', function () {
$header = 'collectionId="https://example.org/users/1/followers", url="https://example.org/users/1/followers_synchronization", digest="c33f48cd341ef046a206b8a72ec97af65079f9a3a9b90eef79c5920dce45c61f"';
expect(FollowersSyncService::parseHeader($header))->toBe([
'collectionId' => 'https://example.org/users/1/followers',
'url' => 'https://example.org/users/1/followers_synchronization',
'digest' => 'c33f48cd341ef046a206b8a72ec97af65079f9a3a9b90eef79c5920dce45c61f',
]);
});
it('rejects malformed headers', function () {
$digest = FollowersSyncService::EMPTY_DIGEST;
expect(FollowersSyncService::parseHeader(''))->toBeNull();
expect(FollowersSyncService::parseHeader('collectionId="a", url="b", digest="abc"'))->toBeNull();
expect(FollowersSyncService::parseHeader('collectionId="a", digest="'.$digest.'"'))->toBeNull();
expect(FollowersSyncService::parseHeader('collectionId="a", url="b", url="c", digest="'.$digest.'"'))->toBeNull();
});
it('only accepts a followers collection on the authority of its actor', function () {
expect(FollowersSyncService::followersUrlFromActor([
'id' => 'https://remote.example/users/alice',
'followers' => 'https://remote.example/users/alice/followers',
]))->toBe('https://remote.example/users/alice/followers');
expect(FollowersSyncService::followersUrlFromActor([
'id' => 'https://remote.example/users/alice',
'followers' => 'https://elsewhere.example/followers',
]))->toBeNull();
expect(FollowersSyncService::followersUrlFromActor([
'id' => 'https://remote.example/users/alice',
]))->toBeNull();
});
Loading…
Cancel
Save