fix: allow unicode filenames in chunked asset uploads
X-File-Name is an HTTP header, so raw en-dashes/emoji blow up fetch() before the request leaves the browser. Percent-encode on the client and rawurldecode on the server. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3194ec2069
commit
f4513efc70
3 changed files with 128 additions and 4 deletions
|
|
@ -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'))),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise<Chun
|
|||
const headers: Record<string, string> = {
|
||||
'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',
|
||||
|
|
|
|||
121
tests/Feature/ChunkedUploadFilenameEncodingTest.php
Normal file
121
tests/Feature/ChunkedUploadFilenameEncodingTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake();
|
||||
|
||||
$this->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();
|
||||
});
|
||||
Loading…
Reference in a new issue