diff --git a/app/Http/Controllers/FederationController.php b/app/Http/Controllers/FederationController.php index 619bd7679..bd93edf16 100644 --- a/app/Http/Controllers/FederationController.php +++ b/app/Http/Controllers/FederationController.php @@ -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); diff --git a/app/Jobs/FollowPipeline/FollowersSyncPipeline.php b/app/Jobs/FollowPipeline/FollowersSyncPipeline.php new file mode 100644 index 000000000..b493c2ca6 --- /dev/null +++ b/app/Jobs/FollowPipeline/FollowersSyncPipeline.php @@ -0,0 +1,93 @@ +profileId = $profileId; + $this->collectionId = $collectionId; + $this->url = $url; + $this->digest = $digest; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + 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(), + ]); + } + } +} diff --git a/app/Jobs/InboxPipeline/InboxValidator.php b/app/Jobs/InboxPipeline/InboxValidator.php index c38e626ce..c6e8f5f15 100644 --- a/app/Jobs/InboxPipeline/InboxValidator.php +++ b/app/Jobs/InboxPipeline/InboxValidator.php @@ -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 { diff --git a/app/Jobs/InboxPipeline/InboxWorker.php b/app/Jobs/InboxPipeline/InboxWorker.php index a99cfac33..adc893ca5 100644 --- a/app/Jobs/InboxPipeline/InboxWorker.php +++ b/app/Jobs/InboxPipeline/InboxWorker.php @@ -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; diff --git a/app/Jobs/ProfilePipeline/SigningActorDiscoveryPipeline.php b/app/Jobs/ProfilePipeline/SigningActorDiscoveryPipeline.php new file mode 100644 index 000000000..6e12bb399 --- /dev/null +++ b/app/Jobs/ProfilePipeline/SigningActorDiscoveryPipeline.php @@ -0,0 +1,129 @@ +keyId = $keyId; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + 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 $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; + } +} diff --git a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php index e4a6f5d2a..e0dec86ac 100644 --- a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php +++ b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php @@ -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' + ); } } diff --git a/app/Models/Profile.php b/app/Models/Profile.php index b158f2c4d..0596c883b 100644 --- a/app/Models/Profile.php +++ b/app/Models/Profile.php @@ -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 diff --git a/app/Services/ActivityPubDeliveryService.php b/app/Services/ActivityPubDeliveryService.php index 20002c309..99cdd51ae 100644 --- a/app/Services/ActivityPubDeliveryService.php +++ b/app/Services/ActivityPubDeliveryService.php @@ -183,6 +183,7 @@ class ActivityPubDeliveryService * @param array $audience Inbox URLs * @param array $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 $extraHeaders * @return array */ 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)) { diff --git a/app/Services/ActivityPubSignedFetchService.php b/app/Services/ActivityPubSignedFetchService.php new file mode 100644 index 000000000..7854dd990 --- /dev/null +++ b/app/Services/ActivityPubSignedFetchService.php @@ -0,0 +1,175 @@ +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()); + } +} diff --git a/app/Services/FollowersSyncService.php b/app/Services/FollowersSyncService.php new file mode 100644 index 000000000..c7cdc3281 --- /dev/null +++ b/app/Services/FollowersSyncService.php @@ -0,0 +1,959 @@ + $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 $segments + * @return Collection 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 + */ + 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|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 + */ + 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 $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 + */ + 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 $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, 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; + } +} diff --git a/app/Services/RelationshipService.php b/app/Services/RelationshipService.php index 97cab0f23..e26cad30c 100644 --- a/app/Services/RelationshipService.php +++ b/app/Services/RelationshipService.php @@ -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); } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index 8b79f9f4e..1544cb4e7 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -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, diff --git a/config/federation.php b/config/federation.php index a409650c5..d8ee2618c 100644 --- a/config/federation.php +++ b/config/federation.php @@ -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' => [ diff --git a/database/migrations/2026_09_18_000001_add_followers_url_to_profiles_table.php b/database/migrations/2026_09_18_000001_add_followers_url_to_profiles_table.php new file mode 100644 index 000000000..7b4012023 --- /dev/null +++ b/database/migrations/2026_09_18_000001_add_followers_url_to_profiles_table.php @@ -0,0 +1,36 @@ +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'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index a9602b7b5..415633fe4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']); diff --git a/tests/Feature/Federation/FollowersSyncTest.php b/tests/Feature/Federation/FollowersSyncTest.php new file mode 100644 index 000000000..03736b363 --- /dev/null +++ b/tests/Feature/Federation/FollowersSyncTest.php @@ -0,0 +1,574 @@ + 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(); + }); +}); diff --git a/tests/Unit/ActivityPub/FollowersSyncDigestTest.php b/tests/Unit/ActivityPub/FollowersSyncDigestTest.php new file mode 100644 index 000000000..eca51db74 --- /dev/null +++ b/tests/Unit/ActivityPub/FollowersSyncDigestTest.php @@ -0,0 +1,74 @@ +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(); +});