fix: stream chunked video finalize to object storage

Unicode filename encoding was correct, but large videos still died on the last chunk: the whole file was loaded into memory and uploaded to R2 via Guzzle within PHP-FPM's 30s limit. Stream non-images with writeStream and lift the time limit on finalize.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-23 10:55:57 -03:00
parent f4513efc70
commit 005d3c9dd6
6 changed files with 71 additions and 25 deletions

View file

@ -98,6 +98,9 @@ public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse
]);
}
// Finalize may stream hundreds of MB to object storage (R2/S3).
set_time_limit(0);
$media = $workspace->addMediaFromPath($tempFile, $fileName, 'assets');
@unlink($tempFile);

View file

@ -28,9 +28,6 @@ protected function prepareForValidation(): void
'range_start' => $parsed[0] ?? null,
'range_end' => $parsed[1] ?? null,
'total_size' => $parsed[2] ?? null,
// Client sends encodeURIComponent(file.name) so unicode filenames
// (en-dash, emoji, …) fit in an ASCII-only HTTP header. Decode
// before lowercasing so `ends_with` sees the real extension.
'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
{
@ -106,6 +107,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr
/**
* Add media from a file path (used for chunked uploads).
* Non-images are streamed to storage instead of loaded into memory.
*/
public function addMediaFromPath(string $filePath, string $originalFilename, string $collection = 'default', array $meta = [], ?string $groupId = null): Media
{
@ -117,17 +119,37 @@ 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,
);
if ($type === Type::Image->value) {
[$bytes, $storedMime, $storedExt] = $this->normalizeImageFormat(
$filePath,
$mimeType,
$type,
$extension,
);
$size = strlen($bytes);
$mediaMeta = array_merge($this->getMediaMetaFromBytes($bytes, $type, $meta), $meta);
$storagePath = 'medias/'.Str::uuid().'.'.$storedExt;
Storage::put($storagePath, $bytes);
} else {
$storedMime = $mimeType;
$storedExt = $extension;
$size = (int) filesize($filePath);
$mediaMeta = $meta;
$storagePath = 'medias/'.Str::uuid().'.'.$storedExt;
$stream = fopen($filePath, 'rb');
$filename = Str::uuid().'.'.$normalizedExt;
$storagePath = 'medias/'.$filename;
if ($stream === false) {
throw new RuntimeException("Unable to open media file for reading: {$filePath}");
}
Storage::put($storagePath, $normalizedBytes);
try {
Storage::writeStream($storagePath, $stream);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
}
return $this->media()->create([
'group_id' => $groupId ?? Str::uuid()->toString(),
@ -135,10 +157,10 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str
'type' => $type,
'path' => $storagePath,
'original_filename' => $originalFilename,
'mime_type' => $normalizedMime,
'size' => strlen($normalizedBytes),
'mime_type' => $storedMime,
'size' => $size,
'order' => 0,
'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta),
'meta' => $mediaMeta,
]);
}

View file

@ -49,8 +49,6 @@ 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}`,
// Headers must be ByteString (≤255). encodeURIComponent keeps
// unicode filenames (en-dash, emoji, …) ASCII-safe for fetch().
'X-File-Name': encodeURIComponent(file.name),
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',

View file

@ -32,10 +32,6 @@
]);
});
/**
* Mimic the browser client: encodeURIComponent → PHP rawurlencode for the
* X-File-Name header value.
*/
function postEncodedChunkedUpload(string $fileName, string $content): TestResponse
{
$size = strlen($content);
@ -55,8 +51,6 @@ function postEncodedChunkedUpload(string $fileName, string $content): TestRespon
}
test('chunked upload accepts filename with en-dash when percent-encoded', function () {
// Customer bug: en-dash (U+2013) is outside ISO-8859-1, so fetch() rejects
// the raw X-File-Name header. The client percent-encodes; we decode here.
$fileName = 'Corte 6 – Quantidade ou qualidade_ Os dois..png';
$content = file_get_contents(__DIR__.'/../fixtures/1x1.png');
@ -75,7 +69,6 @@ function postEncodedChunkedUpload(string $fileName, string $content): TestRespon
$response = postEncodedChunkedUpload($fileName, $content);
$response->assertSuccessful();
$response->assertJson(['done' => true]);
expect($this->workspace->getMedia('assets')->first()->original_filename)
->toBe(strtolower($fileName));
});
@ -92,7 +85,6 @@ function postEncodedChunkedUpload(string $fileName, string $content): TestRespon
});
test('chunked upload still accepts plain ascii filename without encoding', function () {
// Backwards compatible: rawurldecode is a no-op on plain ASCII names.
$content = file_get_contents(__DIR__.'/../fixtures/1x1.png');
$size = strlen($content);
@ -119,3 +111,20 @@ function postEncodedChunkedUpload(string $fileName, string $content): TestRespon
$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);