refactor: pull chunked upload orchestration out of AssetController

Controller only authorizes and returns ChunkReceipt. Multipart vs local assemble lives in ChunkedAssetReceiver; JSON shape comes from MediaResource.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-23 11:18:10 -03:00
parent 08ce75d2ec
commit 2ba5ce2b3f
3 changed files with 155 additions and 126 deletions

View file

@ -9,9 +9,8 @@
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
use App\Http\Resources\App\MediaResource;
use App\Models\Media;
use App\Models\Workspace;
use App\Services\Brand\SafeHttpFetcher;
use App\Services\Media\ChunkedCloudUploader;
use App\Services\Media\ChunkedAssetReceiver;
use App\Services\UnsplashService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -23,7 +22,6 @@
use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Throwable;
class AssetController extends Controller
{
@ -71,135 +69,21 @@ public function store(StoreAssetRequest $request): MediaResource
return new MediaResource($media);
}
public function storeChunked(StoreChunkedAssetRequest $request, ChunkedCloudUploader $cloudUploader): 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');
$chunk = $request->getContent();
$identifier = md5($request->user()->id.$fileName.$totalSize);
if ($cloudUploader->shouldUseMultipart($fileName)) {
return $this->storeChunkedViaMultipart(
$cloudUploader,
$workspace,
$identifier,
$fileName,
$chunk,
$rangeStart,
$rangeEnd,
$totalSize,
);
}
return $this->storeChunkedViaLocalAssemble(
return $receiver->receive(
$workspace,
$identifier,
$fileName,
$chunk,
$rangeStart,
$rangeEnd,
$totalSize,
);
}
private function storeChunkedViaMultipart(
ChunkedCloudUploader $cloudUploader,
Workspace $workspace,
string $identifier,
string $fileName,
string $chunk,
int $rangeStart,
int $rangeEnd,
int $totalSize,
): JsonResponse {
$result = $cloudUploader->receiveChunk(
$identifier,
$fileName,
$chunk,
$rangeStart,
$rangeEnd,
$totalSize,
);
if (! data_get($result, 'done')) {
return response()->json([
'done' => false,
'progress' => 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 $this->chunkedMediaResponse($media);
}
private function storeChunkedViaLocalAssemble(
Workspace $workspace,
string $identifier,
string $fileName,
string $chunk,
int $rangeStart,
int $rangeEnd,
int $totalSize,
): JsonResponse {
$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 response()->json([
'done' => false,
'progress' => (int) round(($rangeEnd + 1) / $totalSize * 100),
]);
}
try {
$media = $workspace->addMediaFromPath($tempFile, $fileName, 'assets');
} finally {
@unlink($tempFile);
}
return $this->chunkedMediaResponse($media);
}
private function chunkedMediaResponse(Media $media): JsonResponse
{
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(),
]);
$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

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