diff --git a/core/modules/file/file.module b/core/modules/file/file.module index 502fd922d9..4cb91d0029 100644 --- a/core/modules/file/file.module +++ b/core/modules/file/file.module @@ -9,6 +9,9 @@ use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\File\Exception\FileException; +use Drupal\Core\File\Exception\FileExistsException; +use Drupal\Core\File\Exception\FileWriteException; +use Drupal\Core\File\Exception\InvalidStreamWrapperException; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Messenger\MessengerInterface; @@ -23,6 +26,8 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Template\Attribute; +use Drupal\file\Upload\FileValidationException; +use Symfony\Component\HttpFoundation\File\Exception\FileException as SymfonyFileException; use Symfony\Component\Mime\MimeTypeGuesserInterface; /** @@ -892,9 +897,60 @@ function file_save_upload($form_field_name, $validators = [], $destination = FAL $uploaded_files = [$file_upload]; } + if ($destination === FALSE || $destination === NULL) { + $destination = 'temporary://'; + } + + /** @var \Drupal\file\Upload\FileUploadHandler $file_uploader */ + $file_uploader = \Drupal::service('file.uploader'); + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); $files = []; foreach ($uploaded_files as $i => $file_info) { - $files[$i] = _file_save_upload_single($file_info, $form_field_name, $validators, $destination, $replace); + try { + $result = $file_uploader->createFromUpload($file_info, $validators, $destination, $replace); + $file = $result->getFile(); + // If the filename has been modified, let the user know. + if ($result->isRenamed()) { + if ($result->isSecurityRename()) { + $message = t('For security reasons, your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]); + } + else { + $message = t('Your upload has been renamed to %filename.', ['%filename' => $file->getFilename()]); + } + \Drupal::messenger()->addStatus($message); + } + $files[$i] = $result->getFile(); + } + catch (FileExistsException | SymfonyFileException | InvalidStreamWrapperException $e) { + \Drupal::messenger()->addError($e->getMessage()); + $files[$i] = FALSE; + } + catch (FileValidationException $e) { + $message = [ + 'error' => [ + '#markup' => t('The specified file %name could not be uploaded.', ['%name' => $e->getFilename()]), + ], + 'item_list' => [ + '#theme' => 'item_list', + '#items' => $e->getErrors(), + ], + ]; + // @todo Add support for render arrays in + // \Drupal\Core\Messenger\MessengerInterface::addMessage()? + // @see https://www.drupal.org/node/2505497. + \Drupal::messenger()->addError($renderer->renderPlain($message)); + $files[$i] = FALSE; + } + catch (FileWriteException $e) { + \Drupal::messenger()->addError(t('File upload error. Could not move uploaded file.')); + \Drupal::logger('file')->notice('Upload error. Could not move uploaded file %file to destination %destination.', ['%file' => $file_info->getClientOriginalName(), '%destination' => $destination . '/' . $file_info->getClientOriginalName()]); + $files[$i] = FALSE; + } + catch (FileException $e) { + \Drupal::messenger()->addError(t('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $file_info->getClientOriginalName()])); + $files[$i] = FALSE; + } } // Add files to the cache. @@ -928,9 +984,14 @@ function file_save_upload($form_field_name, $validators = [], $destination = FAL * This method should only be called from file_save_upload(). Use that method * instead. * + * @deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use + * \Drupal\file\Upload\FileUploader::createFromUpload() instead. + * + * @see https://www.drupal.org/node/123 * @see file_save_upload() */ function _file_save_upload_single(\SplFileInfo $file_info, $form_field_name, $validators = [], $destination = FALSE, $replace = FileSystemInterface::EXISTS_REPLACE) { + @trigger_error(__METHOD__ . '() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\Upload\FileUploader::createFromUpload() instead. See https://www.drupal.org/node/123', E_USER_DEPRECATED); $user = \Drupal::currentUser(); // Remember the original filename so we can print a message if it changes. $original_file_name = $file_info->getClientOriginalName(); diff --git a/core/modules/file/file.services.yml b/core/modules/file/file.services.yml index 037547cb1f..22fb897011 100644 --- a/core/modules/file/file.services.yml +++ b/core/modules/file/file.services.yml @@ -4,3 +4,6 @@ services: arguments: ['@config.factory', '@database', 'file_usage'] tags: - { name: backend_overridable } + file.uploader: + class: Drupal\file\Upload\FileUploader + arguments: [ '@file_system', '@entity_type.manager', '@stream_wrapper_manager', '@event_dispatcher', '@file.mime_type.guesser', '@current_user', '@request_stack' ] diff --git a/core/modules/file/src/Upload/FileUploadHandler.php b/core/modules/file/src/Upload/FileUploadHandler.php new file mode 100644 index 0000000000..f0badf9551 --- /dev/null +++ b/core/modules/file/src/Upload/FileUploadHandler.php @@ -0,0 +1,396 @@ +fileSystem = $fileSystem; + $this->entityTypeManager = $entityTypeManager; + $this->streamWrapperManager = $streamWrapperManager; + $this->eventDispatcher = $eventDispatcher; + $this->mimeTypeGuesser = $mimeTypeGuesser; + $this->currentUser = $currentUser; + $this->requestStack = $requestStack; + } + + /** + * Creates a file from an upload. + * + * @param \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile + * The uploaded file object. + * @param array $validators + * The uploaded file. + * @param string $destination + * The file destination name. + * @param int $replace + * Replace behavior when the destination file already exists: + * - FileSystemInterface::EXISTS_REPLACE - Replace the existing file. + * - FileSystemInterface::EXISTS_RENAME - Append _{incrementing number} + * until the filename is unique. + * - FileSystemInterface::EXISTS_ERROR - Throw an exception. + * + * @return \Drupal\file\Upload\FileUploadResult + * The created file entity. + * + * @throws \Symfony\Component\HttpFoundation\File\Exception\FileException + * Thrown when a file upload error occurred. + * @throws \Drupal\Core\File\Exception\FileWriteException + * Thrown when there is an error moving the file. + * @throws \Drupal\Core\File\Exception\FileException + * Thrown when a file system error occurs. + * @throws \Drupal\file\Upload\FileValidationException + * Thrown when file validation fails. + */ + public function createFromUpload(UploadedFile $uploadedFile, array $validators = [], string $destination = 'temporary://', int $replace = FileSystemInterface::EXISTS_REPLACE): FileUploadResult { + $originalName = $uploadedFile->getClientOriginalName(); + + if (!$uploadedFile->isValid()) { + $this->handleError($originalName, $uploadedFile->getError()); + } + + $extensions = $this->getAllowedExtensions($validators); + + // Assert that the destination contains a valid stream. + $destinationScheme = $this->streamWrapperManager::getScheme($destination); + if (!$this->streamWrapperManager->isValidScheme($destinationScheme)) { + throw new InvalidStreamWrapperException(sprintf('The file could not be uploaded because the destination "%s" is invalid.', $destination)); + } + + // A file URI may already have a trailing slash or look like "public://". + if (substr($destination, -1) != '/') { + $destination .= '/'; + } + + // Call an event to sanitize the filename and to attempt to address security + // issues caused by common server setups. + $event = new FileUploadSanitizeNameEvent($originalName, $extensions); + $this->eventDispatcher->dispatch($event); + $filename = $event->getFilename(); + + $mimeType = $this->mimeTypeGuesser->guessMimeType($filename); + $destinationFilename = $this->fileSystem->getDestinationFilename($destination . $filename, $replace); + if ($destinationFilename === FALSE) { + throw new FileExistsException(sprintf('Destination file "%s" exists', $destinationFilename)); + } + + $file = NULL; + if ($replace == FileSystemInterface::EXISTS_REPLACE) { + $file = $this->loadByUri($destinationFilename); + } + if ($file === NULL) { + $file = File::create([ + 'uid' => $this->currentUser->id(), + 'status' => 0, + 'uri' => $uploadedFile->getRealPath(), + ]); + } + + // This will be replaced later with a filename based on the destination. + $file->setFilename($filename); + $file->setMimeType($mimeType); + $file->setSize($uploadedFile->getSize()); + + // Add in our check of the file name length. + $validators['file_validate_name_length'] = []; + + // Call the validation functions specified by this function's caller. + $errors = file_validate($file, $validators); + if (!empty($errors)) { + throw new FileValidationException("File validation failed", $filename, $errors); + } + + $file->setFileUri($destinationFilename); + if (!$this->fileSystem->moveUploadedFile($uploadedFile->getRealPath(), $file->getFileUri())) { + throw new FileWriteException('File upload error. Could not move uploaded file.'); + } + + // Update the filename with any changes as a result of the renaming due to an + // existing file. + $file->setFilename($this->fileSystem->basename($file->getFileUri())); + + $result = (new FileUploadResult()) + ->setOriginalFilename($originalName) + ->setSanitizedFilename($filename) + ->setFile($file); + + // If the filename has been modified, let the user know. + if ($event->isSecurityRename()) { + $result->setSecurityRename(); + } + + // Set the permissions on the new file. + $this->fileSystem->chmod($file->getFileUri()); + + // Update the filename with any changes as a result of security or renaming + // due to an existing file. + $file->setFilename($this->fileSystem->basename($destinationFilename)); + + // We can now validate the file object itself before it's saved. + $violations = $file->validate(); + foreach ($violations as $violation) { + $errors[] = $violation->getMessage(); + } + if (!empty($errors)) { + throw new FileValidationException("File validation failed", $filename, $errors); + } + + // If we made it this far it's safe to record this file in the database. + $file->save(); + + // Allow an anonymous user who creates a non-public file to see it. See + // \Drupal\file\FileAccessControlHandler::checkAccess(). + if ($this->currentUser->isAnonymous() && $destinationScheme !== 'public') { + $session = $this->requestStack->getCurrentRequest()->getSession(); + $allowed_temp_files = $session->get('anonymous_allowed_file_ids', []); + $allowed_temp_files[$file->id()] = $file->id(); + $session->set('anonymous_allowed_file_ids', $allowed_temp_files); + } + + return $result; + } + + /** + * Gets the list of allowed extensions and updates the validators. + * + * @param array $validators + * + * @return string + */ + protected function getAllowedExtensions(array &$validators): string { + // Build a list of allowed extensions. + $extensions = ''; + if (isset($validators['file_validate_extensions'])) { + if (isset($validators['file_validate_extensions'][0])) { + // Build the list of non-munged extensions if the caller provided them. + $extensions = $validators['file_validate_extensions'][0]; + } + else { + // If 'file_validate_extensions' is set and the list is empty then the + // caller wants to allow any extension. In this case we have to remove the + // validator or else it will reject all extensions. + unset($validators['file_validate_extensions']); + } + } + else { + // No validator was provided, so add one using the default list. + // Build a default non-munged safe list for + // \Drupal\system\EventSubscriber\SecurityFileUploadEventSubscriber::sanitizeName(). + $extensions = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp'; + $validators['file_validate_extensions'] = []; + $validators['file_validate_extensions'][0] = $extensions; + } + return $extensions; + } + + /** + * Throw the appropriate Symfony exception for the given upload error code. + * + * @param string $original_file_name + * The original file name. + * @param int $errorCode + * The error code. + * + * @throws \Symfony\Component\HttpFoundation\File\Exception\FileException + * Throws FileException or a sub-class for each kind of error. + * + * @see https://www.php.net/manual/en/features.file-upload.errors.php + */ + protected function handleError(string $original_file_name, int $errorCode) { + switch ($errorCode) { + case \UPLOAD_ERR_INI_SIZE: + throw new IniSizeFileException($this->getFileSizeMessage($original_file_name)); + + case \UPLOAD_ERR_FORM_SIZE: + throw new FormSizeFileException($this->getFileSizeMessage($original_file_name)); + + case \UPLOAD_ERR_PARTIAL: + throw new PartialFileException($this->getIncompleteMessage($original_file_name)); + + case \UPLOAD_ERR_NO_FILE: + throw new NoFileException($this->getIncompleteMessage($original_file_name)); + + case \UPLOAD_ERR_CANT_WRITE: + throw new CannotWriteFileException($this->getDefaultMessage($original_file_name)); + + case \UPLOAD_ERR_NO_TMP_DIR: + throw new NoTmpDirFileException($this->getDefaultMessage($original_file_name)); + + case \UPLOAD_ERR_EXTENSION: + throw new ExtensionFileException($this->getDefaultMessage($original_file_name)); + + } + + throw new FileException(); + } + + /** + * Gets the file size error message. + * + * @param string $original_file_name + * The original file name. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The formatted error message. + */ + protected function getFileSizeMessage(string $original_file_name): string { + return new TranslatableMarkup('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [ + '%file' => $original_file_name, + '%maxsize' => format_size(Environment::getUploadMaxSize()), + ]); + } + + /** + * Gets the incomplete upload error message. + * + * @param string $original_file_name + * The original file name. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The formatted error message. + */ + protected function getIncompleteMessage(string $original_file_name): string { + return new TranslatableMarkup('The file %file could not be saved because the upload did not complete.', [ + '%file' => $original_file_name, + ]); + } + + /** + * Gets the default error message. + * + * @param string $original_file_name + * The original file name. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The formatted error message. + */ + protected function getDefaultMessage(string $original_file_name): string { + return new TranslatableMarkup('The file %file could not be saved. An unknown error has occurred.', [ + '%file' => $original_file_name, + ]); + } + + /** + * Loads the first File entity found with the specified URI. + * + * @param string $uri + * The file URI. + * + * @return \Drupal\file\FileInterface|null + * The first file with the matched URI if found, NULL otherwise. + * + * @todo replace with https://www.drupal.org/project/drupal/issues/3223209 + */ + protected function loadByUri(string $uri): ?FileInterface { + $fileStorage = $this->entityTypeManager->getStorage('file'); + /** @var \Drupal\file\FileInterface[] $files */ + $files = $fileStorage->loadByProperties(['uri' => $uri]); + if (count($files)) { + foreach ($files as $item) { + // Since some database servers sometimes use a case-insensitive + // comparison by default, double check that the filename is an exact + // match. + if ($item->getFileUri() === $uri) { + return $item; + } + } + } + return NULL; + } + +} diff --git a/core/modules/file/src/Upload/FileUploadResult.php b/core/modules/file/src/Upload/FileUploadResult.php new file mode 100644 index 0000000000..84e143a654 --- /dev/null +++ b/core/modules/file/src/Upload/FileUploadResult.php @@ -0,0 +1,124 @@ +securityRename = TRUE; + return $this; + } + + /** + * @param string $sanitizedFilename + * + * @return FileUploadResult + */ + public function setSanitizedFilename(string $sanitizedFilename): FileUploadResult { + $this->sanitizedFilename = $sanitizedFilename; + return $this; + } + + /** + * @return string + */ + public function getOriginalFilename(): string { + return $this->originalFilename; + } + + /** + * @param string $originalFilename + * + * @return FileUploadResult + */ + public function setOriginalFilename(string $originalFilename): FileUploadResult { + $this->originalFilename = $originalFilename; + return $this; + } + + /** + * @param \Drupal\file\FileInterface $file + * + * @return FileUploadResult + */ + public function setFile(FileInterface $file): FileUploadResult { + $this->file = $file; + return $this; + } + + /** + * @return bool + */ + public function isSecurityRename(): bool { + return $this->securityRename; + } + + /** + * If there was a file rename. + * + * @return bool + */ + public function isRenamed(): bool { + return $this->originalFilename !== $this->sanitizedFilename; + } + + /** + * @return string + */ + public function getSanitizedFilename(): string { + return $this->sanitizedFilename; + } + + /** + * @return \Drupal\file\FileInterface + */ + public function getFile(): FileInterface { + return $this->file; + } + +} diff --git a/core/modules/file/src/Upload/FileValidationException.php b/core/modules/file/src/Upload/FileValidationException.php new file mode 100644 index 0000000000..8c14aa3b4a --- /dev/null +++ b/core/modules/file/src/Upload/FileValidationException.php @@ -0,0 +1,60 @@ +filename = $filename; + $this->errors = $errors; + } + + /** + * Gets the filename. + * + * @return string + * The filename. + */ + public function getFilename(): string { + return $this->filename; + } + + /** + * Gets the errors. + * + * @return array + * The errors. + */ + public function getErrors(): array { + return $this->errors; + } + +} diff --git a/core/modules/file/tests/src/Functional/SaveUploadFormTest.php b/core/modules/file/tests/src/Functional/SaveUploadFormTest.php index eca962ae01..b4cb8e7938 100644 --- a/core/modules/file/tests/src/Functional/SaveUploadFormTest.php +++ b/core/modules/file/tests/src/Functional/SaveUploadFormTest.php @@ -183,7 +183,7 @@ public function testHandleExtension() { $this->assertSession()->pageTextContains("Epic upload FAIL!"); // Check that the correct hooks were called. - $this->assertFileHooksCalled(['validate']); + $this->assertFileHooksCalled(['load', 'validate']); // Reset the hook counters. file_test_reset(); @@ -286,7 +286,7 @@ public function testHandleDangerousFile() { $this->assertSession()->pageTextContains('Epic upload FAIL!'); // Check that the correct hooks were called. - $this->assertFileHooksCalled(['validate']); + $this->assertFileHooksCalled(['load', 'validate']); } /** diff --git a/core/modules/file/tests/src/Functional/SaveUploadTest.php b/core/modules/file/tests/src/Functional/SaveUploadTest.php index f37c2d9bd6..60a706ba13 100644 --- a/core/modules/file/tests/src/Functional/SaveUploadTest.php +++ b/core/modules/file/tests/src/Functional/SaveUploadTest.php @@ -211,7 +211,7 @@ public function testHandleExtension() { $this->assertSession()->pageTextContains("Epic upload FAIL!"); // Check that the correct hooks were called. - $this->assertFileHooksCalled(['validate']); + $this->assertFileHooksCalled(['load', 'validate']); // Reset the hook counters. file_test_reset(); @@ -327,7 +327,7 @@ public function testHandleDangerousFile() { $this->assertSession()->pageTextContains("Epic upload FAIL!"); // Check that the correct hooks were called. - $this->assertFileHooksCalled(['validate']); + $this->assertFileHooksCalled(['load', 'validate']); // Reset the hook counters. file_test_reset(); @@ -343,7 +343,7 @@ public function testHandleDangerousFile() { $this->assertSession()->pageTextContains("Epic upload FAIL!"); // Check that the correct hooks were called. - $this->assertFileHooksCalled(['validate']); + $this->assertFileHooksCalled(['load', 'validate']); // Reset the hook counters. file_test_reset(); @@ -362,7 +362,7 @@ public function testHandleDangerousFile() { $this->assertSession()->pageTextContains('Epic upload FAIL!'); // Check that the correct hooks were called. - $this->assertFileHooksCalled(['validate']); + $this->assertFileHooksCalled(['load', 'validate']); } /** diff --git a/core/modules/file/tests/src/Kernel/FileUploaderTest.php b/core/modules/file/tests/src/Kernel/FileUploaderTest.php new file mode 100644 index 0000000000..d66afd0566 --- /dev/null +++ b/core/modules/file/tests/src/Kernel/FileUploaderTest.php @@ -0,0 +1,51 @@ +fileUploader = $this->container->get('file.uploader'); + } + + /** + * Tests file size upload errors. + */ + public function testFileSaveUploadSingleErrorFormSize() { + $file_name = $this->randomMachineName(); + $file_info = $this->createMock(UploadedFile::class); + $file_info->expects($this->once())->method('getError')->willReturn(UPLOAD_ERR_FORM_SIZE); + $file_info->expects($this->once())->method('getClientOriginalName')->willReturn($file_name); + $this->expectException(FormSizeFileException::class); + $this->expectExceptionMessage(new TranslatableMarkup('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', ['%file' => $file_name, '%maxsize' => format_size(Environment::getUploadMaxSize())])); + $this->fileUploader->createFromUpload($file_info); + } + +} diff --git a/core/modules/file/tests/src/Kernel/FileModuleTest.php b/core/modules/file/tests/src/Kernel/LegacyFileTest.php similarity index 50% rename from core/modules/file/tests/src/Kernel/FileModuleTest.php rename to core/modules/file/tests/src/Kernel/LegacyFileTest.php index beb925f97d..d8366f01f1 100644 --- a/core/modules/file/tests/src/Kernel/FileModuleTest.php +++ b/core/modules/file/tests/src/Kernel/LegacyFileTest.php @@ -2,8 +2,6 @@ namespace Drupal\Tests\file\Kernel; -use Drupal\Component\Utility\Environment; -use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\KernelTests\KernelTestBase; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -11,8 +9,9 @@ * Tests file.module methods. * * @group file + * @group legacy */ -class FileModuleTest extends KernelTestBase { +class LegacyFileTest extends KernelTestBase { /** * {@inheritdoc} @@ -21,23 +20,22 @@ class FileModuleTest extends KernelTestBase { /** * Tests file size upload errors. - * - * @throws \Drupal\Core\Entity\EntityStorageException */ public function testFileSaveUploadSingleErrorFormSize() { $file_name = $this->randomMachineName(); $file_info = $this->createMock(UploadedFile::class); - $file_info->expects($this->once())->method('getError')->willReturn(UPLOAD_ERR_FORM_SIZE); - $file_info->expects($this->once())->method('getClientOriginalName')->willReturn($file_name); - $this->assertFalse(\_file_save_upload_single($file_info, 'name')); - $expected_message = new TranslatableMarkup('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', ['%file' => $file_name, '%maxsize' => format_size(Environment::getUploadMaxSize())]); - $this->assertEquals($expected_message, \Drupal::messenger()->all()['error'][0]); + $file_info->expects($this->once()) + ->method('getError') + ->willReturn(UPLOAD_ERR_FORM_SIZE); + $file_info->expects($this->once()) + ->method('getClientOriginalName') + ->willReturn($file_name); + $this->expectDeprecation('_file_save_upload_single() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. Use \Drupal\file\Upload\FileUploader::createFromUpload() instead. See https://www.drupal.org/node/123'); + $this->assertFalse(_file_save_upload_single($file_info, 'name')); } /** * Tests the deprecation of _views_file_status(). - * - * @group legacy */ public function testViewsFileStatus() { $this->expectDeprecation('_views_file_status() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. There is no replacement. See https://www.drupal.org/node/3227228');