diff --git a/src/Libraries/Storage/Exceptions/FileUploadException.php b/src/Libraries/Storage/Exceptions/FileUploadException.php index 6f1256a43..aa6c9cc9d 100644 --- a/src/Libraries/Storage/Exceptions/FileUploadException.php +++ b/src/Libraries/Storage/Exceptions/FileUploadException.php @@ -32,4 +32,13 @@ public static function fileTypeNotAllowed(string $name): FileUploadException { return new static(_message(ExceptionMessages::FILE_TYPE_NOT_ALLOWED, [$name]), E_WARNING); } + + /** + * @param string $name + * @return FileUploadException + */ + public static function incorrectMimeTypesConfig(string $name): FileUploadException + { + return new static(_message(ExceptionMessages::MISSING_CONFIG, $name), E_ERROR); + } } \ No newline at end of file diff --git a/src/Libraries/Storage/UploadedFile.php b/src/Libraries/Storage/UploadedFile.php index 390ae0052..2038ca05f 100644 --- a/src/Libraries/Storage/UploadedFile.php +++ b/src/Libraries/Storage/UploadedFile.php @@ -23,6 +23,7 @@ use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; +use Quantum\Loader\Setup; use Gumlet\ImageResizeException; use ReflectionException; use Gumlet\ImageResize; @@ -99,15 +100,13 @@ class UploadedFile extends SplFileInfo ]; /** - * Blacklisted extensions + * Allowed mime types => allowed extensions map * @var array */ - protected $blacklistedExtensions = [ - 'php([0-9])?', 'pht', 'phar', 'phpt', 'pgif', 'phtml', 'phtm', 'phps', - 'cgi', 'inc', 'env', 'htaccess', 'htpasswd', 'config', 'conf', - 'bat', 'exe', 'msi', 'cmd', 'dll', 'sh', 'com', 'app', 'sys', 'drv', - 'pl', 'jar', 'jsp', 'js', 'vb', 'vbscript', 'wsf', 'asp', 'py', - 'cer', 'csr', 'crt', + protected $allowedMimeTypes = [ + 'image/jpeg' => ['jpg', 'jpeg'], + 'image/png' => ['png'], + 'application/pdf' => ['pdf'], ]; /** @@ -130,9 +129,23 @@ public function __construct(array $meta) $this->originalName = $meta['name']; $this->errorCode = $meta['error']; + $this->loadAllowedMimeTypesFromConfig(); + parent::__construct($meta['tmp_name']); } + /** + * Sets the allowed mime types => extensions map + * @param array $allowedMimeTypes + * @param bool $merge + * @return $this + */ + public function setAllowedMimeTypes(array $allowedMimeTypes, bool $merge = true): UploadedFile + { + $this->setAllowedMimeTypesMap($allowedMimeTypes, $merge); + return $this; + } + /** * Get name * @return string @@ -265,7 +278,7 @@ public function save(string $dest, bool $overwrite = false): bool throw FileUploadException::fileNotFound($this->getPathname()); } - if (!$this->whitelisted($this->getExtension())) { + if (!$this->allowed($this->getExtension(), $this->getMimeType())) { throw FileUploadException::fileTypeNotAllowed($this->getExtension()); } @@ -377,17 +390,55 @@ protected function moveUploadedFile(string $filePath): bool } /** - * Whitelist the file extension + * Validates upload against allowed mime types => extensions map * @param string $extension + * @param string $mimeType * @return bool */ - protected function whitelisted(string $extension): bool + protected function allowed(string $extension, string $mimeType): bool { - if (!preg_match('/(' . implode('|', $this->blacklistedExtensions) . ')$/i', $extension)) { - return true; + $extension = strtolower($extension); + $mimeType = strtolower($mimeType); + + return isset($this->allowedMimeTypes[$mimeType]) && + in_array($extension, $this->allowedMimeTypes[$mimeType], true); + } + + /** + * Loads allowed mime types from config (shared/config/uploads.php) if present. + * @return void + * @throws FileUploadException + */ + protected function loadAllowedMimeTypesFromConfig(): void + { + if (!config()->has('uploads')) { + config()->import(new Setup('config', 'uploads')); + } + + $allowedMimeTypesMap = config()->get('uploads.allowed_mime_types'); + + if ($allowedMimeTypesMap !== null && !is_array($allowedMimeTypesMap)) { + throw FileUploadException::incorrectMimeTypesConfig('uploads'); + } + + if (is_array($allowedMimeTypesMap)) { + $this->setAllowedMimeTypesMap($allowedMimeTypesMap); } + } - return false; + /** + * Sets the allowed mime types => extensions map + * @param array $allowedMimeTypes + * @param bool $merge + * @return void + */ + protected function setAllowedMimeTypesMap(array $allowedMimeTypes, bool $merge = true): void + { + if ($merge) { + $this->allowedMimeTypes = array_merge_recursive($this->allowedMimeTypes, $allowedMimeTypes); + } else { + $this->allowedMimeTypes = $allowedMimeTypes; + } } /** diff --git a/tests/Unit/Libraries/Storage/UploadedFileTest.php b/tests/Unit/Libraries/Storage/UploadedFileTest.php index fc14f4d5a..014fa8aea 100644 --- a/tests/Unit/Libraries/Storage/UploadedFileTest.php +++ b/tests/Unit/Libraries/Storage/UploadedFileTest.php @@ -48,6 +48,10 @@ public function tearDown(): void if (file_exists(base_dir() . DS . 'foo.jpg')) { unlink(base_dir() . DS . 'foo.jpg'); } + + if (file_exists(base_dir() . DS . 'foo.txt')) { + unlink(base_dir() . DS . 'foo.txt'); + } } public function testUploadedFileConstructor() @@ -146,6 +150,49 @@ public function testUploadedFileUploadAndSave() $this->assertTrue(file_exists(base_dir() . DS . $uploadedFile->getNameWithExtension())); } + public function testUploadedFileUploadNotAllowedExtensionForMimeType() + { + $fileMeta = $this->fileMeta; + $fileMeta['name'] = 'foo.php'; + + $uploadedFile = new UploadedDouble($fileMeta); + + $this->expectException(FileUploadException::class); + $this->expectExceptionMessage('The file type `php` is not allowed.'); + + $uploadedFile->save(base_dir()); + } + + public function testUploadedFileUploadNotAllowedMimeType() + { + $fileMeta = $this->fileMeta; + $fileMeta['name'] = 'foo.jpg'; + $fileMeta['tmp_name'] = base_dir() . DS . 'journal.log'; + + $uploadedFile = new UploadedDouble($fileMeta); + + $this->expectException(FileUploadException::class); + $this->expectExceptionMessage('The file type `jpg` is not allowed.'); + + $uploadedFile->save(base_dir()); + } + + public function testUploadedFileUploadAllowedAfterSetterOverride() + { + $fileMeta = $this->fileMeta; + $fileMeta['name'] = 'foo.txt'; + $fileMeta['tmp_name'] = base_dir() . DS . 'journal.log'; + + $uploadedFile = new UploadedDouble($fileMeta); + + $uploadedFile->setAllowedMimeTypes([ + $uploadedFile->getMimeType() => ['txt'], + ]); + + $this->assertTrue($uploadedFile->save(base_dir())); + $this->assertTrue(file_exists(base_dir() . DS . $uploadedFile->getNameWithExtension())); + } + public function testUploadedFileSaveAndTryOverwrite() { $uploadedFile = new UploadedDouble($this->fileMeta); diff --git a/tests/_root/shared/config/uploads.php b/tests/_root/shared/config/uploads.php new file mode 100644 index 000000000..f8bca8a9b --- /dev/null +++ b/tests/_root/shared/config/uploads.php @@ -0,0 +1,26 @@ + extensions map for UploadedFile validation. + */ + 'allowed_mime_types' => [ + // Images + 'image/jpeg' => ['jpg', 'jpeg'], + 'image/png' => ['png'], + 'image/gif' => ['gif'], + 'image/webp' => ['webp'], + 'image/svg+xml' => ['svg'], + // Audio + 'audio/mpeg' => ['mp3'], + 'audio/wav' => ['wav'], + 'audio/ogg' => ['ogg'], + 'audio/webm' => ['weba'], + // Video + 'video/mp4' => ['mp4'], + 'video/mpeg' => ['mpeg', 'mpg'], + 'video/quicktime' => ['mov'], + 'video/webm' => ['webm'], + 'video/x-msvideo' => ['avi'], + ], +]; \ No newline at end of file