Merge pull request #198 from trypostit/fix/chunked-upload-unicode-filename
fix: allow unicode filenames in chunked asset uploads
This commit is contained in:
commit
71df3bf801
12 changed files with 1196 additions and 59 deletions
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
43
app/Services/Media/ChunkReceipt.php
Normal file
43
app/Services/Media/ChunkReceipt.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
102
app/Services/Media/ChunkedAssetReceiver.php
Normal file
102
app/Services/Media/ChunkedAssetReceiver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
260
app/Services/Media/ChunkedCloudUploader.php
Normal file
260
app/Services/Media/ChunkedCloudUploader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
190
tests/Feature/ChunkedAssetReceiverTest.php
Normal file
190
tests/Feature/ChunkedAssetReceiverTest.php
Normal 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);
|
||||
});
|
||||
341
tests/Feature/ChunkedCloudUploadTest.php
Normal file
341
tests/Feature/ChunkedCloudUploadTest.php
Normal 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);
|
||||
});
|
||||
130
tests/Feature/ChunkedUploadFilenameEncodingTest.php
Normal file
130
tests/Feature/ChunkedUploadFilenameEncodingTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue