pull/6889/head
Your Name 4 weeks ago
parent 00dd5b3d92
commit a142db87b4

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

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

@ -4,14 +4,14 @@ namespace App\Jobs\DeletePipeline;
use App\Profile; use App\Profile;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class FanoutDeletePipeline implements ShouldQueue class FanoutDeletePipeline implements ShouldQueue
@ -53,10 +53,6 @@ class FanoutDeletePipeline implements ShouldQueue
} }
try { try {
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$audience = Cache::remember('pf:ap:known_instances', now()->addHours(6), function () { $audience = Cache::remember('pf:ap:known_instances', now()->addHours(6), function () {
return Profile::whereNotNull('sharedInbox')->groupBy('sharedInbox')->pluck('sharedInbox')->toArray(); return Profile::whereNotNull('sharedInbox')->groupBy('sharedInbox')->pluck('sharedInbox')->toArray();
}); });
@ -73,36 +69,26 @@ class FanoutDeletePipeline implements ShouldQueue
]; ];
$payload = json_encode($activity); $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) { foreach ($audience as $url) {
$version = config('pixelfed.version'); $curlHeaders = HttpSignature::sign($profile, $url, $activity, [
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
'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' => "(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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$promise = $pool->promise(); $pool->withHeaders($headers)
->timeout($timeout)
$promise->wait(); ->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
->post($url);
}
});
} catch (\Exception $e) { } catch (\Exception $e) {
Log::warning("FanoutDeletePipeline: Failed to fanout delete for profile {$profile->id}: ".$e->getMessage()); Log::warning("FanoutDeletePipeline: Failed to fanout delete for profile {$profile->id}: ".$e->getMessage());
throw $e; throw $e;
@ -110,4 +96,23 @@ class FanoutDeletePipeline implements ShouldQueue
return 1; 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; namespace App\Jobs\MovePipeline;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\Middleware\ThrottlesExceptions; use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class MoveSendUndoFollowPipeline implements ShouldQueue class MoveSendUndoFollowPipeline implements ShouldQueue
@ -119,21 +119,22 @@ class MoveSendUndoFollowPipeline implements ShouldQueue
$keyId = $permalink.'#main-key'; $keyId = $permalink.'#main-key';
$payload = json_encode($activity); $payload = json_encode($activity);
$headers = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders); $curlHeaders = HttpSignature::signRaw($follower->private_key, $keyId, $targetInbox, $activity, $addlHeaders);
$client = new Client([ $headers = [];
'timeout' => config('federation.activitypub.delivery.timeout'), foreach ($curlHeaders as $header) {
]); $parts = explode(': ', $header, 2);
if (count($parts) === 2) {
$headers[$parts[0]] = $parts[1];
}
}
try { try {
$client->post($targetInbox, [ Http::withHeaders($headers)
'curl' => [ ->timeout(config('federation.activitypub.delivery.timeout'))
CURLOPT_HTTPHEADER => $headers, ->withBody($payload, 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
CURLOPT_POSTFIELDS => $payload, ->post($targetInbox);
CURLOPT_HEADER => true, } catch (ConnectionException $e) {
],
]);
} catch (ClientException $e) {
} }
} }

@ -4,17 +4,16 @@ namespace App\Jobs\ProfilePipeline;
use App\Transformer\ActivityPub\Verb\Move; use App\Transformer\ActivityPub\Verb\Move;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Batchable; use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Http;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -98,41 +97,44 @@ class ProfileMigrationDeliverMoveActivityPipeline implements ShouldBeUniqueUntil
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version'); $version = config('pixelfed.version');
$appUrl = config('app.url'); $appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})"; $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) { 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"', 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent, '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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {}, 'rejected' => function ($reason, $index) { $pool->withHeaders($headers)
Log::error($reason); ->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\Status;
use App\Transformer\ActivityPub\Verb\Announce; use App\Transformer\ActivityPub\Verb\Announce;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -111,41 +111,45 @@ class SharePipeline implements ShouldQueue
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version'); $version = config('pixelfed.version');
$appUrl = config('app.url'); $appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})"; $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) { 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"', 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent, '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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {}, $pool->withHeaders($headers)
'rejected' => function ($reason, $index) {}, ->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\Status;
use App\Transformer\ActivityPub\Verb\UndoAnnounce; use App\Transformer\ActivityPub\Verb\UndoAnnounce;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -97,44 +97,48 @@ class UndoSharePipeline implements ShouldQueue
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version'); $version = config('pixelfed.version');
$appUrl = config('app.url'); $appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})"; $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) { 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"', 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent, '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(); $status->delete();
return 1; 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\CreateNote;
use App\Transformer\ActivityPub\Verb\CreateQuestion; use App\Transformer\ActivityPub\Verb\CreateQuestion;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -122,40 +122,44 @@ class StatusActivityPubDeliver implements ShouldQueue
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version'); $version = config('pixelfed.version');
$appUrl = config('app.url'); $appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})"; $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) { 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"', 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent, '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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {}, $pool->withHeaders($headers)
'rejected' => function ($reason, $index) {}, ->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;
} }
} }

@ -5,13 +5,13 @@ namespace App\Jobs\StatusPipeline;
use App\Status; use App\Status;
use App\Transformer\ActivityPub\Verb\UpdateNote; use App\Transformer\ActivityPub\Verb\UpdateNote;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -97,40 +97,44 @@ class StatusLocalUpdateActivityPubDeliverPipeline implements ShouldQueue
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$version = config('pixelfed.version'); $version = config('pixelfed.version');
$appUrl = config('app.url'); $appUrl = config('app.url');
$userAgent = "(Pixelfed/{$version}; +{$appUrl})"; $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) { 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"', 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'User-Agent' => $userAgent, '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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {}, $pool->withHeaders($headers)
'rejected' => function ($reason, $index) {}, ->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\Services\StoryService;
use App\Story; use App\Story;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class StoryDelete implements ShouldQueue class StoryDelete implements ShouldQueue
@ -89,44 +89,48 @@ class StoryDelete implements ShouldQueue
$audience = FollowerService::softwareAudience($profile->id, 'pixelfed'); $audience = FollowerService::softwareAudience($profile->id, 'pixelfed');
if (empty($audience)) { if (empty($audience)) {
// Return on profiles with no remote followers
return; return;
} }
$payload = json_encode($activity); $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([ Http::pool(function (Pool $pool) use ($audience, $activity, $profile, $payload, $userAgent, $timeout) {
'timeout' => config('federation.activitypub.delivery.timeout'),
]);
$requests = function ($audience) use ($client, $activity, $profile, $payload) {
foreach ($audience as $url) { foreach ($audience as $url) {
$version = config('pixelfed.version'); $curlHeaders = HttpSignature::sign($profile, $url, $activity, [
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
'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' => "(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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {}, $pool->withHeaders($headers)
'rejected' => function ($reason, $index) {}, ->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\Story;
use App\Transformer\ActivityPub\Verb\DeleteStory; use App\Transformer\ActivityPub\Verb\DeleteStory;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -125,39 +125,45 @@ class StoryExpire implements ShouldQueue
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([ $version = config('pixelfed.version');
'timeout' => config('federation.activitypub.delivery.timeout'), $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) { foreach ($audience as $url) {
$version = config('pixelfed.version'); $curlHeaders = HttpSignature::sign($profile, $url, $activity, [
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
'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' => "(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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$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() protected function handleRemoteExpiry()

@ -7,13 +7,13 @@ use App\Services\StoryService;
use App\Story; use App\Story;
use App\Transformer\ActivityPub\Verb\CreateStory; use App\Transformer\ActivityPub\Verb\CreateStory;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Client;
use GuzzleHttp\Pool;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\Pool;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
@ -70,38 +70,44 @@ class StoryFanout implements ShouldQueue
$payload = json_encode($activity); $payload = json_encode($activity);
$client = new Client([ $version = config('pixelfed.version');
'timeout' => config('federation.activitypub.delivery.timeout'), $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) { foreach ($audience as $url) {
$version = config('pixelfed.version'); $curlHeaders = HttpSignature::sign($profile, $url, $activity, [
$appUrl = config('app.url');
$headers = HttpSignature::sign($profile, $url, $activity, [
'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' => "(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), [ $headers = $this->parseCurlHeaders($curlHeaders);
'concurrency' => config('federation.activitypub.delivery.concurrency'),
'fulfilled' => function ($response, $index) {},
'rejected' => function ($reason, $index) {},
]);
$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\Helpers;
use App\Util\ActivityPub\HttpSignature; use App\Util\ActivityPub\HttpSignature;
use GuzzleHttp\Psr7\Uri as GuzzleUri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use League\Uri\BaseUri;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
class ActivityPubFetchService class ActivityPubFetchService
@ -228,10 +227,7 @@ class ActivityPubFetchService
} }
try { try {
$resolved = UriResolver::resolve( $resolved = BaseUri::from($baseUrl)->resolve($location);
new GuzzleUri($baseUrl),
new GuzzleUri($location)
);
$url = (string) $resolved; $url = (string) $resolved;

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

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