filesystemManager = $filesystemManager; $this->config = $config; } /** * Sanitize filename to prevent path traversal and other security issues * Source: https://stackoverflow.com/questions/2021624/string-sanitizer-for-filename * * @param string $filename The filename to sanitize * @return string Sanitized filename */ private function sanitizeFilename(string $filename): string { // Remove any directory paths $filename = basename($filename); // sanitize filename $filename = preg_replace( '~ [<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words [\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx [\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN [#\[\]@!$&\'()+,;=]| # URI reserved https://www.rfc-editor.org/rfc/rfc3986#section-2.2 [{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt ~x', '-', $filename); // avoids ".", ".." or ".hiddenFiles" $filename = ltrim($filename, '.-'); // maximize filename length to 255 bytes http://serverfault.com/a/9548/44086 $ext = pathinfo($filename, PATHINFO_EXTENSION); return mb_strcut(pathinfo($filename, PATHINFO_FILENAME), 0, 255 - ($ext ? strlen($ext) + 1 : 0), mb_detect_encoding($filename)).($ext ? '.'.$ext : ''); } public function filter_filename($filename, $beautify = true) {} /** * File extensions that are never allowed to be uploaded. * These can be executed server-side or used to override server configuration. */ private const DENIED_EXTENSIONS = [ 'php', 'phtml', 'php3', 'php4', 'php5', 'phar', 'htaccess', 'shtml', ]; /** * Validates a file before upload * * @param UploadedFile $file The file to validate * @param string $module The module name * * @throws FileValidationException If the file is invalid */ private function validateFile(UploadedFile $file, string $module = 'default'): void { // Check if file is valid if (! $file->isValid() || $file->getError()) { Log::error('Invalid file upload attempt: '.$file->getErrorMessage()); throw new FileValidationException('Invalid file upload attempt: '.$file->getErrorMessage(), FileValidationException::INVALID_FILE); } // Check file size (10MB max by default, can be configured) $maxSize = self::getMaximumFileUploadSize(); if ($file->getSize() > $maxSize) { throw new FileValidationException('File size exceeds the maximum allowed size of '.format($maxSize)->formatBytes(), FileValidationException::FILE_TOO_LARGE); } // Reject dangerous file extensions that could be executed server-side $extension = strtolower($file->getClientOriginalExtension()); if (in_array($extension, self::DENIED_EXTENSIONS, true)) { Log::warning('Blocked upload of dangerous file type', [ 'extension' => $extension, 'originalName' => $file->getClientOriginalName(), 'userId' => session('userdata.id'), ]); throw new FileValidationException( 'File type .'.$extension.' is not allowed', FileValidationException::INVALID_MIME_TYPE ); } // Also check for double extensions like file.php.jpg that could bypass // some server configurations $originalName = strtolower($file->getClientOriginalName()); foreach (self::DENIED_EXTENSIONS as $deniedExt) { if (str_contains($originalName, '.'.$deniedExt.'.')) { Log::warning('Blocked upload with dangerous double extension', [ 'originalName' => $file->getClientOriginalName(), 'userId' => session('userdata.id'), ]); throw new FileValidationException( 'File contains a disallowed extension in its name', FileValidationException::INVALID_MIME_TYPE ); } } // SVG files can contain embedded scripts and event handlers. // Reject them outright to prevent stored XSS attacks. if ($extension === 'svg' || $extension === 'svgz') { Log::info('Blocked SVG upload for security', [ 'originalName' => $file->getClientOriginalName(), 'userId' => session('userdata.id'), ]); throw new FileValidationException( 'SVG file uploads are not allowed for security reasons', FileValidationException::INVALID_MIME_TYPE ); } } /** * Upload a file * * @param UploadedFile $file The file to upload * @param string $disk The disk to use for storage * @return array|false Array with file info or false on failure */ public function upload(UploadedFile $file, $disk = 'default'): array|false { try { // Validate file before proceeding $this->validateFile($file, $disk); $extension = $file->getClientOriginalExtension(); $realName = $this->sanitizeFilename($file->getClientOriginalName()); $fileName = $realName; if ($disk === 'default') { $disk = $this->filesystemManager->getDefaultDriver(); } $visibility = null; if ($disk === 'public' && $this->config->useS3) { $disk = 's3'; $visibility = 'public'; } $storage = $this->filesystemManager->disk($disk); $newName = pathinfo($fileName, PATHINFO_FILENAME); if (config('filesystems.disks.'.$disk.'.renameFiles')) { $newName = md5(session('userdata.id').time()); $fileName = $newName.'.'.$extension; } // Store the file $stream = fopen($file->getRealPath(), 'r'); $result = $storage->put($fileName, $stream, $visibility); if (is_resource($stream)) { fclose($stream); } return [ 'encName' => $newName, 'realName' => $realName, 'extension' => $extension, 'fileName' => $fileName, 'newPath' => $newName.'.'.$extension, 'path' => $fileName, 'moduleId' => '', 'module' => '', 'userId' => session('userdata.id'), 'fileId' => '', 'uploadTime' => time(), ]; } catch (\Exception $e) { // Enhanced error logging Log::error('File upload failed: '.$e->getMessage(), [ 'exception' => $e, 'file' => $file->getClientOriginalName(), ]); return false; } } /** * Get a file * * @param string $fileName The file name (with extension) * @param string $realName The original file name (for Content-Disposition) * @param string $disk The disk to use for storage * @return Response|false Response object or false on failure */ public function getFile(string $fileName, string $realName, string $disk = 'default'): Response|false { try { // Determine the disk to use if ($disk === 'default') { $disk = $this->filesystemManager->getDefaultDriver(); } // Public disk but using s3 means we are getting files from S3 but they have public visibility if ($disk === 'public' && $this->config->useS3) { $disk = 's3'; } $storage = $this->filesystemManager->disk($disk); // Check if file exists if (! $storage->exists($fileName)) { return false; } // Get file mime type $mimeType = $storage->mimeType($fileName) ?: 'application/octet-stream'; // Read file contents directly instead of using download() which relies // on fpassthru() — a function disabled on many shared hosting environments. $content = $storage->get($fileName); $response = new Response($content); $response->headers->set('Content-Type', $mimeType); $response->headers->set('Content-Length', (string) $storage->size($fileName)); $response->headers->set('Content-Disposition', 'inline; filename="'.$realName.'"'); // Sandbox all user-uploaded files to prevent script execution $response->headers->set('Content-Security-Policy', 'sandbox'); // Force download for content types that can execute scripts (HTML, SVG, XML) // to prevent inline rendering of potentially malicious content $dangerousMimeTypes = [ 'text/html', 'application/xhtml+xml', 'image/svg+xml', 'application/xml', 'text/xml', ]; if (in_array(strtolower($mimeType), $dangerousMimeTypes, true)) { $response->headers->set('Content-Disposition', 'attachment; filename="'.$realName.'"'); $response->headers->set('X-Content-Type-Options', 'nosniff'); } if (! $this->config->debug) { $response->headers->set('Pragma', 'public'); $response->headers->set('Cache-Control', 'max-age=86400'); $response->headers->set('Last-Modified', gmdate('D, d M Y H:i:s', $storage->lastModified($fileName)).' GMT'); } return $response; } catch (\Exception $e) { Log::error('Error getting file: '.$e->getMessage(), [ 'fileName' => $fileName, 'disk' => $disk, 'exception' => $e, ]); return false; } } /** * Get a file URL * * @param string $fileName The file name (with extension) * @param string $disk The disk to use for storage * @param int $expires Number of minutes before URL expires (for S3) * @return string|false File URL or false on failure */ public function getFileUrl(string $fileName, string $disk = 'default', $expires = 0): string|false { try { // Determine the disk to use if ($disk === 'default') { $disk = $this->filesystemManager->getDefaultDriver(); } if ($disk === 'public' && $this->config->useS3) { $disk = 's3'; } $storage = $this->filesystemManager->disk($disk); // Check if file exists if (! $storage->exists($fileName)) { return false; } // Get file mime type $extension = pathinfo($fileName, PATHINFO_EXTENSION); $mimeType = $this->filesystemManager->mimeType($fileName); if ($disk === 's3') { try { // Generate a signed URL with proper expiration // Use configured expiration time if not specified if ($expires <= 0) { $expires = (int) $this->config->get('filesystems.url_expiration', 60); } // Add some randomness to prevent cache stampede $jitter = random_int(0, min(5, $expires / 10)); $expiration = dtHelper()->now()->addMinutes($expires + $jitter); return $storage->temporaryUrl($fileName, $expiration); } catch (\Exception $e) { Log::error('Failed to generate S3 temporary URL: '.$e->getMessage()); return false; } } else { // Use caching for non-S3 files $cacheEnabled = $this->config->get('filesystems.cache.enabled', true); $cacheDuration = $this->config->get('filesystems.cache.duration', 60); $cacheKey = "file_url_{$disk}_{$fileName}"; if ($cacheEnabled && $disk !== 's3') { return Cache::remember($cacheKey, $cacheDuration * 60, static function () use ($storage, $fileName) { return $storage->url($fileName); }); } return $storage->url($fileName); } } catch (\Exception $e) { Log::error('Error getting file URL: '.$e->getMessage(), [ 'fileName' => $fileName, 'disk' => $disk, 'exception' => $e, ]); return false; } } /** * Delete a file * * @param string $fileName The file name (with extension) * @param string $disk The disk to use for storage * @return bool True on success, false on failure */ public function deleteFile(string $fileName, string $disk = 'default'): bool { if (empty($fileName)) { Log::warning('Attempted to delete a file with empty filename'); return false; } try { // Determine the disk to use if ($disk === 'default') { $disk = $this->filesystemManager->getDefaultDriver(); } $storage = $this->filesystemManager->disk($disk); // Check if file exists before attempting deletion if (! $storage->exists($fileName)) { Log::info("File not found for deletion: {$fileName} on disk {$disk}"); return false; } // Delete the file return $storage->delete($fileName); } catch (\Exception $e) { Log::error('Error deleting file: '.$e->getMessage(), [ 'fileName' => $fileName, 'disk' => $disk, 'exception' => $e, ]); return false; } } /** * This function returns the maximum files size that can be uploaded in PHP * * @return int The filesize allowed by php.ini config in bytes */ public static function getMaximumFileUploadSize(): int { return min(self::convertPHPSizeToBytes(ini_get('post_max_size')), self::convertPHPSizeToBytes(ini_get('upload_max_filesize'))); } /** * This function transforms the php.ini notation for numbers (like '2M') to an integer (2*1024*1024 in this case) * * @return int The value in bytes */ private static function convertPHPSizeToBytes(string $sSize): int { $sSuffix = strtoupper(substr($sSize, -1)); if (! in_array($sSuffix, ['P', 'T', 'G', 'M', 'K'])) { return (int) $sSize; } $iValue = (int) substr($sSize, 0, -1); switch ($sSuffix) { case 'P': $iValue *= 1024; // Fallthrough intended // no break case 'T': $iValue *= 1024; // Fallthrough intended // no break case 'G': $iValue *= 1024; // Fallthrough intended // no break case 'M': $iValue *= 1024; // Fallthrough intended // no break case 'K': $iValue *= 1024; break; } return (int) $iValue; } }