Skip to content

Commit 676afb1

Browse files
fix: chunked upload session collision + workspace name i18n (#263)
* fix: give each chunked upload attempt a unique server-side identifier The upload session identifier was derived only from user+filename+size (ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely concurrent attempts of the same file (e.g. closing and reopening the media picker mid-upload, then re-uploading the same file) collided on the same Redis cache key / temp file, producing RuntimeException("Chunked cloud upload session expired or missing.") on the multipart/cloud path and silent byte corruption on the local-assemble path. The frontend now mints a UUID per upload attempt (X-Upload-Id header) that gets folded into the identifier. Falls back to the old formula when the header is absent, so any already-loaded frontend bundle keeps working. Also guards the media picker's dropzone against re-triggering an upload while one is in flight, and aborts the in-flight fetch when the dialog unmounts mid-upload. Fixes Nightwatch issue #23. * fix: explicitly type upload_id when passing to receive() Matches the existing explicit (int) casts on the sibling validated() calls in the same method — validated() returns mixed, so this keeps the nullable-string contract explicit instead of relying on an implicit runtime type. * style: inline the upload_id null-safe cast Drop the intermediate variable so all receive() arguments read as a single expression each, matching the sibling validated() casts. * fix: require X-Upload-Id instead of falling back to the legacy identifier Nullable upload_id only preserved the old (collision-prone) formula for clients that omit the header — it didn't actually protect them. Making it required closes that gap outright: a request without the header now fails loud (422) instead of silently falling back to the vulnerable identifier. ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest, ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest) to send a real upload id, and added a regression test asserting the endpoint rejects a request with no X-Upload-Id header. * fix: localize hardcoded workspace name validation messages StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR regardless of the user's locale; UpdateWorkspaceRequest had the same bug hardcoded in English. Both now go through __('validation.required' / 'validation.max.string') with the already-localized workspaces.create.name attribute label (present in all 16 lang/ directories), matching the pattern already used by StoreWorkspaceInviteRequest. Unrelated to the chunked upload fix, but caught while reviewing this file's messages() convention. * simplify: drop messages() override on workspace name validation Laravel already localizes the generic required/max messages from lang/{locale}/validation.php automatically — no need to hand-roll messages() for standard rules with no custom copy. * fix: localize StoreChunkedAssetRequest validation messages Drop the hardcoded English messages for required/ends_with rules — Laravel's own localized validation.php messages already cover them adequately (ends_with's generic message is actually more useful, since it lists the accepted extensions). total_size.max still needs a custom message (the rule is in raw bytes, unreadable without MB conversion), so it now goes through __('assets.upload.file_too_large') with the key added to all 16 lang/ locales. Also fixed test flakiness discovered while touching this file: ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk, which occasionally collides with an unrelated magic number (MZ/PE, SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with real mp4 header bytes padded with nulls, so detection is deterministic. * fix: address final code review findings - ChunkedAssetReceiver: use double-quoted interpolation instead of concatenation for the identifier hash, per project convention. - AssetControllerTest: two chunked-upload rejection tests didn't send X-Upload-Id, so their 422 assertions could pass for the wrong reason (upload_id.required) instead of the field they claim to cover. Added the header and asserted the specific validation error field. - GalleryBrowser: centralize the upload-in-progress guard as a single check at the top of uploadFiles() instead of three separate checks at each entry point (click/select/drop) — matches the single-source- of-truth pattern already used in PhotoUpload.vue. - GalleryBrowser: show a toast when an in-flight upload is aborted (dialog closed mid-upload) instead of silently discarding it with no feedback. New assets.upload.cancelled key added to all 16 lang/ locales.
1 parent 8088864 commit 676afb1

27 files changed

Lines changed: 238 additions & 39 deletions

app/Http/Controllers/App/AssetController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public function storeChunked(StoreChunkedAssetRequest $request, ChunkedAssetRece
8383
(int) $request->validated('range_start'),
8484
(int) $request->validated('range_end'),
8585
(int) $request->validated('total_size'),
86+
(string) $request->validated('upload_id'),
8687
)->toResponse();
8788
}
8889

app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ protected function prepareForValidation(): void
2929
'range_end' => $parsed[1] ?? null,
3030
'total_size' => $parsed[2] ?? null,
3131
'file_name' => strtolower(rawurldecode((string) $this->header('X-File-Name', 'upload'))),
32+
'upload_id' => $this->header('X-Upload-Id'),
3233
]);
3334
}
3435

@@ -47,6 +48,7 @@ public function rules(): array
4748
'range_end' => ['required', 'integer', 'gte:range_start'],
4849
'total_size' => ['required', 'integer', 'min:1', 'max:'.MediaType::Video->maxSizeInBytes()],
4950
'file_name' => ['required', 'string', 'ends_with:'.implode(',', $allowedSuffixes)],
51+
'upload_id' => ['required', 'string', 'uuid'],
5052
];
5153
}
5254

@@ -56,11 +58,7 @@ public function rules(): array
5658
public function messages(): array
5759
{
5860
return [
59-
'range_start.required' => 'Invalid Content-Range header',
60-
'range_end.required' => 'Invalid Content-Range header',
61-
'total_size.required' => 'Invalid Content-Range header',
62-
'total_size.max' => 'File size exceeds the maximum allowed ('.MediaType::Video->maxSizeInMb().' MB).',
63-
'file_name.ends_with' => 'File type not supported.',
61+
'total_size.max' => __('assets.upload.file_too_large', ['max' => MediaType::Video->maxSizeInMb()]),
6462
];
6563
}
6664
}

app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,4 @@ public function rules(): array
3838
'logo_url' => ['nullable', 'url', 'max:1024'],
3939
];
4040
}
41-
42-
public function messages(): array
43-
{
44-
return [
45-
'name.required' => 'O nome do workspace é obrigatório.',
46-
'name.max' => 'O nome do workspace deve ter no máximo 255 caracteres.',
47-
];
48-
}
4941
}

app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,4 @@ public function rules(): array
3737
'logo_url' => ['nullable', 'url', 'max:1024'],
3838
];
3939
}
40-
41-
public function messages(): array
42-
{
43-
return [
44-
'name.required' => 'The workspace name is required.',
45-
'name.max' => 'The workspace name must be at most 255 characters.',
46-
];
47-
}
4840
}

app/Services/Media/ChunkedAssetReceiver.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ public function receive(
2121
int $rangeStart,
2222
int $rangeEnd,
2323
int $totalSize,
24+
string $attemptId,
2425
): ChunkReceipt {
25-
$identifier = md5($user->id.$fileName.$totalSize);
26+
$identifier = md5("{$user->id}{$fileName}{$totalSize}{$attemptId}");
2627

2728
return $this->cloud->shouldUseMultipart($fileName)
2829
? $this->receiveViaMultipart($workspace, $identifier, $fileName, $chunk, $rangeStart, $rangeEnd, $totalSize)

lang/ar/assets.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
'formats' => 'JPEG، PNG، GIF، WebP، MP4، PDF',
1515
'uploading' => 'جارٍ الرفع...',
1616
'failed' => 'تعذر رفع :file. يرجى المحاولة مرة أخرى.',
17+
'file_too_large' => 'حجم الملف يتجاوز الحد الأقصى المسموح به (:max ميجابايت).',
18+
'cancelled' => 'تم إلغاء الرفع.',
1719
],
1820

1921
'empty' => [

lang/de/assets.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
1717
'uploading' => 'Wird hochgeladen...',
1818
'failed' => ':file konnte nicht hochgeladen werden. Bitte versuche es erneut.',
19+
'file_too_large' => 'Die Dateigröße überschreitet das zulässige Maximum (:max MB).',
20+
'cancelled' => 'Upload abgebrochen.',
1921
],
2022

2123
'empty' => [

lang/el/assets.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
1515
'uploading' => 'Μεταφόρτωση...',
1616
'failed' => 'Δεν ήταν δυνατή η μεταφόρτωση του :file. Παρακαλούμε δοκιμάστε ξανά.',
17+
'file_too_large' => 'Το μέγεθος του αρχείου υπερβαίνει το μέγιστο επιτρεπόμενο (:max MB).',
18+
'cancelled' => 'Η μεταφόρτωση ακυρώθηκε.',
1719
],
1820

1921
'empty' => [

lang/en/assets.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
1515
'uploading' => 'Uploading...',
1616
'failed' => 'Could not upload :file. Please try again.',
17+
'file_too_large' => 'File size exceeds the maximum allowed (:max MB).',
18+
'cancelled' => 'Upload cancelled.',
1719
],
1820

1921
'empty' => [

lang/es/assets.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
1717
'uploading' => 'Subiendo...',
1818
'failed' => 'No se pudo subir :file. Inténtalo de nuevo.',
19+
'file_too_large' => 'El tamaño del archivo supera el máximo permitido (:max MB).',
20+
'cancelled' => 'Subida cancelada.',
1921
],
2022

2123
'empty' => [

0 commit comments

Comments
 (0)