From 99ce706290ba33135f40441cf79f55054c79c37c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20R=C3=BCtter?= Date: Fri, 23 Oct 2026 17:09:34 +0000 Subject: [PATCH] Reference the actual asset when using the existing file after an upload conflict The server sanitizes the uploaded filename before checking for a duplicate, but the client built the existing asset's id from the raw filename. The 409 response now includes the existing asset's id and both the fieldtype and the asset browser use it. Co-authored-by: Claude Opus 5.5 --- .../js/components/assets/Browser/Browser.vue | 5 +---- resources/js/components/assets/Uploader.vue | 1 + .../fieldtypes/assets/AssetsFieldtype.vue | 3 +-- .../components/fieldtypes/AssetsFieldtype.test.js | 15 +++------------ .../Controllers/CP/Assets/AssetsController.php | 7 ++++++- tests/Feature/Assets/StoreAssetTest.php | 3 ++- 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/resources/js/components/assets/Browser/Browser.vue b/resources/js/components/assets/Browser/Browser.vue index 40eb5ea7107..0ba7ae383b6 100644 --- a/resources/js/components/assets/Browser/Browser.vue +++ b/resources/js/components/assets/Browser/Browser.vue @@ -740,10 +740,7 @@ export default { }, existingUploadSelected(upload) { - const path = `${this.folder.path}/${upload.basename}`.replace(/^\/+/, ''); - const id = `${this.container.id}::${path}`; - - this.selectedAssets.push(id); + this.selectedAssets.push(upload.existingId); this.$emit('selections-updated', this.selectedAssets); }, diff --git a/resources/js/components/assets/Uploader.vue b/resources/js/components/assets/Uploader.vue index 6ec9948155d..a324eb99684 100644 --- a/resources/js/components/assets/Uploader.vue +++ b/resources/js/components/assets/Uploader.vue @@ -307,6 +307,7 @@ export default { upload.errorMessage = msg; upload.errorStatus = status; + upload.existingId = response?.existing; this.$emit('error', upload, this.uploads); this.processUploadQueue(); }, diff --git a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue index c07eb1f1ccc..3a63bba2cc1 100644 --- a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue +++ b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue @@ -656,8 +656,7 @@ export default { }, uploadSelected(upload) { - const path = `${this.folder}/${upload.basename}`.replace(/^\/+/, ''); - const id = `${this.container.id}::${path}`; + const id = upload.existingId; this.uploads.splice(this.uploads.indexOf(upload), 1); diff --git a/resources/js/tests/components/fieldtypes/AssetsFieldtype.test.js b/resources/js/tests/components/fieldtypes/AssetsFieldtype.test.js index ff68cff46fa..4cb805f3786 100644 --- a/resources/js/tests/components/fieldtypes/AssetsFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/AssetsFieldtype.test.js @@ -33,22 +33,13 @@ const makeField = ({ axiosPost = vi.fn(() => Promise.resolve({ data: [] })), pro }); }; -test('selecting the existing file references it by the container id', () => { - const post = vi.fn(() => Promise.resolve({ data: [] })); - const field = makeField({ axiosPost: post }); - - field.vm.uploadSelected({ basename: 'user.png' }); - - expect(post).toHaveBeenCalledWith('assets-fieldtype', { assets: ['main::user.png'] }); -}); - -test('the existing file id includes the configured folder', () => { +test('selecting the existing file references the asset the server reported', () => { const post = vi.fn(() => Promise.resolve({ data: [] })); const field = makeField({ axiosPost: post, props: { config: { folder: 'sub' } } }); - field.vm.uploadSelected({ basename: 'user.png' }); + field.vm.uploadSelected({ basename: 'My File.PNG', existingId: 'main::sub/my-file.png' }); - expect(post).toHaveBeenCalledWith('assets-fieldtype', { assets: ['main::sub/user.png'] }); + expect(post).toHaveBeenCalledWith('assets-fieldtype', { assets: ['main::sub/my-file.png'] }); }); test('loading assets for a new value does not emit an update when the ids are unchanged', async () => { diff --git a/src/Http/Controllers/CP/Assets/AssetsController.php b/src/Http/Controllers/CP/Assets/AssetsController.php index 8aeb7585c95..3b7eb812b1f 100644 --- a/src/Http/Controllers/CP/Assets/AssetsController.php +++ b/src/Http/Controllers/CP/Assets/AssetsController.php @@ -124,7 +124,12 @@ public function store(Request $request) try { $validator->validate(); } catch (ValidationException $e) { - throw $e->status(409); + // The sanitized path can differ from the uploaded filename, so tell the uploader which asset exists. + return response()->json([ + 'message' => __('The given data was invalid.'), + 'errors' => $e->errors(), + 'existing' => $container->asset($path)->id(), + ], 409); } } diff --git a/tests/Feature/Assets/StoreAssetTest.php b/tests/Feature/Assets/StoreAssetTest.php index 96a4e79b0a6..4d389b897e1 100644 --- a/tests/Feature/Assets/StoreAssetTest.php +++ b/tests/Feature/Assets/StoreAssetTest.php @@ -134,7 +134,8 @@ public function it_doesnt_upload_when_file_exists_with_different_casing() 'file' => UploadedFile::fake()->image('tEsT.jpg'), ]) ->assertStatus(409) - ->assertInvalid(['path' => 'A file already exists with this name.']); + ->assertInvalid(['path' => 'A file already exists with this name.']) + ->assertJson(['message' => 'The given data was invalid.', 'existing' => 'test_container::path/to/test.jpg']); } #[Test]