fix: prevent remcache temp file leaks and add GC command

The remote avatar/media fetchers wrote temp files to storage/app/remcache/
and only unlinked them on the happy path. Any exception between the write
and the unlink (e.g. a cloud upload failure) leaked the file, and nothing
swept the directory.

- Wrap post-write logic in fetchAvatar() and remoteToCloud() in try/finally
  so the temp file is always removed, even on failure
- Add gc:remcache command to delete stale remcache files (default >24h old,
  preserves .gitignore, supports --hours and --dry-run)
- Schedule gc:remcache daily to clean up any stragglers

StoryFetch already handled cleanup via try/catch and was left unchanged.
pull/6957/head
Your Name 4 weeks ago
parent 1f85fc8da2
commit 3232761a74

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands\Internal;
use Illuminate\Console\Command;
class RemcacheGarbageCollector extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gc:remcache {--hours=24 : Delete remcache files older than this many hours} {--dry-run : Report what would be deleted without deleting}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete stale temporary files left in storage/app/remcache/';
/**
* Execute the console command.
*/
public function handle(): int
{
$hours = (int) $this->option('hours');
if ($hours < 1) {
$this->error('The --hours value must be at least 1.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$dir = storage_path('app/remcache');
if (! is_dir($dir)) {
$this->info('remcache directory does not exist, nothing to do.');
return self::SUCCESS;
}
$cutoff = now()->subHours($hours)->getTimestamp();
$deleted = 0;
$reclaimed = 0;
foreach (new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS) as $file) {
if (! $file->isFile()) {
continue;
}
// Preserve dotfiles such as the directory's .gitignore.
if (str_starts_with($file->getFilename(), '.')) {
continue;
}
if ($file->getMTime() >= $cutoff) {
continue;
}
$size = $file->getSize();
$path = $file->getPathname();
if ($dryRun) {
$this->line('[dry-run] would delete: '.$file->getFilename());
$deleted++;
$reclaimed += $size;
continue;
}
if (@unlink($path)) {
$deleted++;
$reclaimed += $size;
}
}
$verb = $dryRun ? 'Would delete' : 'Deleted';
$this->info(sprintf('%s %d file(s), %s.', $verb, $deleted, $this->humanBytes($reclaimed)));
return self::SUCCESS;
}
private function humanBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes.' B';
}
$units = ['KB', 'MB', 'GB', 'TB'];
$value = $bytes / 1024;
$i = 0;
while ($value >= 1024 && $i < count($units) - 1) {
$value /= 1024;
$i++;
}
return sprintf('%.2f %s', $value, $units[$i]);
}
}

@ -185,23 +185,28 @@ class MediaStorageService
return;
}
file_put_contents($tmpName, $data);
$hash = hash_file('sha256', $tmpName);
$disk = Storage::disk(config('filesystems.cloud'));
$file = $disk->putFileAs($base, new File($tmpName), $path, 'public');
$permalink = $disk->url($file);
try {
$hash = hash_file('sha256', $tmpName);
$media->media_path = $file;
$media->cdn_url = $permalink;
$media->original_sha256 = $hash;
$media->replicated_at = now();
$media->save();
$disk = Storage::disk(config('filesystems.cloud'));
$file = $disk->putFileAs($base, new File($tmpName), $path, 'public');
$permalink = $disk->url($file);
if ($media->status_id) {
Cache::forget('status:transformer:media:attachments:'.$media->status_id);
}
$media->media_path = $file;
$media->cdn_url = $permalink;
$media->original_sha256 = $hash;
$media->replicated_at = now();
$media->save();
unlink($tmpName);
if ($media->status_id) {
Cache::forget('status:transformer:media:attachments:'.$media->status_id);
}
} finally {
if (is_file($tmpName)) {
@unlink($tmpName);
}
}
}
protected function fetchAvatar($avatar, $local = false, $skipRecentCheck = false)
@ -265,33 +270,36 @@ class MediaStorageService
}
file_put_contents($tmpName, $data);
$mimeCheck = Storage::mimeType('remcache/'.$tmpPath);
try {
$mimeCheck = Storage::mimeType('remcache/'.$tmpPath);
if (! $mimeCheck || ! in_array($mimeCheck, ['image/png', 'image/jpeg', 'image/jpg'])) {
$avatar->last_fetched_at = now();
$avatar->save();
unlink($tmpName);
if (! $mimeCheck || ! in_array($mimeCheck, ['image/png', 'image/jpeg', 'image/jpg'])) {
$avatar->last_fetched_at = now();
$avatar->save();
return;
}
$disk = Storage::disk($driver);
$file = $disk->putFileAs($base, new File($tmpName), $path, 'public');
$permalink = $disk->url($file);
return;
}
$avatar->media_path = $base.'/'.$path;
$avatar->is_remote = true;
$avatar->cdn_url = $local ? config('app.url').$permalink : $permalink;
$avatar->size = $head['length'];
$avatar->change_count = $avatar->change_count + 1;
$avatar->last_fetched_at = now();
$avatar->save();
$disk = Storage::disk($driver);
$file = $disk->putFileAs($base, new File($tmpName), $path, 'public');
$permalink = $disk->url($file);
Cache::forget('avatar:'.$avatar->profile_id);
AccountService::del($avatar->profile_id);
AvatarStorageCleanup::dispatch($avatar)->onQueue($queue)->delay(now()->addMinutes(random_int(3, 15)));
$avatar->media_path = $base.'/'.$path;
$avatar->is_remote = true;
$avatar->cdn_url = $local ? config('app.url').$permalink : $permalink;
$avatar->size = $head['length'];
$avatar->change_count = $avatar->change_count + 1;
$avatar->last_fetched_at = now();
$avatar->save();
unlink($tmpName);
Cache::forget('avatar:'.$avatar->profile_id);
AccountService::del($avatar->profile_id);
AvatarStorageCleanup::dispatch($avatar)->onQueue($queue)->delay(now()->addMinutes(random_int(3, 15)));
} finally {
if (is_file($tmpName)) {
@unlink($tmpName);
}
}
}
public static function delete(Media $media, $confirm = false)

@ -138,6 +138,7 @@ return Application::configure(basePath: dirname(__DIR__))
$schedule->command('gc:failedjobs')->dailyAt(3)->onOneServer();
$schedule->command('gc:passwordreset')->dailyAt('09:41')->onOneServer();
$schedule->command('gc:sessions')->twiceDaily(13, 23)->onOneServer();
$schedule->command('gc:remcache')->dailyAt('04:15')->onOneServer();
$schedule->command('app:weekly-instance-scan')->weeklyOn(2, '4:20')->onOneServer();
$schedule->command('app:cleanup-expired-app-registrations')->dailyAt(1)->onOneServer();
$schedule->command('passport:purge')->everyFourHours(20)->onOneServer();

Loading…
Cancel
Save