diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index 590a2615f..e6a772cee 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -83,6 +83,7 @@ public function storeChunked(StoreChunkedAssetRequest $request, ChunkedAssetRece (int) $request->validated('range_start'), (int) $request->validated('range_end'), (int) $request->validated('total_size'), + (string) $request->validated('upload_id'), )->toResponse(); } diff --git a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php index fbf100c61..d8740a55a 100644 --- a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php @@ -29,6 +29,7 @@ protected function prepareForValidation(): void 'range_end' => $parsed[1] ?? null, 'total_size' => $parsed[2] ?? null, 'file_name' => strtolower(rawurldecode((string) $this->header('X-File-Name', 'upload'))), + 'upload_id' => $this->header('X-Upload-Id'), ]); } @@ -47,6 +48,7 @@ public function rules(): array 'range_end' => ['required', 'integer', 'gte:range_start'], 'total_size' => ['required', 'integer', 'min:1', 'max:'.MediaType::Video->maxSizeInBytes()], 'file_name' => ['required', 'string', 'ends_with:'.implode(',', $allowedSuffixes)], + 'upload_id' => ['required', 'string', 'uuid'], ]; } @@ -56,11 +58,7 @@ public function rules(): array public function messages(): array { return [ - 'range_start.required' => 'Invalid Content-Range header', - 'range_end.required' => 'Invalid Content-Range header', - 'total_size.required' => 'Invalid Content-Range header', - 'total_size.max' => 'File size exceeds the maximum allowed ('.MediaType::Video->maxSizeInMb().' MB).', - 'file_name.ends_with' => 'File type not supported.', + 'total_size.max' => __('assets.upload.file_too_large', ['max' => MediaType::Video->maxSizeInMb()]), ]; } } diff --git a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php index e8cda84b5..95265442f 100644 --- a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php @@ -38,12 +38,4 @@ public function rules(): array 'logo_url' => ['nullable', 'url', 'max:1024'], ]; } - - public function messages(): array - { - return [ - 'name.required' => 'O nome do workspace é obrigatório.', - 'name.max' => 'O nome do workspace deve ter no máximo 255 caracteres.', - ]; - } } diff --git a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php index 6bb20ef0a..ad8add4ee 100644 --- a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php @@ -37,12 +37,4 @@ public function rules(): array 'logo_url' => ['nullable', 'url', 'max:1024'], ]; } - - public function messages(): array - { - return [ - 'name.required' => 'The workspace name is required.', - 'name.max' => 'The workspace name must be at most 255 characters.', - ]; - } } diff --git a/app/Services/Media/ChunkedAssetReceiver.php b/app/Services/Media/ChunkedAssetReceiver.php index fa09a5954..d09949d77 100644 --- a/app/Services/Media/ChunkedAssetReceiver.php +++ b/app/Services/Media/ChunkedAssetReceiver.php @@ -21,8 +21,9 @@ public function receive( int $rangeStart, int $rangeEnd, int $totalSize, + string $attemptId, ): ChunkReceipt { - $identifier = md5($user->id.$fileName.$totalSize); + $identifier = md5("{$user->id}{$fileName}{$totalSize}{$attemptId}"); return $this->cloud->shouldUseMultipart($fileName) ? $this->receiveViaMultipart($workspace, $identifier, $fileName, $chunk, $rangeStart, $rangeEnd, $totalSize) diff --git a/lang/ar/assets.php b/lang/ar/assets.php index fe4108d9c..1c59d9b03 100644 --- a/lang/ar/assets.php +++ b/lang/ar/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG، PNG، GIF، WebP، MP4، PDF', 'uploading' => 'جارٍ الرفع...', 'failed' => 'تعذر رفع :file. يرجى المحاولة مرة أخرى.', + 'file_too_large' => 'حجم الملف يتجاوز الحد الأقصى المسموح به (:max ميجابايت).', + 'cancelled' => 'تم إلغاء الرفع.', ], 'empty' => [ diff --git a/lang/de/assets.php b/lang/de/assets.php index 8386c33de..9ad7195f3 100644 --- a/lang/de/assets.php +++ b/lang/de/assets.php @@ -16,6 +16,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Wird hochgeladen...', 'failed' => ':file konnte nicht hochgeladen werden. Bitte versuche es erneut.', + 'file_too_large' => 'Die Dateigröße überschreitet das zulässige Maximum (:max MB).', + 'cancelled' => 'Upload abgebrochen.', ], 'empty' => [ diff --git a/lang/el/assets.php b/lang/el/assets.php index 9b971ea03..6cae0e14e 100644 --- a/lang/el/assets.php +++ b/lang/el/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Μεταφόρτωση...', 'failed' => 'Δεν ήταν δυνατή η μεταφόρτωση του :file. Παρακαλούμε δοκιμάστε ξανά.', + 'file_too_large' => 'Το μέγεθος του αρχείου υπερβαίνει το μέγιστο επιτρεπόμενο (:max MB).', + 'cancelled' => 'Η μεταφόρτωση ακυρώθηκε.', ], 'empty' => [ diff --git a/lang/en/assets.php b/lang/en/assets.php index 57483f4ac..91f2c9502 100644 --- a/lang/en/assets.php +++ b/lang/en/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Uploading...', 'failed' => 'Could not upload :file. Please try again.', + 'file_too_large' => 'File size exceeds the maximum allowed (:max MB).', + 'cancelled' => 'Upload cancelled.', ], 'empty' => [ diff --git a/lang/es/assets.php b/lang/es/assets.php index 0945d7376..e99be7bcf 100644 --- a/lang/es/assets.php +++ b/lang/es/assets.php @@ -16,6 +16,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Subiendo...', 'failed' => 'No se pudo subir :file. Inténtalo de nuevo.', + 'file_too_large' => 'El tamaño del archivo supera el máximo permitido (:max MB).', + 'cancelled' => 'Subida cancelada.', ], 'empty' => [ diff --git a/lang/fr/assets.php b/lang/fr/assets.php index 6787392d6..9068a3914 100644 --- a/lang/fr/assets.php +++ b/lang/fr/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Import en cours...', 'failed' => 'Impossible d\'importer :file. Veuillez réessayer.', + 'file_too_large' => 'La taille du fichier dépasse le maximum autorisé (:max Mo).', + 'cancelled' => 'Import annulé.', ], 'empty' => [ diff --git a/lang/it/assets.php b/lang/it/assets.php index be12bdeb5..68ea2c122 100644 --- a/lang/it/assets.php +++ b/lang/it/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Caricamento in corso...', 'failed' => 'Impossibile caricare :file. Riprova.', + 'file_too_large' => 'La dimensione del file supera il massimo consentito (:max MB).', + 'cancelled' => 'Caricamento annullato.', ], 'empty' => [ diff --git a/lang/ja/assets.php b/lang/ja/assets.php index f953e2c08..851a90f81 100644 --- a/lang/ja/assets.php +++ b/lang/ja/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG、PNG、GIF、WebP、MP4、PDF', 'uploading' => 'アップロード中...', 'failed' => ':file をアップロードできませんでした。もう一度お試しください。', + 'file_too_large' => 'ファイルサイズが許容される最大値(:max MB)を超えています。', + 'cancelled' => 'アップロードをキャンセルしました。', ], 'empty' => [ diff --git a/lang/ko/assets.php b/lang/ko/assets.php index 328b595e6..b22d5cdea 100644 --- a/lang/ko/assets.php +++ b/lang/ko/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => '업로드 중...', 'failed' => ':file을(를) 업로드할 수 없습니다. 다시 시도해 주세요.', + 'file_too_large' => '파일 크기가 허용된 최대값(:max MB)을 초과했습니다.', + 'cancelled' => '업로드가 취소되었습니다.', ], 'empty' => [ diff --git a/lang/nl/assets.php b/lang/nl/assets.php index 2261883dc..77e6fcc90 100644 --- a/lang/nl/assets.php +++ b/lang/nl/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Uploaden...', 'failed' => ':file kon niet worden geüpload. Probeer het opnieuw.', + 'file_too_large' => 'Bestandsgrootte overschrijdt het toegestane maximum (:max MB).', + 'cancelled' => 'Upload geannuleerd.', ], 'empty' => [ diff --git a/lang/pl/assets.php b/lang/pl/assets.php index 9fbbf7e24..c3a6beab3 100644 --- a/lang/pl/assets.php +++ b/lang/pl/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Przesyłanie...', 'failed' => 'Nie udało się przesłać :file. Spróbuj ponownie.', + 'file_too_large' => 'Rozmiar pliku przekracza dozwolone maksimum (:max MB).', + 'cancelled' => 'Przesyłanie anulowane.', ], 'empty' => [ diff --git a/lang/pt-BR/assets.php b/lang/pt-BR/assets.php index 5da241323..a402f9768 100644 --- a/lang/pt-BR/assets.php +++ b/lang/pt-BR/assets.php @@ -16,6 +16,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Enviando...', 'failed' => 'Não foi possível enviar :file. Tente novamente.', + 'file_too_large' => 'O tamanho do arquivo excede o máximo permitido (:max MB).', + 'cancelled' => 'Envio cancelado.', ], 'empty' => [ diff --git a/lang/ru/assets.php b/lang/ru/assets.php index 5722de788..0413b7092 100644 --- a/lang/ru/assets.php +++ b/lang/ru/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Загрузка...', 'failed' => 'Не удалось загрузить :file. Попробуйте ещё раз.', + 'file_too_large' => 'Размер файла превышает максимально допустимый (:max МБ).', + 'cancelled' => 'Загрузка отменена.', ], 'empty' => [ diff --git a/lang/tr/assets.php b/lang/tr/assets.php index f783f101c..45578fc81 100644 --- a/lang/tr/assets.php +++ b/lang/tr/assets.php @@ -16,6 +16,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Yükleniyor...', 'failed' => ':file yüklenemedi. Lütfen tekrar deneyin.', + 'file_too_large' => 'Dosya boyutu izin verilen maksimumu aşıyor (:max MB).', + 'cancelled' => 'Yükleme iptal edildi.', ], 'empty' => [ diff --git a/lang/uk/assets.php b/lang/uk/assets.php index 774f7d5f9..a2dcea8c9 100644 --- a/lang/uk/assets.php +++ b/lang/uk/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF', 'uploading' => 'Завантаження...', 'failed' => 'Не вдалося завантажити :file. Спробуйте ще раз.', + 'file_too_large' => 'Розмір файлу перевищує максимально допустимий (:max МБ).', + 'cancelled' => 'Завантаження скасовано.', ], 'empty' => [ diff --git a/lang/zh/assets.php b/lang/zh/assets.php index e3cecc474..142dccec4 100644 --- a/lang/zh/assets.php +++ b/lang/zh/assets.php @@ -14,6 +14,8 @@ 'formats' => 'JPEG、PNG、GIF、WebP、MP4、PDF', 'uploading' => '上传中…', 'failed' => '无法上传 :file,请重试。', + 'file_too_large' => '文件大小超过允许的最大值(:max MB)。', + 'cancelled' => '上传已取消。', ], 'empty' => [ diff --git a/resources/js/components/assets/GalleryBrowser.vue b/resources/js/components/assets/GalleryBrowser.vue index a49d2d434..150afdd45 100644 --- a/resources/js/components/assets/GalleryBrowser.vue +++ b/resources/js/components/assets/GalleryBrowser.vue @@ -161,6 +161,7 @@ const uploadsSentinel = useTemplateRef('uploadsSentinel'); const isDragging = ref(false); const uploading = ref(false); let uploadsObserver: IntersectionObserver | null = null; +let uploadAbortController: AbortController | null = null; const fetchUploads = async (page: number, term: string) => { const response = await fetch( @@ -240,15 +241,22 @@ const handleDrop = (event: DragEvent) => { }; const uploadFiles = async (files: File[]) => { + if (uploading.value) return; uploading.value = true; + uploadAbortController = new AbortController(); for (const file of files) { try { await uploadChunked({ file, url: assetsStoreChunked.url(), collection: 'assets', + signal: uploadAbortController.signal, }); - } catch { + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + toast.info(trans('assets.upload.cancelled')); + break; + } toast.error(trans('assets.upload.failed', { file: file.name })); } } @@ -596,6 +604,7 @@ onUnmounted(() => { uploadsObserver?.disconnect(); unsplashObserver?.disconnect(); giphyObserver?.disconnect(); + uploadAbortController?.abort(); }); @@ -612,7 +621,10 @@ onUnmounted(() => {
void; onComplete?: (response: any) => void; onError?: (error: any) => void; @@ -30,6 +31,7 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise('meta[name="csrf-token"]')?.content ?? ''; const totalSize = file.size; const totalChunks = Math.ceil(totalSize / chunkSize); + const uploadId = crypto.randomUUID(); let uploadedBytes = 0; try { @@ -50,6 +53,7 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise 'bytes 0-'.($size - 1).'/'.$size, 'HTTP_X_FILE_NAME' => 'test.png', + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -238,6 +240,7 @@ [ 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, 'HTTP_X_FILE_NAME' => 'deck.pdf', + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -257,6 +260,7 @@ [ 'HTTP_CONTENT_RANGE' => 'bytes 0-499/1000', 'HTTP_X_FILE_NAME' => 'test-video.mp4', + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -276,6 +280,7 @@ [ 'HTTP_CONTENT_RANGE' => 'bytes 0-99/100', 'HTTP_X_FILE_NAME' => 'malware.exe', + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -283,6 +288,7 @@ ); $response->assertUnprocessable(); + $response->assertJsonValidationErrors('file_name'); }); test('chunked upload rejects invalid Content-Range header', function () { @@ -293,6 +299,7 @@ [ 'HTTP_CONTENT_RANGE' => 'invalid', 'HTTP_X_FILE_NAME' => 'test.jpg', + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -302,6 +309,7 @@ // FormRequest validation surfaces parse failures as 422 // (range_start / range_end / total_size all required). $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['range_start', 'range_end', 'total_size']); }); test('chunked upload rejects unauthenticated', function () { diff --git a/tests/Feature/ChunkedAssetReceiverTest.php b/tests/Feature/ChunkedAssetReceiverTest.php index 50d6a8473..affdb5d33 100644 --- a/tests/Feature/ChunkedAssetReceiverTest.php +++ b/tests/Feature/ChunkedAssetReceiverTest.php @@ -73,6 +73,7 @@ 0, strlen($bytes) - 1, strlen($bytes), + 'attempt-1', ); expect($receipt->done)->toBeTrue(); @@ -97,6 +98,7 @@ 0, 99, $total, + 'attempt-1', ); expect($receipt->done)->toBeFalse(); @@ -127,6 +129,7 @@ 0, 11, 12, + 'attempt-1', ); expect($receipt->done)->toBeTrue(); @@ -151,6 +154,7 @@ 0, 99, 200, + 'attempt-1', ); expect($receipt->done)->toBeFalse(); @@ -183,6 +187,7 @@ 0, 13, 14, + 'attempt-1', ))->toThrow(InvalidArgumentException::class); Storage::assertMissing('medias/orphan.mp4'); diff --git a/tests/Feature/ChunkedCloudUploadTest.php b/tests/Feature/ChunkedCloudUploadTest.php index bc0efd60b..ae35fd75e 100644 --- a/tests/Feature/ChunkedCloudUploadTest.php +++ b/tests/Feature/ChunkedCloudUploadTest.php @@ -4,13 +4,16 @@ use App\Enums\UserWorkspace\Role; use App\Models\Account; +use App\Models\Media; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\ChunkedAssetReceiver; use App\Services\Media\ChunkedCloudUploader; use Aws\Result; use Aws\S3\S3Client; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Illuminate\Testing\TestResponse; beforeEach(function () { @@ -41,21 +44,27 @@ function fakeMp4Bytes(): string return "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat("\0", 64); } -function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0, ?int $totalSize = null): TestResponse +function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0, ?int $totalSize = null, ?string $uploadId = null): TestResponse { $totalSize ??= strlen($content); $rangeEnd = $rangeStart + strlen($content) - 1; + $headers = [ + 'HTTP_CONTENT_RANGE' => "bytes {$rangeStart}-{$rangeEnd}/{$totalSize}", + 'HTTP_X_FILE_NAME' => rawurlencode($fileName), + 'HTTP_ACCEPT' => 'application/json', + 'CONTENT_TYPE' => 'application/octet-stream', + ]; + + if ($uploadId !== null) { + $headers['HTTP_X_UPLOAD_ID'] = $uploadId; + } + return test()->actingAs(test()->user)->call( 'POST', route('app.assets.store-chunked'), [], [], [], - [ - 'HTTP_CONTENT_RANGE' => "bytes {$rangeStart}-{$rangeEnd}/{$totalSize}", - 'HTTP_X_FILE_NAME' => rawurlencode($fileName), - 'HTTP_ACCEPT' => 'application/json', - 'CONTENT_TYPE' => 'application/octet-stream', - ], + $headers, $content, ); } @@ -200,6 +209,30 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 expect($retry)->toMatchArray(['done' => false, 'progress' => $first['progress']]); }); +// ─── Per-attempt identifier (concurrent duplicate uploads) ─────── + +test('receive derives a distinct identifier per upload attempt', function () { + seedChunkedUploadWorkspace(); + + $seen = []; + $cloud = Mockery::mock(ChunkedCloudUploader::class); + $cloud->shouldReceive('shouldUseMultipart')->andReturn(true); + $cloud->shouldReceive('receiveChunk') + ->twice() + ->withArgs(function (string $identifier) use (&$seen) { + $seen[] = $identifier; + + return true; + }) + ->andReturn(['done' => false, 'progress' => 10]); + + $receiver = new ChunkedAssetReceiver($cloud); + $receiver->receive(test()->workspace, test()->user, 'video.mp4', 'chunk', 0, 99, 1000, 'attempt-a'); + $receiver->receive(test()->workspace, test()->user, 'video.mp4', 'chunk', 0, 99, 1000, 'attempt-b'); + + expect($seen[0])->not->toBe($seen[1]); +}); + // ─── HTTP: local / public assemble path ────────────────────────── test('chunked upload stores video on the local disk via assemble path', function () { @@ -208,7 +241,7 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 seedChunkedUploadWorkspace(); $content = fakeMp4Bytes(); - $response = postChunkedAsset('clip.mp4', $content); + $response = postChunkedAsset('clip.mp4', $content, uploadId: Str::uuid()->toString()); $response->assertSuccessful(); $response->assertJson(['done' => true, 'type' => 'video']); @@ -225,7 +258,7 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 seedChunkedUploadWorkspace(); $content = fakeMp4Bytes(); - $response = postChunkedAsset('clip.mp4', $content); + $response = postChunkedAsset('clip.mp4', $content, uploadId: Str::uuid()->toString()); $response->assertSuccessful(); $response->assertJson(['done' => true, 'type' => 'video']); @@ -243,13 +276,14 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 $part1 = fakeMp4Bytes(); $part2 = str_repeat("\0", 50); $total = strlen($part1) + strlen($part2); + $uploadId = Str::uuid()->toString(); - $mid = postChunkedAsset('clip.mp4', $part1, 0, $total); + $mid = postChunkedAsset('clip.mp4', $part1, 0, $total, uploadId: $uploadId); $mid->assertSuccessful(); $mid->assertJson(['done' => false]); expect(test()->workspace->getMedia('assets')->count())->toBe(0); - $done = postChunkedAsset('clip.mp4', $part2, strlen($part1), $total); + $done = postChunkedAsset('clip.mp4', $part2, strlen($part1), $total, uploadId: $uploadId); $done->assertSuccessful(); $done->assertJson(['done' => true, 'type' => 'video']); expect(test()->workspace->getMedia('assets')->count())->toBe(1); @@ -261,7 +295,7 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 seedChunkedUploadWorkspace(); $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); - $response = postChunkedAsset('photo.png', $content); + $response = postChunkedAsset('photo.png', $content, uploadId: Str::uuid()->toString()); $response->assertSuccessful(); $response->assertJson(['done' => true, 'type' => 'image']); @@ -291,7 +325,7 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 ]); app()->instance(ChunkedCloudUploader::class, $fake); - $response = postChunkedAsset('clip.mp4', 'fake-video!!'); + $response = postChunkedAsset('clip.mp4', 'fake-video!!', uploadId: Str::uuid()->toString()); $response->assertSuccessful(); $response->assertJson([ @@ -316,7 +350,7 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 app()->instance(ChunkedCloudUploader::class, $mock); $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); - $response = postChunkedAsset('photo.png', $content); + $response = postChunkedAsset('photo.png', $content, uploadId: Str::uuid()->toString()); $response->assertSuccessful(); $response->assertJson(['done' => true, 'type' => 'image']); @@ -333,9 +367,124 @@ function postChunkedAsset(string $fileName, string $content, int $rangeStart = 0 $mock->shouldNotReceive('receiveChunk'); app()->instance(ChunkedCloudUploader::class, $mock); - $response = postChunkedAsset('clip.mp4', fakeMp4Bytes()); + $response = postChunkedAsset('clip.mp4', fakeMp4Bytes(), uploadId: Str::uuid()->toString()); $response->assertSuccessful(); $response->assertJson(['done' => true, 'type' => 'video']); Storage::disk('local')->assertExists(test()->workspace->getMedia('assets')->first()->path); }); + +test('chunked upload rejects a request with no X-Upload-Id header', function () { + config(['filesystems.default' => 'local']); + Storage::fake('local'); + seedChunkedUploadWorkspace(); + + $response = postChunkedAsset('clip.mp4', fakeMp4Bytes()); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('upload_id'); +}); + +// ─── Concurrent duplicate uploads (regression for Nightwatch #23) ─ +// +// Same user, same filename, same total size, in flight at the same time — +// e.g. the media picker dialog is closed mid-upload and reopened, then the +// same file is uploaded again. Pre-fix these two attempts shared a single +// server-side identifier and stepped on each other's state. + +test('a second attempt completing does not corrupt or crash an in-flight sibling attempt on the multipart cloud path', function () { + config(['filesystems.default' => 'r2', 'filesystems.disks.r2.driver' => 's3']); + Storage::fake('r2'); + seedChunkedUploadWorkspace(); + + $client = Mockery::mock(S3Client::class); + $client->shouldReceive('createMultipartUpload') + ->twice() + ->andReturn(new Result(['UploadId' => 'upload-a']), new Result(['UploadId' => 'upload-b'])); + $client->shouldReceive('uploadPart') + ->times(4) + ->andReturn( + new Result(['ETag' => '"etag-a1"']), + new Result(['ETag' => '"etag-b1"']), + new Result(['ETag' => '"etag-b2"']), + new Result(['ETag' => '"etag-a2"']), + ); + $client->shouldReceive('completeMultipartUpload') + ->twice() + ->andReturn(new Result([])); + + app()->instance( + ChunkedCloudUploader::class, + new ChunkedCloudUploader(Cache::store(), $client, 'test-bucket', 'r2'), + ); + + $total = ChunkedCloudUploader::MIN_PART_BYTES + 50; + $attemptA = Str::uuid()->toString(); + $attemptB = Str::uuid()->toString(); + + // Real mp4 magic bytes padded with nulls, so finfo reliably detects + // video/mp4 on the first chunk regardless of libmagic's signature + // database — random/arbitrary byte patterns occasionally collide with + // an unrelated magic number (MZ/PE, SIMH tape, ...) and flake. + $firstPartA = str_pad(fakeMp4Bytes(), ChunkedCloudUploader::MIN_PART_BYTES, "\0"); + $firstPartB = str_pad(fakeMp4Bytes(), ChunkedCloudUploader::MIN_PART_BYTES, "\0"); + + // A0: attempt A starts, first (non-final) part. + postChunkedAsset('clip.mp4', $firstPartA, 0, $total, uploadId: $attemptA) + ->assertSuccessful(); + + // B0: attempt B, identical filename+size, first part. Pre-fix this shares + // A's cache key; since A's next_offset is already > 0 it becomes a no-op + // idempotent replay that silently reuses A's session instead of starting + // its own. + postChunkedAsset('clip.mp4', $firstPartB, 0, $total, uploadId: $attemptB) + ->assertSuccessful(); + + // B1: attempt B's final chunk. Pre-fix this matches A's next_offset + // exactly, so it completes A's own multipart upload using B's bytes as + // part 2 (silent corruption), then forgets the shared cache key. + $doneB = postChunkedAsset('clip.mp4', str_repeat('b', 50), ChunkedCloudUploader::MIN_PART_BYTES, $total, uploadId: $attemptB); + + // A1: attempt A's own final chunk. Pre-fix the cache key is now gone, so + // this throws RuntimeException("Chunked cloud upload session expired or + // missing.") — the exact Nightwatch #23 crash. + $doneA = postChunkedAsset('clip.mp4', str_repeat('a', 50), ChunkedCloudUploader::MIN_PART_BYTES, $total, uploadId: $attemptA); + + $doneA->assertSuccessful(); + $doneA->assertJson(['done' => true]); + $doneB->assertSuccessful(); + $doneB->assertJson(['done' => true]); + + expect($doneA->json('id'))->not->toBe($doneB->json('id')); + expect($doneA->json('path'))->not->toBe($doneB->json('path')); +}); + +test('two concurrent attempts of the same file do not corrupt each other on the local assemble path', function () { + config(['filesystems.default' => 'local']); + Storage::fake('local'); + seedChunkedUploadWorkspace(); + + $header = fakeMp4Bytes(); + $tailA = 'AAAA'; + $tailB = 'BBBB'; + $total = strlen($header) + 4; + $attemptA = Str::uuid()->toString(); + $attemptB = Str::uuid()->toString(); + + postChunkedAsset('clip.mp4', $header, 0, $total, uploadId: $attemptA)->assertSuccessful(); + postChunkedAsset('clip.mp4', $header, 0, $total, uploadId: $attemptB)->assertSuccessful(); + + $doneA = postChunkedAsset('clip.mp4', $tailA, strlen($header), $total, uploadId: $attemptA); + $doneB = postChunkedAsset('clip.mp4', $tailB, strlen($header), $total, uploadId: $attemptB); + + $doneA->assertSuccessful(); + $doneA->assertJson(['done' => true]); + $doneB->assertSuccessful(); + $doneB->assertJson(['done' => true]); + + $mediaA = Media::find($doneA->json('id')); + $mediaB = Media::find($doneB->json('id')); + + expect(Storage::disk('local')->get($mediaA->path))->toBe($header.$tailA); + expect(Storage::disk('local')->get($mediaB->path))->toBe($header.$tailB); +}); diff --git a/tests/Feature/ChunkedUploadFilenameEncodingTest.php b/tests/Feature/ChunkedUploadFilenameEncodingTest.php index 5d0b68861..8dc7ca675 100644 --- a/tests/Feature/ChunkedUploadFilenameEncodingTest.php +++ b/tests/Feature/ChunkedUploadFilenameEncodingTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Models\Workspace; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Illuminate\Testing\TestResponse; beforeEach(function () { @@ -43,6 +44,7 @@ function postEncodedChunkedUpload(string $fileName, string $content): TestRespon [ 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, 'HTTP_X_FILE_NAME' => rawurlencode($fileName), + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ], @@ -95,6 +97,7 @@ function postEncodedChunkedUpload(string $fileName, string $content): TestRespon [ 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, 'HTTP_X_FILE_NAME' => 'plain-ascii.png', + 'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(), 'HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/octet-stream', ],