diff --git a/app/Console/Commands/PostStatus.php b/app/Console/Commands/PostStatus.php new file mode 100644 index 000000000..ddfce8164 --- /dev/null +++ b/app/Console/Commands/PostStatus.php @@ -0,0 +1,319 @@ + + */ + protected $longStatusCols = ['caption', 'cw_summary']; + + public function handle() + { + $id = $this->resolveId($this->argument('id')); + + if (! $id) { + $this->error('Could not extract a status id from "'.$this->argument('id').'".'); + + return 1; + } + + $status = Status::withTrashed()->find($id); + + if (! $status) { + $this->error('No status found with id '.$id.'.'); + + return 1; + } + + $this->line(str_repeat('=', 64)); + $this->info('STATUS ROW (table: statuses)'); + $this->line(str_repeat('=', 64)); + $this->dumpStatus($status); + + $this->newLine(); + $this->line(str_repeat('=', 64)); + $this->info('AUTHOR'); + $this->line(str_repeat('=', 64)); + $this->dumpAuthor($status); + + $this->newLine(); + $this->line(str_repeat('=', 64)); + $this->info('MEDIA'); + $this->line(str_repeat('=', 64)); + $this->dumpMedia($status); + + $this->newLine(); + $this->line(str_repeat('=', 64)); + $this->info('URL HEALTH CHECK'); + $this->line(str_repeat('=', 64)); + $this->urlHealth($status); + + $this->newLine(); + $this->line(str_repeat('=', 64)); + $this->info('CACHE'); + $this->line(str_repeat('=', 64)); + $this->dumpCache($status); + + return 0; + } + + /** + * Accept a numeric id or a post URL (…/p/username/ID) and return the id. + */ + protected function resolveId(string $input): ?string + { + $input = trim($input); + + if (ctype_digit($input)) { + return $input; + } + + // Extract the last numeric path segment from a URL. + if (preg_match('#/p/[^/]+/(\d+)#', $input, $m)) { + return $m[1]; + } + + if (preg_match('#(\d{6,})#', $input, $m)) { + return $m[1]; + } + + return null; + } + + protected function dumpStatus(Status $status): void + { + $rows = []; + foreach ($status->getAttributes() as $key => $value) { + $display = $this->format($value); + if (in_array($key, $this->longStatusCols, true) && is_string($value) && strlen($value) > 60) { + $display = mb_strimwidth($value, 0, 60, '…'); + } + $rows[] = [$key, $display]; + } + $this->table(['Column', 'Value'], $rows); + + $this->comment('Computed:'); + $this->line(' url(): '.$this->safe(fn () => $status->url())); + $this->line(' permalink(): '.$this->safe(fn () => $status->permalink())); + $this->line(' is remote: '.($status->uri ? 'yes ('.$status->uri.')' : 'no (local)')); + if ($status->deleted_at) { + $this->error(' ✗ Status is soft-deleted ('.$status->deleted_at.').'); + } + } + + protected function dumpAuthor(Status $status): void + { + $acct = AccountService::get($status->profile_id, true); + if (! $acct) { + $this->error('No account found for profile_id '.$status->profile_id.'.'); + + return; + } + $this->table(['Field', 'Value'], [ + ['profile_id', $status->profile_id], + ['username', $acct['username'] ?? 'null'], + ['acct', $acct['acct'] ?? 'null'], + ['url', $acct['url'] ?? 'null'], + ['local', ($acct['local'] ?? null) ? 'true' : 'false'], + ]); + } + + protected function dumpMedia(Status $status): void + { + $media = Media::withTrashed()->whereStatusId($status->id)->orderBy('order')->get(); + + if ($media->isEmpty()) { + $this->comment('No media attached to this status.'); + + return; + } + + $cloudHost = $this->cloudHost(); + + foreach ($media as $i => $m) { + $this->newLine(); + $this->comment('Media #'.($i + 1).' (id '.$m->id.', order '.$m->order.')'); + $rows = []; + $cols = [ + 'media_path', 'thumbnail_path', 'cdn_url', 'thumbnail_url', + 'optimized_url', 'remote_url', 'remote_media', 'mime', 'size', + 'version', 'replicated_at', 'original_sha256', 'processed_at', + 'deleted_at', + ]; + $attrs = $m->getAttributes(); + foreach ($cols as $c) { + if (array_key_exists($c, $attrs)) { + $rows[] = [$c, $this->format($attrs[$c])]; + } + } + $this->table(['Media Column', 'Value'], $rows); + + $this->line(' computed url(): '.$this->safe(fn () => $m->url())); + $this->line(' computed thumbnailUrl(): '.$this->safe(fn () => $m->thumbnailUrl())); + $this->line(' expected (from path): '.$this->expectedUrl($m->media_path)); + + // Per-field host comparison. + $this->compareHost(' cdn_url', $m->cdn_url, $cloudHost); + $this->compareHost(' thumbnail_url', $m->thumbnail_url, $cloudHost); + $this->compareHost(' optimized_url', $m->optimized_url, $cloudHost); + } + } + + protected function urlHealth(Status $status): void + { + $cloudHost = $this->cloudHost(); + if (! $cloudHost) { + $this->comment('Cloud storage not configured (or no cloud disk url); skipping host comparison.'); + + return; + } + + $this->line('Configured cloud host (correct base): '.$cloudHost); + $this->newLine(); + + $media = Media::withTrashed()->whereStatusId($status->id)->get(); + $stale = []; + + foreach ($media as $m) { + if ($m->remote_media || Str::startsWith((string) $m->media_path, 'http')) { + continue; + } + foreach (['cdn_url', 'thumbnail_url', 'optimized_url'] as $field) { + $val = $m->{$field}; + if (! $val) { + continue; + } + $host = parse_url($val, PHP_URL_HOST); + if ($host && $cloudHost && strcasecmp($host, $cloudHost) !== 0) { + $stale[] = 'media '.$m->id.' '.$field.' points at '.$host.' (expected '.$cloudHost.')'; + } + } + } + + if ($stale) { + $this->error('STALE MEDIA URLS DETECTED:'); + foreach ($stale as $s) { + $this->line(' ✗ '.$s); + } + $this->newLine(); + $this->comment('Fix with: php artisan admin:MigrateLocalMediaURL '.$status->id); + $this->comment('(or --all to scan every local media row)'); + } else { + $this->info('All local media URLs point at the configured cloud host. ✓'); + } + } + + protected function dumpCache(Status $status): void + { + $cached = MediaService::get($status->id); + if (empty($cached)) { + $this->comment('No cached media_attachments entry (MediaService).'); + + return; + } + $this->comment('Cached media_attachments (MediaService, 6h TTL) — served to clients:'); + foreach ($cached as $i => $item) { + $this->line(' ['.$i.'] url: '.($item['url'] ?? 'null')); + $this->line(' ['.$i.'] preview_url: '.($item['preview_url'] ?? 'null')); + } + $this->newLine(); + $this->comment('If these still show a stale host after a DB fix, run: php artisan cache:clear'); + } + + protected function compareHost(string $label, ?string $url, ?string $cloudHost): void + { + if (! $url) { + return; + } + $host = parse_url($url, PHP_URL_HOST); + if (! $host || ! $cloudHost) { + return; + } + if (strcasecmp($host, $cloudHost) !== 0) { + $this->line(''.$label.' host: '.$host.' ✗ (expected '.$cloudHost.')'); + } else { + $this->line(''.$label.' host: '.$host.' ✓'); + } + } + + protected function expectedUrl(?string $mediaPath): string + { + if (! $mediaPath || Str::startsWith($mediaPath, 'http')) { + return $mediaPath ?? 'null'; + } + + try { + return (string) Storage::disk(config('filesystems.cloud'))->url($mediaPath); + } catch (\Throwable $e) { + return '(cloud disk not resolvable in this environment)'; + } + } + + protected function cloudHost(): ?string + { + try { + if (! (bool) config_cache('pixelfed.cloud_storage')) { + // Still try to read the configured cloud disk host for reference. + } + $url = Storage::disk(config('filesystems.cloud'))->url('probe'); + $host = parse_url($url, PHP_URL_HOST); + + return $host ?: null; + } catch (\Throwable $e) { + return null; + } + } + + protected function safe(callable $fn): string + { + try { + return (string) ($fn() ?? 'null'); + } catch (\Throwable $e) { + return 'error: '.$e->getMessage(); + } + } + + protected function format($value): string + { + if ($value === null) { + return 'null'; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if ($value === '') { + return '(empty string)'; + } + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + return (string) $value; + } +}