diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index 3997bf40..2d8bf40e 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -10,6 +10,7 @@ use App\Http\Resources\App\MediaResource; use App\Models\Media; use App\Services\Brand\SafeHttpFetcher; +use App\Services\Media\ChunkedAssetReceiver; use App\Services\UnsplashService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -68,52 +69,21 @@ public function store(StoreAssetRequest $request): MediaResource return new MediaResource($media); } - public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse + public function storeChunked(StoreChunkedAssetRequest $request, ChunkedAssetReceiver $receiver): JsonResponse { $workspace = $request->user()->currentWorkspace; $this->authorize('createPost', $workspace); - $rangeStart = (int) $request->validated('range_start'); - $rangeEnd = (int) $request->validated('range_end'); - $totalSize = (int) $request->validated('total_size'); - $fileName = (string) $request->validated('file_name'); - - $identifier = md5($request->user()->id.$fileName.$totalSize); - $tempFile = storage_path("app/private/chunks/{$identifier}"); - - $directory = dirname($tempFile); - if (! is_dir($directory)) { - mkdir($directory, 0755, true); - } - - file_put_contents($tempFile, $request->getContent(), $rangeStart === 0 ? 0 : FILE_APPEND); - - $isLastChunk = ($rangeEnd + 1) >= $totalSize; - - if (! $isLastChunk) { - return response()->json([ - 'done' => false, - 'progress' => (int) round(($rangeEnd + 1) / $totalSize * 100), - ]); - } - - $media = $workspace->addMediaFromPath($tempFile, $fileName, 'assets'); - - @unlink($tempFile); - - return response()->json([ - 'done' => true, - 'id' => $media->id, - 'path' => $media->path, - 'url' => $media->url, - 'type' => $media->type->value, - 'mime_type' => $media->mime_type, - 'original_filename' => $media->original_filename, - 'size' => $media->size, - 'meta' => $media->meta, - 'created_at' => $media->created_at->toISOString(), - ]); + return $receiver->receive( + $workspace, + $request->user(), + (string) $request->validated('file_name'), + $request->getContent(), + (int) $request->validated('range_start'), + (int) $request->validated('range_end'), + (int) $request->validated('total_size'), + )->toResponse(); } public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $unsplash, SafeHttpFetcher $safeHttp): MediaResource @@ -150,7 +120,7 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService }; $filename = Str::uuid().'.'.$extension; - $path = 'medias/'.$filename; + $path = "medias/{$filename}"; Storage::put($path, $response->body()); diff --git a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php index 3244d682..fbf100c6 100644 --- a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php @@ -28,9 +28,7 @@ protected function prepareForValidation(): void 'range_start' => $parsed[0] ?? null, 'range_end' => $parsed[1] ?? null, 'total_size' => $parsed[2] ?? null, - // Lowercase the name so `ends_with` validation is effectively - // case-insensitive (IMG_1234.JPG vs img_1234.jpg). - 'file_name' => strtolower((string) $this->header('X-File-Name', 'upload')), + 'file_name' => strtolower(rawurldecode((string) $this->header('X-File-Name', 'upload'))), ]); } diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index f2e1752d..da4b39eb 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -16,6 +16,7 @@ use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Encoders\JpegEncoder; use Intervention\Image\ImageManager; +use RuntimeException; trait HasMedia { @@ -87,7 +88,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr ); $filename = Str::uuid().'.'.$normalizedExt; - $path = 'medias/'.$filename; + $path = "medias/{$filename}"; Storage::put($path, $normalizedBytes); @@ -106,6 +107,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr /** * Add media from a file path (used for chunked uploads). + * Images are normalized in memory; videos/PDFs are streamed to storage. */ public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null): Media { @@ -117,17 +119,41 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str $type = $this->getMediaType($mimeType); $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); - [$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat( - $filePath, - $mimeType, - $type, - $extension, - ); + $stored = $type === Type::Image->value + ? $this->storeImageFromPath($filePath, $mimeType, $type, $extension, $meta) + : $this->streamFileToStorage($filePath, $mimeType, $extension, $meta); - $filename = Str::uuid().'.'.$normalizedExt; - $storagePath = 'medias/'.$filename; + return $this->media()->create([ + 'group_id' => $groupId ?? Str::uuid()->toString(), + 'collection' => $collection, + 'type' => $type, + 'path' => $stored['path'], + 'original_filename' => $originalFilename, + 'mime_type' => $stored['mime_type'], + 'size' => $stored['size'], + 'order' => 0, + 'meta' => $stored['meta'], + ]); + } - Storage::put($storagePath, $normalizedBytes); + /** + * Register media that is already stored on the default disk (e.g. after a + * multipart cloud upload). + */ + public function addMediaFromStoredPath( + string $storagePath, + string $originalFilename, + string $mimeType, + int $size, + string $collection = 'default', + array $meta = [], + ?string $groupId = null, + ): Media { + if ($this->isSingleMediaCollection($collection)) { + $this->clearMediaCollection($collection); + } + + $type = $this->getMediaType($mimeType); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), @@ -135,10 +161,10 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str 'type' => $type, 'path' => $storagePath, 'original_filename' => $originalFilename, - 'mime_type' => $normalizedMime, - 'size' => strlen($normalizedBytes), + 'mime_type' => $mimeType, + 'size' => $size, 'order' => 0, - 'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta), + 'meta' => $meta, ]); } @@ -155,6 +181,61 @@ public function isSingleMediaCollection(string $collection): bool return $config === 'single'; } + /** + * @param array $meta + * @return array{path: string, mime_type: string, size: int, meta: array} + */ + private function storeImageFromPath(string $filePath, string $mimeType, string $type, string $extension, array $meta): array + { + [$bytes, $storedMime, $storedExt] = $this->normalizeImageFormat( + $filePath, + $mimeType, + $type, + $extension, + ); + + $filename = Str::uuid().".{$storedExt}"; + $path = "medias/{$filename}"; + Storage::put($path, $bytes); + + return [ + 'path' => $path, + 'mime_type' => $storedMime, + 'size' => strlen($bytes), + 'meta' => array_merge($this->getMediaMetaFromBytes($bytes, $type, $meta), $meta), + ]; + } + + /** + * @param array $meta + * @return array{path: string, mime_type: string, size: int, meta: array} + */ + private function streamFileToStorage(string $filePath, string $mimeType, string $extension, array $meta): array + { + $filename = Str::uuid().".{$extension}"; + $path = "medias/{$filename}"; + $stream = fopen($filePath, 'rb'); + + if ($stream === false) { + throw new RuntimeException("Unable to open media file for reading: {$filePath}"); + } + + try { + Storage::writeStream($path, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + return [ + 'path' => $path, + 'mime_type' => $mimeType, + 'size' => (int) filesize($filePath), + 'meta' => $meta, + ]; + } + private function getMediaType(string $mimeType): string { return (Type::classify($mimeType) diff --git a/app/Services/Media/ChunkReceipt.php b/app/Services/Media/ChunkReceipt.php new file mode 100644 index 00000000..e5f576c7 --- /dev/null +++ b/app/Services/Media/ChunkReceipt.php @@ -0,0 +1,43 @@ +done) { + return response()->json([ + 'done' => false, + 'progress' => $this->progress, + ]); + } + + return response()->json([ + 'done' => true, + ...(new MediaResource($this->media))->resolve(), + ]); + } +} diff --git a/app/Services/Media/ChunkedAssetReceiver.php b/app/Services/Media/ChunkedAssetReceiver.php new file mode 100644 index 00000000..fa09a595 --- /dev/null +++ b/app/Services/Media/ChunkedAssetReceiver.php @@ -0,0 +1,102 @@ +id.$fileName.$totalSize); + + return $this->cloud->shouldUseMultipart($fileName) + ? $this->receiveViaMultipart($workspace, $identifier, $fileName, $chunk, $rangeStart, $rangeEnd, $totalSize) + : $this->receiveViaLocalAssemble($workspace, $identifier, $fileName, $chunk, $rangeStart, $rangeEnd, $totalSize); + } + + private function receiveViaMultipart( + Workspace $workspace, + string $identifier, + string $fileName, + string $chunk, + int $rangeStart, + int $rangeEnd, + int $totalSize, + ): ChunkReceipt { + $result = $this->cloud->receiveChunk( + $identifier, + $fileName, + $chunk, + $rangeStart, + $rangeEnd, + $totalSize, + ); + + if (! data_get($result, 'done')) { + return ChunkReceipt::inProgress((int) data_get($result, 'progress')); + } + + $path = (string) data_get($result, 'path'); + + try { + $media = $workspace->addMediaFromStoredPath( + $path, + $fileName, + (string) data_get($result, 'mime_type'), + (int) data_get($result, 'size'), + 'assets', + ); + } catch (Throwable $exception) { + Storage::delete($path); + + throw $exception; + } + + return ChunkReceipt::completed($media); + } + + private function receiveViaLocalAssemble( + Workspace $workspace, + string $identifier, + string $fileName, + string $chunk, + int $rangeStart, + int $rangeEnd, + int $totalSize, + ): ChunkReceipt { + $tempFile = storage_path("app/private/chunks/{$identifier}"); + + if (! is_dir(dirname($tempFile))) { + mkdir(dirname($tempFile), 0755, true); + } + + file_put_contents($tempFile, $chunk, $rangeStart === 0 ? 0 : FILE_APPEND); + + if (($rangeEnd + 1) < $totalSize) { + return ChunkReceipt::inProgress((int) round(($rangeEnd + 1) / $totalSize * 100)); + } + + try { + $media = $workspace->addMediaFromPath($tempFile, $fileName, 'assets'); + } finally { + @unlink($tempFile); + } + + return ChunkReceipt::completed($media); + } +} diff --git a/app/Services/Media/ChunkedCloudUploader.php b/app/Services/Media/ChunkedCloudUploader.php new file mode 100644 index 00000000..47312b0e --- /dev/null +++ b/app/Services/Media/ChunkedCloudUploader.php @@ -0,0 +1,260 @@ +isObjectStorageDisk($disk)) { + return false; + } + + $type = MediaType::fromExtension(pathinfo($fileName, PATHINFO_EXTENSION)); + + return in_array($type, [MediaType::Video, MediaType::Document], true); + } + + public function isObjectStorageDisk(?string $disk = null): bool + { + $disk ??= $this->diskName(); + + return config("filesystems.disks.{$disk}.driver") === 's3'; + } + + /** + * @return array{done: bool, progress: int, path?: string, size?: int, mime_type?: string} + */ + public function receiveChunk( + string $identifier, + string $fileName, + string $chunk, + int $rangeStart, + int $rangeEnd, + int $totalSize, + ): array { + $cacheKey = self::CACHE_PREFIX.$identifier; + $chunkSize = strlen($chunk); + $isLastChunk = ($rangeEnd + 1) >= $totalSize; + + if ($rangeEnd < $rangeStart || $chunkSize !== ($rangeEnd - $rangeStart + 1)) { + throw new InvalidArgumentException('Chunk bytes do not match Content-Range.'); + } + + if (! $isLastChunk && $chunkSize < self::MIN_PART_BYTES) { + throw new InvalidArgumentException( + 'Non-final multipart parts must be at least '.self::MIN_PART_BYTES.' bytes.' + ); + } + + $state = $this->cache->get($cacheKey); + + if ($rangeStart === 0) { + $alreadyAcceptedFirstPart = is_array($state) && (int) data_get($state, 'next_offset', 0) > 0; + + if (! $alreadyAcceptedFirstPart) { + $this->abortIfPresent($cacheKey); + $state = $this->startUpload($fileName, $chunk); + $this->cache->put($cacheKey, $state, now()->addHours(self::CACHE_TTL_HOURS)); + } + } elseif (! is_array($state)) { + throw new RuntimeException('Chunked cloud upload session expired or missing.'); + } + + $nextOffset = (int) data_get($state, 'next_offset', 0); + + // Idempotent replay: client retried a chunk the server already accepted. + if ($rangeStart < $nextOffset) { + return $this->status($state, $totalSize, completed: $nextOffset >= $totalSize); + } + + if ($rangeStart !== $nextOffset) { + throw new InvalidArgumentException( + "Unexpected chunk offset {$rangeStart}, expected {$nextOffset}." + ); + } + + $partNumber = count(data_get($state, 'parts', [])) + 1; + + $result = $this->s3()->uploadPart([ + 'Bucket' => $this->bucket(), + 'Key' => data_get($state, 'key'), + 'UploadId' => data_get($state, 'upload_id'), + 'PartNumber' => $partNumber, + 'Body' => $chunk, + ]); + + $state['parts'][] = [ + 'ETag' => data_get($result, 'ETag'), + 'PartNumber' => $partNumber, + ]; + $state['next_offset'] = $rangeEnd + 1; + $state['bytes_received'] = (int) data_get($state, 'bytes_received', 0) + $chunkSize; + + if (! $isLastChunk) { + $this->cache->put($cacheKey, $state, now()->addHours(self::CACHE_TTL_HOURS)); + + return $this->status($state, $totalSize, completed: false); + } + + $this->s3()->completeMultipartUpload([ + 'Bucket' => $this->bucket(), + 'Key' => data_get($state, 'key'), + 'UploadId' => data_get($state, 'upload_id'), + 'MultipartUpload' => [ + 'Parts' => data_get($state, 'parts', []), + ], + ]); + + $this->cache->forget($cacheKey); + + return $this->status($state, $totalSize, completed: true); + } + + /** + * @param array $state + * @return array{done: bool, progress: int, path?: string, size?: int, mime_type?: string} + */ + private function status(array $state, int $totalSize, bool $completed): array + { + $received = (int) data_get($state, 'bytes_received', 0); + $progress = $totalSize > 0 + ? (int) round(min($received, $totalSize) / $totalSize * 100) + : 0; + + if (! $completed) { + return [ + 'done' => false, + 'progress' => $progress, + ]; + } + + return [ + 'done' => true, + 'progress' => 100, + 'path' => (string) data_get($state, 'key'), + 'size' => $received, + 'mime_type' => (string) data_get($state, 'mime_type'), + ]; + } + + /** + * @return array{upload_id: string, key: string, mime_type: string, parts: array, next_offset: int, bytes_received: int} + */ + private function startUpload(string $fileName, string $firstChunk): array + { + $extension = strtolower((string) pathinfo($fileName, PATHINFO_EXTENSION)); + $filename = Str::uuid().".{$extension}"; + $key = "medias/{$filename}"; + $mimeType = $this->detectMimeType($firstChunk, $extension); + + $created = $this->s3()->createMultipartUpload([ + 'Bucket' => $this->bucket(), + 'Key' => $key, + 'ContentType' => $mimeType, + ]); + + return [ + 'upload_id' => (string) data_get($created, 'UploadId'), + 'key' => $key, + 'mime_type' => $mimeType, + 'parts' => [], + 'next_offset' => 0, + 'bytes_received' => 0, + ]; + } + + private function abortIfPresent(string $cacheKey): void + { + $existing = $this->cache->get($cacheKey); + + if (! is_array($existing)) { + return; + } + + try { + $this->s3()->abortMultipartUpload([ + 'Bucket' => $this->bucket(), + 'Key' => data_get($existing, 'key'), + 'UploadId' => data_get($existing, 'upload_id'), + ]); + } catch (Throwable) { + // Best-effort cleanup of a previous incomplete upload. + } + + $this->cache->forget($cacheKey); + } + + private function detectMimeType(string $chunk, string $extension): string + { + $detected = (new finfo(FILEINFO_MIME_TYPE))->buffer($chunk) ?: null; + + if (is_string($detected) && $detected !== 'application/octet-stream') { + return $detected; + } + + return MediaType::fromExtension($extension)?->allowedMimeTypes()[0] + ?? 'application/octet-stream'; + } + + private function s3(): S3Client + { + if ($this->client instanceof S3Client) { + return $this->client; + } + + $adapter = Storage::disk($this->diskName()); + + if (! $adapter instanceof AwsS3V3Adapter) { + throw new RuntimeException('Chunked cloud uploads require an S3-compatible disk.'); + } + + return $adapter->getClient(); + } + + private function bucket(): string + { + if (filled($this->bucket)) { + return $this->bucket; + } + + return (string) config("filesystems.disks.{$this->diskName()}.bucket"); + } + + private function diskName(): string + { + return $this->disk ?? (string) config('filesystems.default'); + } +} diff --git a/resources/js/components/assets/GalleryBrowser.vue b/resources/js/components/assets/GalleryBrowser.vue index 20e7d6ea..a49d2d43 100644 --- a/resources/js/components/assets/GalleryBrowser.vue +++ b/resources/js/components/assets/GalleryBrowser.vue @@ -264,6 +264,10 @@ const handleDelete = (assetId: string) => { }); }; +const onAssetDeleted = async () => { + await loadUploadsFirstPage(); +}; + const createPostFromAsset = (asset: AssetMedia) => { router.post(storePost.url(), { media: [{ id: asset.id, path: asset.path, url: asset.url, type: asset.type, mime_type: asset.mime_type }], @@ -940,6 +944,7 @@ onUnmounted(() => { :description="trans('assets.delete.description')" :action="trans('assets.delete.confirm')" :cancel="trans('assets.delete.cancel')" + @deleted="onAssetDeleted" /> diff --git a/resources/js/utils/chunkedUpload.ts b/resources/js/utils/chunkedUpload.ts index 0114cef2..8e6e6a6f 100644 --- a/resources/js/utils/chunkedUpload.ts +++ b/resources/js/utils/chunkedUpload.ts @@ -49,7 +49,7 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`, - 'X-File-Name': file.name, + 'X-File-Name': encodeURIComponent(file.name), 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', diff --git a/tests/Feature/ChunkedAssetReceiverTest.php b/tests/Feature/ChunkedAssetReceiverTest.php new file mode 100644 index 00000000..50d6a847 --- /dev/null +++ b/tests/Feature/ChunkedAssetReceiverTest.php @@ -0,0 +1,190 @@ +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]); +}); + +test('chunk receipt in-progress response only exposes progress', function () { + $response = ChunkReceipt::inProgress(42)->toResponse(); + + expect($response->getData(true))->toBe([ + 'done' => false, + 'progress' => 42, + ]); +}); + +test('chunk receipt completed response merges media resource fields', function () { + $media = Media::factory()->create([ + 'mediable_type' => $this->workspace->getMorphClass(), + 'mediable_id' => $this->workspace->id, + 'collection' => 'assets', + 'type' => 'video', + 'path' => 'medias/clip.mp4', + 'original_filename' => 'clip.mp4', + 'mime_type' => 'video/mp4', + 'size' => 12, + ]); + + $payload = ChunkReceipt::completed($media)->toResponse()->getData(true); + + expect($payload['done'])->toBeTrue(); + expect($payload['id'])->toBe($media->id); + expect($payload['path'])->toBe('medias/clip.mp4'); + expect($payload['type'])->toBe('video'); + expect($payload['original_filename'])->toBe('clip.mp4'); + expect($payload)->toHaveKeys(['url', 'mime_type', 'size', 'meta', 'created_at']); +}); + +test('receiver assembles locally when multipart is not used', function () { + $cloud = Mockery::mock(ChunkedCloudUploader::class); + $cloud->shouldReceive('shouldUseMultipart')->with('clip.mp4')->andReturn(false); + $cloud->shouldNotReceive('receiveChunk'); + + $receiver = new ChunkedAssetReceiver($cloud); + $bytes = "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat("\0", 64); + + $receipt = $receiver->receive( + $this->workspace, + $this->user, + 'clip.mp4', + $bytes, + 0, + strlen($bytes) - 1, + strlen($bytes), + ); + + expect($receipt->done)->toBeTrue(); + expect($receipt->media)->toBeInstanceOf(Media::class); + expect($receipt->media->type->value)->toBe('video'); + Storage::assertExists($receipt->media->path); +}); + +test('receiver reports progress for intermediate local chunks', function () { + $cloud = Mockery::mock(ChunkedCloudUploader::class); + $cloud->shouldReceive('shouldUseMultipart')->andReturn(false); + + $receiver = new ChunkedAssetReceiver($cloud); + $part = str_repeat('a', 100); + $total = 250; + + $receipt = $receiver->receive( + $this->workspace, + $this->user, + 'clip.mp4', + $part, + 0, + 99, + $total, + ); + + expect($receipt->done)->toBeFalse(); + expect($receipt->progress)->toBe(40); + expect($this->workspace->getMedia('assets')->count())->toBe(0); +}); + +test('receiver completes multipart uploads through the cloud uploader', function () { + $cloud = Mockery::mock(ChunkedCloudUploader::class); + $cloud->shouldReceive('shouldUseMultipart')->with('clip.mp4')->andReturn(true); + $cloud->shouldReceive('receiveChunk') + ->once() + ->andReturn([ + 'done' => true, + 'progress' => 100, + 'path' => 'medias/from-cloud.mp4', + 'size' => 12, + 'mime_type' => 'video/mp4', + ]); + + $receiver = new ChunkedAssetReceiver($cloud); + + $receipt = $receiver->receive( + $this->workspace, + $this->user, + 'clip.mp4', + 'fake-video!!', + 0, + 11, + 12, + ); + + expect($receipt->done)->toBeTrue(); + expect($receipt->media->path)->toBe('medias/from-cloud.mp4'); + expect($receipt->media->size)->toBe(12); +}); + +test('receiver returns in-progress when multipart chunk is not final', function () { + $cloud = Mockery::mock(ChunkedCloudUploader::class); + $cloud->shouldReceive('shouldUseMultipart')->andReturn(true); + $cloud->shouldReceive('receiveChunk') + ->once() + ->andReturn(['done' => false, 'progress' => 55]); + + $receiver = new ChunkedAssetReceiver($cloud); + + $receipt = $receiver->receive( + $this->workspace, + $this->user, + 'clip.mp4', + str_repeat('a', 100), + 0, + 99, + 200, + ); + + expect($receipt->done)->toBeFalse(); + expect($receipt->progress)->toBe(55); + expect($this->workspace->getMedia('assets')->count())->toBe(0); +}); + +test('receiver deletes the cloud object when media registration fails after multipart', function () { + Storage::put('medias/orphan.mp4', 'uploaded-bytes'); + + $cloud = Mockery::mock(ChunkedCloudUploader::class); + $cloud->shouldReceive('shouldUseMultipart')->andReturn(true); + $cloud->shouldReceive('receiveChunk') + ->once() + ->andReturn([ + 'done' => true, + 'progress' => 100, + 'path' => 'medias/orphan.mp4', + 'size' => 14, + 'mime_type' => 'application/zip', + ]); + + $receiver = new ChunkedAssetReceiver($cloud); + + expect(fn () => $receiver->receive( + $this->workspace, + $this->user, + 'clip.mp4', + 'uploaded-bytes', + 0, + 13, + 14, + ))->toThrow(InvalidArgumentException::class); + + Storage::assertMissing('medias/orphan.mp4'); + expect($this->workspace->getMedia('assets')->count())->toBe(0); +}); diff --git a/tests/Feature/ChunkedCloudUploadTest.php b/tests/Feature/ChunkedCloudUploadTest.php new file mode 100644 index 00000000..bc0efd60 --- /dev/null +++ b/tests/Feature/ChunkedCloudUploadTest.php @@ -0,0 +1,341 @@ +account = Account::factory()->create(); + test()->user = User::factory()->create(['account_id' => test()->account->id]); + test()->account->update(['owner_id' => test()->user->id]); + test()->workspace = Workspace::factory()->create([ + 'account_id' => test()->account->id, + 'user_id' => test()->user->id, + ]); + test()->workspace->members()->attach(test()->user->id, ['role' => Role::Member->value]); + test()->user->update(['current_workspace_id' => test()->workspace->id]); + test()->account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_test_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_123', + ]); +} + +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 +{ + $totalSize ??= strlen($content); + $rangeEnd = $rangeStart + strlen($content) - 1; + + 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', + ], + $content, + ); +} + +// ─── Strategy selection (all disks × file types) ───────────────── + +test('shouldUseMultipart is only true for object-storage disks with video or pdf', function (string $disk, string $driver, string $fileName, bool $expected) { + config([ + "filesystems.disks.{$disk}.driver" => $driver, + 'filesystems.default' => $disk, + ]); + + $uploader = new ChunkedCloudUploader(Cache::store(), disk: $disk); + + expect($uploader->shouldUseMultipart($fileName))->toBe($expected); + expect($uploader->isObjectStorageDisk($disk))->toBe($driver === 's3'); +})->with([ + 'local video' => ['local', 'local', 'clip.mp4', false], + 'local pdf' => ['local', 'local', 'deck.pdf', false], + 'local image' => ['local', 'local', 'photo.png', false], + 'public video' => ['public', 'local', 'clip.mp4', false], + 'public pdf' => ['public', 'local', 'deck.pdf', false], + 'public image' => ['public', 'local', 'photo.png', false], + 's3 video' => ['s3', 's3', 'clip.mp4', true], + 's3 pdf' => ['s3', 's3', 'deck.pdf', true], + 's3 image' => ['s3', 's3', 'photo.png', false], + 'r2 video' => ['r2', 's3', 'clip.mp4', true], + 'r2 pdf' => ['r2', 's3', 'deck.pdf', true], + 'r2 image' => ['r2', 's3', 'photo.png', false], + 'spaces video' => ['spaces', 's3', 'clip.mp4', true], + 'spaces pdf' => ['spaces', 's3', 'deck.pdf', true], + 'spaces image' => ['spaces', 's3', 'photo.png', false], +]); + +// ─── Multipart mechanics (object storage) ──────────────────────── + +test('chunked cloud uploader uploads parts and completes multipart', function () { + $client = Mockery::mock(S3Client::class); + + $client->shouldReceive('createMultipartUpload') + ->once() + ->andReturn(new Result(['UploadId' => 'upload-1'])); + + $client->shouldReceive('uploadPart') + ->twice() + ->andReturn(new Result(['ETag' => '"etag-a"']), new Result(['ETag' => '"etag-b"'])); + + $client->shouldReceive('completeMultipartUpload') + ->once() + ->withArgs(function (array $args) { + expect(data_get($args, 'UploadId'))->toBe('upload-1'); + expect(data_get($args, 'MultipartUpload.Parts'))->toHaveCount(2); + + return true; + }) + ->andReturn(new Result([])); + + $uploader = new ChunkedCloudUploader(Cache::store(), $client, 'test-bucket', 'r2'); + $chunk1 = str_repeat('a', ChunkedCloudUploader::MIN_PART_BYTES); + $chunk2 = str_repeat('b', 50); + $total = strlen($chunk1) + strlen($chunk2); + + $mid = $uploader->receiveChunk('id-1', 'video.mp4', $chunk1, 0, strlen($chunk1) - 1, $total); + expect($mid)->toMatchArray(['done' => false]); + + $done = $uploader->receiveChunk( + 'id-1', + 'video.mp4', + $chunk2, + strlen($chunk1), + $total - 1, + $total, + ); + + expect($done['done'])->toBeTrue(); + expect($done['size'])->toBe($total); + expect($done['path'])->toStartWith('medias/'); + expect($done['path'])->toEndWith('.mp4'); + expect(Cache::get('chunked-cloud-upload:id-1'))->toBeNull(); +}); + +test('chunked cloud uploader rejects undersized non-final parts', function () { + $uploader = new ChunkedCloudUploader( + Cache::store(), + Mockery::mock(S3Client::class), + 'test-bucket', + 'r2', + ); + + expect(fn () => $uploader->receiveChunk( + 'id-small', + 'video.mp4', + str_repeat('a', 100), + 0, + 99, + ChunkedCloudUploader::MIN_PART_BYTES + 200, + ))->toThrow(InvalidArgumentException::class); +}); + +test('chunked cloud uploader rejects unexpected offsets', function () { + $client = Mockery::mock(S3Client::class); + $client->shouldReceive('createMultipartUpload') + ->once() + ->andReturn(new Result(['UploadId' => 'upload-1'])); + $client->shouldReceive('uploadPart') + ->once() + ->andReturn(new Result(['ETag' => '"etag-a"'])); + + $uploader = new ChunkedCloudUploader(Cache::store(), $client, 'test-bucket', 'r2'); + $chunk1 = str_repeat('a', ChunkedCloudUploader::MIN_PART_BYTES); + $total = ChunkedCloudUploader::MIN_PART_BYTES * 2; + + $uploader->receiveChunk('id-gap', 'video.mp4', $chunk1, 0, strlen($chunk1) - 1, $total); + + expect(fn () => $uploader->receiveChunk( + 'id-gap', + 'video.mp4', + $chunk1, + ChunkedCloudUploader::MIN_PART_BYTES + 10, + ChunkedCloudUploader::MIN_PART_BYTES * 2 + 9, + $total, + ))->toThrow(InvalidArgumentException::class); +}); + +test('chunked cloud uploader is idempotent when a chunk is retried', function () { + $client = Mockery::mock(S3Client::class); + $client->shouldReceive('createMultipartUpload') + ->once() + ->andReturn(new Result(['UploadId' => 'upload-1'])); + $client->shouldReceive('uploadPart') + ->once() + ->andReturn(new Result(['ETag' => '"etag-a"'])); + + $uploader = new ChunkedCloudUploader(Cache::store(), $client, 'test-bucket', 'r2'); + $chunk1 = str_repeat('a', ChunkedCloudUploader::MIN_PART_BYTES); + $total = ChunkedCloudUploader::MIN_PART_BYTES + 50; + + $first = $uploader->receiveChunk('id-retry', 'video.mp4', $chunk1, 0, strlen($chunk1) - 1, $total); + $retry = $uploader->receiveChunk('id-retry', 'video.mp4', $chunk1, 0, strlen($chunk1) - 1, $total); + + expect($first)->toMatchArray(['done' => false]); + expect($retry)->toMatchArray(['done' => false, 'progress' => $first['progress']]); +}); + +// ─── HTTP: local / public assemble path ────────────────────────── + +test('chunked upload stores video on the local disk via assemble path', function () { + config(['filesystems.default' => 'local']); + Storage::fake('local'); + seedChunkedUploadWorkspace(); + + $content = fakeMp4Bytes(); + $response = postChunkedAsset('clip.mp4', $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true, 'type' => 'video']); + + $media = test()->workspace->getMedia('assets')->first(); + expect($media->original_filename)->toBe('clip.mp4'); + expect($media->size)->toBe(strlen($content)); + Storage::disk('local')->assertExists($media->path); +}); + +test('chunked upload stores video on the public disk via assemble path', function () { + config(['filesystems.default' => 'public']); + Storage::fake('public'); + seedChunkedUploadWorkspace(); + + $content = fakeMp4Bytes(); + $response = postChunkedAsset('clip.mp4', $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true, 'type' => 'video']); + + $media = test()->workspace->getMedia('assets')->first(); + expect($media->type->value)->toBe('video'); + Storage::disk('public')->assertExists($media->path); +}); + +test('chunked upload on local disk reports progress across multiple chunks', function () { + config(['filesystems.default' => 'local']); + Storage::fake('local'); + seedChunkedUploadWorkspace(); + + $part1 = fakeMp4Bytes(); + $part2 = str_repeat("\0", 50); + $total = strlen($part1) + strlen($part2); + + $mid = postChunkedAsset('clip.mp4', $part1, 0, $total); + $mid->assertSuccessful(); + $mid->assertJson(['done' => false]); + expect(test()->workspace->getMedia('assets')->count())->toBe(0); + + $done = postChunkedAsset('clip.mp4', $part2, strlen($part1), $total); + $done->assertSuccessful(); + $done->assertJson(['done' => true, 'type' => 'video']); + expect(test()->workspace->getMedia('assets')->count())->toBe(1); +}); + +test('chunked upload stores image on local disk', function () { + config(['filesystems.default' => 'local']); + Storage::fake('local'); + seedChunkedUploadWorkspace(); + + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + $response = postChunkedAsset('photo.png', $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true, 'type' => 'image']); + Storage::disk('local')->assertExists(test()->workspace->getMedia('assets')->first()->path); +}); + +// ─── HTTP: object storage ──────────────────────────────────────── + +test('chunked upload uses multipart for videos on s3 disks', function (string $disk) { + config([ + 'filesystems.default' => $disk, + "filesystems.disks.{$disk}.driver" => 's3', + ]); + Storage::fake($disk); + seedChunkedUploadWorkspace(); + + $fake = Mockery::mock(ChunkedCloudUploader::class); + $fake->shouldReceive('shouldUseMultipart')->with('clip.mp4')->andReturn(true); + $fake->shouldReceive('receiveChunk') + ->once() + ->andReturn([ + 'done' => true, + 'progress' => 100, + 'path' => "medias/{$disk}-clip.mp4", + 'size' => 12, + 'mime_type' => 'video/mp4', + ]); + app()->instance(ChunkedCloudUploader::class, $fake); + + $response = postChunkedAsset('clip.mp4', 'fake-video!!'); + + $response->assertSuccessful(); + $response->assertJson([ + 'done' => true, + 'path' => "medias/{$disk}-clip.mp4", + 'type' => 'video', + ]); + expect(test()->workspace->getMedia('assets')->first()->path)->toBe("medias/{$disk}-clip.mp4"); +})->with(['s3', 'r2', 'spaces']); + +test('chunked upload on s3 still assembles images without multipart', function () { + config([ + 'filesystems.default' => 's3', + 'filesystems.disks.s3.driver' => 's3', + ]); + Storage::fake('s3'); + seedChunkedUploadWorkspace(); + + $mock = Mockery::mock(ChunkedCloudUploader::class); + $mock->shouldReceive('shouldUseMultipart')->with('photo.png')->andReturn(false); + $mock->shouldNotReceive('receiveChunk'); + app()->instance(ChunkedCloudUploader::class, $mock); + + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + $response = postChunkedAsset('photo.png', $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true, 'type' => 'image']); + Storage::disk('s3')->assertExists(test()->workspace->getMedia('assets')->first()->path); +}); + +test('chunked upload on local never calls multipart receiveChunk for videos', function () { + config(['filesystems.default' => 'local']); + Storage::fake('local'); + seedChunkedUploadWorkspace(); + + $mock = Mockery::mock(ChunkedCloudUploader::class); + $mock->shouldReceive('shouldUseMultipart')->with('clip.mp4')->andReturn(false); + $mock->shouldNotReceive('receiveChunk'); + app()->instance(ChunkedCloudUploader::class, $mock); + + $response = postChunkedAsset('clip.mp4', fakeMp4Bytes()); + + $response->assertSuccessful(); + $response->assertJson(['done' => true, 'type' => 'video']); + Storage::disk('local')->assertExists(test()->workspace->getMedia('assets')->first()->path); +}); diff --git a/tests/Feature/ChunkedUploadFilenameEncodingTest.php b/tests/Feature/ChunkedUploadFilenameEncodingTest.php new file mode 100644 index 00000000..5d0b6886 --- /dev/null +++ b/tests/Feature/ChunkedUploadFilenameEncodingTest.php @@ -0,0 +1,130 @@ +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', + ]); +}); + +function postEncodedChunkedUpload(string $fileName, string $content): TestResponse +{ + $size = strlen($content); + + return test()->actingAs(test()->user)->call( + 'POST', + route('app.assets.store-chunked'), + [], [], [], + [ + 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, + 'HTTP_X_FILE_NAME' => rawurlencode($fileName), + 'HTTP_ACCEPT' => 'application/json', + 'CONTENT_TYPE' => 'application/octet-stream', + ], + $content, + ); +} + +test('chunked upload accepts filename with en-dash when percent-encoded', function () { + $fileName = 'Corte 6 – Quantidade ou qualidade_ Os dois..png'; + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true]); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe(strtolower($fileName)); +}); + +test('chunked upload accepts filename with emoji when percent-encoded', function () { + $fileName = 'launch-🚀-photo.png'; + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe(strtolower($fileName)); +}); + +test('chunked upload accepts filename with spaces and double-dot extension', function () { + $fileName = 'my video file..png'; + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe('my video file..png'); +}); + +test('chunked upload still accepts plain ascii filename without encoding', function () { + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + $size = strlen($content); + + $response = $this->actingAs($this->user)->call( + 'POST', + route('app.assets.store-chunked'), + [], [], [], + [ + 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, + 'HTTP_X_FILE_NAME' => 'plain-ascii.png', + 'HTTP_ACCEPT' => 'application/json', + 'CONTENT_TYPE' => 'application/octet-stream', + ], + $content, + ); + + $response->assertSuccessful(); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe('plain-ascii.png'); +}); + +test('chunked upload rejects unsupported extension even when percent-encoded', function () { + $response = postEncodedChunkedUpload('malware – payload.exe', str_repeat('x', 100)); + + $response->assertUnprocessable(); +}); + +test('chunked upload streams a video file to storage on finalize', function () { + // Minimal ISO BMFF ("ftyp") so mime_content_type reports video/mp4. + $content = "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat("\0", 64); + $fileName = 'Quantidade ou Qualidade_ Os dois..mp4'; + + $response = postEncodedChunkedUpload($fileName, $content); + + $response->assertSuccessful(); + $response->assertJson(['done' => true, 'type' => 'video']); + + $media = $this->workspace->getMedia('assets')->first(); + expect($media->original_filename)->toBe(strtolower($fileName)); + expect($media->type->value)->toBe('video'); + expect($media->size)->toBe(strlen($content)); + Storage::assertExists($media->path); +}); diff --git a/tests/Unit/Traits/HasMediaTest.php b/tests/Unit/Traits/HasMediaTest.php index 7380fc51..87a658ca 100644 --- a/tests/Unit/Traits/HasMediaTest.php +++ b/tests/Unit/Traits/HasMediaTest.php @@ -118,6 +118,23 @@ unlink($tempFile); }); +test('model can add video media from file path via stream', function () { + $workspace = Workspace::factory()->create(); + + $tempFile = tempnam(sys_get_temp_dir(), 'vid'); + $bytes = "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat("\0", 64); + file_put_contents($tempFile, $bytes); + + $media = $workspace->addMediaFromPath($tempFile, 'clip.mp4', 'assets'); + + expect($media->type->value)->toBe('video'); + expect($media->size)->toBe(strlen($bytes)); + expect($media->mime_type)->toBe('video/mp4'); + Storage::assertExists($media->path); + + unlink($tempFile); +}); + test('model can clear media collection', function () { $workspace = Workspace::factory()->create(); $file1 = UploadedFile::fake()->image('logo1.jpg', 100, 100);