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.
pull/6627/head
Dima 1 month ago
parent 032e4436d9
commit 1e582e387b
No known key found for this signature in database
GPG Key ID: E1DA120000000000

@ -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<int, string>
*/
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;
}
}

@ -0,0 +1,91 @@
<?php
namespace Tests\Feature;
use App\Avatar;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class UserAvatarDeleteTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_resets_custom_avatar_to_default_with_force(): void
{
Storage::fake('local');
$user = User::factory()->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()
);
}
}
Loading…
Cancel
Save