You are here

FileUpload.php in GraphQL 8.4

File

src/GraphQL/Utility/FileUpload.php
View source
<?php

namespace Drupal\graphql\GraphQL\Utility;

use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\Bytes;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Environment;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Utility\Token;
use Drupal\file\FileInterface;
use Drupal\graphql\GraphQL\Response\FileUploadResponse;
use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
 * Service to manage file uploads within GraphQL mutations.
 *
 * This service handles file validations like max upload size.
 */
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);
  }

}

Classes

Namesort descending Description
FileUpload Service to manage file uploads within GraphQL mutations.