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.
pixelfed/app/Console/Commands/Status/StatusUser.php

468 lines
16 KiB
PHTML

<?php
namespace App\Console\Commands\Status;
use App\Models\AccountLog;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class StatusUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:user {id : Username or numeric user id}
{--logs=10 : Number of recent account log entries to show}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show detailed debug/status info for a user account (login & password reset diagnostics)';
/**
* Columns whose values must never be printed in full.
*
* @var array<string, string>
*/
protected $sensitive = [
'password' => 'REDACTED (hash)',
'2fa_secret' => 'REDACTED',
'2fa_backup_codes' => 'REDACTED',
'remember_token' => 'REDACTED',
];
/**
* Auth-affecting columns we want to call out explicitly.
*
* @var array<int, string>
*/
protected $authColumns = [
'status',
'email_verified_at',
'deleted_at',
'2fa_enabled',
'is_admin',
'app_register_ip',
];
public function handle()
{
$id = $this->argument('id');
// Include soft-deleted users so we can detect deletion-related lockouts.
$query = User::withTrashed();
if (ctype_digit((string) $id)) {
$user = $query->find($id);
} else {
$user = $query->whereUsername($id)->first();
}
if (! $user) {
$this->error('No user row found for "'.$id.'".');
$this->diagnoseMissingUser($id);
return 1;
}
$this->line(str_repeat('=', 60));
$this->info('USER ROW (table: users)');
$this->line(str_repeat('=', 60));
$this->dumpModelColumns($user);
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info('AUTH DIAGNOSTICS');
$this->line(str_repeat('=', 60));
$this->authDiagnostics($user);
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info('LINKED PROFILE');
$this->line(str_repeat('=', 60));
$this->dumpProfile($user);
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info('RELATED RECORDS');
$this->line(str_repeat('=', 60));
$this->dumpRelated($user);
$this->newLine();
$this->line(str_repeat('=', 60));
$this->info('RECENT ACCOUNT LOG (last '.(int) $this->option('logs').')');
$this->line(str_repeat('=', 60));
$this->dumpLogs($user);
return 0;
}
/**
* Dump every column of the users row, redacting sensitive values.
*/
protected function dumpModelColumns(User $user): void
{
$attrs = $user->getAttributes();
$rows = [];
foreach ($attrs as $key => $value) {
if (array_key_exists($key, $this->sensitive)) {
$display = $value === null || $value === ''
? '(empty!)'
: $this->sensitive[$key];
} else {
$display = $this->format($value);
}
$rows[] = [$key, $display];
}
$this->table(['Column', 'Value'], $rows);
}
/**
* Highlight the specific conditions that block login or password reset.
*/
protected function authDiagnostics(User $user): void
{
$problems = [];
$ok = [];
// 1. Soft delete
if ($user->deleted_at) {
$problems[] = 'SOFT DELETED: deleted_at = '.$user->deleted_at.' (row is trashed; excluded from normal auth queries & password reset)';
} else {
$ok[] = 'Not soft-deleted (deleted_at is null)';
}
// 2. status column (LoginController blocks "deleted"; RegisterController treats deleted/delete specially)
$status = $user->status;
if ($status === null) {
$ok[] = 'status = null (active)';
} elseif (in_array($status, ['deleted', 'delete'])) {
$problems[] = 'status = "'.$status.'" (treated as deleted account)';
} else {
$problems[] = 'status = "'.$status.'" (non-null status may restrict the account)';
}
// 3. Email verification (password reset requires a matching, valid email)
if (empty($user->email)) {
$problems[] = 'email is EMPTY (password reset cannot find/notify this account)';
} else {
$ok[] = 'email present: '.$user->email;
}
if ($user->email_verified_at) {
$ok[] = 'email verified at '.$user->email_verified_at;
} else {
$problems[] = 'email NOT verified (email_verified_at is null) — login may be gated to /i/verify-email and some flows reject unverified accounts';
}
// 4. 2FA
if ($this->attr($user, '2fa_enabled')) {
$ok[] = '2FA enabled (login requires TOTP/backup code — a lost 2FA device looks like a rejected login)';
} else {
$ok[] = '2FA disabled';
}
// 5. password presence
if (empty($user->password)) {
$problems[] = 'password hash is EMPTY (cannot authenticate with a password)';
}
// 6. profile link
if (empty($user->profile_id)) {
$problems[] = 'profile_id is EMPTY (user has no linked profile — avatarUrl/profile routes will misbehave)';
}
if ($problems) {
$this->error('POTENTIAL LOGIN / RESET BLOCKERS:');
foreach ($problems as $p) {
$this->line(' ✗ '.$p);
}
} else {
$this->info('No obvious auth blockers detected on the user row.');
}
if ($ok) {
$this->newLine();
$this->comment('OK checks:');
foreach ($ok as $o) {
$this->line(' ✓ '.$o);
}
}
// Duplicate-email detection (unique constraint / reset ambiguity)
if (! empty($user->email)) {
$dupes = User::withTrashed()->where('email', $user->email)->count();
if ($dupes > 1) {
$this->newLine();
$this->error(' ✗ DUPLICATE EMAIL: '.$dupes.' user rows share email "'.$user->email.'" — password reset may target the wrong row.');
}
}
}
protected function dumpProfile(User $user): void
{
$profile = null;
if ($user->profile_id) {
$profile = Profile::withTrashed()->find($user->profile_id);
}
if (! $profile) {
$profile = Profile::withTrashed()->where('user_id', $user->id)->first();
}
if (! $profile) {
$this->error('No profile row found for this user (profile_id='.($user->profile_id ?? 'null').', user_id='.$user->id.').');
return;
}
// 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) {
$problems[] = 'Profile is soft-deleted (deleted_at='.$profile->deleted_at.').';
}
if ($profile->user_id && $profile->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';
}
}
protected function dumpRelated(User $user): void
{
$rows = [];
$rows[] = ['statuses (posts)', (string) $this->safeCount(fn () => $user->statuses()->count())];
$rows[] = ['user_settings row', $user->settings()->exists() ? 'yes' : 'MISSING'];
$rows[] = ['devices', (string) $this->safeCount(fn () => $user->devices()->count())];
$rows[] = ['oauth access tokens', (string) $this->oauthTokenCount($user)];
$this->table(['Relation', 'Count / Presence'], $rows);
}
protected function dumpLogs(User $user): void
{
$limit = (int) $this->option('logs');
if (! Schema::hasTable('account_logs')) {
$this->comment('account_logs table not present.');
return;
}
$logs = AccountLog::where('user_id', $user->id)
->orderByDesc('created_at')
->limit($limit)
->get();
if ($logs->isEmpty()) {
$this->comment('No account log entries. (No recorded logins — consistent with never successfully logging in.)');
return;
}
$rows = [];
foreach ($logs as $log) {
$rows[] = [
(string) $log->created_at,
(string) $log->action,
(string) $log->message,
(string) $log->ip_address,
mb_strimwidth((string) $log->user_agent, 0, 40, '…'),
];
}
$this->table(['When', 'Action', 'Message', 'IP', 'User Agent'], $rows);
}
/**
* When no user row exists, look for an orphan profile or similar usernames.
*/
protected function diagnoseMissingUser(string $id): void
{
$this->newLine();
$this->comment('Diagnosing missing user...');
// Orphan profile? (profile exists but its user row is gone)
$profile = Profile::withTrashed()
->when(ctype_digit($id), fn ($q) => $q->where('id', $id))
->orWhere('username', $id)
->first();
if ($profile) {
$this->warn('Found a PROFILE named "'.$profile->username.'" (id='.$profile->id.', user_id='.($profile->user_id ?? 'null').').');
if ($profile->user_id) {
$exists = User::withTrashed()->whereKey($profile->user_id)->exists();
if (! $exists) {
$this->error(' ✗ ORPHAN: profile.user_id='.$profile->user_id.' points to a user row that does NOT exist. This explains "account doesn\'t exist" during login/reset.');
}
} else {
$this->error(' ✗ Profile has no user_id (remote profile, or the local user row was deleted).');
}
if ($profile->deleted_at) {
$this->error(' ✗ Profile is soft-deleted (deleted_at='.$profile->deleted_at.').');
}
} else {
$this->line('No profile matches "'.$id.'" either.');
}
// Similar usernames (typo / case sensitivity)
$similar = User::withTrashed()
->where('username', 'like', '%'.$id.'%')
->limit(10)
->get(['id', 'username', 'email', 'status', 'deleted_at']);
if ($similar->isNotEmpty()) {
$this->newLine();
$this->comment('Similar usernames (case-insensitive match):');
$rows = [];
foreach ($similar as $u) {
$rows[] = [$u->id, $u->username, $u->email, $u->status ?? 'null', $u->deleted_at ?? 'null'];
}
$this->table(['id', 'username', 'email', 'status', 'deleted_at'], $rows);
}
}
protected function oauthTokenCount(User $user): int
{
try {
if (! Schema::hasTable('oauth_access_tokens')) {
return 0;
}
return (int) DB::table('oauth_access_tokens')->where('user_id', $user->id)->count();
} catch (\Throwable $e) {
return 0;
}
}
protected function safeCount(callable $fn): string
{
try {
return (string) $fn();
} catch (\Throwable $e) {
return 'error';
}
}
protected function attr(User $user, string $key)
{
return $user->getAttributes()[$key] ?? null;
}
/**
* Human-friendly value formatting.
*/
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;
}
}