Merge pull request #198 from trypostit/fix/chunked-upload-unicode-filename

fix: allow unicode filenames in chunked asset uploads
This commit is contained in:
Paulo Castellano 2026-07-23 12:00:01 -03:00 committed by GitHub
commit 71df3bf801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1196 additions and 59 deletions

View file

@ -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());

View file

@ -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'))),
]);
}

View file

@ -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<string, mixed> $meta
* @return array{path: string, mime_type: string, size: int, meta: array<string, mixed>}
*/
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<string, mixed> $meta
* @return array{path: string, mime_type: string, size: int, meta: array<string, mixed>}
*/
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)

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
use App\Http\Resources\App\MediaResource;
use App\Models\Media;
use Illuminate\Http\JsonResponse;
final readonly class ChunkReceipt
{
private function __construct(
public bool $done,
public ?int $progress = null,
public ?Media $media = null,
) {}
public static function inProgress(int $progress): self
{
return new self(done: false, progress: $progress);
}
public static function completed(Media $media): self
{
return new self(done: true, media: $media);
}
public function toResponse(): JsonResponse
{
if (! $this->done) {
return response()->json([
'done' => false,
'progress' => $this->progress,
]);
}
return response()->json([
'done' => true,
...(new MediaResource($this->media))->resolve(),
]);
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
use Throwable;
final class ChunkedAssetReceiver
{
public function __construct(private readonly ChunkedCloudUploader $cloud) {}
public function receive(
Workspace $workspace,
User $user,
string $fileName,
string $chunk,
int $rangeStart,
int $rangeEnd,
int $totalSize,
): ChunkReceipt {
$identifier = md5($user->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);
}
}

View file

@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
use App\Enums\Media\Type as MediaType;
use Aws\S3\S3Client;
use finfo;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Filesystem\AwsS3V3Adapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
class ChunkedCloudUploader
{
private const CACHE_PREFIX = 'chunked-cloud-upload:';
private const CACHE_TTL_HOURS = 6;
/** S3/R2 require every non-final multipart part to be at least 5 MiB. */
public const MIN_PART_BYTES = 5 * 1024 * 1024;
public function __construct(
private readonly CacheRepository $cache,
private readonly ?S3Client $client = null,
private readonly ?string $bucket = null,
private readonly ?string $disk = null,
) {}
/**
* Multipart is only needed when the disk is remote object storage and the
* file will not be rewritten locally (videos/PDFs). Local, public, and
* image uploads keep the assemble-then-store path.
*/
public function shouldUseMultipart(string $fileName, ?string $disk = null): bool
{
if (! $this->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<string, mixed> $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<int, mixed>, 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');
}
}

View file

@ -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"
/>
<ImagePreviewDialog ref="lightbox" />

View file

@ -49,7 +49,7 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise<Chun
const headers: Record<string, string> = {
'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',

View file

@ -0,0 +1,190 @@
<?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\Media\ChunkedAssetReceiver;
use App\Services\Media\ChunkedCloudUploader;
use App\Services\Media\ChunkReceipt;
use Illuminate\Support\Facades\Storage;
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]);
});
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);
});

View file

@ -0,0 +1,341 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Media\ChunkedCloudUploader;
use Aws\Result;
use Aws\S3\S3Client;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
beforeEach(function () {
Cache::flush();
});
function seedChunkedUploadWorkspace(): void
{
test()->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);
});

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
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',
]);
});
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);
});

View file

@ -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);