2026-05-04 17:54:47 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Requests\App\Asset;
|
|
|
|
|
|
|
|
|
|
use App\Enums\Media\Type as MediaType;
|
|
|
|
|
use Illuminate\Foundation\Http\FormRequest;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validates a chunked upload request. The chunk metadata (offset / total
|
|
|
|
|
* size / filename) is encoded in the `Content-Range` and `X-File-Name`
|
|
|
|
|
* headers, not in the body, so we lift it into the request bag via
|
|
|
|
|
* `prepareForValidation` and then run standard rules against it.
|
|
|
|
|
*/
|
|
|
|
|
class StoreChunkedAssetRequest extends FormRequest
|
|
|
|
|
{
|
|
|
|
|
public function authorize(): bool
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function prepareForValidation(): void
|
|
|
|
|
{
|
2026-05-04 17:59:57 +00:00
|
|
|
$parsed = sscanf((string) $this->header('Content-Range'), 'bytes %d-%d/%d') ?: [];
|
2026-05-04 17:54:47 +00:00
|
|
|
|
|
|
|
|
$this->merge([
|
2026-05-04 17:59:57 +00:00
|
|
|
'range_start' => $parsed[0] ?? null,
|
|
|
|
|
'range_end' => $parsed[1] ?? null,
|
|
|
|
|
'total_size' => $parsed[2] ?? null,
|
2026-07-23 13:46:48 +00:00
|
|
|
'file_name' => strtolower(rawurldecode((string) $this->header('X-File-Name', 'upload'))),
|
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.
2026-08-09 17:06:47 +00:00
|
|
|
'upload_id' => $this->header('X-Upload-Id'),
|
2026-05-04 17:54:47 +00:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, array<int, string>>
|
|
|
|
|
*/
|
|
|
|
|
public function rules(): array
|
|
|
|
|
{
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
$allowedSuffixes = collect([MediaType::Image, MediaType::Video, MediaType::Document])
|
2026-05-04 17:59:57 +00:00
|
|
|
->flatMap(fn (MediaType $type) => $type->extensions())
|
|
|
|
|
->map(fn (string $ext) => '.'.$ext)
|
|
|
|
|
->all();
|
|
|
|
|
|
2026-05-04 17:54:47 +00:00
|
|
|
return [
|
|
|
|
|
'range_start' => ['required', 'integer', 'min:0'],
|
|
|
|
|
'range_end' => ['required', 'integer', 'gte:range_start'],
|
|
|
|
|
'total_size' => ['required', 'integer', 'min:1', 'max:'.MediaType::Video->maxSizeInBytes()],
|
2026-05-04 17:59:57 +00:00
|
|
|
'file_name' => ['required', 'string', 'ends_with:'.implode(',', $allowedSuffixes)],
|
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.
2026-08-09 17:06:47 +00:00
|
|
|
'upload_id' => ['required', 'string', 'uuid'],
|
2026-05-04 17:54:47 +00:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, string>
|
|
|
|
|
*/
|
|
|
|
|
public function messages(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
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.
2026-08-09 17:06:47 +00:00
|
|
|
'total_size.max' => __('assets.upload.file_too_large', ['max' => MediaType::Video->maxSizeInMb()]),
|
2026-05-04 17:54:47 +00:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|