diff --git a/app/Console/Commands/UserAccountDelete.php b/app/Console/Commands/UserAccountDelete.php index 0062a3006..b03010e50 100644 --- a/app/Console/Commands/UserAccountDelete.php +++ b/app/Console/Commands/UserAccountDelete.php @@ -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(); diff --git a/app/Http/Controllers/RemoteAuthController.php b/app/Http/Controllers/RemoteAuthController.php index 268851983..0d293787c 100644 --- a/app/Http/Controllers/RemoteAuthController.php +++ b/app/Http/Controllers/RemoteAuthController.php @@ -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; diff --git a/app/Jobs/DeletePipeline/FanoutDeletePipeline.php b/app/Jobs/DeletePipeline/FanoutDeletePipeline.php index 3ea2734d9..5cbafd063 100644 --- a/app/Jobs/DeletePipeline/FanoutDeletePipeline.php +++ b/app/Jobs/DeletePipeline/FanoutDeletePipeline.php @@ -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 $curlHeaders + * @return array + */ + 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; + } } diff --git a/app/Jobs/MovePipeline/MoveSendUndoFollowPipeline.php b/app/Jobs/MovePipeline/MoveSendUndoFollowPipeline.php index fbf93adc1..c4fd8e66c 100644 --- a/app/Jobs/MovePipeline/MoveSendUndoFollowPipeline.php +++ b/app/Jobs/MovePipeline/MoveSendUndoFollowPipeline.php @@ -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) { } } diff --git a/app/Jobs/ProfilePipeline/ProfileMigrationDeliverMoveActivityPipeline.php b/app/Jobs/ProfilePipeline/ProfileMigrationDeliverMoveActivityPipeline.php index 7f6cb053f..bd796c913 100644 --- a/app/Jobs/ProfilePipeline/ProfileMigrationDeliverMoveActivityPipeline.php +++ b/app/Jobs/ProfilePipeline/ProfileMigrationDeliverMoveActivityPipeline.php @@ -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 $curlHeaders + * @return array + */ + 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; } } diff --git a/app/Jobs/SharePipeline/SharePipeline.php b/app/Jobs/SharePipeline/SharePipeline.php index aff03c4e1..851e08171 100644 --- a/app/Jobs/SharePipeline/SharePipeline.php +++ b/app/Jobs/SharePipeline/SharePipeline.php @@ -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 $curlHeaders + * @return array + */ + 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; } } diff --git a/app/Jobs/SharePipeline/UndoSharePipeline.php b/app/Jobs/SharePipeline/UndoSharePipeline.php index 79a379249..42d1fb946 100644 --- a/app/Jobs/SharePipeline/UndoSharePipeline.php +++ b/app/Jobs/SharePipeline/UndoSharePipeline.php @@ -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 $curlHeaders + * @return array + */ + 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; + } } diff --git a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php index cf939e357..e9ec24992 100644 --- a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php +++ b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php @@ -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 $curlHeaders + * @return array + */ + 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; } } diff --git a/app/Jobs/StatusPipeline/StatusDelete.php b/app/Jobs/StatusPipeline/StatusDelete.php index ed25b1a30..4eb9d2836 100644 --- a/app/Jobs/StatusPipeline/StatusDelete.php +++ b/app/Jobs/StatusPipeline/StatusDelete.php @@ -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 $curlHeaders + * @return array + */ + 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; + } } diff --git a/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php b/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php index 0bfab7c02..08b486fc4 100644 --- a/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php +++ b/app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php @@ -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 $curlHeaders + * @return array + */ + 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; } } diff --git a/app/Jobs/StoryPipeline/StoryDelete.php b/app/Jobs/StoryPipeline/StoryDelete.php index 91783b065..b2777eef6 100644 --- a/app/Jobs/StoryPipeline/StoryDelete.php +++ b/app/Jobs/StoryPipeline/StoryDelete.php @@ -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 $curlHeaders + * @return array + */ + 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; } } diff --git a/app/Jobs/StoryPipeline/StoryExpire.php b/app/Jobs/StoryPipeline/StoryExpire.php index ddb70f5c1..6d979bd24 100644 --- a/app/Jobs/StoryPipeline/StoryExpire.php +++ b/app/Jobs/StoryPipeline/StoryExpire.php @@ -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 $curlHeaders + * @return array + */ + 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() diff --git a/app/Jobs/StoryPipeline/StoryFanout.php b/app/Jobs/StoryPipeline/StoryFanout.php index ea4fca6ec..0e6e436bf 100644 --- a/app/Jobs/StoryPipeline/StoryFanout.php +++ b/app/Jobs/StoryPipeline/StoryFanout.php @@ -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 $curlHeaders + * @return array + */ + 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; } } diff --git a/app/Services/ActivityPubFetchService.php b/app/Services/ActivityPubFetchService.php index 0a4a7b98f..db7eaf850 100644 --- a/app/Services/ActivityPubFetchService.php +++ b/app/Services/ActivityPubFetchService.php @@ -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; diff --git a/app/Services/MediaStorageService.php b/app/Services/MediaStorageService.php index 925481d0f..5c6593ca7 100644 --- a/app/Services/MediaStorageService.php +++ b/app/Services/MediaStorageService.php @@ -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)]; }); diff --git a/bootstrap/app.php b/bootstrap/app.php index 631e7bf2e..b9dd57a76 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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, ]); diff --git a/tests/Feature/HttpClientMigrationTest.php b/tests/Feature/HttpClientMigrationTest.php new file mode 100644 index 000000000..eff0d028d --- /dev/null +++ b/tests/Feature/HttpClientMigrationTest.php @@ -0,0 +1,236 @@ + 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(); + }); +});