Update MovePipeline

This commit is contained in:
Daniel Supernault 2024-09-09 04:12:23 -06:00
parent 59645f683e
commit 5cc34b161b
No known key found for this signature in database
GPG key ID: 23740873EE6F76A1
2 changed files with 217 additions and 199 deletions

View file

@ -14,50 +14,26 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis; use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;
use GuzzleHttp\Psr7\Request;
class MoveMigrateFollowersPipeline implements ShouldQueue class MoveMigrateFollowersPipeline implements ShouldQueue
{ {
use Queueable; use Queueable;
public $target; public string $target;
public string $activity;
public $activity; public int $tries = 15;
public int $maxExceptions = 5;
public int $timeout = 900;
/** public function __construct(string $target, string $activity)
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 15;
/**
* The maximum number of unhandled exceptions to allow before failing.
*
* @var int
*/
public $maxExceptions = 5;
/**
* The number of seconds the job can run before timing out.
*
* @var int
*/
public $timeout = 900;
/**
* Create a new job instance.
*/
public function __construct($target, $activity)
{ {
$this->target = $target; $this->target = $target;
$this->activity = $activity; $this->activity = $activity;
} }
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array public function middleware(): array
{ {
return [ return [
@ -66,47 +42,24 @@ class MoveMigrateFollowersPipeline implements ShouldQueue
]; ];
} }
/**
* Determine the time at which the job should timeout.
*/
public function retryUntil(): DateTime public function retryUntil(): DateTime
{ {
return now()->addMinutes(15); return now()->addMinutes(15);
} }
/**
* Execute the job.
*/
public function handle(): void public function handle(): void
{ {
if (config('app.env') !== 'production' || (bool) config_cache('federation.activitypub.enabled') == false) { try {
throw new Exception('Activitypub not enabled'); $this->validateEnvironment();
}
$target = $this->target; $targetAccount = $this->fetchProfile($this->target);
$actor = $this->activity; $actorAccount = $this->fetchProfile($this->activity);
$targetAccount = Helpers::profileFetch($target); if (!$targetAccount || !$actorAccount) {
$actorAccount = Helpers::profileFetch($actor);
if (! $targetAccount || ! $actorAccount) {
throw new Exception('Invalid move accounts'); throw new Exception('Invalid move accounts');
} }
$activity = [ $client = $this->createHttpClient();
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Follow',
'actor' => null,
'object' => $target,
];
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$addlHeaders = [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
];
$targetInbox = $targetAccount['sharedInbox'] ?? $targetAccount['inbox_url']; $targetInbox = $targetAccount['sharedInbox'] ?? $targetAccount['inbox_url'];
$targetPid = $targetAccount['id']; $targetPid = $targetAccount['id'];
@ -116,50 +69,99 @@ class MoveMigrateFollowersPipeline implements ShouldQueue
->whereNotNull('profiles.user_id') ->whereNotNull('profiles.user_id')
->whereNull('profiles.deleted_at') ->whereNull('profiles.deleted_at')
->select('profiles.id', 'profiles.user_id', 'profiles.username', 'profiles.private_key', 'profiles.status') ->select('profiles.id', 'profiles.user_id', 'profiles.username', 'profiles.private_key', 'profiles.status')
->chunkById(100, function ($followers) use ($addlHeaders, $targetInbox, $targetPid, $target) { ->chunkById(100, function ($followers) use ($client, $targetInbox, $targetPid) {
$client = new Client([ $this->processFollowerChunk($followers, $client, $targetInbox, $targetPid);
}, 'id');
} catch (Exception $e) {
Log::error('MoveMigrateFollowersPipeline failed', [
'target' => $this->target,
'activity' => $this->activity,
'error' => $e->getMessage()
]);
throw $e;
}
}
private function validateEnvironment(): void
{
if (config('app.env') !== 'production' || !(bool)config('federation.activitypub.enabled')) {
throw new Exception('ActivityPub not enabled');
}
}
private function fetchProfile(string $url): ?array
{
return Helpers::profileFetch($url);
}
private function createHttpClient(): Client
{
return new Client([
'timeout' => config('federation.activitypub.delivery.timeout'), 'timeout' => config('federation.activitypub.delivery.timeout'),
]); ]);
$requests = function ($followers) use ($client, $target, $addlHeaders, $targetInbox, $targetPid) { }
private function processFollowerChunk($followers, Client $client, string $targetInbox, int $targetPid): void
{
$requests = $this->generateRequests($followers, $targetInbox, $targetPid);
$pool = new Pool($client, $requests, [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
// Log success if needed
},
'rejected' => function ($reason, $index) {
Log::error('Failed to process follower', ['reason' => $reason, 'index' => $index]);
},
]);
$pool->promise()->wait();
}
private function generateRequests($followers, string $targetInbox, int $targetPid): \Generator
{
foreach ($followers as $follower) {
if (!$this->isValidFollower($follower)) {
continue;
}
yield $this->createFollowRequest($follower, $targetInbox, $targetPid);
}
}
private function isValidFollower($follower): bool
{
return $follower->private_key && $follower->username && $follower->user_id && $follower->status !== 'delete';
}
private function createFollowRequest($follower, string $targetInbox, int $targetPid): \GuzzleHttp\Psr7\Request
{
$permalink = 'https://'.config('pixelfed.domain.app').'/users/'.$follower->username;
$activity = [ $activity = [
'@context' => 'https://www.w3.org/ns/activitystreams', '@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Follow', 'type' => 'Follow',
'actor' => null, 'actor' => $permalink,
'object' => $target, 'object' => $this->target,
]; ];
foreach ($followers as $follower) {
if (! $follower->private_key || ! $follower->username || ! $follower->user_id || $follower->status === 'delete') {
continue;
}
$permalink = 'https://'.config('pixelfed.domain.app').'/users/'.$follower->username;
$activity['actor'] = $permalink;
$keyId = $permalink.'#main-key'; $keyId = $permalink.'#main-key';
$payload = json_encode($activity); $payload = json_encode($activity);
$url = $targetInbox;
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$addlHeaders = [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
];
$headers = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders); $headers = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders);
Follower::updateOrCreate([ Follower::updateOrCreate([
'profile_id' => $follower->id, 'profile_id' => $follower->id,
'following_id' => $targetPid, 'following_id' => $targetPid,
]); ]);
yield new $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
}
};
$pool = new Pool($client, $requests($followers), [ return new Request('POST', $targetInbox, $headers, $payload);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$promise = $pool->promise();
$promise->wait();
}, 'id');
} }
} }

View file

@ -9,47 +9,29 @@ use DB;
use Exception; use Exception;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Pool; use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\ThrottlesExceptions; use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;
class UnfollowLegacyAccountMovePipeline implements ShouldQueue class UnfollowLegacyAccountMovePipeline implements ShouldQueue
{ {
use Queueable; use Queueable;
public $target; public string $target;
public string $activity;
public $activity; public int $tries = 6;
public int $maxExceptions = 3;
/** public function __construct(string $target, string $activity)
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 6;
/**
* The maximum number of unhandled exceptions to allow before failing.
*
* @var int
*/
public $maxExceptions = 3;
/**
* Create a new job instance.
*/
public function __construct($target, $activity)
{ {
$this->target = $target; $this->target = $target;
$this->activity = $activity; $this->activity = $activity;
} }
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array public function middleware(): array
{ {
return [ return [
@ -58,33 +40,122 @@ class UnfollowLegacyAccountMovePipeline implements ShouldQueue
]; ];
} }
/**
* Determine the time at which the job should timeout.
*/
public function retryUntil(): DateTime public function retryUntil(): DateTime
{ {
return now()->addMinutes(5); return now()->addMinutes(5);
} }
/**
* Execute the job.
*/
public function handle(): void public function handle(): void
{ {
if (config('app.env') !== 'production' || (bool) config_cache('federation.activitypub.enabled') == false) { try {
throw new Exception('Activitypub not enabled'); $this->validateEnvironment();
}
$target = $this->target; $targetAccount = $this->fetchProfile($this->target);
$actor = $this->activity; $actorAccount = $this->fetchProfile($this->activity);
$targetAccount = Helpers::profileFetch($target); if (!$targetAccount || !$actorAccount) {
$actorAccount = Helpers::profileFetch($actor);
if (! $targetAccount || ! $actorAccount) {
throw new Exception('Invalid move accounts'); throw new Exception('Invalid move accounts');
} }
$client = $this->createHttpClient();
$targetInbox = $actorAccount['sharedInbox'] ?? $actorAccount['inbox_url'];
$targetPid = $actorAccount['id'];
$this->processFollowers($client, $targetInbox, $targetPid);
} catch (Exception $e) {
Log::error('UnfollowLegacyAccountMovePipeline failed', [
'target' => $this->target,
'activity' => $this->activity,
'error' => $e->getMessage()
]);
throw $e;
}
}
private function validateEnvironment(): void
{
if (config('app.env') !== 'production' || !(bool)config('federation.activitypub.enabled')) {
throw new Exception('ActivityPub not enabled');
}
}
private function fetchProfile(string $url): ?array
{
return Helpers::profileFetch($url);
}
private function createHttpClient(): Client
{
return new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
}
private function processFollowers(Client $client, string $targetInbox, int $targetPid): void
{
DB::table('followers')
->join('profiles', 'followers.profile_id', '=', 'profiles.id')
->where('followers.following_id', $targetPid)
->whereNotNull('profiles.user_id')
->whereNull('profiles.deleted_at')
->select('profiles.id', 'profiles.user_id', 'profiles.username', 'profiles.private_key', 'profiles.status')
->chunkById(100, function ($followers) use ($client, $targetInbox, $targetPid) {
$this->processFollowerChunk($followers, $client, $targetInbox, $targetPid);
}, 'id');
}
private function processFollowerChunk($followers, Client $client, string $targetInbox, int $targetPid): void
{
$requests = $this->generateRequests($followers, $targetInbox, $targetPid);
$pool = new Pool($client, $requests, [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {
// Log success if needed
},
'rejected' => function ($reason, $index) {
Log::error('Failed to process unfollow', ['reason' => $reason, 'index' => $index]);
},
]);
$pool->promise()->wait();
}
private function generateRequests($followers, string $targetInbox, int $targetPid): \Generator
{
foreach ($followers as $follower) {
if (!$this->isValidFollower($follower)) {
continue;
}
yield $this->createUnfollowRequest($follower, $targetInbox, $targetPid);
}
}
private function isValidFollower($follower): bool
{
return $follower->private_key && $follower->username && $follower->user_id && $follower->status !== 'delete';
}
private function createUnfollowRequest($follower, string $targetInbox, int $targetPid): Request
{
$permalink = 'https://'.config('pixelfed.domain.app').'/users/'.$follower->username;
$activity = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Undo',
'id' => $permalink.'#follow/'.$targetPid.'/undo',
'actor' => $permalink,
'object' => [
'type' => 'Follow',
'id' => $permalink.'#follows/'.$targetPid,
'object' => $this->activity,
'actor' => $permalink,
],
];
$keyId = $permalink.'#main-key';
$payload = json_encode($activity);
$version = config('pixelfed.version'); $version = config('pixelfed.version');
$appUrl = config('app.url'); $appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})"; $userAgent = "(Pixelfed/{$version}; +{$appUrl})";
@ -92,64 +163,9 @@ class UnfollowLegacyAccountMovePipeline implements ShouldQueue
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent, 'User-Agent' => $userAgent,
]; ];
$targetInbox = $actorAccount['sharedInbox'] ?? $actorAccount['inbox_url'];
$targetPid = $actorAccount['id'];
DB::table('followers')
->join('profiles', 'followers.profile_id', '=', 'profiles.id')
->where('followers.following_id', $actorAccount['id'])
->whereNotNull('profiles.user_id')
->whereNull('profiles.deleted_at')
->select('profiles.id', 'profiles.user_id', 'profiles.username', 'profiles.private_key', 'profiles.status')
->chunkById(100, function ($followers) use ($actor, $addlHeaders, $targetInbox, $targetPid) {
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$requests = function ($followers) use ($client, $actor, $addlHeaders, $targetInbox, $targetPid) {
$activity = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Undo',
'id' => null,
'actor' => null,
'object' => [
'type' => 'Follow',
'id' => null,
'object' => $actor,
'actor' => null,
],
];
foreach ($followers as $follower) {
if (! $follower->private_key || ! $follower->username || ! $follower->user_id || $follower->status === 'delete') {
continue;
}
$permalink = 'https://'.config('pixelfed.domain.app').'/users/'.$follower->username;
$activity['id'] = $permalink.'#follow/'.$targetPid.'/undo';
$activity['actor'] = $permalink;
$activity['object']['id'] = $permalink.'#follows/'.$targetPid;
$activity['object']['actor'] = $permalink;
$keyId = $permalink.'#main-key';
$payload = json_encode($activity);
$url = $targetInbox;
$headers = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders); $headers = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders);
yield new $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
}
};
$pool = new Pool($client, $requests($followers), [ return new Request('POST', $targetInbox, $headers, $payload);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$promise = $pool->promise();
$promise->wait();
}, 'id');
} }
} }