From b4414eea199c6622458984aeff081100fc06cf75 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 27 May 2026 09:21:57 +0100 Subject: [PATCH 1/3] Make Files fieldtype temporary upload storage configurable --- config/system.php | 16 ++++++++++++++++ src/Actions/ReuploadAsset.php | 3 ++- src/Assets/FileUploader.php | 4 ++-- src/Assets/ReplacementFile.php | 7 +++---- src/Forms/DeleteTemporaryAttachments.php | 7 +++++-- src/Forms/Email.php | 5 ++++- .../Middleware/DeleteTemporaryFileUploads.php | 7 ++++--- tests/Assets/ReplacementFileTest.php | 15 +++++++++++++++ 8 files changed, 51 insertions(+), 13 deletions(-) diff --git a/config/system.php b/config/system.php index a8777f14064..fd3dd1b068d 100644 --- a/config/system.php +++ b/config/system.php @@ -286,4 +286,20 @@ // ], + /* + |-------------------------------------------------------------------------- + | File Uploads + |-------------------------------------------------------------------------- + | + | Temporary file uploads from the Files fieldtype are stored here before + | being moved to their final destination. You may configure the disk and + | path to use a shared filesystem in multiserver environments. + | + */ + + 'file_uploads' => [ + 'disk' => env('STATAMIC_FILE_UPLOADS_DISK', 'local'), + 'path' => env('STATAMIC_FILE_UPLOADS_PATH', 'statamic/file-uploads'), + ], + ]; diff --git a/src/Actions/ReuploadAsset.php b/src/Actions/ReuploadAsset.php index 8f1cce20bf9..3d974802b89 100644 --- a/src/Actions/ReuploadAsset.php +++ b/src/Actions/ReuploadAsset.php @@ -56,7 +56,8 @@ public function run($assets, $values) { $asset = $assets->first(); - $file = new ReplacementFile('statamic/file-uploads/'.$values['file']); + $basePath = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + $file = new ReplacementFile($basePath.'/'.$values['file']); try { $asset->reupload($file); diff --git a/src/Assets/FileUploader.php b/src/Assets/FileUploader.php index 58a1e3f086b..7575c15247b 100644 --- a/src/Assets/FileUploader.php +++ b/src/Assets/FileUploader.php @@ -27,7 +27,7 @@ protected function uploadPath(UploadedFile $file) protected function uploadPathPrefix() { - return 'statamic/file-uploads/'; + return config('statamic.system.file_uploads.path', 'statamic/file-uploads').'/'; } protected function preset() @@ -37,6 +37,6 @@ protected function preset() protected function disk() { - return Storage::disk('local'); + return Storage::disk(config('statamic.system.file_uploads.disk', 'local')); } } diff --git a/src/Assets/ReplacementFile.php b/src/Assets/ReplacementFile.php index aa6fc3804c1..db8c7c70a31 100644 --- a/src/Assets/ReplacementFile.php +++ b/src/Assets/ReplacementFile.php @@ -31,9 +31,8 @@ public function basename() public function writeTo(Filesystem $disk, $path) { - $disk->put( - $path, - Storage::disk('local')->readStream($this->path) - ); + $sourceDisk = Storage::disk(config('statamic.system.file_uploads.disk', 'local')); + + $disk->put($path, $sourceDisk->readStream($this->path)); } } diff --git a/src/Forms/DeleteTemporaryAttachments.php b/src/Forms/DeleteTemporaryAttachments.php index 2905f270cd0..03c37dd0f83 100644 --- a/src/Forms/DeleteTemporaryAttachments.php +++ b/src/Forms/DeleteTemporaryAttachments.php @@ -22,11 +22,14 @@ public function __construct(public Submission $submission) public function handle() { + $disk = Storage::disk(config('statamic.system.file_uploads.disk', 'local')); + $basePath = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + $this->submission->form()->blueprint()->fields()->all() ->filter(fn (Field $field) => $field->type() === 'files') - ->each(function (Field $field) { + ->each(function (Field $field) use ($disk, $basePath) { Collection::wrap($this->submission->get($field->handle(), [])) - ->each(fn ($path) => Storage::disk('local')->delete('statamic/file-uploads/'.$path)); + ->each(fn ($path) => $disk->delete($basePath.'/'.$path)); $this->submission->remove($field->handle()); }); diff --git a/src/Forms/Email.php b/src/Forms/Email.php index 013266d1d52..25822eea736 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -150,8 +150,11 @@ private function attachFiles($field) return; } + $disk = config('statamic.system.file_uploads.disk', 'local'); + $basePath = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + foreach ($value as $file) { - $this->attachFromStorageDisk('local', 'statamic/file-uploads/'.$file); + $this->attachFromStorageDisk($disk, $basePath.'/'.$file); } } diff --git a/src/Http/Middleware/DeleteTemporaryFileUploads.php b/src/Http/Middleware/DeleteTemporaryFileUploads.php index 50e18b07347..3668e3e300d 100644 --- a/src/Http/Middleware/DeleteTemporaryFileUploads.php +++ b/src/Http/Middleware/DeleteTemporaryFileUploads.php @@ -20,10 +20,11 @@ public function handle($request, Closure $next) private function deleteFilesOverAnHourOld() { - $disk = File::disk('local'); + $disk = File::disk(config('statamic.system.file_uploads.disk', 'local')); + $directory = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); $disk - ->getFilesRecursively($dir = 'statamic/file-uploads') + ->getFilesRecursively($directory) ->filter(function ($path) { $bits = explode('/', $path); $timestamp = $bits[count($bits) - 2]; @@ -32,6 +33,6 @@ private function deleteFilesOverAnHourOld() }) ->each(fn ($path) => $disk->delete($path)); - $disk->deleteEmptySubfolders($dir); + $disk->deleteEmptySubfolders($directory); } } diff --git a/tests/Assets/ReplacementFileTest.php b/tests/Assets/ReplacementFileTest.php index 3db4a5b00b6..35287c99065 100644 --- a/tests/Assets/ReplacementFileTest.php +++ b/tests/Assets/ReplacementFileTest.php @@ -30,4 +30,19 @@ public function it_writes_the_file_to_another_disk() $targetDisk->assertExists('the/new/path.jpg'); } + + #[Test] + public function it_reads_from_configured_disk() + { + config(['statamic.system.file_uploads.disk' => 'uploads']); + + $originDisk = Storage::fake('uploads'); + $targetDisk = Storage::fake('target'); + $originDisk->write('foo/bar/baz.jpg', 'contents'); + + (new ReplacementFile('foo/bar/baz.jpg'))->writeTo($targetDisk, 'the/new/path.jpg'); + + $targetDisk->assertExists('the/new/path.jpg'); + $this->assertEquals('contents', $targetDisk->get('the/new/path.jpg')); + } } From 26d230f5b6c765a46a16f36550193127d269fecb Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 13:00:08 -0400 Subject: [PATCH 2/3] Flatten file_uploads config into separate disk/path settings Splits the nested file_uploads.disk/path config into top-level file_uploads_disk and file_uploads_path settings, matching the flat key convention used elsewhere in config/system.php. Keeping disk and path as distinct top-level keys also makes it easier for other kinds of temporary uploads (e.g. forms) to reuse the same disk via their own dedicated path setting. Co-Authored-By: Claude Sonnet 5 --- config/system.php | 27 +++++++++++++------ src/Actions/ReuploadAsset.php | 2 +- src/Assets/FileUploader.php | 4 +-- src/Assets/ReplacementFile.php | 2 +- src/Forms/DeleteTemporaryAttachments.php | 4 +-- src/Forms/Email.php | 4 +-- .../Middleware/DeleteTemporaryFileUploads.php | 4 +-- tests/Assets/ReplacementFileTest.php | 2 +- 8 files changed, 30 insertions(+), 19 deletions(-) diff --git a/config/system.php b/config/system.php index fd3dd1b068d..90a0c2b1615 100644 --- a/config/system.php +++ b/config/system.php @@ -288,18 +288,29 @@ /* |-------------------------------------------------------------------------- - | File Uploads + | File Uploads Disk |-------------------------------------------------------------------------- | - | Temporary file uploads from the Files fieldtype are stored here before - | being moved to their final destination. You may configure the disk and - | path to use a shared filesystem in multiserver environments. + | Temporary file uploads are stored here before being moved to their + | final destination. You may configure this to use a shared filesystem + | in multiserver environments. This disk may be shared by other kinds + | of temporary file uploads (e.g. forms) that use their own path below. | */ - 'file_uploads' => [ - 'disk' => env('STATAMIC_FILE_UPLOADS_DISK', 'local'), - 'path' => env('STATAMIC_FILE_UPLOADS_PATH', 'statamic/file-uploads'), - ], + 'file_uploads_disk' => env('STATAMIC_FILE_UPLOADS_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | File Uploads Path + |-------------------------------------------------------------------------- + | + | The path (on the file uploads disk above) where temporary file uploads + | from the Files fieldtype are stored before being moved to their final + | destination. These files are automatically cleaned up over time. + | + */ + + 'file_uploads_path' => env('STATAMIC_FILE_UPLOADS_PATH', 'statamic/file-uploads'), ]; diff --git a/src/Actions/ReuploadAsset.php b/src/Actions/ReuploadAsset.php index 3d974802b89..bb3f6ad3de0 100644 --- a/src/Actions/ReuploadAsset.php +++ b/src/Actions/ReuploadAsset.php @@ -56,7 +56,7 @@ public function run($assets, $values) { $asset = $assets->first(); - $basePath = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + $basePath = config('statamic.system.file_uploads_path', 'statamic/file-uploads'); $file = new ReplacementFile($basePath.'/'.$values['file']); try { diff --git a/src/Assets/FileUploader.php b/src/Assets/FileUploader.php index 55628e6b5bf..e37cf5f2ab7 100644 --- a/src/Assets/FileUploader.php +++ b/src/Assets/FileUploader.php @@ -30,7 +30,7 @@ protected function uploadPath(UploadedFile $file) protected function uploadPathPrefix() { - return config('statamic.system.file_uploads.path', 'statamic/file-uploads').'/'; + return config('statamic.system.file_uploads_path', 'statamic/file-uploads').'/'; } protected function preset() @@ -40,6 +40,6 @@ protected function preset() protected function disk() { - return Storage::disk(config('statamic.system.file_uploads.disk', 'local')); + return Storage::disk(config('statamic.system.file_uploads_disk', 'local')); } } diff --git a/src/Assets/ReplacementFile.php b/src/Assets/ReplacementFile.php index db8c7c70a31..93e80e17237 100644 --- a/src/Assets/ReplacementFile.php +++ b/src/Assets/ReplacementFile.php @@ -31,7 +31,7 @@ public function basename() public function writeTo(Filesystem $disk, $path) { - $sourceDisk = Storage::disk(config('statamic.system.file_uploads.disk', 'local')); + $sourceDisk = Storage::disk(config('statamic.system.file_uploads_disk', 'local')); $disk->put($path, $sourceDisk->readStream($this->path)); } diff --git a/src/Forms/DeleteTemporaryAttachments.php b/src/Forms/DeleteTemporaryAttachments.php index 03c37dd0f83..5d53ee938bb 100644 --- a/src/Forms/DeleteTemporaryAttachments.php +++ b/src/Forms/DeleteTemporaryAttachments.php @@ -22,8 +22,8 @@ public function __construct(public Submission $submission) public function handle() { - $disk = Storage::disk(config('statamic.system.file_uploads.disk', 'local')); - $basePath = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + $disk = Storage::disk(config('statamic.system.file_uploads_disk', 'local')); + $basePath = config('statamic.system.file_uploads_path', 'statamic/file-uploads'); $this->submission->form()->blueprint()->fields()->all() ->filter(fn (Field $field) => $field->type() === 'files') diff --git a/src/Forms/Email.php b/src/Forms/Email.php index 25822eea736..41adbc0566e 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -150,8 +150,8 @@ private function attachFiles($field) return; } - $disk = config('statamic.system.file_uploads.disk', 'local'); - $basePath = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + $disk = config('statamic.system.file_uploads_disk', 'local'); + $basePath = config('statamic.system.file_uploads_path', 'statamic/file-uploads'); foreach ($value as $file) { $this->attachFromStorageDisk($disk, $basePath.'/'.$file); diff --git a/src/Http/Middleware/DeleteTemporaryFileUploads.php b/src/Http/Middleware/DeleteTemporaryFileUploads.php index 3668e3e300d..ef13ab19634 100644 --- a/src/Http/Middleware/DeleteTemporaryFileUploads.php +++ b/src/Http/Middleware/DeleteTemporaryFileUploads.php @@ -20,8 +20,8 @@ public function handle($request, Closure $next) private function deleteFilesOverAnHourOld() { - $disk = File::disk(config('statamic.system.file_uploads.disk', 'local')); - $directory = config('statamic.system.file_uploads.path', 'statamic/file-uploads'); + $disk = File::disk(config('statamic.system.file_uploads_disk', 'local')); + $directory = config('statamic.system.file_uploads_path', 'statamic/file-uploads'); $disk ->getFilesRecursively($directory) diff --git a/tests/Assets/ReplacementFileTest.php b/tests/Assets/ReplacementFileTest.php index 35287c99065..477ed7058b1 100644 --- a/tests/Assets/ReplacementFileTest.php +++ b/tests/Assets/ReplacementFileTest.php @@ -34,7 +34,7 @@ public function it_writes_the_file_to_another_disk() #[Test] public function it_reads_from_configured_disk() { - config(['statamic.system.file_uploads.disk' => 'uploads']); + config(['statamic.system.file_uploads_disk' => 'uploads']); $originDisk = Storage::fake('uploads'); $targetDisk = Storage::fake('target'); From 8d2ca9c68c4441dc141b1c64e2dc6d97d91f7a95 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 15 Jul 2026 16:23:10 -0400 Subject: [PATCH 3/3] Add test coverage for configurable file upload storage Covers the write path (FileUploader via the Files fieldtype upload route), the DeleteTemporaryFileUploads cleanup middleware, and the DeleteTemporaryAttachments form job, asserting each respects the file_uploads_disk/file_uploads_path config instead of the hardcoded local disk and statamic/file-uploads path. Co-Authored-By: Claude Sonnet 5 --- tests/Feature/Fieldtypes/FilesTest.php | 29 ++++++++++++++ tests/Forms/SendEmailsTest.php | 29 ++++++++++++++ .../DeleteTemporaryFileUploadsTest.php | 40 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 tests/Http/Middleware/DeleteTemporaryFileUploadsTest.php diff --git a/tests/Feature/Fieldtypes/FilesTest.php b/tests/Feature/Fieldtypes/FilesTest.php index fb408f4f7b3..5b23a7069c0 100644 --- a/tests/Feature/Fieldtypes/FilesTest.php +++ b/tests/Feature/Fieldtypes/FilesTest.php @@ -96,6 +96,35 @@ public static function uploadProvider() ]; } + #[Test] + public function it_uploads_a_file_to_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.system.file_uploads_path' => 'temp-uploads', + ]); + + $localDisk = Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + $file = UploadedFile::fake()->create('test.txt'); + + Date::setTestNow(Date::createFromTimestamp(1671484636, config('app.timezone'))); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->post('/cp/fieldtypes/files/upload', ['file' => $file]) + ->assertOk() + ->assertJson([ + 'data' => [ + 'id' => '1671484636/test.txt', + ], + ]); + + $uploadsDisk->assertExists('temp-uploads/1671484636/test.txt'); + $localDisk->assertMissing('statamic/file-uploads/1671484636/test.txt'); + } + #[Test] public function it_replaces_dimensions_rule() { diff --git a/tests/Forms/SendEmailsTest.php b/tests/Forms/SendEmailsTest.php index 0360d85f685..aafcf9f9b9c 100644 --- a/tests/Forms/SendEmailsTest.php +++ b/tests/Forms/SendEmailsTest.php @@ -3,6 +3,7 @@ namespace Tests\Forms; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Forms\SubmissionRepository; @@ -139,6 +140,34 @@ public function delete_attachments_job_only_saves_submission_when_enabled() $this->assertEmpty($submissions); } + #[Test] + public function delete_attachments_job_deletes_files_from_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.system.file_uploads_path' => 'temp-uploads', + ]); + + $localDisk = Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + $uploadsDisk->put('temp-uploads/1234567/file.txt', 'contents'); + $localDisk->put('statamic/file-uploads/1234567/file.txt', 'contents'); + + $form = tap(FacadesForm::make('attachments_test')->email([ + 'from' => 'first@sender.com', + 'to' => 'first@recipient.com', + ]))->save(); + + $form->blueprint()->ensureField('attachments', ['type' => 'files'])->save(); + + $submission = $form->makeSubmission()->data(['attachments' => ['1234567/file.txt']]); + + (new DeleteTemporaryAttachments($submission))->handle(); + + $uploadsDisk->assertMissing('temp-uploads/1234567/file.txt'); + $localDisk->assertExists('statamic/file-uploads/1234567/file.txt'); + } + #[Test] #[DataProvider('noEmailsProvider')] public function no_email_jobs_are_queued_if_none_are_configured($emailConfig) diff --git a/tests/Http/Middleware/DeleteTemporaryFileUploadsTest.php b/tests/Http/Middleware/DeleteTemporaryFileUploadsTest.php new file mode 100644 index 00000000000..1784473b09f --- /dev/null +++ b/tests/Http/Middleware/DeleteTemporaryFileUploadsTest.php @@ -0,0 +1,40 @@ + 'uploads', + 'statamic.system.file_uploads_path' => 'temp-uploads', + ]); + + $localDisk = Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + Date::setTestNow(now()); + + $oldTimestamp = now()->subHours(2)->timestamp; + $newTimestamp = now()->timestamp; + + $uploadsDisk->put("temp-uploads/{$oldTimestamp}/old.txt", 'old'); + $uploadsDisk->put("temp-uploads/{$newTimestamp}/new.txt", 'new'); + $localDisk->put("statamic/file-uploads/{$oldTimestamp}/old.txt", 'old'); + + (new \ReflectionMethod(DeleteTemporaryFileUploads::class, 'deleteFilesOverAnHourOld')) + ->invoke(new DeleteTemporaryFileUploads); + + $uploadsDisk->assertMissing("temp-uploads/{$oldTimestamp}/old.txt"); + $uploadsDisk->assertExists("temp-uploads/{$newTimestamp}/new.txt"); + $localDisk->assertExists("statamic/file-uploads/{$oldTimestamp}/old.txt"); + } +}