mirror of https://github.com/pixelfed/pixelfed
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
parent
c6d98fb72d
commit
dedfa67b70
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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…
Reference in New Issue