diff --git a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php index 3244d682..ca34015b 100644 --- a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php @@ -28,9 +28,10 @@ protected function prepareForValidation(): void 'range_start' => $parsed[0] ?? null, 'range_end' => $parsed[1] ?? null, 'total_size' => $parsed[2] ?? null, - // Lowercase the name so `ends_with` validation is effectively - // case-insensitive (IMG_1234.JPG vs img_1234.jpg). - 'file_name' => strtolower((string) $this->header('X-File-Name', 'upload')), + // Client sends encodeURIComponent(file.name) so unicode filenames + // (en-dash, emoji, …) fit in an ASCII-only HTTP header. Decode + // before lowercasing so `ends_with` sees the real extension. + 'file_name' => strtolower(rawurldecode((string) $this->header('X-File-Name', 'upload'))), ]); } diff --git a/resources/js/utils/chunkedUpload.ts b/resources/js/utils/chunkedUpload.ts index 0114cef2..2a3c536e 100644 --- a/resources/js/utils/chunkedUpload.ts +++ b/resources/js/utils/chunkedUpload.ts @@ -49,7 +49,9 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`, - 'X-File-Name': file.name, + // Headers must be ByteString (≤255). encodeURIComponent keeps + // unicode filenames (en-dash, emoji, …) ASCII-safe for fetch(). + 'X-File-Name': encodeURIComponent(file.name), 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', diff --git a/tests/Feature/ChunkedUploadFilenameEncodingTest.php b/tests/Feature/ChunkedUploadFilenameEncodingTest.php new file mode 100644 index 00000000..cc304af7 --- /dev/null +++ b/tests/Feature/ChunkedUploadFilenameEncodingTest.php @@ -0,0 +1,121 @@ +account = Account::factory()->create(); + $this->user = User::factory()->create([ + 'account_id' => $this->account->id, + ]); + $this->account->update(['owner_id' => $this->user->id]); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); +}); + +/** + * Mimic the browser client: encodeURIComponent → PHP rawurlencode for the + * X-File-Name header value. + */ +function postEncodedChunkedUpload(string $fileName, string $content): TestResponse +{ + $size = strlen($content); + + return test()->actingAs(test()->user)->call( + 'POST', + route('app.assets.store-chunked'), + [], [], [], + [ + 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, + 'HTTP_X_FILE_NAME' => rawurlencode($fileName), + 'HTTP_ACCEPT' => 'application/json', + 'CONTENT_TYPE' => 'application/octet-stream', + ], + $content, + ); +} + +test('chunked upload accepts filename with en-dash when percent-encoded', function () { + // Customer bug: en-dash (U+2013) is outside ISO-8859-1, so fetch() rejects + // the raw X-File-Name header. The client percent-encodes; we decode here. + $fileName = 'Corte 6 – Quantidade ou qualidade_ Os dois..png'; + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true]); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe(strtolower($fileName)); +}); + +test('chunked upload accepts filename with emoji when percent-encoded', function () { + $fileName = 'launch-🚀-photo.png'; + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true]); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe(strtolower($fileName)); +}); + +test('chunked upload accepts filename with spaces and double-dot extension', function () { + $fileName = 'my video file..png'; + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe('my video file..png'); +}); + +test('chunked upload still accepts plain ascii filename without encoding', function () { + // Backwards compatible: rawurldecode is a no-op on plain ASCII names. + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + $size = strlen($content); + + $response = $this->actingAs($this->user)->call( + 'POST', + route('app.assets.store-chunked'), + [], [], [], + [ + 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, + 'HTTP_X_FILE_NAME' => 'plain-ascii.png', + 'HTTP_ACCEPT' => 'application/json', + 'CONTENT_TYPE' => 'application/octet-stream', + ], + $content, + ); + + $response->assertSuccessful(); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe('plain-ascii.png'); +}); + +test('chunked upload rejects unsupported extension even when percent-encoded', function () { + $response = postEncodedChunkedUpload('malware – payload.exe', str_repeat('x', 100)); + + $response->assertUnprocessable(); +});