Unverified Commit bfe52748 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3232248 by kim.pepper, dww, andypost, paulocs, alexpott, mstrelan,...

Issue #3232248 by kim.pepper, dww, andypost, paulocs, alexpott, mstrelan, Berdir: Move _file_save_upload_single to a service and deprecate
parent a4b057b8
Loading
Loading
Loading
Loading
+94 −7
Original line number Diff line number Diff line
@@ -6,23 +6,32 @@
 */

use Drupal\Component\Utility\Environment;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Datetime\Entity\DateFormat;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
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\Link;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\Element;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Link;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Drupal\file\FileInterface;
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\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\Exception\IniSizeFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\PartialFileException;
use Symfony\Component\Mime\MimeTypeGuesserInterface;

/**
@@ -892,9 +901,82 @@ 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_upload_handler */
  $file_upload_handler = \Drupal::service('file.upload_handler');
  /** @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);
  /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploaded_file */
  foreach ($uploaded_files as $i => $uploaded_file) {
    try {
      $result = $file_upload_handler->handleFileUpload($uploaded_file, $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] = $file;
    }
    catch (FileExistsException $e) {
      \Drupal::messenger()->addError(t('Destination file "%file" exists', ['%file' => $destination . $uploaded_file->getFilename()]));
      $files[$i] = FALSE;
    }
    catch (InvalidStreamWrapperException $e) {
      \Drupal::messenger()->addError(t('The file could not be uploaded because the destination "%destination" is invalid.', ['%destination' => $destination]));
      $files[$i] = FALSE;
    }
    catch (IniSizeFileException | FormSizeFileException $e) {
      \Drupal::messenger()->addError(t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [
          '%file' => $uploaded_file->getFilename(),
          '%maxsize' => format_size(Environment::getUploadMaxSize()),
        ]));
      $files[$i] = FALSE;
    }
    catch (PartialFileException | NoFileException $e) {
      \Drupal::messenger()->addError(t('The file %file could not be saved because the upload did not complete.', [
          '%file' => $uploaded_file->getFilename(),
        ]));
      $files[$i] = FALSE;
    }
    catch (SymfonyFileException $e) {
      \Drupal::messenger()->addError(t('The file %file could not be saved. An unknown error has occurred.', ['%file' => $uploaded_file->getFilename()]));
      $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' => $uploaded_file->getClientOriginalName(), '%destination' => $destination . '/' . $uploaded_file->getClientOriginalName()]);
      $files[$i] = FALSE;
    }
    catch (FileException $e) {
      \Drupal::messenger()->addError(t('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $uploaded_file->getClientOriginalName()]));
      $files[$i] = FALSE;
    }
  }

  // Add files to the cache.
@@ -928,9 +1010,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\FileUploadHandler::handleFileUpload() instead.
 *
 * @see https://www.drupal.org/node/3239547
 * @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\FileUploadHandler::handleFileUpload() instead. See https://www.drupal.org/node/3239547', 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();
+3 −0
Original line number Diff line number Diff line
@@ -4,3 +4,6 @@ services:
    arguments: ['@config.factory', '@database', 'file_usage']
    tags:
      - { name: backend_overridable }
  file.upload_handler:
    class: Drupal\file\Upload\FileUploadHandler
    arguments: [ '@file_system', '@entity_type.manager', '@stream_wrapper_manager', '@event_dispatcher', '@file.mime_type.guesser', '@current_user', '@request_stack' ]
+336 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\file\Upload;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
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\Session\AccountInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\Exception\CannotWriteFileException;
use Symfony\Component\HttpFoundation\File\Exception\ExtensionFileException;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\Exception\IniSizeFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoTmpDirFileException;
use Symfony\Component\HttpFoundation\File\Exception\PartialFileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mime\MimeTypeGuesserInterface;

/**
 * Handles validating and creating file entities from file uploads.
 */
class FileUploadHandler {

  /**
   * The default extensions if none are provided.
   */
  const DEFAULT_EXTENSIONS = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp';

  /**
   * The file system service.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The stream wrapper manager.
   *
   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
   */
  protected $streamWrapperManager;

  /**
   * The event dispatcher.
   *
   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
   */
  protected $eventDispatcher;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * The MIME type guesser.
   *
   * @var \Symfony\Component\Mime\MimeTypeGuesserInterface
   */
  protected $mimeTypeGuesser;

  /**
   * The request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * Constructs a FileUploadHandler object.
   *
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   The file system service.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager.
   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $streamWrapperManager
   *   The stream wrapper manager.
   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
   *   The event dispatcher.
   * @param \Symfony\Component\Mime\MimeTypeGuesserInterface $mimeTypeGuesser
   *   The MIME type guesser.
   * @param \Drupal\Core\Session\AccountInterface $currentUser
   *   The current user.
   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
   *   The request stack.
   */
  public function __construct(FileSystemInterface $fileSystem, EntityTypeManagerInterface $entityTypeManager, StreamWrapperManagerInterface $streamWrapperManager, EventDispatcherInterface $eventDispatcher, MimeTypeGuesserInterface $mimeTypeGuesser, AccountInterface $currentUser, RequestStack $requestStack) {
    $this->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 validators to run against the uploaded file.
   * @param string $destination
   *   The destination directory.
   * @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 handleFileUpload(UploadedFile $uploadedFile, array $validators = [], string $destination = 'temporary://', int $replace = FileSystemInterface::EXISTS_REPLACE): FileUploadResult {
    $originalName = $uploadedFile->getClientOriginalName();

    if (!$uploadedFile->isValid()) {
      switch ($uploadedFile->getError()) {
        case \UPLOAD_ERR_INI_SIZE:
          throw new IniSizeFileException($uploadedFile->getErrorMessage());

        case \UPLOAD_ERR_FORM_SIZE:
          throw new FormSizeFileException($uploadedFile->getErrorMessage());

        case \UPLOAD_ERR_PARTIAL:
          throw new PartialFileException($uploadedFile->getErrorMessage());

        case \UPLOAD_ERR_NO_FILE:
          throw new NoFileException($uploadedFile->getErrorMessage());

        case \UPLOAD_ERR_CANT_WRITE:
          throw new CannotWriteFileException($uploadedFile->getErrorMessage());

        case \UPLOAD_ERR_NO_TMP_DIR:
          throw new NoTmpDirFileException($uploadedFile->getErrorMessage());

        case \UPLOAD_ERR_EXTENSION:
          throw new ExtensionFileException($uploadedFile->getErrorMessage());

      }

      throw new FileException($uploadedFile->getErrorMessage());
    }

    $extensions = $this->handleExtensionValidation($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 = 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 security or renaming
    // due to an existing file.
    $file->setFilename($this->fileSystem->basename($file->getFileUri()));

    if ($replace === FileSystemInterface::EXISTS_REPLACE) {
      $existingFile = $this->loadByUri($file->getFileUri());
      if ($existingFile) {
        $file->fid = $existingFile->id();
        $file->setOriginalId($existingFile->id());
      }
    }

    $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());

    // 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.
   *
   * This will add an extension validator to the list of validators if one is
   * not set.
   *
   * If the extension validator is set, but no extensions are specified, it
   * means all extensions are allowed, so the validator is removed from the list
   * of validators.
   *
   * @param array $validators
   *   The file validators in use.
   *
   * @return string
   *   The space delimited list of allowed file extensions.
   */
  protected function handleExtensionValidation(array &$validators): string {
    // Build a list of allowed extensions.
    if (isset($validators['file_validate_extensions'])) {
      if (!isset($validators['file_validate_extensions'][0])) {
        // 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().
      $validators['file_validate_extensions'] = [self::DEFAULT_EXTENSIONS];
    }
    return $validators['file_validate_extensions'][0] ?? '';
  }

  /**
   * 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;
  }

}
+131 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\file\Upload;

use Drupal\file\FileInterface;

/**
 * Value object for a file upload result.
 */
class FileUploadResult {

  /**
   * If the filename was renamed for security reasons.
   *
   * @var bool
   */
  protected $securityRename = FALSE;

  /**
   * The sanitized filename.
   *
   * @var string
   */
  protected $sanitizedFilename;

  /**
   * The original filename.
   *
   * @var string
   */
  protected $originalFilename;

  /**
   * The File entity.
   *
   * @var \Drupal\file\FileInterface
   */
  protected $file;

  /**
   * Flags the result as having had a security rename.
   *
   * @return $this
   */
  public function setSecurityRename(): FileUploadResult {
    $this->securityRename = TRUE;
    return $this;
  }

  /**
   * Sets the sanitized filename.
   *
   * @param string $sanitizedFilename
   *
   * @return $this
   */
  public function setSanitizedFilename(string $sanitizedFilename): FileUploadResult {
    $this->sanitizedFilename = $sanitizedFilename;
    return $this;
  }

  /**
   * Gets the original filename.
   *
   * @return string
   */
  public function getOriginalFilename(): string {
    return $this->originalFilename;
  }

  /**
   * Sets the original filename.
   *
   * @param string $originalFilename
   *
   * @return $this
   */
  public function setOriginalFilename(string $originalFilename): FileUploadResult {
    $this->originalFilename = $originalFilename;
    return $this;
  }

  /**
   * Sets the File entity.
   *
   * @param \Drupal\file\FileInterface $file
   *
   * @return $this
   */
  public function setFile(FileInterface $file): FileUploadResult {
    $this->file = $file;
    return $this;
  }

  /**
   * Returns if there was a security rename.
   *
   * @return bool
   */
  public function isSecurityRename(): bool {
    return $this->securityRename;
  }

  /**
   * Returns if there was a file rename.
   *
   * @return bool
   */
  public function isRenamed(): bool {
    return $this->originalFilename !== $this->sanitizedFilename;
  }

  /**
   * Gets the sanitized filename.
   *
   * @return string
   */
  public function getSanitizedFilename(): string {
    return $this->sanitizedFilename;
  }

  /**
   * Gets the File entity.
   *
   * @return \Drupal\file\FileInterface
   */
  public function getFile(): FileInterface {
    return $this->file;
  }

}
+60 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\file\Upload;

/**
 * Provides an exception for upload validation errors.
 */
class FileValidationException extends \RuntimeException {

  /**
   * The validation errors.
   *
   * @var array
   */
  protected $errors;

  /**
   * The file name.
   *
   * @var string
   */
  protected $fileName;

  /**
   * Constructs a new FileValidationException.
   *
   * @param string $message
   *   The message.
   * @param string $file_name
   *   The file name.
   * @param array $errors
   *   The validation errors.
   */
  public function __construct(string $message, string $file_name, array $errors) {
    parent::__construct($message, 0, NULL);
    $this->fileName = $file_name;
    $this->errors = $errors;
  }

  /**
   * Gets the file name.
   *
   * @return string
   *   The file name.
   */
  public function getFilename(): string {
    return $this->fileName;
  }

  /**
   * Gets the errors.
   *
   * @return array
   *   The errors.
   */
  public function getErrors(): array {
    return $this->errors;
  }

}
Loading