From 1e582e387b704cc650cf4fece9f4f949f7964f42 Mon Sep 17 00:00:00 2001 From: Dima Date: Wed, 10 Jun 2026 11:05:14 +0200 Subject: [PATCH] fix: align user:avatar-delete with API avatar reset behavior Bring the CLI command in line with deleteAvatar endpoints: increment change_count, clear all avatar/account caches, support --force for scripts, and guard cloud deletion when S3 is not configured. Add feature tests for the command. --- app/Console/Commands/UserAvatarDelete.php | 103 +++++++++++++++------- tests/Feature/UserAvatarDeleteTest.php | 91 +++++++++++++++++++ 2 files changed, 164 insertions(+), 30 deletions(-) create mode 100644 tests/Feature/UserAvatarDeleteTest.php diff --git a/app/Console/Commands/UserAvatarDelete.php b/app/Console/Commands/UserAvatarDelete.php index 55a320ee9..95b2f92a3 100644 --- a/app/Console/Commands/UserAvatarDelete.php +++ b/app/Console/Commands/UserAvatarDelete.php @@ -3,7 +3,6 @@ namespace App\Console\Commands; use App\Avatar; -use App\Services\AccountService; use App\User; use Illuminate\Console\Command; use Illuminate\Contracts\Console\PromptsForMissingInput; @@ -17,7 +16,7 @@ class UserAvatarDelete extends Command implements PromptsForMissingInput * * @var string */ - protected $signature = 'user:avatar-delete {username}'; + protected $signature = 'user:avatar-delete {username} {--force : Delete without confirmation prompts}'; /** * The console command description. @@ -26,6 +25,14 @@ class UserAvatarDelete extends Command implements PromptsForMissingInput */ protected $description = 'Delete user avatar'; + /** + * @var array + */ + protected array $defaultPaths = [ + 'public/avatars/default.jpg', + 'public/avatars/default.png', + ]; + /** * Prompt for missing input arguments using the returned questions. * @@ -41,18 +48,20 @@ class UserAvatarDelete extends Command implements PromptsForMissingInput /** * Execute the console command. */ - public function handle() + public function handle(): int { $user = User::whereUsername($this->argument('username'))->first(); if (! $user) { $this->error('Could not find any user with that username'); - exit; + + return Command::FAILURE; } if (! $user->profile_id) { $this->error('Could not find the profile with that username'); - exit; + + return Command::FAILURE; } $pid = $user->profile_id; @@ -61,43 +70,77 @@ class UserAvatarDelete extends Command implements PromptsForMissingInput if (! $avatarModel) { $this->error('No avatar model found'); - Cache::forget('avatar:'.$pid); - exit; - } + $this->forgetAvatarCaches($pid, $user->id); - $defaultPaths = ['public/avatars/default.jpg', 'public/avatars/default.png']; - $mediaPath = $avatarModel->media_path; + return Command::FAILURE; + } - if (in_array($mediaPath, $defaultPaths)) { + if ($this->isDefaultAvatar($avatarModel->media_path)) { $this->info('Default avatar already used, aborting...'); - Cache::forget('avatar:'.$pid); - exit; - } + $this->forgetAvatarCaches($pid, $user->id); - if (Storage::disk(config('filesystems.cloud'))->exists($mediaPath)) { - if ($this->confirm('Found a S3 avatar at '.$mediaPath.'! Are you sure you want to delete this?')) { - Storage::disk(config('filesystems.cloud'))->delete($mediaPath); - $this->info('Deleting S3 copy'); - } else { - exit; - } + return Command::SUCCESS; } - if (Storage::disk('local')->exists($mediaPath)) { - if ($this->confirm('Found a local avatar at '.$mediaPath.'! Are you sure you want to delete this?')) { - Storage::disk('local')->delete($mediaPath); - $this->info('Deleting local copy'); - } else { - exit; - } + if (! $this->deleteStoredAvatar($avatarModel->media_path, 'avatar')) { + return Command::FAILURE; } $avatarModel->media_path = 'public/avatars/default.jpg'; $avatarModel->cdn_url = null; + $avatarModel->change_count = $avatarModel->change_count + 1; $avatarModel->save(); - Cache::forget('avatar:'.$pid); - AccountService::del($pid); + + $this->forgetAvatarCaches($pid, $user->id); $this->info('Successfully deleted user avatar!'); + + return Command::SUCCESS; + } + + protected function isDefaultAvatar(?string $path): bool + { + return in_array($path, $this->defaultPaths, true); + } + + protected function forgetAvatarCaches(int $profileId, int $userId): void + { + Cache::forget('avatar:'.$profileId); + Cache::forget("avatar:{$profileId}"); + Cache::forget('user:account:id:'.$userId); + } + + protected function deleteStoredAvatar(string $path, string $label): bool + { + if ((bool) config_cache('pixelfed.cloud_storage')) { + $cloudDisk = Storage::disk(config('filesystems.cloud')); + + if ($cloudDisk->exists($path)) { + if (! $this->option('force') && ! $this->confirm("Found a cloud {$label} at {$path}! Are you sure you want to delete this?")) { + return false; + } + + $cloudDisk->delete($path); + $this->info("Deleting cloud {$label} copy"); + } + } + + if (Storage::disk('local')->exists($path)) { + if (! $this->option('force') && ! $this->confirm("Found a local {$label} at {$path}! Are you sure you want to delete this?")) { + return false; + } + + Storage::disk('local')->delete($path); + $this->info("Deleting local {$label} copy"); + } elseif (is_file(storage_path('app/'.$path))) { + if (! $this->option('force') && ! $this->confirm("Found a local {$label} at {$path}! Are you sure you want to delete this?")) { + return false; + } + + @unlink(storage_path('app/'.$path)); + $this->info("Deleting local {$label} copy"); + } + + return true; } } diff --git a/tests/Feature/UserAvatarDeleteTest.php b/tests/Feature/UserAvatarDeleteTest.php new file mode 100644 index 000000000..7afc23765 --- /dev/null +++ b/tests/Feature/UserAvatarDeleteTest.php @@ -0,0 +1,91 @@ +create(); + $user->refresh(); + + $mediaPath = 'public/avatars/custom-avatar.jpg'; + + Storage::disk('local')->put($mediaPath, 'avatar-bytes'); + + $avatar = Avatar::where('profile_id', $user->profile_id)->firstOrFail(); + $avatar->update([ + 'media_path' => $mediaPath, + 'cdn_url' => 'https://cdn.example.test/avatar.jpg', + 'change_count' => 1, + ]); + + Cache::put('avatar:'.$user->profile_id, 'cached', 60); + Cache::put("avatar:{$user->profile_id}", 'cached', 60); + Cache::put('user:account:id:'.$user->id, 'cached', 60); + + $exitCode = Artisan::call('user:avatar-delete', [ + 'username' => $user->username, + '--force' => true, + ]); + + $this->assertSame(0, $exitCode); + + $avatar->refresh(); + + $this->assertSame('public/avatars/default.jpg', $avatar->media_path); + $this->assertNull($avatar->cdn_url); + $this->assertSame(2, $avatar->change_count); + Storage::disk('local')->assertMissing($mediaPath); + $this->assertFalse(Cache::has('avatar:'.$user->profile_id)); + $this->assertFalse(Cache::has("avatar:{$user->profile_id}")); + $this->assertFalse(Cache::has('user:account:id:'.$user->id)); + } + + #[Test] + public function it_aborts_when_avatar_is_already_default(): void + { + $user = User::factory()->create(); + $user->refresh(); + + $exitCode = Artisan::call('user:avatar-delete', [ + 'username' => $user->username, + '--force' => true, + ]); + + $this->assertSame(0, $exitCode); + $this->assertStringContainsString( + 'Default avatar already used', + Artisan::output() + ); + } + + #[Test] + public function it_fails_for_unknown_username(): void + { + $exitCode = Artisan::call('user:avatar-delete', [ + 'username' => 'missing-user-'.uniqid(), + '--force' => true, + ]); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString( + 'Could not find any user with that username', + Artisan::output() + ); + } +}