Merge pull request #6992 from pixelfed/feat/status-inspector-commands

Feat/status inspector commands
pull/6991/head
Shlee 3 weeks ago committed by GitHub
commit 8dafe75567
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,207 @@
<?php
namespace App\Console\Commands\Status;
use App\Models\Avatar;
use App\Services\AccountService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class StatusAvatar extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:avatar {id : Avatar id, or a profile_id}
{--check : Perform a live HEAD request against the avatar remote_url}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show all metadata for an avatar: DB columns, storage state, owning profile, and optional live URL check';
public function handle(): int
{
$avatar = $this->resolve($this->argument('id'));
if (! $avatar) {
$this->error('No avatar found for "'.$this->argument('id').'" (tried avatar id then profile_id).');
return self::FAILURE;
}
$this->section('AVATAR ROW (table: avatars)');
$this->dumpRow($avatar);
if ($avatar->deleted_at) {
$this->error(' ✗ Avatar is soft-deleted ('.$avatar->deleted_at.').');
}
$this->newLine();
$this->section('STORAGE STATE');
$isRemote = (bool) $avatar->is_remote;
$this->table(['Field', 'Value'], [
['is_remote', $this->b($avatar->is_remote)],
['media_path', $avatar->media_path ?? 'null'],
['cdn_url', $avatar->cdn_url ?? 'null'],
['remote_url', $avatar->remote_url ?? 'null'],
['size', $avatar->size ?? 'null'],
['last_fetched_at', optional($avatar->last_fetched_at)->toDateTimeString() ?? 'null'],
]);
if ($avatar->media_path && ! Str::startsWith($avatar->media_path, 'http')) {
$exists = $this->localExists($avatar->media_path);
$line = ' stored file ('.$avatar->media_path.'): '.($exists ? 'present ✓' : 'MISSING ✗');
$exists ? $this->info($line) : $this->error($line);
}
$this->newLine();
$this->section('OWNER');
$this->dumpOwner($avatar);
if ($this->option('check')) {
$this->newLine();
$this->section('LIVE URL CHECK (remote_url)');
$this->urlCheck($avatar);
}
return self::SUCCESS;
}
protected function resolve(string $input): ?Avatar
{
if (! ctype_digit($input)) {
return null;
}
// Prefer an avatar with this id; fall back to the profile's avatar.
return Avatar::withTrashed()->find($input)
?? Avatar::withTrashed()->whereProfileId($input)->first();
}
protected function dumpRow(Avatar $avatar): void
{
$rows = [];
foreach ($avatar->getAttributes() as $key => $value) {
$rows[] = [$key, $this->format($value)];
}
$this->table(['Column', 'Value'], $rows);
}
protected function dumpOwner(Avatar $avatar): void
{
if (! $avatar->profile_id) {
$this->comment('No profile_id on this avatar.');
return;
}
$acct = AccountService::get($avatar->profile_id, true);
if (! $acct) {
$this->error('No account found for profile_id '.$avatar->profile_id.'.');
return;
}
$this->table(['Field', 'Value'], [
['profile_id', $avatar->profile_id],
['username', $acct['username'] ?? 'null'],
['acct', $acct['acct'] ?? 'null'],
['url', $acct['url'] ?? 'null'],
['local', ($acct['local'] ?? null) ? 'true' : 'false'],
]);
}
protected function urlCheck(Avatar $avatar): void
{
$url = $avatar->remote_url;
if (! $url || ! Str::startsWith($url, 'http')) {
$this->comment('No remote_url to check.');
return;
}
$this->line('HEAD '.$url);
try {
$res = Http::withOptions(['allow_redirects' => true])
->timeout(10)
->head($url);
$line = ' HTTP '.$res->status().' ('.($res->header('content-type') ?: 'no content-type').', '.($res->header('content-length') ?: '?').' bytes)';
if ($res->successful()) {
$this->info($line.' ✓');
} else {
$this->error($line.' ✗');
if (in_array($res->status(), [404, 410])) {
$this->line(' → Origin no longer serves this avatar (deleted upstream).');
}
}
} catch (\Throwable $e) {
$this->error(' request failed: '.$e->getMessage());
}
}
protected function localExists(?string $mediaPath): bool
{
if (! $mediaPath) {
return false;
}
try {
if ((bool) config_cache('pixelfed.cloud_storage')) {
if (Storage::disk(config('filesystems.cloud'))->exists($mediaPath)) {
return true;
}
}
return Storage::disk('local')->exists('public/'.$mediaPath)
|| Storage::disk('local')->exists($mediaPath);
} catch (\Throwable $e) {
return false;
}
}
protected function section(string $title): void
{
$this->line(str_repeat('=', 64));
$this->info($title);
$this->line(str_repeat('=', 64));
}
protected function b($value): string
{
if ($value === null) {
return 'null';
}
return $value ? 'true' : 'false';
}
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;
}
}

@ -0,0 +1,196 @@
<?php
namespace App\Console\Commands\Status;
use App\Models\CustomEmoji;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class StatusEmoji extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:emoji {id : Custom emoji id, shortcode (:blobcat:), or media filename (1234.png)}
{--check : Perform a live HEAD request against the emoji image_remote_url}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show all metadata for a custom emoji: DB columns, origin, and local storage state';
public function handle(): int
{
$emoji = $this->resolve($this->argument('id'));
if (! $emoji) {
$this->error('No custom emoji found for "'.$this->argument('id').'" (tried id, shortcode, then media filename).');
return self::FAILURE;
}
$this->section('CUSTOM EMOJI ROW (table: custom_emoji)');
$this->dumpRow($emoji);
$this->newLine();
$this->section('ORIGIN');
$isRemote = ! empty($emoji->image_remote_url) || ! empty($emoji->domain);
$this->table(['Field', 'Value'], [
['origin', $isRemote ? 'remote' : 'local'],
['domain', $emoji->domain ?? 'null'],
['uri', $emoji->uri ?? 'null'],
['image_remote_url', $emoji->image_remote_url ?? 'null'],
['disabled', $this->b($emoji->disabled)],
]);
$this->newLine();
$this->section('LOCAL STORAGE STATE');
if ($emoji->media_path) {
$exists = $this->localExists($emoji->media_path);
$this->table(['Field', 'Value'], [
['media_path', $emoji->media_path],
['stored file', $exists ? 'present ✓' : 'MISSING ✗'],
['url', Str::startsWith($emoji->media_path, 'http') ? $emoji->media_path : url('/storage/'.$emoji->media_path)],
]);
if (! $exists) {
$this->error(' ✗ Local file is missing.');
if (! empty($emoji->image_remote_url)) {
$this->comment(' Fix with: php artisan admin:resyncemoji "'.basename($emoji->media_path).'"');
}
}
} else {
$this->comment('No media_path set for this emoji.');
}
if ($this->option('check')) {
$this->newLine();
$this->section('LIVE URL CHECK (image_remote_url)');
$this->urlCheck($emoji);
}
return self::SUCCESS;
}
protected function resolve(string $input): ?CustomEmoji
{
$input = trim($input);
if (ctype_digit($input)) {
return CustomEmoji::find($input);
}
// Shortcode form, with or without surrounding colons.
$shortcode = ':'.trim($input, ':').':';
if ($emoji = CustomEmoji::whereShortcode($shortcode)->first()) {
return $emoji;
}
// Media filename form (e.g. 1234.png -> emoji/1234.png).
$filename = basename($input);
return CustomEmoji::whereMediaPath('emoji/'.$filename)
->orWhere('media_path', $filename)
->first();
}
protected function dumpRow(CustomEmoji $emoji): void
{
$rows = [];
foreach ($emoji->getAttributes() as $key => $value) {
$rows[] = [$key, $this->format($value)];
}
$this->table(['Column', 'Value'], $rows);
}
protected function urlCheck(CustomEmoji $emoji): void
{
$url = $emoji->image_remote_url;
if (! $url || ! Str::startsWith($url, 'http')) {
$this->comment('No image_remote_url to check (local emoji).');
return;
}
$this->line('HEAD '.$url);
try {
$res = Http::withOptions(['allow_redirects' => true])
->timeout(10)
->head($url);
$line = ' HTTP '.$res->status().' ('.($res->header('content-type') ?: 'no content-type').', '.($res->header('content-length') ?: '?').' bytes)';
if ($res->successful()) {
$this->info($line.' ✓');
} else {
$this->error($line.' ✗');
if (in_array($res->status(), [404, 410])) {
$this->line(' → Origin no longer serves this emoji (deleted upstream).');
}
}
} catch (\Throwable $e) {
$this->error(' request failed: '.$e->getMessage());
}
}
protected function localExists(?string $mediaPath): bool
{
if (! $mediaPath) {
return false;
}
try {
if ((bool) config_cache('pixelfed.cloud_storage')) {
if (Storage::disk(config('filesystems.cloud'))->exists($mediaPath)) {
return true;
}
}
return Storage::disk('local')->exists('public/'.$mediaPath)
|| Storage::disk('local')->exists($mediaPath);
} catch (\Throwable $e) {
return false;
}
}
protected function section(string $title): void
{
$this->line(str_repeat('=', 64));
$this->info($title);
$this->line(str_repeat('=', 64));
}
protected function b($value): string
{
if ($value === null) {
return 'null';
}
return $value ? 'true' : 'false';
}
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;
}
}

@ -0,0 +1,148 @@
<?php
namespace App\Console\Commands\Status;
use App\Models\Instance;
use App\Models\Profile;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class StatusInstance extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:instance {id : Instance id, domain (example.com), or a URL/webfinger containing a domain}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show all metadata for a federated instance: DB columns, moderation state, sync timestamps, and related counts';
public function handle(): int
{
$instance = $this->resolve($this->argument('id'));
if (! $instance) {
$this->error('No instance found for "'.$this->argument('id').'".');
return self::FAILURE;
}
$this->section('INSTANCE ROW (table: instances)');
$this->dumpRow($instance);
$this->newLine();
$this->section('MODERATION STATE');
$this->table(['Field', 'Value'], [
['banned', $this->b($instance->banned)],
['unlisted', $this->b($instance->unlisted)],
['auto_cw', $this->b($instance->auto_cw)],
['limit_reason', $instance->limit_reason ?? 'null'],
['active_deliver', $this->b($instance->active_deliver)],
['valid_nodeinfo', $this->b($instance->valid_nodeinfo)],
]);
$this->newLine();
$this->section('RELATED COUNTS (local records for this domain)');
$this->table(['Relation', 'Count'], [
['profiles', Profile::whereDomain($instance->domain)->count()],
]);
return self::SUCCESS;
}
protected function resolve(string $input): ?Instance
{
$input = trim($input);
if (ctype_digit($input)) {
return Instance::find($input);
}
$domain = $this->extractDomain($input);
if (! $domain) {
return null;
}
return Instance::whereDomain($domain)->first();
}
/**
* Extract a bare domain from a raw domain, URL, or @user@domain webfinger.
*/
protected function extractDomain(string $input): ?string
{
$input = ltrim($input, '@');
// user@domain or @user@domain
if (str_contains($input, '@')) {
$input = last(explode('@', $input));
}
// URL form
if (Str::startsWith($input, ['http://', 'https://'])) {
$host = parse_url($input, PHP_URL_HOST);
return $host ?: null;
}
// Strip any path if a bare host/path slipped through.
$input = explode('/', $input)[0];
return $input !== '' ? strtolower($input) : null;
}
protected function dumpRow(Instance $instance): void
{
$rows = [];
foreach ($instance->getAttributes() as $key => $value) {
$rows[] = [$key, $this->format($value, $key)];
}
$this->table(['Column', 'Value'], $rows);
}
protected function section(string $title): void
{
$this->line(str_repeat('=', 64));
$this->info($title);
$this->line(str_repeat('=', 64));
}
protected function b($value): string
{
if ($value === null) {
return 'null';
}
return $value ? 'true' : 'false';
}
protected function format($value, ?string $key = null): 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');
}
$value = (string) $value;
if ($key === 'notes' && strlen($value) > 120) {
return mb_strimwidth($value, 0, 120, '…');
}
return $value;
}
}

@ -10,14 +10,14 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class StatusPost extends Command
class StatusStatuses extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:post {id : Status id, or a post URL like https://host/p/username/ID}';
protected $signature = 'status:statuses {id : Status id, or a post URL like https://host/p/username/ID}';
/**
* The console command description.
Loading…
Cancel
Save