Merge pull request #6920 from pixelfed/feature/user-status-command

new admin commands
pull/6921/head
Shlee 4 weeks ago committed by GitHub
commit 350b8fdca5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,359 @@
<?php
namespace App\Console\Commands;
use App\Models\Instance;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class ProfileStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'profile:status {id : Profile id, username, user@domain, @user@domain, webfinger, or remote_url}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show detailed debug/metadata for a LOCAL or REMOTE profile (federation-aware)';
/**
* Columns to redact / trim.
*
* @var array<int, string>
*/
protected $redact = ['private_key', 'public_key'];
protected $longText = ['bio', 'note', 'private_key', 'public_key'];
public function handle()
{
$id = $this->argument('id');
$profile = $this->resolveProfile($id);
if (! $profile) {
$this->error('No profile found for "'.$id.'".');
$this->suggestSimilar($id);
return 1;
}
$isLocal = empty($profile->domain);
$this->line(str_repeat('=', 60));
$this->info(($isLocal ? 'LOCAL' : 'REMOTE').' PROFILE ROW (table: profiles)');
$this->line(str_repeat('=', 60));
$this->dumpColumns($profile);
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info('DERIVED METADATA');
$this->line(str_repeat('=', 60));
$this->dumpMetadata($profile, $isLocal);
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info($isLocal ? 'LINKED LOCAL USER' : 'REMOTE INSTANCE');
$this->line(str_repeat('=', 60));
if ($isLocal) {
$this->dumpLocalUser($profile);
} else {
$this->dumpInstance($profile);
}
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info('HEALTH CHECKS');
$this->line(str_repeat('=', 60));
$this->healthChecks($profile, $isLocal);
return 0;
}
/**
* Resolve a profile from many identifier forms.
*/
protected function resolveProfile(string $id): ?Profile
{
// Numeric id
if (ctype_digit($id)) {
$p = Profile::withTrashed()->find($id);
if ($p) {
return $p;
}
}
// remote_url (full URL)
if (Str::startsWith($id, ['http://', 'https://'])) {
$p = Profile::withTrashed()->where('remote_url', $id)->first();
if ($p) {
return $p;
}
}
$needle = ltrim($id, '@');
// user@domain form -> split into username + domain
if (str_contains($needle, '@')) {
[$uname, $domain] = explode('@', $needle, 2);
$p = Profile::withTrashed()
->where('domain', $domain)
->where(function ($q) use ($uname, $needle) {
$q->where('username', $uname)
->orWhere('username', $needle)
->orWhere('username', '@'.$needle)
->orWhere('webfinger', $needle)
->orWhere('webfinger', '@'.$needle);
})
->first();
if ($p) {
return $p;
}
}
// webfinger exact
$p = Profile::withTrashed()
->where('webfinger', $needle)
->orWhere('webfinger', '@'.$needle)
->first();
if ($p) {
return $p;
}
// Plain username. Prefer a LOCAL match (domain null), else any.
$local = Profile::withTrashed()->whereNull('domain')->where('username', $needle)->first();
if ($local) {
return $local;
}
return Profile::withTrashed()->where('username', $needle)->first();
}
protected function dumpColumns(Profile $profile): void
{
$rows = [];
foreach ($profile->getAttributes() as $key => $value) {
if (in_array($key, $this->redact, true)) {
$rows[] = [$key, empty($value) ? '(empty!)' : 'present ('.strlen((string) $value).' chars)'];
continue;
}
$display = $this->format($value);
if (in_array($key, $this->longText, true) && is_string($value) && strlen($value) > 60) {
$display = mb_strimwidth($value, 0, 60, '…');
}
$rows[] = [$key, $display];
}
$this->table(['Profile Column', 'Value'], $rows);
}
protected function dumpMetadata(Profile $profile, bool $isLocal): void
{
$meta = [];
$meta[] = ['type', $isLocal ? 'LOCAL' : 'REMOTE'];
$meta[] = ['domain', $isLocal ? '(local)' : $profile->domain];
$meta[] = ['profile url', $this->safeCall(fn () => $profile->url())];
$meta[] = ['permalink', $this->safeCall(fn () => $profile->permalink())];
$meta[] = ['emailUrl (handle)', $this->safeCall(fn () => $profile->emailUrl())];
if (! $isLocal) {
$meta[] = ['remote_url', $this->format($this->attr($profile, 'remote_url'))];
$meta[] = ['webfinger', $this->format($this->attr($profile, 'webfinger'))];
$meta[] = ['key_id', $this->format($this->attr($profile, 'key_id'))];
$meta[] = ['inbox_url', $this->format($this->attr($profile, 'inbox_url'))];
$meta[] = ['outbox_url', $this->format($this->attr($profile, 'outbox_url'))];
$meta[] = ['shared_inbox', $this->format($this->attr($profile, 'sharedInbox'))];
$meta[] = ['last_fetched_at', $this->format($profile->last_fetched_at)];
}
$meta[] = ['followers (live)', (string) $this->safeCount(fn () => $profile->followers()->count())];
$meta[] = ['following (live)', (string) $this->safeCount(fn () => $profile->following()->count())];
$meta[] = ['followers_count (cached)', $this->format($this->attr($profile, 'followers_count'))];
$meta[] = ['following_count (cached)', $this->format($this->attr($profile, 'following_count'))];
$meta[] = ['statuses (live)', (string) $this->safeCount(fn () => $profile->statuses()->count())];
$meta[] = ['status_count (cached)', $this->format($profile->status_count)];
$meta[] = ['last_status_at', $this->format($profile->last_status_at)];
$meta[] = ['avatar row', $this->avatarInfo($profile)];
$this->table(['Metadata', 'Value'], $meta);
}
protected function dumpLocalUser(Profile $profile): void
{
if (! $profile->user_id) {
$this->error('This local profile has NO user_id — orphaned profile (no login account).');
return;
}
$user = User::withTrashed()->find($profile->user_id);
if (! $user) {
$this->error('profile.user_id='.$profile->user_id.' points to a MISSING user row (orphaned profile).');
return;
}
$rows = [
['user id', $this->format($user->id)],
['username', $this->format($user->username)],
['email', $this->format($user->email)],
['is_admin', $this->format($user->getAttributes()['is_admin'] ?? null)],
['status', $this->format($user->status)],
['email_verified_at', $this->format($user->email_verified_at)],
['2fa_enabled', $this->format($user->getAttributes()['2fa_enabled'] ?? null)],
['deleted_at', $this->format($user->deleted_at)],
['last_active_at', $this->format($user->last_active_at)],
];
$this->table(['User Field', 'Value'], $rows);
$this->comment('Tip: run `user:status '.$user->username.'` for full auth diagnostics.');
}
protected function dumpInstance(Profile $profile): void
{
$instance = Instance::whereDomain($profile->domain)->first();
if (! $instance) {
$this->comment('No Instance row recorded for domain "'.$profile->domain.'".');
return;
}
$rows = [];
foreach ($instance->getAttributes() as $key => $value) {
$rows[] = [$key, $this->format($value)];
}
$this->table(['Instance Column', 'Value'], $rows);
}
protected function healthChecks(Profile $profile, bool $isLocal): void
{
$problems = [];
$ok = [];
if ($profile->deleted_at) {
$problems[] = 'Profile is soft-deleted (deleted_at='.$profile->deleted_at.').';
} else {
$ok[] = 'Not soft-deleted.';
}
if ($isLocal) {
if (! $profile->user_id) {
$problems[] = 'Local profile has no user_id (orphaned — no login account).';
}
if (empty($this->attr($profile, 'private_key'))) {
$problems[] = 'Missing private_key — ActivityPub signing/federation will fail.';
}
if (empty($this->attr($profile, 'public_key'))) {
$problems[] = 'Missing public_key — remote servers cannot verify this actor.';
}
} else {
if ($profile->user_id) {
$problems[] = 'Remote profile unexpectedly has a user_id ('.$profile->user_id.').';
}
if (empty($this->attr($profile, 'remote_url'))) {
$problems[] = 'Remote profile missing remote_url (ActivityPub id).';
}
if (empty($this->attr($profile, 'inbox_url'))) {
$problems[] = 'Remote profile missing inbox_url — cannot deliver activities.';
}
if (empty($this->attr($profile, 'public_key'))) {
$problems[] = 'Remote profile missing public_key — cannot verify its signatures.';
}
}
$cached = (int) $this->attr($profile, 'followers_count');
$live = (int) $this->safeCount(fn () => $profile->followers()->count());
if ($cached !== $live) {
$problems[] = 'followers_count ('.$cached.') out of sync with live count ('.$live.').';
}
if ($problems) {
$this->error('ISSUES:');
foreach ($problems as $p) {
$this->line(' ✗ '.$p);
}
} else {
$this->info('No profile issues detected.');
}
foreach ($ok as $o) {
$this->line(' ✓ '.$o);
}
}
protected function suggestSimilar(string $id): void
{
$needle = ltrim($id, '@');
$needle = str_contains($needle, '@') ? explode('@', $needle)[0] : $needle;
$similar = Profile::withTrashed()
->where('username', 'like', '%'.$needle.'%')
->limit(10)
->get(['id', 'username', 'domain']);
if ($similar->isEmpty()) {
return;
}
$this->newLine();
$this->comment('Similar profiles:');
$rows = [];
foreach ($similar as $p) {
$rows[] = [$p->id, $p->username, $p->domain ?? '(local)'];
}
$this->table(['id', 'username', 'domain'], $rows);
}
protected function avatarInfo(Profile $profile): string
{
try {
$avatar = $profile->avatar;
return $avatar ? 'present (media_path='.($avatar->media_path ?? 'null').')' : 'MISSING';
} catch (\Throwable $e) {
return 'error';
}
}
protected function attr($model, string $key)
{
return $model->getAttributes()[$key] ?? null;
}
protected function safeCall(callable $fn): string
{
try {
return (string) ($fn() ?? 'null');
} catch (\Throwable $e) {
return 'error';
}
}
protected function safeCount(callable $fn): string
{
try {
return (string) $fn();
} catch (\Throwable $e) {
return 'error';
}
}
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;
}
}

@ -221,25 +221,107 @@ class UserStatus extends Command
return;
}
$rows = [
['profile id', $this->format($profile->id)],
['user_id', $this->format($profile->user_id)],
['username', $this->format($profile->username)],
['name', $this->format($profile->name)],
['domain', $this->format($profile->domain).($profile->domain ? '' : ' (local)')],
['is_private', $this->format($profile->is_private)],
['status', $this->format($profile->status ?? null)],
['deleted_at', $this->format($profile->deleted_at)],
['status_count', $this->format($profile->status_count)],
['created_at', $this->format($profile->created_at)],
];
$this->table(['Field', 'Value'], $rows);
// Dump every column on the profiles row, redacting crypto keys and
// trimming long text so the table stays readable.
$keysToRedact = ['private_key', 'public_key'];
$longText = ['bio', 'note', 'private_key', 'public_key'];
$rows = [];
foreach ($profile->getAttributes() as $key => $value) {
if (in_array($key, $keysToRedact, true)) {
$rows[] = [$key, empty($value) ? '(empty!)' : 'present ('.strlen((string) $value).' chars)'];
continue;
}
$display = $this->format($value);
if (in_array($key, $longText, true) && is_string($value) && strlen($value) > 60) {
$display = mb_strimwidth($value, 0, 60, '…');
}
$rows[] = [$key, $display];
}
$this->table(['Profile Column', 'Value'], $rows);
// Derived / computed metadata not stored directly on the row.
$this->newLine();
$this->comment('Derived metadata:');
$meta = [];
$isLocal = empty($profile->domain);
$meta[] = ['type', $isLocal ? 'LOCAL' : 'REMOTE ('.$profile->domain.')'];
$meta[] = ['profile url', $this->safeCall(fn () => $profile->url())];
$meta[] = ['permalink', $this->safeCall(fn () => $profile->permalink())];
if (! $isLocal) {
$meta[] = ['remote_url', $this->format($profile->remote_url ?? null)];
$meta[] = ['inbox_url', $this->format($this->attrOf($profile, 'inbox_url'))];
$meta[] = ['outbox_url', $this->format($this->attrOf($profile, 'outbox_url'))];
$meta[] = ['shared_inbox', $this->format($this->attrOf($profile, 'sharedInbox'))];
$meta[] = ['last_fetched_at', $this->format($profile->last_fetched_at)];
}
$meta[] = ['followers (live count)', (string) $this->safeCount(fn () => $profile->followers()->count())];
$meta[] = ['following (live count)', (string) $this->safeCount(fn () => $profile->following()->count())];
$meta[] = ['followers_count (cached)', $this->format($this->attrOf($profile, 'followers_count'))];
$meta[] = ['following_count (cached)', $this->format($this->attrOf($profile, 'following_count'))];
$meta[] = ['statuses (live count)', (string) $this->safeCount(fn () => $profile->statuses()->count())];
$meta[] = ['status_count (cached)', $this->format($profile->status_count)];
$meta[] = ['last_status_at', $this->format($profile->last_status_at)];
$meta[] = ['avatar row', $this->profileAvatar($profile)];
$this->table(['Metadata', 'Value'], $meta);
// Consistency / health checks.
$problems = [];
if ($profile->deleted_at) {
$this->error(' ✗ Profile is soft-deleted.');
$problems[] = 'Profile is soft-deleted (deleted_at='.$profile->deleted_at.').';
}
if ($profile->user_id && $profile->user_id != $user->id) {
$this->error(' ✗ MISMATCH: profile.user_id ('.$profile->user_id.') != user.id ('.$user->id.').');
$problems[] = 'MISMATCH: profile.user_id ('.$profile->user_id.') != user.id ('.$user->id.').';
}
if ($user->profile_id && (string) $user->profile_id !== (string) $profile->id) {
$problems[] = 'MISMATCH: user.profile_id ('.$user->profile_id.') != profile.id ('.$profile->id.').';
}
if ($isLocal && empty($this->attrOf($profile, 'private_key'))) {
$problems[] = 'Local profile is MISSING its private_key — ActivityPub signing/federation will fail.';
}
if ($isLocal && empty($this->attrOf($profile, 'public_key'))) {
$problems[] = 'Local profile is MISSING its public_key — remote servers cannot verify this actor.';
}
$cachedFollowers = (int) $this->attrOf($profile, 'followers_count');
$liveFollowers = (int) $this->safeCount(fn () => $profile->followers()->count());
if ($cachedFollowers !== $liveFollowers) {
$problems[] = 'followers_count ('.$cachedFollowers.') is out of sync with live count ('.$liveFollowers.').';
}
if ($problems) {
$this->newLine();
$this->error('PROFILE ISSUES:');
foreach ($problems as $p) {
$this->line(' ✗ '.$p);
}
}
}
protected function profileAvatar(Profile $profile): string
{
try {
$avatar = $profile->avatar;
if (! $avatar) {
return 'MISSING';
}
return 'present (media_path='.($avatar->media_path ?? 'null').')';
} catch (\Throwable $e) {
return 'error';
}
}
protected function attrOf($model, string $key)
{
return $model->getAttributes()[$key] ?? null;
}
protected function safeCall(callable $fn): string
{
try {
return (string) ($fn() ?? 'null');
} catch (\Throwable $e) {
return 'error';
}
}

Loading…
Cancel
Save