You are here

class FileUpload in GraphQL 8.4

Service to manage file uploads within GraphQL mutations.

This service handles file validations like max upload size.

Hierarchy

Expanded class hierarchy of FileUpload

1 file declares its use of FileUpload
UploadFileServiceTest.php in tests/src/Kernel/Framework/UploadFileServiceTest.php
1 string reference to 'FileUpload'
graphql.services.yml in ./graphql.services.yml
graphql.services.yml
1 service uses FileUpload
graphql.file_upload in ./graphql.services.yml
Drupal\graphql\GraphQL\Utility\FileUpload

File

src/GraphQL/Utility/FileUpload.php, line 30

Namespace

Drupal\graphql\GraphQL\Utility
View source
class FileUpload {
  use StringTranslationTrait;

  /**
   * The file storage where we will create new file entities from.
   *
   * @var \Drupal\file\FileStorageInterface
   */
  protected $fileStorage;

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

  /**
   * The mime type guesser service.
   *
   * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
   */
  protected $mimeTypeGuesser;

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

  /**
   * GraphQL logger channel.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected $logger;

  /**
   * The token replacement instance for tokens in file directory paths.
   *
   * @var \Drupal\Core\Utility\Token
   */
  protected $token;

  /**
   * The lock service to prevent duplicate file uploads to the same destination.
   *
   * @var \Drupal\Core\Lock\LockBackendInterface
   */
  protected $lock;

  /**
   * The file system configuration to determine if we allow insecure uploads.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $systemFileConfig;

  /**
   * The renderer service.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

  /**
   * Constructor.
   */
  public function __construct(EntityTypeManagerInterface $entityTypeManager, AccountProxyInterface $currentUser, MimeTypeGuesserInterface $mimeTypeGuesser, FileSystemInterface $fileSystem, LoggerChannelInterface $logger, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory, RendererInterface $renderer) {

    /** @var \Drupal\file\FileStorageInterface $file_storage */
    $file_storage = $entityTypeManager
      ->getStorage('file');
    $this->fileStorage = $file_storage;
    $this->currentUser = $currentUser;
    $this->mimeTypeGuesser = $mimeTypeGuesser;
    $this->fileSystem = $fileSystem;
    $this->logger = $logger;
    $this->token = $token;
    $this->lock = $lock;
    $this->systemFileConfig = $config_factory
      ->get('system.file');
    $this->renderer = $renderer;
  }

  /**
   * Gets max upload size.
   *
   * @param array $settings
   *   The file field settings.
   *
   * @return int
   *   Max upload size.
   */
  protected function getMaxUploadSize(array $settings) {

    // Cap the upload size according to the PHP limit.
    $max_filesize = Bytes::toInt(Environment::getUploadMaxSize());
    if (!empty($settings['max_filesize'])) {
      $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
    }
    return $max_filesize;
  }

  /**
   * Validates an uploaded file, saves it and returns a file upload response.
   *
   * Based on several file upload handlers, see
   * _file_save_upload_single()
   * \Drupal\file\Plugin\Field\FieldType\FileItem
   * \Drupal\file\Plugin\rest\resource\FileUploadResource.
   *
   * @param \Symfony\Component\HttpFoundation\File\UploadedFile $uploaded_file
   *   The file entity to upload.
   * @param array $settings
   *   File settings as specified in regular file field config. Contains keys:
   *   - file_directory: Where to upload the file
   *   - uri_scheme: Uri scheme to upload the file to (eg public://, private://)
   *   - file_extensions: List of valid file extensions (eg [xml, pdf])
   *   - max_filesize: Maximum allowed size of uploaded file.
   *
   * @return \Drupal\graphql\GraphQL\Response\FileUploadResponse
   *   The file upload response containing file entity or list of violations.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \RuntimeException
   */
  public function saveFileUpload(UploadedFile $uploaded_file, array $settings) : FileUploadResponse {
    $response = new FileUploadResponse();

    // Check for file upload errors and return FALSE for this file if a lower
    // level system error occurred.
    // @see http://php.net/manual/features.file-upload.errors.php.
    switch ($uploaded_file
      ->getError()) {
      case UPLOAD_ERR_INI_SIZE:
      case UPLOAD_ERR_FORM_SIZE:
        $maxUploadSize = format_size($this
          ->getMaxUploadSize($settings));
        $response
          ->addViolation($this
          ->t('The file @file could not be saved because it exceeds @maxsize, the maximum allowed size for uploads.', [
          '@file' => $uploaded_file
            ->getClientOriginalName(),
          '@maxsize' => $maxUploadSize,
        ]));
        return $response;
      case UPLOAD_ERR_PARTIAL:
      case UPLOAD_ERR_NO_FILE:
        $response
          ->addViolation($this
          ->t('The file "@file" could not be saved because the upload did not complete.', [
          '@file' => $uploaded_file
            ->getClientOriginalName(),
        ]));
        return $response;
      case UPLOAD_ERR_OK:

        // Final check that this is a valid upload, if it isn't, use the
        // default error handler.
        if ($uploaded_file
          ->isValid()) {
          break;
        }
      default:
        $response
          ->addViolation($this
          ->t('Unknown error while uploading the file "@file".', [
          '@file' => $uploaded_file
            ->getClientOriginalName(),
        ]));
        $this->logger
          ->error('Error while uploading the file "@file" with an error code "@code".', [
          '@file' => $uploaded_file
            ->getFilename(),
          '@code' => $uploaded_file
            ->getError(),
        ]);
        return $response;
    }
    if (empty($settings['uri_scheme']) || empty($settings['file_directory'])) {
      throw new \RuntimeException('uri_scheme or file_directory missing in settings');
    }
    $destination = $this
      ->getUploadLocation($settings);

    // Check the destination file path is writable.
    if (!$this->fileSystem
      ->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) {
      $response
        ->addViolation($this
        ->t('Unknown error while uploading the file "@file".', [
        '@file' => $uploaded_file
          ->getClientOriginalName(),
      ]));
      $this->logger
        ->error('Could not create directory "@upload_directory".', [
        "@upload_directory" => $destination,
      ]);
      return $response;
    }
    $validators = $this
      ->getUploadValidators($settings);
    $prepared_filename = $this
      ->prepareFilename($uploaded_file
      ->getClientOriginalName(), $validators);

    // Create the file.
    $file_uri = "{$destination}/{$prepared_filename}";
    $temp_file_path = $uploaded_file
      ->getRealPath();
    $file_uri = $this->fileSystem
      ->getDestinationFilename($file_uri, FileSystemInterface::EXISTS_RENAME);

    // Lock based on the prepared file URI.
    $lock_id = $this
      ->generateLockIdFromFileUri($file_uri);
    if (!$this->lock
      ->acquire($lock_id)) {
      $response
        ->addViolation($this
        ->t('Unknown error while uploading the file "@file".', [
        '@file' => $uploaded_file
          ->getClientOriginalName(),
      ]));
      return $response;
    }
    try {

      // Begin building file entity.

      /** @var \Drupal\file\FileInterface $file */
      $file = $this->fileStorage
        ->create([]);
      $file
        ->setOwnerId($this->currentUser
        ->id());
      $file
        ->setFilename($prepared_filename);
      $file
        ->setMimeType($this->mimeTypeGuesser
        ->guess($prepared_filename));
      $file
        ->setFileUri($temp_file_path);

      // Set the size. This is done in File::preSave() but we validate the file
      // before it is saved.
      $file
        ->setSize(@filesize($temp_file_path));

      // Validate against file_validate() first with the temporary path.
      $errors = file_validate($file, $validators);
      if (!empty($errors)) {
        $response
          ->addViolations($errors);
        return $response;
      }
      $file
        ->setFileUri($file_uri);

      // Move the file to the correct location after validation. Use
      // FileSystemInterface::EXISTS_ERROR as the file location has already been
      // determined above in FileSystem::getDestinationFilename().
      try {
        $this->fileSystem
          ->move($temp_file_path, $file_uri, FileSystemInterface::EXISTS_ERROR);
      } catch (FileException $e) {
        $response
          ->addViolation($this
          ->t('Unknown error while uploading the file "@file".', [
          '@file' => $uploaded_file
            ->getClientOriginalName(),
        ]));
        $this->logger
          ->error('Unable to move file from "@file" to "@destination".', [
          '@file' => $uploaded_file
            ->getRealPath(),
          '@destination' => $file
            ->getFileUri(),
        ]);
        return $response;
      }

      // Validate the file entity against entity-level validation now after the
      // file has moved.
      if (!$this
        ->validate($file, $validators, $response)) {
        return $response;
      }
      $file
        ->save();
      $response
        ->setFileEntity($file);
      return $response;
    } finally {

      // This will always be executed before any return statement or exception
      // in the try {} block.
      $this->lock
        ->release($lock_id);
    }
  }

  /**
   * Validates uploaded files, saves them and returns a file upload response.
   *
   * @param \Symfony\Component\HttpFoundation\File\UploadedFile[] $uploaded_files
   *   The file entities to upload.
   * @param array $settings
   *   File settings as specified in regular file field config. Contains keys:
   *   - file_directory: Where to upload the file
   *   - uri_scheme: Uri scheme to upload the file to (eg public://, private://)
   *   - file_extensions: List of valid file extensions (eg [xml, pdf])
   *   - max_filesize: Maximum allowed size of uploaded file.
   *
   * @return \Drupal\graphql\GraphQL\Response\FileUploadResponse
   *   The file upload response containing file entities or list of violations.
   */
  public function saveMultipleFileUploads(array $uploaded_files, array $settings) : FileUploadResponse {
    $response = new FileUploadResponse();
    foreach ($uploaded_files as $uploaded_file) {
      if (!$uploaded_file instanceof UploadedFile) {
        continue;
      }
      $file_upload_response = $this
        ->saveFileUpload($uploaded_file, $settings);
      $file_entity = $file_upload_response
        ->getFileEntity();
      if ($file_entity) {
        $response
          ->setFileEntity($file_entity);
      }
      else {

        // If one file upload fails we need to delete any other uploaded files
        // before that. Avoids file orphans that don't belong to any entity.
        foreach ($response
          ->getFileEntities() as $saved_file_entity) {
          $saved_file_entity
            ->delete();
        }

        // Reset list of file entities as this is a violation response.
        $response
          ->setFileEntities([]);
        $response
          ->mergeViolations($file_upload_response);
        return $response;
      }
    }
    return $response;
  }

  /**
   * Validates the file.
   *
   * @param \Drupal\file\FileInterface $file
   *   The file entity to validate.
   * @param array $validators
   *   An array of upload validators to pass to file_validate().
   * @param \Drupal\graphql\GraphQL\Response\FileUploadResponse $response
   *   The response where validation errors will be added.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
   *   Thrown when there are file validation errors.
   *
   * @return bool
   *   TRUE if validation was successful, FALSE otherwise.
   */
  protected function validate(FileInterface $file, array $validators, FileUploadResponse $response) : bool {
    $violations = $file
      ->validate();
    if ($violations
      ->count()) {
      foreach ($violations as $violation) {
        $response
          ->addViolation($violation
          ->getMessage());
        return FALSE;
      }
    }
    return TRUE;
  }

  /**
   * Prepares the filename to strip out any malicious extensions.
   *
   * @param string $filename
   *   The file name.
   * @param array $validators
   *   The array of upload validators.
   *
   * @return string
   *   The prepared/munged filename.
   */
  protected function prepareFilename(string $filename, array &$validators) : string {

    // Don't rename if 'allow_insecure_uploads' evaluates to TRUE.
    if (!$this->systemFileConfig
      ->get('allow_insecure_uploads')) {
      if (!empty($validators['file_validate_extensions'][0])) {

        // If there is a file_validate_extensions validator and a list of
        // valid extensions, munge the filename to protect against possible
        // malicious extension hiding within an unknown file type. For example,
        // "filename.html.foo".
        $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]);
      }

      // Rename potentially executable files, to help prevent exploits (i.e.
      // will rename filename.php.foo and filename.php to filename._php._foo.txt
      // and filename._php.txt, respectively).
      if (preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename)) {

        // If the file will be rejected anyway due to a disallowed extension, it
        // should not be renamed; rather, we'll let file_validate_extensions()
        // reject it below.
        $passes_validation = FALSE;
        if (!empty($validators['file_validate_extensions'][0])) {

          /** @var \Drupal\file\FileInterface $file */
          $file = $this->fileStorage
            ->create([]);
          $file
            ->setFilename($filename);
          $passes_validation = empty(file_validate_extensions($file, $validators['file_validate_extensions'][0]));
        }
        if (empty($validators['file_validate_extensions'][0]) || $passes_validation) {
          if (substr($filename, -4) != '.txt') {

            // The destination filename will also later be used to create the
            // URI.
            $filename .= '.txt';
          }
          $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0] ?? '');

          // The .txt extension may not be in the allowed list of extensions. We
          // have to add it here or else the file upload will fail.
          if (!empty($validators['file_validate_extensions'][0])) {
            $validators['file_validate_extensions'][0] .= ' txt';
          }
        }
      }
    }
    return $filename;
  }

  /**
   * Determines the URI for a file field.
   *
   * @param array $settings
   *   The array of field settings.
   *
   * @return string
   *   An un-sanitized file directory URI with tokens replaced. The result of
   *   the token replacement is then converted to plain text and returned.
   */
  protected function getUploadLocation(array $settings) : string {
    $destination = trim($settings['file_directory'], '/');

    // Replace tokens first. This might produce cacheable metadata if tokens
    // are used in the path. As this service is intended to be used in mutations
    // which are not cached at all, it's enough to just catch leaked metadata
    // and skip including them in current GraphQL field's context.
    $context = new RenderContext();
    $destination = $this->renderer
      ->executeInRenderContext($context, function () use ($destination) : string {
      return $this->token
        ->replace($destination, []);
    });

    // As the tokens might contain HTML we convert it to plain text.
    $destination = PlainTextOutput::renderFromHtml($destination);
    return $settings['uri_scheme'] . '://' . $destination;
  }

  /**
   * Retrieves the upload validators for the given file field settings.
   *
   * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there
   * is no entity instance available here that a FileItem would exist for.
   *
   * @param array $settings
   *   The file field settings.
   *
   * @return array
   *   An array suitable for passing to file_save_upload() or the file field
   *   element's '#upload_validators' property.
   */
  protected function getUploadValidators(array $settings) : array {
    $validators = [
      // Add in our check of the file name length.
      'file_validate_name_length' => [],
    ];

    // Cap the upload size according to the PHP limit.
    $max_filesize = Bytes::toInt(Environment::getUploadMaxSize());
    if (!empty($settings['max_filesize'])) {
      $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
    }

    // There is always a file size limit due to the PHP server limit.
    $validators['file_validate_size'] = [
      $max_filesize,
    ];

    // Add the extension check if necessary.
    if (!empty($settings['file_extensions'])) {
      $validators['file_validate_extensions'] = [
        $settings['file_extensions'],
      ];
    }
    return $validators;
  }

  /**
   * Generates a lock ID based on the file URI.
   *
   * @param string $file_uri
   *   The file URI.
   *
   * @return string
   *   The generated lock ID.
   */
  protected static function generateLockIdFromFileUri(string $file_uri) : string {
    return 'file:rest:' . Crypt::hashBase64($file_uri);
  }

}

Members

Namesort descending Modifiers Type Description Overrides
FileUpload::$currentUser protected property The current user.
FileUpload::$fileStorage protected property The file storage where we will create new file entities from.
FileUpload::$fileSystem protected property The file system service.
FileUpload::$lock protected property The lock service to prevent duplicate file uploads to the same destination.
FileUpload::$logger protected property GraphQL logger channel.
FileUpload::$mimeTypeGuesser protected property The mime type guesser service.
FileUpload::$renderer protected property The renderer service.
FileUpload::$systemFileConfig protected property The file system configuration to determine if we allow insecure uploads.
FileUpload::$token protected property The token replacement instance for tokens in file directory paths.
FileUpload::generateLockIdFromFileUri protected static function Generates a lock ID based on the file URI.
FileUpload::getMaxUploadSize protected function Gets max upload size.
FileUpload::getUploadLocation protected function Determines the URI for a file field.
FileUpload::getUploadValidators protected function Retrieves the upload validators for the given file field settings.
FileUpload::prepareFilename protected function Prepares the filename to strip out any malicious extensions.
FileUpload::saveFileUpload public function Validates an uploaded file, saves it and returns a file upload response.
FileUpload::saveMultipleFileUploads public function Validates uploaded files, saves them and returns a file upload response.
FileUpload::validate protected function Validates the file.
FileUpload::__construct public function Constructor.
StringTranslationTrait::$stringTranslation protected property The string translation service. 1
StringTranslationTrait::formatPlural protected function Formats a string containing a count of items.
StringTranslationTrait::getNumberOfPlurals protected function Returns the number of plurals supported by a given language.
StringTranslationTrait::getStringTranslation protected function Gets the string translation service.
StringTranslationTrait::setStringTranslation public function Sets the string translation service to use. 2
StringTranslationTrait::t protected function Translates a string to the current language or to a given language.