diff --git a/graphql.services.yml b/graphql.services.yml index b86695715..39a5eed99 100644 --- a/graphql.services.yml +++ b/graphql.services.yml @@ -183,6 +183,7 @@ services: - '@config.factory' - '@renderer' - '@event_dispatcher' + - '@image.factory' plugin.manager.graphql.persisted_query: class: Drupal\graphql\Plugin\PersistedQueryPluginManager diff --git a/src/GraphQL/Utility/FileUpload.php b/src/GraphQL/Utility/FileUpload.php index 1fbb3fadc..bd22f7e0a 100644 --- a/src/GraphQL/Utility/FileUpload.php +++ b/src/GraphQL/Utility/FileUpload.php @@ -10,6 +10,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Image\ImageFactory; use Drupal\Core\Lock\LockBackendInterface; use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\Core\Render\RenderContext; @@ -103,6 +104,13 @@ class FileUpload { */ protected $eventDispatcher; + /** + * The image factory service. + * + * @var \Drupal\Core\Image\ImageFactory + */ + protected $imageFactory; + /** * Constructor. */ @@ -116,7 +124,8 @@ public function __construct( LockBackendInterface $lock, ConfigFactoryInterface $config_factory, RendererInterface $renderer, - EventDispatcherInterface $eventDispatcher + EventDispatcherInterface $eventDispatcher, + ImageFactory $image_factory ) { /** @var \Drupal\file\FileStorageInterface $file_storage */ $file_storage = $entityTypeManager->getStorage('file'); @@ -130,6 +139,7 @@ public function __construct( $this->systemFileConfig = $config_factory->get('system.file'); $this->renderer = $renderer; $this->eventDispatcher = $eventDispatcher; + $this->imageFactory = $image_factory; } /** @@ -259,6 +269,11 @@ public function saveFileUpload(UploadedFile $uploaded_file, array $settings): Fi // Validate against file_validate() first with the temporary path. $errors = file_validate($file, $validators); + $maxResolution = $settings['max_resolution'] ?? 0; + $minResolution = $settings['min_resolution'] ?? 0; + if (!empty($maxResolution) || !empty($minResolution)) { + $errors += $this->validateFileImageResolution($file, $maxResolution, $minResolution); + } if (!empty($errors)) { $response->addViolations($errors); @@ -370,6 +385,84 @@ protected function validate(FileInterface $file, array $validators, FileUploadRe return TRUE; } + /** + * Copy of file_validate_image_resolution() without creating messages. + * + * Verifies that image dimensions are within the specified maximum and + * minimum. + * + * Non-image files will be ignored. If an image toolkit is available the image + * will be scaled to fit within the desired maximum dimensions. + * + * @param \Drupal\file\FileInterface $file + * A file entity. This function may resize the file affecting its size. + * @param string|int $maximum_dimensions + * (optional) A string in the form WIDTHxHEIGHT; for example, '640x480' or + * '85x85'. If an image toolkit is installed, the image will be resized down + * to these dimensions. A value of zero (the default) indicates no + * restriction on size, so no resizing will be attempted. + * @param string|int $minimum_dimensions + * (optional) A string in the form WIDTHxHEIGHT. This will check that the + * image meets a minimum size. A value of zero (the default) indicates that + * there is no restriction on size. + * + * @return array + * An empty array if the file meets the specified dimensions, was resized + * successfully to meet those requirements or is not an image. If the image + * does not meet the requirements or an attempt to resize it fails, an array + * containing the error message will be returned. + */ + protected function validateFileImageResolution(FileInterface $file, $maximum_dimensions = 0, $minimum_dimensions = 0): array { + $errors = []; + + // Check first that the file is an image. + /** @var \Drupal\Core\Image\ImageInterface $image */ + $image = $this->imageFactory->get($file->getFileUri()); + + if ($image->isValid()) { + $scaling = FALSE; + if ($maximum_dimensions) { + // Check that it is smaller than the given dimensions. + [$width, $height] = explode('x', $maximum_dimensions); + if ($image->getWidth() > $width || $image->getHeight() > $height) { + // Try to resize the image to fit the dimensions. + if ($image->scale((int) $width, (int) $height)) { + $scaling = TRUE; + $image->save(); + } + else { + $errors[] = $this->t('The image exceeds the maximum allowed dimensions and an attempt to resize it failed.'); + } + } + } + + if ($minimum_dimensions) { + // Check that it is larger than the given dimensions. + [$width, $height] = explode('x', $minimum_dimensions); + if ($image->getWidth() < $width || $image->getHeight() < $height) { + if ($scaling) { + $errors[] = $this->t('The resized image is too small. The minimum dimensions are %dimensions pixels and after resizing, the image size will be %widthx%height pixels.', + [ + '%dimensions' => $minimum_dimensions, + '%width' => $image->getWidth(), + '%height' => $image->getHeight(), + ]); + } + else { + $errors[] = $this->t('The image is too small. The minimum dimensions are %dimensions pixels and the image size is %widthx%height pixels.', + [ + '%dimensions' => $minimum_dimensions, + '%width' => $image->getWidth(), + '%height' => $image->getHeight(), + ]); + } + } + } + } + + return $errors; + } + /** * Prepares the filename to strip out any malicious extensions. * diff --git a/tests/files/image/10x10.png b/tests/files/image/10x10.png new file mode 100644 index 000000000..f59f2d5e1 Binary files /dev/null and b/tests/files/image/10x10.png differ diff --git a/tests/src/Kernel/Framework/UploadFileServiceTest.php b/tests/src/Kernel/Framework/UploadFileServiceTest.php index c3f6d8d9c..ce668c835 100644 --- a/tests/src/Kernel/Framework/UploadFileServiceTest.php +++ b/tests/src/Kernel/Framework/UploadFileServiceTest.php @@ -146,6 +146,56 @@ public function testSizeValidation(): void { ); } + /** + * Tests that a larger image is resized to maximum dimensions. + */ + public function testDimensionTooLargeValidation(): void { + // Create a Symfony dummy uploaded file in test mode. + $uploadFile = $this->getUploadedFile(UPLOAD_ERR_OK, 4); + + $image = file_get_contents(\Drupal::service('extension.list.module')->getPath('graphql') . '/tests/files/image/10x10.png'); + + // Create a file with 4 bytes. + file_put_contents($uploadFile->getRealPath(), $image); + + $file_upload_response = $this->uploadService->saveFileUpload($uploadFile, [ + 'uri_scheme' => 'public', + 'file_directory' => 'test', + // Only allow maximum 5x5 dimension. + 'max_resolution' => '5x5', + ]); + $file_entity = $file_upload_response->getFileEntity(); + $image = \Drupal::service('image.factory')->get($file_entity->getFileUri()); + $this->assertEquals(5, $image->getWidth()); + $this->assertEquals(5, $image->getHeight()); + } + + /** + * Tests that a image that is too small returns a violation. + */ + public function testDimensionTooSmallValidation(): void { + // Create a Symfony dummy uploaded file in test mode. + $uploadFile = $this->getUploadedFile(UPLOAD_ERR_OK, 4); + + $image = file_get_contents(\Drupal::service('extension.list.module')->getPath('graphql') . '/tests/files/image/10x10.png'); + + // Create a file with 4 bytes. + file_put_contents($uploadFile->getRealPath(), $image); + + $file_upload_response = $this->uploadService->saveFileUpload($uploadFile, [ + 'uri_scheme' => 'public', + 'file_directory' => 'test', + // Only allow minimum dimension 15x15. + 'min_resolution' => '15x15', + ]); + $violations = $file_upload_response->getViolations(); + + $this->assertStringMatchesFormat( + 'The image is too small. The minimum dimensions are 15x15 pixels and the image size is 10x10 pixels.', + $violations[0]['message'] + ); + } + /** * Tests that the uploaded file extension is renamed to txt. */ @@ -205,6 +255,7 @@ public function testLockReleased(): void { \Drupal::service('config.factory'), \Drupal::service('renderer'), \Drupal::service('event_dispatcher'), + \Drupal::service('image.factory'), ); // Create a Symfony dummy uploaded file in test mode.