diff options
Diffstat (limited to 'app/Http/Controllers/FileController.php')
| -rw-r--r-- | app/Http/Controllers/FileController.php | 447 |
1 files changed, 447 insertions, 0 deletions
diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php new file mode 100644 index 0000000..5d0f09a --- /dev/null +++ b/app/Http/Controllers/FileController.php @@ -0,0 +1,447 @@ +<?php + +namespace App\Http\Controllers; + +use App\Exceptions\FileUploadException; +use App\Http\Requests\FileUploadRequest; +use App\Models\File; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; + +class FileController extends Controller +{ + /** + * Show the upload form + */ + public function showUploadForm() + { + $config = config('fileupload'); + + return view('files.upload', [ + 'maxSizeMB' => round($config['max_file_size'] / 1024, 2), + 'maxFiles' => $config['max_files_per_upload'], + 'allowedExtensions' => $config['allowed_extensions'], + ]); + } + + /** + * Handle standard file upload (non-JS fallback) + */ + public function upload(FileUploadRequest $request) + { + try { + $uploadedFiles = []; + $urls = []; + + foreach ($request->file('files') as $uploadedFile) { + $file = $this->storeFile($uploadedFile, $request); + $uploadedFiles[] = $file; + $urls[] = $file->download_url; + } + + Log::info('File upload successful', [ + 'ip' => $request->ip(), + 'count' => count($uploadedFiles), + 'total_size' => array_sum(array_map(fn($f) => $f->size, $uploadedFiles)), + ]); + + // For AJAX requests + if ($request->expectsJson()) { + return response()->json([ + 'success' => true, + 'message' => count($uploadedFiles) . ' file(s) uploaded successfully', + 'urls' => $urls, + 'files' => $uploadedFiles, + ]); + } + + // For standard form submission + return redirect()->route('files.upload.form') + ->with('success', 'Files uploaded successfully!') + ->with('urls', $urls); + + } catch (FileUploadException $e) { + Log::warning('File upload validation failed', [ + 'ip' => $request->ip(), + 'error' => $e->getMessage(), + ]); + + if ($request->expectsJson()) { + return response()->json([ + 'success' => false, + 'error' => $e->getMessage(), + ], 422); + } + + return redirect()->back() + ->withErrors(['upload' => $e->getMessage()]) + ->withInput(); + + } catch (\Exception $e) { + Log::error('File upload failed', [ + 'ip' => $request->ip(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + if ($request->expectsJson()) { + return response()->json([ + 'success' => false, + 'error' => 'Upload failed. Please try again.', + ], 500); + } + + return redirect()->back() + ->withErrors(['upload' => 'Upload failed. Please try again.']) + ->withInput(); + } + } + + /** + * Handle individual chunk upload + */ + public function uploadChunk(Request $request) + { + $request->validate([ + 'chunk' => 'required|file', + 'upload_id' => 'required|string', + 'chunk_number' => 'required|integer|min:0', + 'total_chunks' => 'required|integer|min:1', + 'original_filename' => 'required|string', + 'mime_type' => 'nullable|string', + ]); + + try { + $uploadId = $request->input('upload_id'); + $chunkNumber = $request->input('chunk_number'); + $totalChunks = $request->input('total_chunks'); + + // Store chunk temporarily + $chunkPath = "chunks/{$uploadId}"; + $chunkFilename = "chunk_{$chunkNumber}"; + + Storage::put( + "{$chunkPath}/{$chunkFilename}", + file_get_contents($request->file('chunk')->getRealPath()) + ); + + // Track chunk in database + DB::table('upload_chunks')->insert([ + 'upload_id' => $uploadId, + 'session_id' => $request->session()->getId(), + 'ip_address' => $request->ip(), + 'chunk_number' => $chunkNumber, + 'total_chunks' => $totalChunks, + 'chunk_filename' => $chunkFilename, + 'chunk_size' => $request->file('chunk')->getSize(), + 'filename_og' => $request->input('original_filename'), + 'mime_type' => $request->input('mime_type'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Log::info('Chunk uploaded', [ + 'upload_id' => $uploadId, + 'chunk' => $chunkNumber + 1, + 'total' => $totalChunks, + ]); + + return response()->json([ + 'success' => true, + 'message' => "Chunk {$chunkNumber} uploaded successfully", + ]); + + } catch (\Exception $e) { + Log::error('Chunk upload failed', [ + 'upload_id' => $request->input('upload_id'), + 'chunk_number' => $request->input('chunk_number'), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'Chunk upload failed', + ], 500); + } + } + + /** + * Complete chunked upload by merging chunks + */ + public function completeChunkedUpload(Request $request) + { + $request->validate([ + 'upload_id' => 'required|string', + 'original_filename' => 'required|string', + ]); + + try { + $uploadId = $request->input('upload_id'); + + // Get all chunks for this upload + $chunks = DB::table('upload_chunks') + ->where('upload_id', $uploadId) + ->orderBy('chunk_number') + ->get(); + + if ($chunks->isEmpty()) { + throw FileUploadException::chunkMismatch($uploadId); + } + + $totalChunks = $chunks->first()->total_chunks; + + if ($chunks->count() !== $totalChunks) { + throw FileUploadException::chunksIncomplete($uploadId, $totalChunks, $chunks->count()); + } + + // Merge chunks + $mergedContent = ''; + $chunkPath = "chunks/{$uploadId}"; + + foreach ($chunks as $chunk) { + $chunkFile = "{$chunkPath}/{$chunk->chunk_filename}"; + if (!Storage::exists($chunkFile)) { + throw FileUploadException::chunkMismatch($uploadId); + } + $mergedContent .= Storage::get($chunkFile); + } + + // Create temporary file to validate + $tempPath = storage_path("app/temp_{$uploadId}"); + file_put_contents($tempPath, $mergedContent); + + // Validate merged file + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $tempPath); + finfo_close($finfo); + + $config = config('fileupload'); + if (!in_array($mimeType, $config['allowed_mimes'])) { + unlink($tempPath); + throw FileUploadException::invalidMimeType($mimeType); + } + + // Store the merged file + $originalFilename = $request->input('original_filename'); + $storagePath = $this->generateStoragePath(); + $uniqueFilename = $this->generateUniqueFilename($originalFilename, $storagePath); + $fullPath = "{$storagePath}/{$uniqueFilename}"; + + Storage::disk($config['disk'])->put($fullPath, $mergedContent); + + // Create File record + $file = File::create([ + 'filename_og' => $originalFilename, + 'filename_stored' => $uniqueFilename, + 'storage_path' => $fullPath, + 'disk' => $config['disk'], + 'mime_type' => $mimeType, + 'extension' => pathinfo($originalFilename, PATHINFO_EXTENSION), + 'size' => strlen($mergedContent), + 'hash' => hash('sha256', $mergedContent), + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'is_chunked' => true, + ]); + + $file->setExpiration(); + + // Log upload activity + $file->activityLogs()->create([ + 'event_type' => 'upload', + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'data' => [ + 'is_chunked' => true, + 'total_chunks' => $totalChunks, + ], + ]); + + // Cleanup + unlink($tempPath); + Storage::deleteDirectory($chunkPath); + DB::table('upload_chunks')->where('upload_id', $uploadId)->delete(); + + Log::info('Chunked upload completed', [ + 'upload_id' => $uploadId, + 'file_id' => $file->id, + 'size' => $file->size, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'File uploaded successfully', + 'download_url' => $file->download_url, + 'file' => $file, + ]); + + } catch (FileUploadException $e) { + Log::warning('Chunked upload completion failed', [ + 'upload_id' => $request->input('upload_id'), + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'error' => $e->getMessage(), + ], 422); + + } catch (\Exception $e) { + Log::error('Chunked upload completion failed', [ + 'upload_id' => $request->input('upload_id'), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'Upload completion failed', + ], 500); + } + } + + /** + * Browse uploaded files + */ + public function browse(Request $request) + { + $query = File::notExpired()->latest(); + + // Search functionality + if ($search = $request->input('search')) { + $query->where('filename_og', 'like', "%{$search}%"); + } + + $files = $query->paginate(20); + + return view('files.browse', compact('files')); + } + + /** + * Gallery view for images and videos + */ + public function gallery(Request $request) + { + // Filter for images and videos only + $query = File::notExpired() + ->where(function($q) { + $q->where('mime_type', 'like', 'image/%') + ->orWhere('mime_type', 'like', 'video/%'); + }) + ->latest(); + + $files = $query->paginate(30); + + return view('files.gallery', compact('files')); + } + + /** + * Download/view a file + */ + public function download(File $file) + { + if ($file->isExpired()) { + abort(404, 'File not found or has expired'); + } + + // Log the download + $file->logDownload(request()->ip(), request()->userAgent()); + + // Get the full file path + $filePath = Storage::disk($file->disk)->path($file->storage_path); + + // Serve the file inline (browser will render if possible) + return response()->file($filePath, [ + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => 'inline; filename="' . $file->filename_og . '"' + ]); + } + + /** + * Store a single uploaded file + */ + protected function storeFile($uploadedFile, Request $request) + { + $config = config('fileupload'); + + // Generate storage path and filename + $storagePath = $this->generateStoragePath(); + $uniqueFilename = $this->generateUniqueFilename( + $uploadedFile->getClientOriginalName(), + $storagePath + ); + $fullPath = "{$storagePath}/{$uniqueFilename}"; + + // Store the file + $uploadedFile->storeAs( + $storagePath, + $uniqueFilename, + $config['disk'] + ); + + // Generate file hash + $hash = hash_file('sha256', $uploadedFile->getRealPath()); + + // Create File record + $file = File::create([ + 'filename_og' => $uploadedFile->getClientOriginalName(), + 'filename_stored' => $uniqueFilename, + 'storage_path' => $fullPath, + 'disk' => $config['disk'], + 'mime_type' => $uploadedFile->getMimeType(), + 'extension' => $uploadedFile->getClientOriginalExtension(), + 'size' => $uploadedFile->getSize(), + 'hash' => $hash, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'is_chunked' => false, + ]); + + $file->setExpiration(); + + // Log upload activity + $file->activityLogs()->create([ + 'event_type' => 'upload', + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return $file; + } + + /** + * Generate date-based storage path + */ + protected function generateStoragePath() + { + $basePath = config('fileupload.storage_path', 'uploads'); + $datePath = now()->format('Y/m/d'); + + return "{$basePath}/{$datePath}"; + } + + /** + * Generate unique filename, appending epoch on collision + */ + protected function generateUniqueFilename($originalFilename, $storagePath) + { + $pathInfo = pathinfo($originalFilename); + $baseName = Str::slug($pathInfo['filename'] ?: 'file'); + $extension = $pathInfo['extension'] ?? ''; + + $filename = $baseName . ($extension ? ".{$extension}" : ''); + $fullPath = "{$storagePath}/{$filename}"; + + // Check for collision + $disk = config('fileupload.disk'); + if (Storage::disk($disk)->exists($fullPath)) { + // Append epoch seconds + $filename = $baseName . '_' . time() . ($extension ? ".{$extension}" : ''); + } + + return $filename; + } +} |
