2026-04-15 13:20:37 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Enums\UserWorkspace\Role;
|
|
|
|
|
use App\Models\Account;
|
|
|
|
|
use App\Models\Media;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use App\Services\UnsplashService;
|
|
|
|
|
use Illuminate\Http\UploadedFile;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
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
|
|
|
use Illuminate\Support\Str;
|
2026-04-15 13:20:37 +00:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('assets index shows assets page', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)->get(route('app.assets.index'));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
$response->assertInertia(fn ($page) => $page->component('assets/Index', false));
|
2026-04-15 13:20:37 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('assets index requires authentication', function () {
|
|
|
|
|
$response = $this->get(route('app.assets.index'));
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
feat: media gallery picker, custom emoji picker, preview tabs, real platform logos
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
2026-05-01 17:53:49 +00:00
|
|
|
test('assets search returns paginated json filtered by name', function () {
|
|
|
|
|
$matching = $this->workspace->addMedia(UploadedFile::fake()->image('vacation-beach.jpg'), 'assets');
|
|
|
|
|
$this->workspace->addMedia(UploadedFile::fake()->image('office-shot.jpg'), 'assets');
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.assets.search', ['search' => 'vacation']));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertJsonCount(1, 'data');
|
|
|
|
|
$response->assertJsonPath('data.0.id', $matching->id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('assets search filters by type', function () {
|
|
|
|
|
$this->workspace->addMedia(UploadedFile::fake()->image('photo.jpg'), 'assets');
|
|
|
|
|
$this->workspace->addMedia(UploadedFile::fake()->create('clip.mp4', 100, 'video/mp4'), 'assets');
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.assets.search', ['type' => 'video']));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertJsonCount(1, 'data');
|
|
|
|
|
$response->assertJsonPath('data.0.type', 'video');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('assets search only returns the current workspace assets', function () {
|
|
|
|
|
$this->workspace->addMedia(UploadedFile::fake()->image('mine.jpg'), 'assets');
|
|
|
|
|
|
|
|
|
|
$otherAccount = Account::factory()->create();
|
|
|
|
|
$otherUser = User::factory()->create(['account_id' => $otherAccount->id]);
|
|
|
|
|
$otherWorkspace = Workspace::factory()->create([
|
|
|
|
|
'account_id' => $otherAccount->id,
|
|
|
|
|
'user_id' => $otherUser->id,
|
|
|
|
|
]);
|
|
|
|
|
$otherWorkspace->addMedia(UploadedFile::fake()->image('theirs.jpg'), 'assets');
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.assets.search'));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertJsonCount(1, 'data');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 13:20:37 +00:00
|
|
|
test('can upload an image asset', function () {
|
|
|
|
|
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->postJson(route('app.assets.store'), ['media' => $file]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$response->assertCreated();
|
2026-04-15 13:20:37 +00:00
|
|
|
$response->assertJsonStructure(['id', 'url', 'type', 'original_filename', 'size']);
|
|
|
|
|
|
|
|
|
|
expect($this->workspace->getMedia('assets')->count())->toBe(1);
|
|
|
|
|
|
|
|
|
|
$media = $this->workspace->getMedia('assets')->first();
|
|
|
|
|
expect($media->original_filename)->toBe('photo.jpg');
|
|
|
|
|
expect($media->collection)->toBe('assets');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('can delete an asset', function () {
|
|
|
|
|
$file = UploadedFile::fake()->image('photo.jpg');
|
|
|
|
|
$media = $this->workspace->addMedia($file, 'assets');
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->delete(route('app.assets.destroy', $media));
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
expect(Media::find($media->id))->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('cannot delete asset from another workspace', function () {
|
|
|
|
|
$otherWorkspace = Workspace::factory()->create([
|
|
|
|
|
'account_id' => $this->account->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$file = UploadedFile::fake()->image('photo.jpg');
|
|
|
|
|
$media = $otherWorkspace->addMedia($file, 'assets');
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->delete(route('app.assets.destroy', $media));
|
|
|
|
|
|
|
|
|
|
$response->assertForbidden();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('can store asset from url', function () {
|
|
|
|
|
$fakeImage = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
|
|
|
|
$imageContent = file_get_contents($fakeImage->getPathname());
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'images.unsplash.com/*' => Http::response(
|
|
|
|
|
$imageContent,
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/jpeg']
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$unsplash = $this->mock(UnsplashService::class);
|
|
|
|
|
$unsplash->shouldReceive('trackDownload')->once();
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
2026-04-17 02:05:51 +00:00
|
|
|
->postJson(route('app.assets.store-from-url'), [
|
2026-04-15 13:20:37 +00:00
|
|
|
'url' => 'https://images.unsplash.com/photo-test',
|
|
|
|
|
'filename' => 'unsplash-test.jpg',
|
|
|
|
|
'download_location' => 'https://api.unsplash.com/photos/test/download',
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-17 02:05:51 +00:00
|
|
|
$response->assertCreated();
|
|
|
|
|
$response->assertJsonStructure(['id', 'path', 'url', 'type', 'mime_type', 'original_filename', 'size']);
|
2026-04-15 13:20:37 +00:00
|
|
|
|
|
|
|
|
expect($this->workspace->getMedia('assets')->count())->toBe(1);
|
2026-04-17 02:05:51 +00:00
|
|
|
|
|
|
|
|
$media = $this->workspace->getMedia('assets')->first();
|
|
|
|
|
$response->assertJsonPath('id', $media->id);
|
|
|
|
|
$response->assertJsonPath('type', 'image');
|
2026-04-15 13:20:37 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-17 18:40:15 +00:00
|
|
|
test('rejects a raw private-network url before it reaches the controller', function () {
|
|
|
|
|
// StoreAssetFromUrlRequest already allow-lists the host to
|
|
|
|
|
// images.unsplash.com / media*.giphy.com, so a bare private-IP url like
|
|
|
|
|
// 127.0.0.1 never reaches the controller — it 422s at the FormRequest
|
|
|
|
|
// layer. The SSRF guard below is defense-in-depth against a redirect
|
|
|
|
|
// (or DNS rebind) from one of the allow-listed hosts to an internal one.
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->postJson(route('app.assets.store-from-url'), [
|
|
|
|
|
'url' => 'http://127.0.0.1/evil.jpg',
|
|
|
|
|
'filename' => 'evil.jpg',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertUnprocessable();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('blocks store asset from url when an allow-listed host redirects to a private ip', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'images.unsplash.com/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']),
|
|
|
|
|
'http://127.0.0.1/*' => Http::response('internal secret', 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$unsplash = $this->mock(UnsplashService::class);
|
|
|
|
|
$unsplash->shouldReceive('trackDownload')->once();
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->postJson(route('app.assets.store-from-url'), [
|
|
|
|
|
'url' => 'https://images.unsplash.com/photo-test',
|
|
|
|
|
'filename' => 'unsplash-test.jpg',
|
|
|
|
|
'download_location' => 'https://api.unsplash.com/photos/test/download',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertStatus(400);
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($r) => str_contains($r->url(), '127.0.0.1'));
|
|
|
|
|
expect(Media::count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
test('chunked upload completes with single chunk', function () {
|
2026-05-04 17:54:47 +00:00
|
|
|
// Use real PNG bytes so mime_content_type detects image/png. The MIME
|
|
|
|
|
// is sniffed from content magic bytes, not the X-File-Name header.
|
|
|
|
|
$content = file_get_contents(__DIR__.'/../fixtures/1x1.png');
|
|
|
|
|
$size = strlen($content);
|
2026-05-02 15:22:42 +00:00
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->call(
|
|
|
|
|
'POST',
|
|
|
|
|
route('app.assets.store-chunked'),
|
|
|
|
|
[], [], [],
|
|
|
|
|
[
|
2026-05-04 17:54:47 +00:00
|
|
|
'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size,
|
|
|
|
|
'HTTP_X_FILE_NAME' => 'test.png',
|
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
|
|
|
'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(),
|
2026-05-02 15:22:42 +00:00
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
'CONTENT_TYPE' => 'application/octet-stream',
|
|
|
|
|
],
|
|
|
|
|
$content,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$response->assertSuccessful();
|
|
|
|
|
$response->assertJson(['done' => true]);
|
|
|
|
|
$response->assertJsonStructure(['done', 'id', 'path', 'url', 'type', 'mime_type', 'original_filename', 'size']);
|
|
|
|
|
expect($this->workspace->getMedia('assets')->count())->toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
test('chunked upload completes for a pdf document', function () {
|
|
|
|
|
// Real %PDF magic bytes so mime_content_type detects application/pdf.
|
|
|
|
|
$content = "%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n";
|
|
|
|
|
$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' => 'deck.pdf',
|
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
|
|
|
'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(),
|
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
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
'CONTENT_TYPE' => 'application/octet-stream',
|
|
|
|
|
],
|
|
|
|
|
$content,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$response->assertSuccessful();
|
|
|
|
|
$response->assertJson(['done' => true]);
|
|
|
|
|
expect($this->workspace->getMedia('assets')->first()->type->value)->toBe('document');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
test('chunked upload reports progress on intermediate chunks', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)->call(
|
|
|
|
|
'POST',
|
|
|
|
|
route('app.assets.store-chunked'),
|
|
|
|
|
[], [], [],
|
|
|
|
|
[
|
|
|
|
|
'HTTP_CONTENT_RANGE' => 'bytes 0-499/1000',
|
|
|
|
|
'HTTP_X_FILE_NAME' => 'test-video.mp4',
|
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
|
|
|
'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(),
|
2026-05-02 15:22:42 +00:00
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
'CONTENT_TYPE' => 'application/octet-stream',
|
|
|
|
|
],
|
|
|
|
|
str_repeat('a', 500),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$response->assertSuccessful();
|
|
|
|
|
$response->assertJson(['done' => false, 'progress' => 50]);
|
|
|
|
|
expect(Media::count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('chunked upload rejects unsupported file extension', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)->call(
|
|
|
|
|
'POST',
|
|
|
|
|
route('app.assets.store-chunked'),
|
|
|
|
|
[], [], [],
|
|
|
|
|
[
|
|
|
|
|
'HTTP_CONTENT_RANGE' => 'bytes 0-99/100',
|
|
|
|
|
'HTTP_X_FILE_NAME' => 'malware.exe',
|
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
|
|
|
'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(),
|
2026-05-02 15:22:42 +00:00
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
'CONTENT_TYPE' => 'application/octet-stream',
|
|
|
|
|
],
|
|
|
|
|
str_repeat('x', 100),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$response->assertUnprocessable();
|
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
|
|
|
$response->assertJsonValidationErrors('file_name');
|
2026-05-02 15:22:42 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('chunked upload rejects invalid Content-Range header', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)->call(
|
|
|
|
|
'POST',
|
|
|
|
|
route('app.assets.store-chunked'),
|
|
|
|
|
[], [], [],
|
|
|
|
|
[
|
|
|
|
|
'HTTP_CONTENT_RANGE' => 'invalid',
|
|
|
|
|
'HTTP_X_FILE_NAME' => 'test.jpg',
|
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
|
|
|
'HTTP_X_UPLOAD_ID' => Str::uuid()->toString(),
|
2026-05-02 15:22:42 +00:00
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
'CONTENT_TYPE' => 'application/octet-stream',
|
|
|
|
|
],
|
|
|
|
|
'data',
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-04 17:54:47 +00:00
|
|
|
// FormRequest validation surfaces parse failures as 422
|
|
|
|
|
// (range_start / range_end / total_size all required).
|
|
|
|
|
$response->assertUnprocessable();
|
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
|
|
|
$response->assertJsonValidationErrors(['range_start', 'range_end', 'total_size']);
|
2026-05-02 15:22:42 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('chunked upload rejects unauthenticated', function () {
|
|
|
|
|
$response = $this->call(
|
|
|
|
|
'POST',
|
|
|
|
|
route('app.assets.store-chunked'),
|
|
|
|
|
[], [], [],
|
|
|
|
|
[
|
|
|
|
|
'HTTP_CONTENT_RANGE' => 'bytes 0-99/100',
|
|
|
|
|
'HTTP_X_FILE_NAME' => 'test.jpg',
|
|
|
|
|
'HTTP_ACCEPT' => 'application/json',
|
|
|
|
|
'CONTENT_TYPE' => 'application/octet-stream',
|
|
|
|
|
],
|
|
|
|
|
str_repeat('x', 100),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$response->assertUnauthorized();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 13:20:37 +00:00
|
|
|
test('unsplash search returns results', function () {
|
|
|
|
|
$this->mock(UnsplashService::class)
|
|
|
|
|
->shouldReceive('search')
|
|
|
|
|
->with('nature', 1)
|
|
|
|
|
->once()
|
|
|
|
|
->andReturn([
|
|
|
|
|
'results' => [
|
|
|
|
|
['id' => 'abc', 'url_small' => 'https://example.com/small.jpg'],
|
|
|
|
|
],
|
|
|
|
|
'total' => 1,
|
|
|
|
|
'total_pages' => 1,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.assets.unsplash.search', ['query' => 'nature']));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertJsonPath('total', 1);
|
|
|
|
|
});
|