mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
420 lines
14 KiB
PHP
420 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Federation\ActivityBuilders\AccountDeleteActivityBuilder;
|
|
use App\Jobs\Federation\DeliverAccountDeleteActivity;
|
|
use App\Models\Instance;
|
|
use App\Models\Profile;
|
|
use App\Util\ActivityPub\Helpers;
|
|
use App\Util\ActivityPub\HttpSignature;
|
|
use Closure;
|
|
use Illuminate\Http\Client\Pool;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use JsonException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Tells the rest of the network that a local account has been deleted.
|
|
*
|
|
* This does not go through ActivityPubDeliveryService on purpose. By the time
|
|
* a deletion is federated the profile is inactive and soft deleted, which
|
|
* validateSender() refuses, and the audience is every known shared inbox
|
|
* rather than a follower list. The activity is serialized and hashed once,
|
|
* and every request is signed against that one digest.
|
|
*
|
|
* Callers:
|
|
* - FanoutAccountDeleteActivity / DeliverAccountDeleteActivity (queued)
|
|
* - app:user-account-delete (interactive)
|
|
*/
|
|
class AccountDeleteFederationService
|
|
{
|
|
public const string CONTENT_TYPE = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
|
|
/**
|
|
* Inboxes per DeliverAccountDeleteActivity job. At the default delivery
|
|
* concurrency of 10 and a 10 second timeout the worst case is about 100
|
|
* seconds, well inside the 300 second Horizon worker timeout.
|
|
*/
|
|
public const int CHUNK_SIZE = 100;
|
|
|
|
private const string ACCEPT = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
|
|
private const array RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
|
|
private const array DELETED_STATUSES = ['delete', 'deleted'];
|
|
|
|
private const int TIMEOUT = 10;
|
|
|
|
private const int CONNECT_TIMEOUT = 5;
|
|
|
|
private const int AUDIENCE_WINDOW_DAYS = 30;
|
|
|
|
private const string FANOUT_LOCK = 'pf:services:account-delete:fanout:';
|
|
|
|
private const int FANOUT_LOCK_TTL = 43200;
|
|
|
|
public function __construct(protected AccountDeleteActivityBuilder $builder) {}
|
|
|
|
/**
|
|
* Whether this profile is in a state where announcing its deletion is
|
|
* correct. Not every path into DeleteAccountPipeline sets the status
|
|
* column, so a soft deleted profile counts too.
|
|
*/
|
|
public function isDeleted(Profile $profile): bool
|
|
{
|
|
return $profile->trashed() || in_array($profile->status, self::DELETED_STATUSES, true);
|
|
}
|
|
|
|
/**
|
|
* Build, serialize and hash the activity, and prove the profile key can
|
|
* sign it, so a broken key fails here once and not once per inbox.
|
|
*
|
|
* The private key is deliberately not part of the result: it is read off
|
|
* the profile at signing time and never travels through a queue payload.
|
|
*
|
|
* @return array{payload: string, digest: string, length: int, key_id: string}
|
|
*
|
|
* @throws RuntimeException when this profile cannot federate a deletion
|
|
*/
|
|
public function prepare(Profile $profile): array
|
|
{
|
|
if ($profile->domain !== null) {
|
|
throw new RuntimeException('Only local accounts can federate their own deletion.');
|
|
}
|
|
|
|
if (! $this->isDeleted($profile)) {
|
|
throw new RuntimeException('Refusing to federate a deletion for an account that is not deleted.');
|
|
}
|
|
|
|
if (empty($profile->private_key)) {
|
|
throw new RuntimeException('Profile private key has been wiped, cannot sign deletion activity.');
|
|
}
|
|
|
|
$keyId = $profile->keyId();
|
|
|
|
if (empty($keyId)) {
|
|
throw new RuntimeException('Profile key id is missing, cannot sign deletion activity.');
|
|
}
|
|
|
|
try {
|
|
$payload = json_encode(
|
|
$this->builder->build($profile),
|
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
|
|
);
|
|
} catch (JsonException $e) {
|
|
throw new RuntimeException("Failed to encode delete payload: {$e->getMessage()}", 0, $e);
|
|
}
|
|
|
|
$prepared = [
|
|
'payload' => $payload,
|
|
'digest' => base64_encode(hash('sha256', $payload, true)),
|
|
'length' => strlen($payload),
|
|
'key_id' => $keyId,
|
|
];
|
|
|
|
try {
|
|
$this->signedHeaders($profile, $prepared, rtrim((string) config('app.url'), '/').'/inbox');
|
|
} catch (Throwable $e) {
|
|
throw new RuntimeException("Unable to sign with this profile's key: {$e->getMessage()}", 0, $e);
|
|
}
|
|
|
|
return $prepared;
|
|
}
|
|
|
|
/**
|
|
* Every shared inbox of an instance seen recently enough to be worth a
|
|
* request.
|
|
*
|
|
* @return Collection<int, string>
|
|
*/
|
|
public function audience(): Collection
|
|
{
|
|
return Instance::query()
|
|
->whereNotNull('shared_inbox')
|
|
->whereNotNull('nodeinfo_last_fetched')
|
|
->where('nodeinfo_last_fetched', '>', now()->subDays(self::AUDIENCE_WINDOW_DAYS))
|
|
->toBase()
|
|
->distinct()
|
|
->orderBy('shared_inbox')
|
|
->pluck('shared_inbox')
|
|
->filter(fn ($inbox): bool => is_string($inbox) && trim($inbox) !== '')
|
|
->unique()
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Queue the deletion for delivery to the whole audience.
|
|
*
|
|
* Guarded by a lock so two runs of the delete pipeline for the same
|
|
* account (a double click, a retried job) broadcast once. A deliberate
|
|
* resend goes through app:user-account-delete, which does not take it.
|
|
*
|
|
* @return int Number of inboxes queued, 0 when a fanout already ran
|
|
*
|
|
* @throws RuntimeException when this profile cannot federate a deletion
|
|
*/
|
|
public function fanout(Profile $profile, int $chunkSize = self::CHUNK_SIZE): int
|
|
{
|
|
$this->prepare($profile);
|
|
|
|
$lock = self::FANOUT_LOCK.$profile->id;
|
|
|
|
if (! Cache::add($lock, 1, self::FANOUT_LOCK_TTL)) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
$audience = $this->audience();
|
|
|
|
foreach ($audience->chunk(max(1, $chunkSize)) as $chunk) {
|
|
DeliverAccountDeleteActivity::dispatch($profile->id, $chunk->values()->all())
|
|
->onQueue('delete');
|
|
}
|
|
} catch (Throwable $e) {
|
|
// A Delete that arrives twice is harmless, one that never
|
|
// arrives is not, so let the retry start over.
|
|
Cache::forget($lock);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
return $audience->count();
|
|
}
|
|
|
|
/**
|
|
* One pooled pass over a set of inboxes. Nothing is retried here: the
|
|
* caller decides what to do with `retryable`, since a queued job and an
|
|
* interactive command want very different waits.
|
|
*
|
|
* Every inbox handed in lands in exactly one bucket, keyed by the url
|
|
* as it was passed:
|
|
*
|
|
* delivered 2xx
|
|
* skipped host currently marked unavailable by DeliveryHostService
|
|
* invalid failed url validation (private ip, banned, malformed) or signing
|
|
* http_failed answered with a status that another attempt will not fix
|
|
* retryable no answer, or one of RETRYABLE_STATUSES
|
|
*
|
|
* @param array{payload: string, digest: string, length: int, key_id: string} $prepared
|
|
* @param array<int, string> $inboxes
|
|
* @param Closure|null $onFailure fn (string $url, string $reason, ?int $status): void
|
|
* @return array{delivered: array<string, int>, skipped: array<int, string>, invalid: array<string, string>, http_failed: array<string, array{status: int, body: string}>, retryable: array<string, string>}
|
|
*/
|
|
public function deliver(
|
|
Profile $profile,
|
|
array $prepared,
|
|
array $inboxes,
|
|
?int $concurrency = null,
|
|
?Closure $onFailure = null
|
|
): array {
|
|
$result = [
|
|
'delivered' => [],
|
|
'skipped' => [],
|
|
'invalid' => [],
|
|
'http_failed' => [],
|
|
'retryable' => [],
|
|
];
|
|
|
|
$inboxes = array_values(array_unique(array_filter(
|
|
$inboxes,
|
|
fn ($inbox): bool => is_string($inbox) && trim($inbox) !== ''
|
|
)));
|
|
|
|
if ($inboxes === []) {
|
|
return $result;
|
|
}
|
|
|
|
$reachable = array_values(DeliveryHostService::filter($inboxes));
|
|
|
|
$result['skipped'] = array_values(array_diff($inboxes, $reachable));
|
|
|
|
// Validate and sign everything before the pool starts, so one bad
|
|
// row cannot take the rest of the batch down with it.
|
|
$requests = [];
|
|
|
|
foreach ($reachable as $inbox) {
|
|
$destination = Helpers::validateUrlWithReason($inbox);
|
|
|
|
if (! is_string($destination['url']) || $destination['url'] === '') {
|
|
$result['invalid'][$inbox] = $destination['reason'];
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$requests[$inbox] = [
|
|
'url' => $destination['url'],
|
|
'headers' => $this->signedHeaders($profile, $prepared, $destination['url']),
|
|
];
|
|
} catch (Throwable $e) {
|
|
$result['invalid'][$inbox] = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
if ($requests === []) {
|
|
return $result;
|
|
}
|
|
|
|
$payload = $prepared['payload'];
|
|
|
|
$responses = Http::pool(
|
|
function (Pool $pool) use ($requests, $payload) {
|
|
foreach ($requests as $inbox => $request) {
|
|
// Http::pool requests inherit nothing, so the signed
|
|
// User-Agent and Accept have to be set on each one or
|
|
// Guzzle sends its own and the signature stops matching.
|
|
$pool->as($inbox)
|
|
->timeout(self::TIMEOUT)
|
|
->connectTimeout(self::CONNECT_TIMEOUT)
|
|
->withOptions(['allow_redirects' => false])
|
|
->withHeaders($request['headers'])
|
|
->withBody($payload, self::CONTENT_TYPE)
|
|
->post($request['url']);
|
|
}
|
|
},
|
|
max(1, $concurrency ?? self::concurrency())
|
|
);
|
|
|
|
$hostFailures = [];
|
|
$hostSuccesses = [];
|
|
|
|
foreach ($requests as $inbox => $request) {
|
|
$response = $responses[$inbox] ?? null;
|
|
$domain = DeliveryHostService::domain($request['url']);
|
|
|
|
if (! $response instanceof Response) {
|
|
$reason = $response instanceof Throwable ? $response->getMessage() : 'No response';
|
|
|
|
$result['retryable'][$inbox] = $reason;
|
|
|
|
if ($domain) {
|
|
$hostFailures[$domain] = true;
|
|
}
|
|
|
|
if ($onFailure) {
|
|
$onFailure($inbox, $reason, null);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$status = $response->status();
|
|
|
|
if ($domain) {
|
|
// 5xx is a broken host. Anything else means it answered,
|
|
// even if it did not like the request.
|
|
if ($response->serverError()) {
|
|
$hostFailures[$domain] = true;
|
|
} else {
|
|
$hostSuccesses[$domain] = true;
|
|
}
|
|
}
|
|
|
|
if ($response->successful()) {
|
|
$result['delivered'][$inbox] = $status;
|
|
|
|
continue;
|
|
}
|
|
|
|
$body = mb_substr((string) $response->body(), 0, 500);
|
|
|
|
if ($onFailure) {
|
|
$onFailure($inbox, $body, $status);
|
|
}
|
|
|
|
if (self::isRetryableStatus($status)) {
|
|
$result['retryable'][$inbox] = "HTTP {$status}";
|
|
|
|
continue;
|
|
}
|
|
|
|
$result['http_failed'][$inbox] = [
|
|
'status' => $status,
|
|
'body' => $body,
|
|
];
|
|
}
|
|
|
|
foreach (array_keys($hostSuccesses) as $domain) {
|
|
unset($hostFailures[$domain]);
|
|
}
|
|
|
|
DeliveryHostService::recordFailures(array_keys($hostFailures));
|
|
DeliveryHostService::recordSuccesses(array_keys($hostSuccesses));
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Send to exactly one inbox and hand back the raw response. This is the
|
|
* debugging path: the url is used as given, with no validation and no
|
|
* host health, so it can be pointed at anything.
|
|
*
|
|
* Pass $headers to send exactly the set signedHeaders() returned earlier,
|
|
* so what was printed is what went over the wire.
|
|
*
|
|
* @param array{payload: string, digest: string, length: int, key_id: string} $prepared
|
|
* @param array<string, string>|null $headers
|
|
*/
|
|
public function send(
|
|
Profile $profile,
|
|
array $prepared,
|
|
string $url,
|
|
int $timeout = self::TIMEOUT,
|
|
?array $headers = null
|
|
): Response {
|
|
return Http::timeout(max(1, $timeout))
|
|
->connectTimeout(self::CONNECT_TIMEOUT)
|
|
->withOptions(['allow_redirects' => false])
|
|
->withHeaders($headers ?? $this->signedHeaders($profile, $prepared, $url))
|
|
->withBody($prepared['payload'], self::CONTENT_TYPE)
|
|
->post($url);
|
|
}
|
|
|
|
/**
|
|
* @param array{payload: string, digest: string, length: int, key_id: string} $prepared
|
|
* @return array<string, string>
|
|
*
|
|
* @throws RuntimeException
|
|
*/
|
|
public function signedHeaders(Profile $profile, array $prepared, string $url): array
|
|
{
|
|
$headers = HttpSignature::signRawWithDigest(
|
|
(string) $profile->private_key,
|
|
$prepared['key_id'],
|
|
$url,
|
|
$prepared['digest'],
|
|
[
|
|
'User-Agent' => 'Pixelfed ('.config('app.url').')',
|
|
'Accept' => self::ACCEPT,
|
|
]
|
|
);
|
|
|
|
if (! isset($headers['Signature'])) {
|
|
throw new RuntimeException('Signing produced no Signature header.');
|
|
}
|
|
|
|
$headers['Content-Type'] = self::CONTENT_TYPE;
|
|
$headers['Content-Length'] = (string) $prepared['length'];
|
|
|
|
return $headers;
|
|
}
|
|
|
|
public static function isRetryableStatus(int $status): bool
|
|
{
|
|
return in_array($status, self::RETRYABLE_STATUSES, true);
|
|
}
|
|
|
|
private static function concurrency(): int
|
|
{
|
|
return max(
|
|
1,
|
|
(int) config('federation.activitypub.delivery.concurrency', 10)
|
|
);
|
|
}
|
|
}
|