From 60a38dae9e5a45d83351a22cea98150e2b13c625 Mon Sep 17 00:00:00 2001
From: Lee Rowlands <lee.rowlands@previousnext.com.au>
Date: Fri, 22 Dec 2023 11:19:19 +1000
Subject: [PATCH] Issue #3388985 by kim.pepper, Wim Leers: Make
 CKEditor5ImageController reuse FileUploadHandler

---
 .../Controller/CKEditor5ImageController.php   | 228 ++++++------------
 1 file changed, 80 insertions(+), 148 deletions(-)

diff --git a/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php b/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php
index 56360996b7a3..1a9cc15d1fb7 100644
--- a/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php
+++ b/core/modules/ckeditor5/src/Controller/CKEditor5ImageController.php
@@ -1,6 +1,6 @@
 <?php
 
-declare(strict_types = 1);
+declare(strict_types=1);
 
 namespace Drupal\ckeditor5\Controller;
 
@@ -9,20 +9,21 @@
 use Drupal\Component\Utility\Environment;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Controller\ControllerBase;
-use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
 use Drupal\Core\File\Exception\FileException;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\Lock\LockBackendInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\editor\Entity\Editor;
-use Drupal\file\Entity\File;
-use Drupal\file\FileInterface;
+use Drupal\file\Upload\FileUploadHandler;
+use Drupal\file\Upload\FormUploadedFile;
 use Drupal\file\Validation\FileValidatorInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Lock\Exception\LockAcquiringException;
 use Symfony\Component\Mime\MimeTypeGuesserInterface;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
@@ -35,74 +36,62 @@
 class CKEditor5ImageController extends ControllerBase {
 
   /**
-   * The file system service.
-   *
-   * @var \Drupal\Core\File\FileSystem
-   */
-  protected $fileSystem;
-
-  /**
-   * The currently authenticated user.
-   *
-   * @var \Drupal\Core\Session\AccountInterface
-   */
-  protected $currentUser;
-
-  /**
-   * The MIME type guesser.
-   *
-   * @var \Symfony\Component\Mime\MimeTypeGuesserInterface
+   * The default allowed image extensions.
    */
-  protected $mimeTypeGuesser;
+  const DEFAULT_IMAGE_EXTENSIONS = 'gif png jpg jpeg';
 
   /**
-   * The lock service.
-   *
-   * @var \Drupal\Core\Lock\LockBackendInterface
+   * The file system service.
    */
-  protected $lock;
+  protected FileSystemInterface $fileSystem;
 
   /**
-   * The event dispatcher.
-   *
-   * @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
+   * The lock.
    */
-  protected $eventDispatcher;
+  protected LockBackendInterface $lock;
 
   /**
-   * The file validator.
-   *
-   * @var \Drupal\file\Validation\FileValidatorInterface
+   * The file upload handler.
    */
-  protected FileValidatorInterface $fileValidator;
+  protected FileUploadHandler $fileUploadHandler;
 
   /**
    * Constructs a new CKEditor5ImageController.
    *
-   * @param \Drupal\Core\File\FileSystemInterface $file_system
-   *   The file system service.
-   * @param \Drupal\Core\Session\AccountInterface $current_user
+   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
+   *   The file upload handler.
+   * @param \Drupal\Core\Session\AccountInterface|\Drupal\file\Upload\FileUploadHandler $fileUploadHandler
    *   The currently authenticated user.
-   * @param \Symfony\Component\Mime\MimeTypeGuesserInterface $mime_type_guesser
+   * @param \Symfony\Component\Mime\MimeTypeGuesserInterface|\Drupal\Core\Lock\LockBackendInterface $mime_type_guesser
    *   The MIME type guesser.
-   * @param \Drupal\Core\Lock\LockBackendInterface $lock
+   * @param \Drupal\Core\Lock\LockBackendInterface|null $lock
    *   The lock service.
-   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|null $event_dispatcher
    *   The event dispatcher.
    * @param \Drupal\file\Validation\FileValidatorInterface|null $file_validator
    *   The file validator.
    */
-  public function __construct(FileSystemInterface $file_system, AccountInterface $current_user, MimeTypeGuesserInterface $mime_type_guesser, LockBackendInterface $lock, EventDispatcherInterface $event_dispatcher, FileValidatorInterface $file_validator = NULL) {
-    $this->fileSystem = $file_system;
-    $this->currentUser = $current_user;
-    $this->mimeTypeGuesser = $mime_type_guesser;
-    $this->lock = $lock;
-    $this->eventDispatcher = $event_dispatcher;
-    if (!$file_validator) {
-      @trigger_error('Calling ' . __METHOD__ . '() without the $file_validator argument is deprecated in drupal:10.2.0 and is required in drupal:11.0.0. See https://www.drupal.org/node/3363700', E_USER_DEPRECATED);
-      $file_validator = \Drupal::service('file.validator');
+  public function __construct(FileSystemInterface $fileSystem, AccountInterface | FileUploadHandler $fileUploadHandler, MimeTypeGuesserInterface | LockBackendInterface $mime_type_guesser, LockBackendInterface $lock = NULL, EventDispatcherInterface $event_dispatcher = NULL, FileValidatorInterface $file_validator = NULL) {
+    $this->fileSystem = $fileSystem;
+    if ($fileUploadHandler instanceof AccountInterface) {
+      @trigger_error('Calling ' . __METHOD__ . '() with the $current_user argument is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3388990', E_USER_DEPRECATED);
+      $fileUploadHandler = \Drupal::service('file.upload_handler');
+    }
+    $this->fileUploadHandler = $fileUploadHandler;
+    if ($mime_type_guesser instanceof MimeTypeGuesserInterface) {
+      @trigger_error('Calling ' . __METHOD__ . '() with the $mime_type_guesser argument is deprecated in drupal:10.3.0 and is replaced with $lock from drupal:11.0.0. See https://www.drupal.org/node/3388990', E_USER_DEPRECATED);
+      $mime_type_guesser = \Drupal::service('lock');
+    }
+    $this->lock = $mime_type_guesser;
+    if ($lock) {
+      @trigger_error('Calling ' . __METHOD__ . '() with the $lock argument in position 4 is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3388990', E_USER_DEPRECATED);
+    }
+    if ($event_dispatcher) {
+      @trigger_error('Calling ' . __METHOD__ . '() with the $event_dispatcher argument is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3388990', E_USER_DEPRECATED);
+    }
+    if ($file_validator) {
+      @trigger_error('Calling ' . __METHOD__ . '() with the $file_validator argument is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3388990', E_USER_DEPRECATED);
     }
-    $this->fileValidator = $file_validator;
   }
 
   /**
@@ -111,11 +100,8 @@ public function __construct(FileSystemInterface $file_system, AccountInterface $
   public static function create(ContainerInterface $container) {
     return new static(
       $container->get('file_system'),
-      $container->get('current_user'),
-      $container->get('file.mime_type.guesser'),
-      $container->get('lock'),
-      $container->get('event_dispatcher'),
-      $container->get('file.validator')
+      $container->get('file.upload_handler'),
+      $container->get('lock')
     );
   }
 
@@ -132,52 +118,26 @@ public static function create(ContainerInterface $container) {
    *   Thrown when file system errors occur.
    * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
    *   Thrown when validation errors occur.
-   * @throws \Drupal\Core\Entity\EntityStorageException
-   *   Thrown when file entity could not be saved.
    */
-  public function upload(Request $request) {
+  public function upload(Request $request): Response {
     // Getting the UploadedFile directly from the request.
+    /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $upload */
     $upload = $request->files->get('upload');
     $filename = $upload->getClientOriginalName();
 
+    /** @var \Drupal\editor\EditorInterface $editor */
     $editor = $request->attributes->get('editor');
-    $image_upload = $editor->getImageUploadSettings();
-    $destination = $image_upload['scheme'] . '://' . $image_upload['directory'];
+    $settings = $editor->getImageUploadSettings();
+    $destination = $settings['scheme'] . '://' . $settings['directory'];
 
     // Check the destination file path is writable.
     if (!$this->fileSystem->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) {
       throw new HttpException(500, 'Destination file path is not writable');
     }
 
-    $max_filesize = min(Bytes::toNumber($image_upload['max_size']), Environment::getUploadMaxSize());
-    if (!empty($image_upload['max_dimensions']['width']) || !empty($image_upload['max_dimensions']['height'])) {
-      $max_dimensions = $image_upload['max_dimensions']['width'] . 'x' . $image_upload['max_dimensions']['height'];
-    }
-    else {
-      $max_dimensions = 0;
-    }
-
-    $allowed_extensions = 'gif png jpg jpeg';
-    $validators = [
-      'FileExtension' => [
-        'extensions' => $allowed_extensions,
-      ],
-      'FileSizeLimit' => [
-        'fileLimit' => $max_filesize,
-      ],
-      'FileImageDimensions' => [
-        'maxDimensions' => $max_dimensions,
-      ],
-    ];
-
-    $prepared_filename = $this->prepareFilename($filename, $allowed_extensions);
-
-    // Create the file.
-    $file_uri = "{$destination}/{$prepared_filename}";
-
-    // Using the UploadedFile method instead of streamUploadData.
-    $temp_file_path = $upload->getRealPath();
+    $validators = $this->getImageUploadValidators($settings);
 
+    $file_uri = "{$destination}/{$filename}";
     $file_uri = $this->fileSystem->getDestinationFilename($file_uri, FileSystemInterface::EXISTS_RENAME);
 
     // Lock based on the prepared file URI.
@@ -187,31 +147,23 @@ public function upload(Request $request) {
       throw new HttpException(503, sprintf('File "%s" is already locked for writing.', $file_uri), NULL, ['Retry-After' => 1]);
     }
 
-    // Begin building file entity.
-    $file = File::create([]);
-    $file->setOwnerId($this->currentUser->id());
-    $file->setFilename($prepared_filename);
-    $file->setMimeType($this->mimeTypeGuesser->guessMimeType($prepared_filename));
-
-    $file->setFileUri($file_uri);
-    $file->setSize(@filesize($temp_file_path));
-
-    $violations = $this->validate($file, $validators);
-    if ($violations->count() > 0) {
-      throw new UnprocessableEntityHttpException($violations->__toString());
-    }
-
     try {
-      $this->fileSystem->move($temp_file_path, $file_uri, FileSystemInterface::EXISTS_ERROR);
+      $uploadedFile = new FormUploadedFile($upload);
+      $uploadResult = $this->fileUploadHandler->handleFileUpload($uploadedFile, $validators, $destination, FileSystemInterface::EXISTS_RENAME, FALSE);
+      if ($uploadResult->hasViolations()) {
+        throw new UnprocessableEntityHttpException((string) $uploadResult->getViolations());
+      }
     }
     catch (FileException $e) {
-      throw new HttpException(500, 'Temporary file could not be moved to file location');
+      throw new HttpException(500, 'File could not be saved');
+    }
+    catch (LockAcquiringException $e) {
+      throw new HttpException(503, sprintf('File "%s" is already locked for writing.', $upload->getClientOriginalName()), NULL, ['Retry-After' => 1]);
     }
-
-    $file->save();
 
     $this->lock->release($lock_id);
 
+    $file = $uploadResult->getFile();
     return new JsonResponse([
       'url' => $file->createFileUrl(),
       'uuid' => $file->uuid(),
@@ -219,6 +171,28 @@ public function upload(Request $request) {
     ], 201);
   }
 
+  /**
+   * Gets the image upload validators.
+   */
+  protected function getImageUploadValidators(array $settings): array {
+    $max_filesize = min(Bytes::toNumber($settings['max_size']), Environment::getUploadMaxSize());
+    $max_dimensions = 0;
+    if (!empty($settings['max_dimensions']['width']) || !empty($settings['max_dimensions']['height'])) {
+      $max_dimensions = $settings['max_dimensions']['width'] . 'x' . $settings['max_dimensions']['height'];
+    }
+    return [
+      'FileExtension' => [
+        'extensions' => self::DEFAULT_IMAGE_EXTENSIONS,
+      ],
+      'FileSizeLimit' => [
+        'fileLimit' => $max_filesize,
+      ],
+      'FileImageDimensions' => [
+        'maxDimensions' => $max_dimensions,
+      ],
+    ];
+  }
+
   /**
    * Access check based on whether image upload is enabled or not.
    *
@@ -239,48 +213,6 @@ public function imageUploadEnabledAccess(Editor $editor) {
     return AccessResult::allowed();
   }
 
-  /**
-   * Validates the file.
-   *
-   * @param \Drupal\file\FileInterface $file
-   *   The file entity to validate.
-   * @param array $validators
-   *   An array of upload validators to pass to the FileValidator.
-   *
-   * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
-   *   The list of constraint violations, if any.
-   */
-  protected function validate(FileInterface $file, array $validators) {
-    $violations = $file->validate();
-
-    // Remove violations of inaccessible fields as they cannot stem from our
-    // changes.
-    $violations->filterByFieldAccess();
-
-    // Validate the file based on the field definition configuration.
-    $violations->addAll($this->fileValidator->validate($file, $validators));
-
-    return $violations;
-  }
-
-  /**
-   * Prepares the filename to strip out any malicious extensions.
-   *
-   * @param string $filename
-   *   The file name.
-   * @param string $allowed_extensions
-   *   The allowed extensions.
-   *
-   * @return string
-   *   The prepared/munged filename.
-   */
-  protected function prepareFilename(string $filename, string $allowed_extensions): string {
-    $event = new FileUploadSanitizeNameEvent($filename, $allowed_extensions);
-    $this->eventDispatcher->dispatch($event);
-
-    return $event->getFilename();
-  }
-
   /**
    * Generates a lock ID based on the file URI.
    *
-- 
GitLab