promptForDeletedUser(); if (! $user) { $this->error('No deleted user selected.'); return self::FAILURE; } $profile = Profile::withTrashed()->find($user->profile_id); if (! $profile) { $this->error('Profile not found for selected user.'); return self::FAILURE; } $this->showUserSummary($user); $confirmed = confirm( label: 'Do you want to federate this account deletion?', default: false, yes: 'Proceed', no: 'Cancel', hint: 'This action is irreversible' ); if (! $confirmed) { $this->warn('Aborting...'); return self::FAILURE; } $activity = $this->buildDeleteActivity($profile); try { $payload = json_encode( $activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); $digest = base64_encode(hash('sha256', $payload, true)); } catch (JsonException $e) { $this->error("Failed to encode delete payload: {$e->getMessage()}"); return self::FAILURE; } $query = $this->sharedInboxQuery(); $chunkSize = max(1, (int) $this->option('chunk')); $attempts = max(1, (int) $this->option('attempts')); $concurrency = max(1, (int) $this->option('concurrency')); $totalTargets = (clone $query) ->toBase() ->distinct() ->count('shared_inbox'); $privateKey = $profile->private_key; if (empty($privateKey)) { $this->error('Profile private key has been wiped — cannot sign deletion activity.'); return self::FAILURE; } $keyId = $profile->keyId(); if (empty($keyId)) { $this->error('Profile key id has been wiped — cannot sign deletion activity.'); return self::FAILURE; } try { $testHeaders = HttpSignature::signRawWithDigest( $privateKey, $keyId, config('app.url').'/inbox', $digest, ); if (empty($testHeaders) || ! isset($testHeaders['Signature'])) { $this->error('Instance actor signing failed — run php artisan instance:actor'); return self::FAILURE; } } catch (\Exception $e) { $this->error("Instance actor error: {$e->getMessage()}"); return self::FAILURE; } if ($this->option('dry-run')) { $this->line('Dry run only.'); $this->line("Audience size: {$totalTargets}"); $this->line("Chunk size: {$chunkSize}"); $this->line("Attempts: {$attempts}"); $this->line("Concurrency: {$concurrency}"); $this->line("Digest: {$digest}"); $this->line("Key ID: {$keyId}"); $this->line($payload); return self::SUCCESS; } if ($target = $this->option('target')) { return $this->sendDebug($target, $payload, $digest, $privateKey, $keyId); } if ($totalTargets === 0) { $this->warn('No candidate shared inboxes found.'); return self::SUCCESS; } $client = $this->makeHttpClient(); $results = [ 'delivered' => 0, 'http_failed' => [], 'transport_failed' => [], 'retry_exhausted' => [], ]; $bar = $this->output->createProgressBar($totalTargets); $bar->start(); $query ->orderBy('shared_inbox') ->chunk($chunkSize, function ($instances) use ( $client, $payload, $privateKey, $keyId, $digest, $concurrency, $attempts, &$results, $bar ) { $urls = $instances ->pluck('shared_inbox') ->filter() ->unique() ->values(); if ($urls->isEmpty()) { return; } $pending = $urls; $terminalDelivered = 0; $terminalHttpFailed = []; $terminalTransportFailed = []; for ($attempt = 1; $attempt <= $attempts && $pending->isNotEmpty(); $attempt++) { $batch = $this->sendBatch( client: $client, privateKey: $privateKey, keyId: $keyId, digest: $digest, urls: $pending, payload: $payload, concurrency: $concurrency, verboseErrors: $this->option('verbose-errors') ); $terminalDelivered += count($batch['delivered']); $terminalHttpFailed += $batch['http_failed']; $pending = collect($batch['retryable']->keys())->values(); if ($attempt === $attempts && $pending->isNotEmpty()) { foreach ($pending as $url) { $terminalTransportFailed[$url] = $batch['retryable'][$url] ?? 'retry exhausted'; } } if ($attempt < $attempts && $pending->isNotEmpty()) { usleep(100_000); } } $results['delivered'] += $terminalDelivered; $results['http_failed'] += $terminalHttpFailed; $results['transport_failed'] += $terminalTransportFailed; $results['retry_exhausted'] += $terminalTransportFailed; $resolved = $terminalDelivered + count($terminalHttpFailed) + count($terminalTransportFailed); $bar->advance($resolved); }); $bar->finish(); $this->newLine(2); $this->info("Delivered: {$results['delivered']}"); $this->warn('HTTP failures: '.count($results['http_failed'])); $this->warn('Transport/retry-exhausted failures: '.count($results['transport_failed'])); return self::SUCCESS; } protected function promptForDeletedUser(): ?User { $id = search( label: 'Search for the account to delete by username', placeholder: 'john.appleseed', options: fn (string $value) => strlen($value) > 0 ? User::withTrashed() ->whereStatus('deleted') ->where('username', 'like', "%{$value}%") ->pluck('username', 'id') ->all() : [], ); return User::withTrashed()->find($id); } protected function showUserSummary(User $user): void { table( ['Username', 'Name', 'Email', 'Created'], [[ $user->username, $user->name, $user->email, (string) $user->created_at, ]] ); } protected function buildDeleteActivity(Profile $profile): array { $actorId = $profile->permalink(); return [ '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => $actorId.'#delete', 'type' => 'Delete', 'actor' => $actorId, 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'object' => $actorId, ]; } protected function sharedInboxQuery() { return Instance::query() ->whereNotNull('shared_inbox') ->whereNotNull('nodeinfo_last_fetched') ->where('nodeinfo_last_fetched', '>', now()->subDays(30)) ->select('shared_inbox') ->distinct(); } protected function makeHttpClient(): Client { return new Client([ 'timeout' => 15.0, 'connect_timeout' => 5.0, 'http_errors' => false, 'allow_redirects' => false, 'curl' => [ CURLOPT_TCP_FASTOPEN => true, CURLOPT_TCP_NODELAY => true, CURLOPT_FORBID_REUSE => false, CURLOPT_FRESH_CONNECT => false, ], ]); } protected function sendBatch( Client $client, string $privateKey, string $keyId, string $digest, Collection $urls, string $payload, int $concurrency, bool $verboseErrors = false ): array { $delivered = []; $httpFailed = []; $retryable = collect(); $requests = function () use ($client, $urls, $privateKey, $keyId, $digest, $payload) { foreach ($urls as $url) { $headers = HttpSignature::signRawWithDigest($privateKey, $keyId, $url, $digest); $headers['Content-Type'] = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; yield function () use ($client, $url, $headers, $payload) { return $client->postAsync($url, [ 'headers' => $headers, 'body' => $payload, ]); }; } }; $pool = new Pool($client, $requests(), [ 'concurrency' => $concurrency, 'fulfilled' => function ($response, $index) use ($urls, &$delivered, &$httpFailed, &$retryable, $verboseErrors) { $url = $urls[$index]; $status = $response->getStatusCode(); if ($status >= 200 && $status < 300) { $delivered[$url] = $status; return; } if ($verboseErrors) { $body = mb_substr((string) $response->getBody(), 0, 200); $this->warn(" [{$status}] {$url} — {$body}"); } if ($this->isRetryableStatus($status)) { $retryable->put($url, $status); return; } $httpFailed[$url] = ['status' => $status, 'body' => null]; }, 'rejected' => function ($reason, $index) use ($urls, &$retryable, $verboseErrors) { $url = $urls[$index]; $message = $reason instanceof \Throwable ? $reason->getMessage() : (string) $reason; if ($verboseErrors) { $this->error(" [TRANSPORT] {$url} — {$message}"); } $retryable->put($url, $message); }, ]); $pool->promise()->wait(); return [ 'delivered' => $delivered, 'http_failed' => $httpFailed, 'retryable' => $retryable, ]; } protected function sendDebug(string $url, string $payload, string $digest, string $privateKey, string $keyId): int { $headers = HttpSignature::signRawWithDigest($privateKey, $keyId, $url, $digest); $headers['Content-Type'] = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'; $this->info('Target: '.$url); $this->newLine(); $this->info('Request headers:'); foreach ($headers as $key => $value) { $this->line(" {$key}: {$value}"); } $this->newLine(); $this->info('Payload:'); $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, ]); $status = $response->getStatusCode(); $body = (string) $response->getBody(); $this->info("Response status: {$status}"); $this->newLine(); $this->info('Response headers:'); foreach ($response->getHeaders() as $name => $values) { $this->line(" {$name}: ".implode(', ', $values)); } $this->newLine(); $this->info('Response body:'); $this->line($body ?: '(empty)'); return $status >= 200 && $status < 300 ? self::SUCCESS : self::FAILURE; } catch (\Throwable $e) { $this->error("Transport error: {$e->getMessage()}"); return self::FAILURE; } } protected function isRetryableStatus(int $status): bool { return in_array($status, [408, 425, 429, 500, 502, 503, 504], true); } }