From ec6827bae2f49f1ddaefaf7a4abb84480e3577f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 20:28:03 +0930 Subject: [PATCH] Check media blocklist before storing uploads to prevent orphaned files --- app/Http/Controllers/Api/ApiV2Controller.php | 9 ++- app/Http/Controllers/ComposeController.php | 11 ++-- .../Controllers/DirectMessageController.php | 9 ++- .../Media/MediaBlocklistOrderingTest.php | 58 +++++++++++++++++++ 4 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/Media/MediaBlocklistOrderingTest.php diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 954d5d1f6..a6208e3a9 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -267,9 +267,14 @@ class ApiV2Controller extends Controller abort(403, 'Invalid or unsupported mime type.'); } + // Check the blocklist against the temp upload BEFORE storing, so a + // blocked upload never leaves an orphaned file on disk (media:gc only + // reaps files that have a Media row). + $hash = \hash_file('sha256', $photo->getRealPath()); + abort_if(MediaBlocklistService::exists($hash) == true, 451); + $storagePath = MediaPathService::get($user, 2); $path = $photo->storePublicly($storagePath); - $hash = \hash_file('sha256', $photo); $license = null; $mime = $photo->getMimeType(); @@ -283,8 +288,6 @@ class ApiV2Controller extends Controller } } - abort_if(MediaBlocklistService::exists($hash) == true, 451); - if ($request->has('replace_id')) { $rpid = $request->input('replace_id'); $removeMedia = Media::whereNull('status_id') diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index b68d86084..ffca18fd3 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -107,12 +107,15 @@ class ComposeController extends Controller abort_if(in_array($photo->getMimeType(), $mimes) == false, 400, 'Invalid media format'); + // Check the blocklist against the temp upload BEFORE storing, so a + // blocked upload never leaves an orphaned file on disk (media:gc only + // reaps files that have a Media row). + $hash = \hash_file('sha256', $photo->getRealPath()); + abort_if(MediaBlocklistService::exists($hash) == true, 451); + + $mime = $photo->getMimeType(); $storagePath = MediaPathService::get($user, 2); $path = $photo->storePublicly($storagePath); - $hash = \hash_file('sha256', $photo); - $mime = $photo->getMimeType(); - - abort_if(MediaBlocklistService::exists($hash) == true, 451); $media = new Media; $media->status_id = null; diff --git a/app/Http/Controllers/DirectMessageController.php b/app/Http/Controllers/DirectMessageController.php index d5477dacd..60a0abf64 100644 --- a/app/Http/Controllers/DirectMessageController.php +++ b/app/Http/Controllers/DirectMessageController.php @@ -475,11 +475,14 @@ class DirectMessageController extends Controller abort(403, 'Invalid or unsupported mime type.'); } + // Check the blocklist against the temp upload BEFORE storing, so a + // blocked upload never leaves an orphaned file on disk (media:gc only + // reaps files that have a Media row). + $hash = \hash_file('sha256', $photo->getRealPath()); + abort_if(MediaBlocklistService::exists($hash) == true, 451); + $storagePath = MediaPathService::get($user, 2).Str::random(8); $path = $photo->storePublicly($storagePath); - $hash = \hash_file('sha256', $photo); - - abort_if(MediaBlocklistService::exists($hash) == true, 451); $status = new Status; $status->profile_id = $profile->id; diff --git a/tests/Feature/Media/MediaBlocklistOrderingTest.php b/tests/Feature/Media/MediaBlocklistOrderingTest.php new file mode 100644 index 000000000..8e35bb514 --- /dev/null +++ b/tests/Feature/Media/MediaBlocklistOrderingTest.php @@ -0,0 +1,58 @@ +create(); + $user->refresh(); + + // Build the upload first so we can blocklist its exact hash. + $file = UploadedFile::fake()->image('blocked.jpg', 1080, 1080); + $hash = hash_file('sha256', $file->getRealPath()); + + MediaBlocklistService::add($hash, ['note' => 'test']); + + $this->actingAs($user) + ->post('/api/compose/v0/media/upload', ['file' => $file]) + ->assertStatus(451); + + // No media (image) file was written to the default disk. Other unrelated + // service files (e.g. cached stats json) may exist, so filter to images. + $imageFiles = collect(Storage::disk(config('filesystems.default'))->allFiles()) + ->filter(fn ($p) => preg_match('/\.(jpe?g|png|gif|webp)$/i', $p)) + ->values(); + + expect($imageFiles)->toBeEmpty(); +}); + +it('accepts a non-blocklisted upload', function () { + Storage::fake(config('filesystems.default')); + + $user = User::factory()->create(); + $user->refresh(); + + $file = UploadedFile::fake()->image('ok.jpg', 1080, 1080); + + $this->actingAs($user) + ->post('/api/compose/v0/media/upload', ['file' => $file]) + ->assertOk(); +});