From 8ae9bc7e906cdb3ba72ed26cb50ff8b8457b9cd1 Mon Sep 17 00:00:00 2001 From: krekas Date: Thu, 10 Jul 2025 14:11:00 +0300 Subject: [PATCH] Chunk upload --- app/Http/Controllers/UploadController.php | 27 ++- app/Services/ChunkUploadService.php | 201 ++++++++++++++++++++++ app/Services/FileUploadService.php | 91 ++++++++++ composer.json | 2 +- config/filesystems.php | 2 +- resources/views/dashboard.blade.php | 23 ++- routes/web.php | 2 +- 7 files changed, 335 insertions(+), 13 deletions(-) create mode 100644 app/Services/ChunkUploadService.php create mode 100644 app/Services/FileUploadService.php diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 9274ed93..a87b2fdf 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -2,10 +2,33 @@ namespace App\Http\Controllers; +use App\Services\ChunkUploadService; +use App\Services\FileUploadService; +use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; + class UploadController extends Controller { - public function __invoke() + public function __construct( + private readonly ChunkUploadService $chunkUploadService, + private readonly FileUploadService $fileUploadService, + ) {} + + public function __invoke(Request $request): JsonResponse { - // implement upload + // Handle FilePond chunk uploads + if ($request->isMethod('patch') || + $request->hasHeader('Upload-Length') || + $request->hasHeader('Upload-Name') || + $request->hasHeader('Upload-Offset')) { + return $this->chunkUploadService->handleChunkUpload($request); + } + + // Handle regular file uploads + if ($request->hasFile('file')) { + return $this->fileUploadService->handleRegularUpload($request); + } + + return response()->json(['error' => 'No file uploaded'], 400); } } diff --git a/app/Services/ChunkUploadService.php b/app/Services/ChunkUploadService.php new file mode 100644 index 00000000..4ea89301 --- /dev/null +++ b/app/Services/ChunkUploadService.php @@ -0,0 +1,201 @@ +disk = Storage::disk('local'); + } + + public function handleChunkUpload(Request $request): JsonResponse + { + $uploadOffset = (int) $request->header('Upload-Offset', 0); + $uploadLength = (int) $request->header('Upload-Length'); + $uploadName = $request->header('Upload-Name'); + $uploadId = $request->header('Upload-Id', session()->getId()); + + // Create temp path for chunks + $tempPath = "chunks/{$uploadId}"; + + // Ensure chunks directory exists + $this->disk->makeDirectory($tempPath); + + // Get chunk content + $chunkContent = $request->getContent(); + $chunkSize = strlen($chunkContent); + + // Skip empty chunks (FilePond sometimes sends empty first chunk) + if ($chunkSize > 0) { + // Save this chunk using Laravel Storage + $chunkPath = "{$tempPath}/chunk_{$uploadOffset}"; + $this->disk->put($chunkPath, $chunkContent); + } + + // Check if all chunks are received + $currentSize = $this->getTotalChunksSize($tempPath); + + if ($currentSize >= $uploadLength) { + // All chunks received, assemble the file + return $this->assembleChunks($tempPath, $uploadName, $uploadLength); + } + + // Clean up empty chunks + $this->cleanupEmptyChunks($tempPath, $chunkSize); + + // Return progress + return response()->json([ + 'status' => 'chunk_received', + ]); + } + + private function getTotalChunksSize(string $tempPath): int + { + $totalSize = 0; + + if ($this->disk->exists($tempPath)) { + $files = $this->disk->files($tempPath); + foreach ($files as $file) { + if (str_contains($file, 'chunk_')) { + $totalSize += $this->disk->size($file); + } + } + } + + return $totalSize; + } + + private function assembleChunks(string $tempPath, string $uploadName, int $expectedSize): JsonResponse + { + // Generate filename from original name + $fileName = $this->generateSafeFilename($uploadName); + $filePath = "uploads/{$fileName}"; + + // Ensure uploads directory exists + $this->disk->makeDirectory('uploads'); + + // Get all chunk files and sort by offset + $chunks = $this->disk->files($tempPath); + $chunks = collect($chunks) + ->filter(fn ($file) => str_contains($file, 'chunk_')) + ->sort(function ($a, $b) use ($tempPath) { + $offsetA = (int) str_replace($tempPath . '/chunk_', '', $a); + $offsetB = (int) str_replace($tempPath . '/chunk_', '', $b); + return $offsetA <=> $offsetB; + }) + ->values() + ->all(); + + // Use streaming to combine chunks efficiently + $assembledContent = ''; + foreach ($chunks as $chunkFile) { + $assembledContent .= $this->disk->get($chunkFile); + } + + // Store the assembled file + $this->disk->put($filePath, $assembledContent); + + // Verify file size + $actualSize = $this->disk->size($filePath); + if ($actualSize !== $expectedSize) { + // Clean up on error + $this->disk->delete($filePath); + $this->cleanupTempChunks($tempPath); + throw new UnableToWriteFile("File size mismatch: expected {$expectedSize}, got {$actualSize}"); + } + + // Save file info to database + $file = $this->createFileRecord($uploadName, $filePath, $actualSize); + + // Clean up temp chunks + $this->cleanupTempChunks($tempPath); + + // Return success response + return $this->createSuccessResponse($file); + } + + private function cleanupEmptyChunks(string $tempPath, int $chunkSize): void + { + // If we have no chunks and this was an empty chunk, clean up immediately + if ($this->getTotalChunksSize($tempPath) === 0 && $chunkSize === 0) { + $this->cleanupTempChunks($tempPath); + return; + } + + // Also clean up if directory exists but has no valid chunks + if ($this->disk->exists($tempPath)) { + $files = $this->disk->files($tempPath); + $hasValidChunks = collect($files)->some(function ($file) { + return str_contains($file, 'chunk_') && $this->disk->size($file) > 0; + }); + + if (!$hasValidChunks) { + $this->cleanupTempChunks($tempPath); + } + } + } + + private function cleanupTempChunks(string $tempPath): void + { + if ($this->disk->exists($tempPath)) { + $this->disk->deleteDirectory($tempPath); + } + } + + private function generateSafeFilename(string $originalName): string + { + // Sanitize the filename + $name = pathinfo($originalName, PATHINFO_FILENAME); + $extension = pathinfo($originalName, PATHINFO_EXTENSION); + + // Remove unsafe characters and replace with underscores + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name); + + // Ensure filename isn't too long (max 100 chars for the name part) + if (strlen($safeName) > 100) { + $safeName = substr($safeName, 0, 100); + } + + // Combine name and extension + $fileName = $safeName . ($extension ? '.' . $extension : ''); + + // Check if file already exists and add counter if needed + $counter = 1; + + while ($this->disk->exists("uploads/{$fileName}")) { + $fileName = $safeName . '_' . $counter . ($extension ? '.' . $extension : ''); + $counter++; + } + + return $fileName; + } + + private function createFileRecord(string $fileName, string $filePath, int $fileSize): File + { + return Auth::user()->files()->create([ + 'file_name' => $fileName, + 'file_path' => $filePath, + 'file_size' => $fileSize, + ]); + } + + private function createSuccessResponse(File $file): JsonResponse + { + return response()->json([ + 'id' => $file->id, + 'file' => $file->file_name, + 'path' => $file->file_path, + 'size' => $file->file_size, + ]); + } +} diff --git a/app/Services/FileUploadService.php b/app/Services/FileUploadService.php new file mode 100644 index 00000000..c09d723c --- /dev/null +++ b/app/Services/FileUploadService.php @@ -0,0 +1,91 @@ +disk = Storage::disk('local'); + } + + public function handleRegularUpload(Request $request): JsonResponse + { + $uploadedFile = $request->file('file'); + + if (!$uploadedFile instanceof UploadedFile) { + throw new \InvalidArgumentException('Invalid file upload'); + } + + // Get original filename + $originalName = $uploadedFile->getClientOriginalName(); + + // Generate safe filename from original name + $fileName = $this->generateSafeFilename($originalName); + + // Use Laravel's putFileAs for better file handling + $filePath = $this->disk->putFileAs('uploads', $uploadedFile, $fileName); + + // Save file info to database + $file = $this->createFileRecord($originalName, $filePath, $uploadedFile->getSize()); + + // Return success response + return $this->createSuccessResponse($file); + } + + private function generateSafeFilename(string $originalName): string + { + // Sanitize the filename + $name = pathinfo($originalName, PATHINFO_FILENAME); + $extension = pathinfo($originalName, PATHINFO_EXTENSION); + + // Remove unsafe characters and replace with underscores + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name); + + // Ensure filename isn't too long (max 100 chars for the name part) + if (strlen($safeName) > 100) { + $safeName = substr($safeName, 0, 100); + } + + // Combine name and extension + $fileName = $safeName . ($extension ? '.' . $extension : ''); + + // Check if file already exists and add counter if needed + $counter = 1; + + while ($this->disk->exists("uploads/{$fileName}")) { + $fileName = $safeName . '_' . $counter . ($extension ? '.' . $extension : ''); + $counter++; + } + + return $fileName; + } + + private function createFileRecord(string $fileName, string $filePath, int $fileSize): File + { + return Auth::user()->files()->create([ + 'file_name' => $fileName, + 'file_path' => $filePath, + 'file_size' => $fileSize, + ]); + } + + private function createSuccessResponse(File $file): JsonResponse + { + return response()->json([ + 'id' => $file->id, + 'file' => $file->file_name, + 'path' => $file->file_path, + 'size' => $file->file_size, + ]); + } +} diff --git a/composer.json b/composer.json index bdd00013..032cdfac 100644 --- a/composer.json +++ b/composer.json @@ -77,4 +77,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd9..93bfb69b 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -34,7 +34,7 @@ 'driver' => 'local', 'root' => storage_path('app/private'), 'serve' => true, - 'throw' => false, + 'throw' => true, 'report' => false, ], diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 0a35e741..1086b869 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,5 +1,5 @@ - +
@@ -40,22 +40,29 @@ @push('scripts') @endpush diff --git a/routes/web.php b/routes/web.php index 1bc5734c..472b0a40 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,7 +16,7 @@ ->name('dashboard'); Route::middleware(['auth'])->group(function () { - Route::post('upload', UploadController::class)->name('upload'); + Route::match(['post', 'patch'], 'upload', UploadController::class)->name('upload'); Route::redirect('settings', 'settings/profile');