Merge pull request #6889 from pixelfed/refactor/status-delete-http-client

refactor: replace Guzzle pool with Laravel HTTP client in StatusDelete
pull/6897/head
Shlee 4 weeks ago committed by GitHub
commit b4afda12d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,13 +6,13 @@ use App\Instance;
use App\Profile;
use App\User;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
use Illuminate\Console\Command;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use JsonException;
use Psr\Http\Message\ResponseInterface;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\search;
@ -289,23 +289,21 @@ class UserAccountDelete extends Command
->distinct();
}
protected function makeHttpClient(): Client
protected function makeHttpClient(): PendingRequest
{
return new Client([
'timeout' => 10.0,
'connect_timeout' => 5.0,
'http_errors' => false,
'allow_redirects' => false,
'version' => '1.1',
'headers' => [
return Http::timeout(10)
->connectTimeout(5)
->withOptions([
'allow_redirects' => false,
])
->withHeaders([
'User-Agent' => 'Pixelfed ('.config('app.url').')',
'Accept' => 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
],
]);
]);
}
protected function sendBatch(
Client $client,
PendingRequest $client,
string $privateKey,
string $keyId,
string $digest,
@ -320,27 +318,43 @@ class UserAccountDelete extends Command
$httpFailed = [];
$retryable = [];
$requests = function () use ($urls, $privateKey, $keyId, $digest, $payload, $payloadLen) {
foreach ($urls as $url) {
$urlList = $urls->values()->all();
$responses = Http::pool(function (Pool $pool) use ($urlList, $privateKey, $keyId, $digest, $payload, $payloadLen) {
foreach ($urlList as $url) {
$headers = HttpSignature::signRawWithDigest($privateKey, $keyId, $url, $digest);
$headers['Content-Type'] = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
$headers['Content-Length'] = (string) $payloadLen;
yield $url => new Request('POST', $url, $headers, $payload);
$pool->as($url)
->timeout(10)
->connectTimeout(5)
->withOptions(['allow_redirects' => false])
->withHeaders($headers)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
foreach ($urlList as $url) {
$response = $responses[$url] ?? null;
if (! $response) {
$retryable[$url] = 'No response';
continue;
}
};
$pool = new Pool($client, $requests(), [
'concurrency' => $concurrency,
'fulfilled' => function (ResponseInterface $response, string $url) use (&$delivered, &$httpFailed, &$retryable, $verboseErrors) {
$status = $response->getStatusCode();
if ($response instanceof Response) {
$status = $response->status();
if ($status >= 200 && $status < 300) {
$delivered[$url] = $status;
return;
continue;
}
$body = mb_substr((string) $response->getBody(), 0, 500);
$body = mb_substr((string) $response->body(), 0, 500);
if ($verboseErrors) {
$this->warn(" [{$status}] {$url} — {$body}");
@ -349,28 +363,25 @@ class UserAccountDelete extends Command
if ($this->isRetryableStatus($status)) {
$retryable[$url] = "HTTP {$status}";
return;
continue;
}
$httpFailed[$url] = [
'status' => $status,
'body' => $body,
];
},
'rejected' => function ($reason, string $url) use (&$retryable, $verboseErrors) {
$message = $reason instanceof \Throwable
? $reason->getMessage()
: (string) $reason;
} else {
$message = $response instanceof \Throwable
? $response->getMessage()
: (string) $response;
if ($verboseErrors) {
$this->error(" [TRANSPORT] {$url} — {$message}");
}
$retryable[$url] = $message;
},
]);
$pool->promise()->wait();
}
}
return [
'delivered' => $delivered,
@ -398,27 +409,22 @@ class UserAccountDelete extends Command
$this->line($payload);
$this->newLine();
$client = new Client([
'timeout' => 15.0,
'connect_timeout' => 5.0,
'http_errors' => false,
'allow_redirects' => false,
]);
try {
$response = $client->post($url, [
'headers' => $headers,
'body' => $payload,
]);
$response = Http::timeout(15)
->connectTimeout(5)
->withOptions(['allow_redirects' => false])
->withHeaders($headers)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
$status = $response->status();
$body = $response->body();
$this->info("Response status: {$status}");
$this->newLine();
$this->info('Response headers:');
foreach ($response->getHeaders() as $name => $values) {
foreach ($response->headers() as $name => $values) {
$this->line(" {$name}: ".implode(', ', $values));
}
$this->newLine();

@ -11,9 +11,9 @@ use App\Services\SanitizeService;
use App\User;
use App\Util\ActivityPub\Helpers;
use App\Util\Lexer\RestrictedNames;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Auth\Events\Registered;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

@ -4,14 +4,14 @@ namespace App\Jobs\DeletePipeline;
use App\Profile;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FanoutDeletePipeline implements ShouldQueue
@ -53,10 +53,6 @@ class FanoutDeletePipeline implements ShouldQueue
}
try {
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$audience = Cache::remember('pf:ap:known_instances', now()->addHours(6), function () {
return Profile::whereNotNull('sharedInbox')->groupBy('sharedInbox')->pluck('sharedInbox')->toArray();
});
@ -73,36 +69,26 @@ class FanoutDeletePipeline implements ShouldQueue
];
$payload = json_encode($activity);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$promise = $pool->promise();
$promise->wait();
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
} catch (\Exception $e) {
Log::warning("FanoutDeletePipeline: Failed to fanout delete for profile {$profile->id}: ".$e->getMessage());
throw $e;
@ -110,4 +96,23 @@ class FanoutDeletePipeline implements ShouldQueue
return 1;
}
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
return $headers;
}
}

@ -3,12 +3,12 @@
namespace App\Jobs\MovePipeline;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MoveSendUndoFollowPipeline implements ShouldQueue
@ -119,21 +119,22 @@ class MoveSendUndoFollowPipeline implements ShouldQueue
$keyId = $permalink.'#main-key';
$payload = json_encode($activity);
$headers = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$curlHeaders = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders);
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
try {
$client->post($targetInbox, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
} catch (ClientException $e) {
Http::withHeaders($headers)
->timeout(config('federation.activitypub.delivery.timeout'))
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($targetInbox);
} catch (ConnectionException $e) {
}
}

@ -4,17 +4,16 @@ namespace App\Jobs\ProfilePipeline;
use App\Transformer\ActivityPub\Verb\Move;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -98,41 +97,44 @@ class ProfileMigrationDeliverMoveActivityPipeline implements ShouldBeUniqueUntil
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {}, 'rejected' => function ($reason, $index) {
Log::error($reason);
},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
}
$promise = $pool->promise();
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
$promise->wait();
return $headers;
}
}

@ -9,13 +9,13 @@ use App\Services\StatusService;
use App\Status;
use App\Transformer\ActivityPub\Verb\Announce;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -111,41 +111,45 @@ class SharePipeline implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
$promise = $pool->promise();
}
$promise->wait();
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
return $headers;
}
}

@ -9,13 +9,13 @@ use App\Services\StatusService;
use App\Status;
use App\Transformer\ActivityPub\Verb\UndoAnnounce;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -97,44 +97,48 @@ class UndoSharePipeline implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$promise = $pool->promise();
$headers = $this->parseCurlHeaders($curlHeaders);
$promise->wait();
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
$status->delete();
return 1;
}
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
return $headers;
}
}

@ -7,13 +7,13 @@ use App\Status;
use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\CreateQuestion;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -122,40 +122,44 @@ class StatusActivityPubDeliver implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
}
$promise = $pool->promise();
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
$promise->wait();
return $headers;
}
}

@ -22,14 +22,14 @@ use App\StatusHashtag;
use App\StatusView;
use App\Transformer\ActivityPub\Verb\DeleteNote;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -195,42 +195,46 @@ class StatusDelete implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
$responses = Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$promise = $pool->promise();
$promise->wait();
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
return 1;
}
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
return $headers;
}
}

@ -5,13 +5,13 @@ namespace App\Jobs\StatusPipeline;
use App\Status;
use App\Transformer\ActivityPub\Verb\UpdateNote;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -97,40 +97,44 @@ class StatusLocalUpdateActivityPubDeliverPipeline implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload, $userAgent) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
}
$promise = $pool->promise();
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
$promise->wait();
return $headers;
}
}

@ -6,13 +6,13 @@ use App\Services\FollowerService;
use App\Services\StoryService;
use App\Story;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class StoryDelete implements ShouldQueue
@ -89,44 +89,48 @@ class StoryDelete implements ShouldQueue
$audience = FollowerService::softwareAudience($profile->id, 'pixelfed');
if (empty($audience)) {
// Return on profiles with no remote followers
return;
}
$payload = json_encode($activity);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$requests = function ($audience) use ($client, $activity, $profile, $payload) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
}
$promise = $pool->promise();
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
$promise->wait();
return $headers;
}
}

@ -8,13 +8,13 @@ use App\Services\StoryService;
use App\Story;
use App\Transformer\ActivityPub\Verb\DeleteStory;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -125,39 +125,45 @@ class StoryExpire implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$promise = $pool->promise();
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
}
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
$promise->wait();
return $headers;
}
protected function handleRemoteExpiry()

@ -7,13 +7,13 @@ use App\Services\StoryService;
use App\Story;
use App\Transformer\ActivityPub\Verb\CreateStory;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
@ -70,38 +70,44 @@ class StoryFanout implements ShouldQueue
$payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version');
$appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
$timeout = config('federation.activitypub.delivery.timeout');
$requests = function ($audience) use ($client, $activity, $profile, $payload) {
Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
foreach ($audience as $url) {
$version = config('pixelfed.version');
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
$curlHeaders = HttpSignature::sign($profile, $url, $activity, [
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
'User-Agent' => $userAgent,
]);
yield function () use ($client, $url, $headers, $payload) {
return $client->postAsync($url, [
'curl' => [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true,
],
]);
};
}
};
$pool = new Pool($client, $requests($audience), [
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$headers = $this->parseCurlHeaders($curlHeaders);
$promise = $pool->promise();
$pool->withHeaders($headers)
->timeout($timeout)
->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
}
/**
* Convert curl-format header array ("Header: value") to associative array.
*
* @param array<int, string> $curlHeaders
* @return array<string, string>
*/
private function parseCurlHeaders(array $curlHeaders): array
{
$headers = [];
foreach ($curlHeaders as $header) {
$parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
$promise->wait();
return $headers;
}
}

@ -4,12 +4,11 @@ namespace App\Services;
use App\Util\ActivityPub\Helpers;
use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Psr7\Uri as GuzzleUri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use League\Uri\BaseUri;
use Psr\Http\Message\ResponseInterface;
class ActivityPubFetchService
@ -228,10 +227,7 @@ class ActivityPubFetchService
}
try {
$resolved = UriResolver::resolve(
new GuzzleUri($baseUrl),
new GuzzleUri($location)
);
$resolved = BaseUri::from($baseUrl)->resolve($location);
$url = (string) $resolved;

@ -8,11 +8,11 @@ use App\Jobs\StatusPipeline\NewStatusPipeline;
use App\Media;
use App\Status;
use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\File;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@ -44,14 +44,17 @@ class MediaStorageService
public static function head($url)
{
$c = new Client;
try {
$r = $c->request('HEAD', $url);
} catch (RequestException $e) {
$r = Http::head($url);
} catch (ConnectionException $e) {
return false;
}
$h = Arr::mapWithKeys($r->getHeaders(), function ($item, $key) {
if (! $r->successful()) {
return false;
}
$h = Arr::mapWithKeys($r->headers(), function ($item, $key) {
return [strtolower($key) => last($item)];
});

@ -9,7 +9,6 @@ use App\Http\Middleware\Localization;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\RestrictedAccess;
use App\Http\Middleware\TwoFactorAuth;
use GuzzleHttp\Exception\ConnectException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
@ -166,7 +165,6 @@ return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
$exceptions->dontReport([
OAuthServerException::class,
ConnectException::class,
ConnectionException::class,
]);

@ -0,0 +1,236 @@
<?php
use App\Follower;
use App\Jobs\DeletePipeline\FanoutDeletePipeline;
use App\Jobs\StatusPipeline\StatusActivityPubDeliver;
use App\Profile;
use App\Services\ActivityPubFetchService;
use App\Services\MediaStorageService;
use App\Status;
use App\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| HTTP Client Migration Tests
|--------------------------------------------------------------------------
|
| Verifies that the Guzzle-to-Laravel HTTP client migration works
| correctly for MediaStorageService, ActivityPub delivery jobs, and
| ActivityPubFetchService URI resolution.
|
*/
describe('MediaStorageService::head()', function () {
it('returns length and mime on successful HEAD response', function () {
Http::fake([
'https://example.com/image.jpg' => Http::response('', 200, [
'Content-Length' => '50000',
'Content-Type' => 'image/jpeg',
]),
]);
$result = MediaStorageService::head('https://example.com/image.jpg');
expect($result)->toBeArray();
expect($result['length'])->toBe(50000);
expect($result['mime'])->toBe('image/jpeg');
});
it('returns false when content-length is too small', function () {
Http::fake([
'https://example.com/tiny.jpg' => Http::response('', 200, [
'Content-Length' => '5',
'Content-Type' => 'image/jpeg',
]),
]);
$result = MediaStorageService::head('https://example.com/tiny.jpg');
expect($result)->toBeFalse();
});
it('returns false when response is not successful', function () {
Http::fake([
'https://example.com/missing.jpg' => Http::response('', 404),
]);
$result = MediaStorageService::head('https://example.com/missing.jpg');
expect($result)->toBeFalse();
});
it('returns false when content-length header is missing', function () {
Http::fake([
'https://example.com/noheaders.jpg' => Http::response('', 200, [
'Content-Type' => 'image/jpeg',
]),
]);
$result = MediaStorageService::head('https://example.com/noheaders.jpg');
expect($result)->toBeFalse();
});
it('returns false on connection exception', function () {
Http::fake([
'https://unreachable.example.com/*' => fn () => throw new ConnectionException('Connection refused'),
]);
$result = MediaStorageService::head('https://unreachable.example.com/image.jpg');
expect($result)->toBeFalse();
});
});
describe('FanoutDeletePipeline delivery', function () {
it('sends delete activities to known shared inboxes via Http::pool', function () {
Http::fake();
$user = User::factory()->create();
$user->refresh();
$profile = $user->profile;
// Create remote profiles with shared inboxes
Profile::factory()->remote()->create([
'sharedInbox' => 'https://remote1.example/inbox',
]);
Profile::factory()->remote()->create([
'sharedInbox' => 'https://remote2.example/inbox',
]);
Cache::forget('pf:ap:known_instances');
$job = new FanoutDeletePipeline($profile);
$job->handle();
Http::assertSentCount(2);
Http::assertSent(fn ($request) => $request->url() === 'https://remote1.example/inbox'
&& $request->method() === 'POST'
&& str_contains($request->header('Content-Type')[0] ?? '', 'application/ld+json')
);
Http::assertSent(fn ($request) => $request->url() === 'https://remote2.example/inbox'
&& $request->method() === 'POST'
);
});
it('skips delivery when profile lacks private key', function () {
Http::fake();
$profile = Profile::factory()->remote()->create([
'private_key' => null,
]);
$job = new FanoutDeletePipeline($profile);
$job->handle();
Http::assertNothingSent();
});
});
describe('StatusActivityPubDeliver delivery', function () {
it('sends create activities to audience inboxes via Http::pool', function () {
Http::fake();
$user = User::factory()->create();
$user->refresh();
$profile = $user->profile;
// Create a remote follower with inbox
$remoteFollower = Profile::factory()->remote()->create([
'sharedInbox' => 'https://remote.example/inbox',
'inbox_url' => 'https://remote.example/users/bob/inbox',
]);
// Make them a follower
Follower::create([
'profile_id' => $remoteFollower->id,
'following_id' => $profile->id,
]);
// Clear audience cache
Cache::forget('pf:services:follower:audience:'.$profile->id);
$status = Status::factory()->create([
'profile_id' => $profile->id,
'type' => 'photo',
'scope' => 'public',
'visibility' => 'public',
]);
// Force local and no url/uri to pass the guard clause
$status->local = true;
$status->setAttribute('url', null);
$status->uri = null;
$status->saveQuietly();
$status->refresh();
$job = new StatusActivityPubDeliver($status);
$job->handle();
Http::assertSent(fn ($request) => $request->url() === 'https://remote.example/inbox'
&& $request->method() === 'POST'
);
});
it('does not deliver non-local statuses', function () {
Http::fake();
$user = User::factory()->create();
$user->refresh();
$status = Status::factory()->create([
'profile_id' => $user->profile->id,
'uri' => 'https://remote.example/status/1',
'local' => false,
]);
$job = new StatusActivityPubDeliver($status);
$job->handle();
Http::assertNothingSent();
});
});
describe('ActivityPubFetchService URI resolution', function () {
it('resolves a relative location against a base URL', function () {
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
$method->setAccessible(true);
$result = $method->invoke(null, 'https://example.com/users/alice', '/statuses/123');
expect($result)->toBe('https://example.com/statuses/123');
});
it('resolves an absolute location unchanged', function () {
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
$method->setAccessible(true);
$result = $method->invoke(null, 'https://example.com/users/alice', 'https://other.example/status/456');
expect($result)->toBe('https://other.example/status/456');
});
it('returns null for empty location', function () {
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
$method->setAccessible(true);
$result = $method->invoke(null, 'https://example.com/users/alice', '');
expect($result)->toBeNull();
});
it('returns null for location with control characters', function () {
$method = new ReflectionMethod(ActivityPubFetchService::class, 'resolveRedirect');
$method->setAccessible(true);
$result = $method->invoke(null, 'https://example.com/users/alice', "/bad\x00path");
expect($result)->toBeNull();
});
});
Loading…
Cancel
Save