AvatarStorage.php 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use App\Avatar;
  5. use App\Profile;
  6. use App\User;
  7. use Cache;
  8. use Storage;
  9. use App\Services\AccountService;
  10. use App\Util\Lexer\PrettyNumber;
  11. use Illuminate\Support\Str;
  12. use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
  13. class AvatarStorage extends Command
  14. {
  15. /**
  16. * The name and signature of the console command.
  17. *
  18. * @var string
  19. */
  20. protected $signature = 'avatar:storage';
  21. /**
  22. * The console command description.
  23. *
  24. * @var string
  25. */
  26. protected $description = 'Manage avatar storage';
  27. public $found = 0;
  28. public $notFetched = 0;
  29. public $fixed = 0;
  30. public $missing = 0;
  31. /**
  32. * Execute the console command.
  33. *
  34. * @return int
  35. */
  36. public function handle()
  37. {
  38. $this->info('Pixelfed Avatar Storage Manager');
  39. $this->line(' ');
  40. $segments = [
  41. [
  42. 'Local',
  43. Avatar::whereNull('is_remote')->count(),
  44. PrettyNumber::size(Avatar::whereNull('is_remote')->sum('size'))
  45. ],
  46. [
  47. 'Remote',
  48. Avatar::whereIsRemote(true)->count(),
  49. PrettyNumber::size(Avatar::whereIsRemote(true)->sum('size'))
  50. ],
  51. [
  52. 'Cached (CDN)',
  53. Avatar::whereNotNull('cdn_url')->count(),
  54. PrettyNumber::size(Avatar::whereNotNull('cdn_url')->sum('size'))
  55. ],
  56. [
  57. 'Uncached',
  58. Avatar::whereNull('cdn_url')->count(),
  59. PrettyNumber::size(Avatar::whereNull('cdn_url')->sum('size'))
  60. ],
  61. [
  62. '------------',
  63. '----------',
  64. '-----'
  65. ],
  66. [
  67. 'Total',
  68. Avatar::count(),
  69. PrettyNumber::size(Avatar::sum('size'))
  70. ],
  71. ];
  72. $this->table(
  73. ['Segment', 'Count', 'Space Used'],
  74. $segments
  75. );
  76. $this->line(' ');
  77. if((bool) config_cache('pixelfed.cloud_storage')) {
  78. $this->info('✅ - Cloud storage configured');
  79. $this->line(' ');
  80. }
  81. if(config('instance.avatar.local_to_cloud')) {
  82. $this->info('✅ - Store avatars on cloud filesystem');
  83. $this->line(' ');
  84. }
  85. if((bool) config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud')) {
  86. $disk = Storage::disk(config_cache('filesystems.cloud'));
  87. $exists = $disk->exists('cache/avatars/default.jpg');
  88. $state = $exists ? '✅' : '❌';
  89. $msg = $state . ' - Cloud default avatar exists';
  90. $this->info($msg);
  91. }
  92. $options = (bool) config_cache('pixelfed.cloud_storage') && config('instance.avatar.local_to_cloud') ?
  93. [
  94. 'Cancel',
  95. 'Upload default avatar to cloud',
  96. 'Move local avatars to cloud',
  97. 'Re-fetch remote avatars'
  98. ] : [
  99. 'Cancel',
  100. 'Re-fetch remote avatars'
  101. ];
  102. $this->missing = Profile::where('created_at', '<', now()->subDays(1))->doesntHave('avatar')->count();
  103. if($this->missing != 0) {
  104. $options[] = 'Fix missing avatars';
  105. }
  106. $choice = $this->choice(
  107. 'Select action:',
  108. $options,
  109. 0
  110. );
  111. return $this->handleChoice($choice);
  112. }
  113. protected function handleChoice($id)
  114. {
  115. switch ($id) {
  116. case 'Cancel':
  117. return;
  118. break;
  119. case 'Upload default avatar to cloud':
  120. return $this->uploadDefaultAvatar();
  121. break;
  122. case 'Move local avatars to cloud':
  123. return $this->uploadAvatarsToCloud();
  124. break;
  125. case 'Re-fetch remote avatars':
  126. return $this->refetchRemoteAvatars();
  127. break;
  128. case 'Fix missing avatars':
  129. return $this->fixMissingAvatars();
  130. break;
  131. }
  132. }
  133. protected function uploadDefaultAvatar()
  134. {
  135. if(!$this->confirm('Are you sure you want to upload the default avatar to the cloud storage disk?')) {
  136. return;
  137. }
  138. $disk = Storage::disk(config_cache('filesystems.cloud'));
  139. $disk->put('cache/avatars/default.jpg', Storage::get('public/avatars/default.jpg'));
  140. Avatar::whereMediaPath('public/avatars/default.jpg')->update(['cdn_url' => $disk->url('cache/avatars/default.jpg')]);
  141. $this->info('Successfully uploaded default avatar to cloud storage!');
  142. $this->info($disk->url('cache/avatars/default.jpg'));
  143. }
  144. protected function uploadAvatarsToCloud()
  145. {
  146. if(!(bool) config_cache('pixelfed.cloud_storage') || !config('instance.avatar.local_to_cloud')) {
  147. $this->error('Enable cloud storage and avatar cloud storage to perform this action');
  148. return;
  149. }
  150. $confirm = $this->confirm('Are you sure you want to move local avatars to cloud storage?');
  151. if(!$confirm) {
  152. $this->error('Aborted action');
  153. return;
  154. }
  155. $disk = Storage::disk(config_cache('filesystems.cloud'));
  156. if($disk->missing('cache/avatars/default.jpg')) {
  157. $disk->put('cache/avatars/default.jpg', Storage::get('public/avatars/default.jpg'));
  158. }
  159. Avatar::whereNull('is_remote')->chunk(5, function($avatars) use($disk) {
  160. foreach($avatars as $avatar) {
  161. if($avatar->media_path === 'public/avatars/default.jpg') {
  162. $avatar->cdn_url = $disk->url('cache/avatars/default.jpg');
  163. $avatar->save();
  164. } else {
  165. if(!$avatar->media_path || !Str::of($avatar->media_path)->startsWith('public/avatars/')) {
  166. continue;
  167. }
  168. $ext = pathinfo($avatar->media_path, PATHINFO_EXTENSION);
  169. $newPath = 'cache/avatars/' . $avatar->profile_id . '/avatar_' . strtolower(Str::random(6)) . '.' . $ext;
  170. $existing = Storage::disk('local')->get($avatar->media_path);
  171. if(!$existing) {
  172. continue;
  173. }
  174. $newMediaPath = $disk->put($newPath, $existing);
  175. $avatar->media_path = $newPath;
  176. $avatar->cdn_url = $disk->url($newPath);
  177. $avatar->save();
  178. }
  179. Cache::forget('avatar:' . $avatar->profile_id);
  180. Cache::forget(AccountService::CACHE_KEY . $avatar->profile_id);
  181. }
  182. });
  183. }
  184. protected function refetchRemoteAvatars()
  185. {
  186. if(!$this->confirm('Are you sure you want to refetch all remote avatars? This could take a while.')) {
  187. return;
  188. }
  189. if((bool) config_cache('pixelfed.cloud_storage') == false && config_cache('federation.avatars.store_local') == false) {
  190. $this->error('You have cloud storage disabled and local avatar storage disabled, we cannot refetch avatars.');
  191. return;
  192. }
  193. $count = Profile::has('avatar')
  194. ->with('avatar')
  195. ->whereNull('user_id')
  196. ->count();
  197. $this->info('Found ' . $count . ' remote avatars to re-fetch');
  198. $this->line(' ');
  199. $bar = $this->output->createProgressBar($count);
  200. Profile::has('avatar')
  201. ->with('avatar')
  202. ->whereNull('user_id')
  203. ->chunk(50, function($profiles) use($bar) {
  204. foreach($profiles as $profile) {
  205. $avatar = $profile->avatar;
  206. $avatar->last_fetched_at = null;
  207. $avatar->save();
  208. RemoteAvatarFetch::dispatch($profile)->onQueue('low');
  209. $bar->advance();
  210. }
  211. });
  212. $this->line(' ');
  213. $this->line(' ');
  214. $this->info('Finished dispatching avatar refetch jobs!');
  215. $this->line(' ');
  216. $this->info('This may take a few minutes to complete, you may need to run "php artisan cache:clear" after the jobs are processed.');
  217. $this->line(' ');
  218. }
  219. protected function incr($name)
  220. {
  221. switch($name) {
  222. case 'found':
  223. $this->found = $this->found + 1;
  224. break;
  225. case 'notFetched':
  226. $this->notFetched = $this->notFetched + 1;
  227. break;
  228. case 'fixed':
  229. $this->fixed++;
  230. break;
  231. }
  232. }
  233. protected function fixMissingAvatars()
  234. {
  235. if(!$this->confirm('Are you sure you want to fix missing avatars?')) {
  236. return;
  237. }
  238. $this->info('Found ' . $this->missing . ' accounts with missing profiles');
  239. Profile::where('created_at', '<', now()->subDays(1))
  240. ->doesntHave('avatar')
  241. ->chunk(50, function($profiles) {
  242. foreach($profiles as $profile) {
  243. Avatar::updateOrCreate([
  244. 'profile_id' => $profile->id
  245. ], [
  246. 'media_path' => 'public/avatars/default.jpg',
  247. 'is_remote' => $profile->domain == null && $profile->private_key == null
  248. ]);
  249. $this->incr('fixed');
  250. }
  251. });
  252. $this->line(' ');
  253. $this->line(' ');
  254. $this->info('Fixed ' . $this->fixed . ' accounts with a blank avatar');
  255. }
  256. }