diff --git a/core/modules/image/src/Hook/ImageDownloadFileHook.php b/core/modules/image/src/Hook/ImageDownloadFileHook.php new file mode 100644 index 0000000000000000000000000000000000000000..9b12ebbae3d2bbc17dcd71bd45d94133cc8d8910 --- /dev/null +++ b/core/modules/image/src/Hook/ImageDownloadFileHook.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\image\Hook; + +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Image\ImageFactory; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StreamWrapper\StreamWrapperManager; +use Drupal\image\Controller\ImageStyleDownloadController; +use Drupal\image\Plugin\Field\FieldType\ImageItem; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +/** + * Implements hook_file_download(). + */ +#[Hook('file_download')] +class ImageDownloadFileHook { + + /** + * Cache for private default images. + * + * @var array<string, \Drupal\Core\Field\FieldDefinitionInterface[]> + */ + protected array $cachedPrivateDefaultImages; + + public function __construct( + #[Autowire(service: 'cache.default')] + protected readonly CacheBackendInterface $cache, + protected readonly EntityTypeManagerInterface $entityTypeManager, + protected readonly EntityRepositoryInterface $entityRepository, + protected readonly EntityFieldManagerInterface $entityFieldManager, + protected readonly AccountInterface $currentUser, + protected readonly ImageFactory $imageFactory, + protected readonly ConfigFactoryInterface $configFactory, + protected readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Implements hook_file_download(). + */ + public function __invoke(string $uri): array|int|null { + $path = StreamWrapperManager::getTarget($uri); + // Private file access for image style derivatives. + if (str_starts_with($path, 'styles/')) { + $args = explode('/', $path); + // Discard "styles", style name, and scheme from the path + $args = array_slice($args, 3); + // Then the remaining parts are the path to the image. + $original_uri = StreamWrapperManager::getScheme($uri) . '://' . implode('/', $args); + // Check that the file exists and is an image. + $image = $this->imageFactory->get($uri); + if ($image->isValid()) { + // If the image style converted the extension, it has been added to the + // original file, resulting in filenames like image.png.jpeg. So to find + // the actual source image, we remove the extension and check if that + // image exists. + if (!file_exists($original_uri)) { + $converted_original_uri = ImageStyleDownloadController::getUriWithoutConvertedExtension($original_uri); + if ($converted_original_uri !== $original_uri && file_exists($converted_original_uri)) { + // The converted file does exist, use it as the source. + $original_uri = $converted_original_uri; + } + } + // Check the permissions of the original to grant access to this image. + $headers = $this->moduleHandler->invokeAll('file_download', [$original_uri]); + // Confirm there's at least one module granting access and none denying access. + if (!empty($headers) && !in_array(-1, $headers)) { + return [ + // Send headers describing the image's size, and MIME-type. + 'Content-Type' => $image->getMimeType(), + 'Content-Length' => $image->getFileSize(), + ]; + } + } + return -1; + } + // If it is the sample image we need to grant access. + $samplePath = $this->configFactory->get('image.settings')->get('preview_image'); + if ($path === $samplePath) { + $image = $this->imageFactory->get($samplePath); + return [ + // Send headers describing the image's size, and MIME-type. + 'Content-Type' => $image->getMimeType(), + 'Content-Length' => $image->getFileSize(), + ]; + } + + // Private file access for image fields' default images. Default images are + // displayed as a fallback when an image is not uploaded to an image field. + if (str_starts_with($path, ImageItem::DEFAULT_IMAGE_DIRECTORY . DIRECTORY_SEPARATOR)) { + $image = $this->imageFactory->get($uri); + if ($image->isValid()) { + $private_default_images = $this->getPrivateDefaultImages(); + if (isset($private_default_images[$uri])) { + foreach ($private_default_images[$uri] as $field_definition) { + $access_control_handler = $this->entityTypeManager->getAccessControlHandler($field_definition->getTargetEntityTypeId()); + // As long as the user has view access to at least one of the fields, + // that uses this image as a default, we can exit this foreach loop, + // and grant access. + if ($access_control_handler->fieldAccess('view', $field_definition)) { + return [ + // Send headers describing the image's size, and MIME-type. + 'Content-Type' => $image->getMimeType(), + 'Content-Length' => $image->getFileSize(), + // By not explicitly setting them here, this uses normal Drupal + // Expires, Cache-Control and ETag headers to prevent proxy or + // browser caching of private images. + ]; + } + } + } + } + return -1; + } + + return NULL; + } + + /** + * Returns the mapping of private default images to field definitions. + * + * @return array<string, \Drupal\Core\Field\FieldDefinitionInterface[]> + * An associative array where keys are private image URIs and values are + * arrays of field definitions that reference these images as defaults. + */ + protected function getPrivateDefaultImages(): array { + if (!isset($this->cachedPrivateDefaultImages)) { + $cid = 'image:default_images'; + if ($cache = $this->cache->get($cid)) { + $this->cachedPrivateDefaultImages = $cache->data; + } + else { + // Save a map of all default image UUIDs and their corresponding field + // definitions for quick lookup. + $private_default_images = []; + $field_map = $this->entityFieldManager->getFieldMapByFieldType('image'); + $cache_tags = [ + 'image_default_images', + 'entity_field_info', + ]; + foreach ($field_map as $entity_type_id => $fields) { + // Do not filter field storages by uri_scheme, as file entities may + // change regardless of the field storage configuration. + $field_storages = $this->entityFieldManager->getFieldStorageDefinitions($entity_type_id); + foreach ($fields as $field_name => $field_info) { + // First, check if the default image is set on the field storage. + $uri_from_storage = NULL; + $file_uuid = $field_storages[$field_name]->getSetting('default_image')['uuid']; + if ($file_uuid && $file = $this->entityRepository->loadEntityByUuid('file', $file_uuid)) { + /** @var \Drupal\file\FileInterface $file */ + $uri_from_storage = $file->getFileUri(); + $cache_tags = Cache::mergeTags($cache_tags, $file->getCacheTags()); + } + + foreach ($field_info['bundles'] as $bundle) { + $field_definition = $this->entityFieldManager->getFieldDefinitions($entity_type_id, $bundle)[$field_name]; + $default_uri = $uri_from_storage; + $file_uuid = $field_definition->getSetting('default_image')['uuid']; + // If the default image is overridden in the field definition, use + // that instead of the one set on the field storage. + if ($file_uuid && $file = $this->entityRepository->loadEntityByUuid('file', $file_uuid)) { + /** @var \Drupal\file\FileInterface $file */ + $default_uri = $file->getFileUri(); + $cache_tags = Cache::mergeTags($cache_tags, $file->getCacheTags()); + } + // Finally, if a private default image URI was found, + // add it to the list. + if ($default_uri && StreamWrapperManager::getScheme($default_uri) === 'private') { + $private_default_images[$default_uri][] = $field_definition; + } + } + } + } + // Cache the default image list. + $this->cachedPrivateDefaultImages = $private_default_images; + $this->cache->set($cid, $private_default_images, CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + } + } + return $this->cachedPrivateDefaultImages; + } + +} diff --git a/core/modules/image/src/Hook/ImageHooks.php b/core/modules/image/src/Hook/ImageHooks.php index 2d6af269220b0e0e0ddf20c52ba3ad93d1422ab4..06d2af69cb44e2103b93de1b69afd179cc595f61 100644 --- a/core/modules/image/src/Hook/ImageHooks.php +++ b/core/modules/image/src/Hook/ImageHooks.php @@ -8,7 +8,6 @@ use Drupal\field\FieldStorageConfigInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\file\FileInterface; -use Drupal\image\Controller\ImageStyleDownloadController; use Drupal\Core\StreamWrapper\StreamWrapperManager; use Drupal\Core\Url; use Drupal\Core\Routing\RouteMatchInterface; @@ -166,62 +165,6 @@ public function theme() : array { ]; } - /** - * Implements hook_file_download(). - * - * Control the access to files underneath the styles directory. - */ - #[Hook('file_download')] - public function fileDownload($uri): array|int|null { - $path = StreamWrapperManager::getTarget($uri); - // Private file access for image style derivatives. - if (str_starts_with($path, 'styles/')) { - $args = explode('/', $path); - // Discard "styles", style name, and scheme from the path - $args = array_slice($args, 3); - // Then the remaining parts are the path to the image. - $original_uri = StreamWrapperManager::getScheme($uri) . '://' . implode('/', $args); - // Check that the file exists and is an image. - $image = \Drupal::service('image.factory')->get($uri); - if ($image->isValid()) { - // If the image style converted the extension, it has been added to the - // original file, resulting in filenames like image.png.jpeg. So to find - // the actual source image, we remove the extension and check if that - // image exists. - if (!file_exists($original_uri)) { - $converted_original_uri = ImageStyleDownloadController::getUriWithoutConvertedExtension($original_uri); - if ($converted_original_uri !== $original_uri && file_exists($converted_original_uri)) { - // The converted file does exist, use it as the source. - $original_uri = $converted_original_uri; - } - } - // Check the permissions of the original to grant access to this image. - $headers = \Drupal::moduleHandler()->invokeAll('file_download', [$original_uri]); - // Confirm there's at least one module granting access and none denying - // access. - if (!empty($headers) && !in_array(-1, $headers)) { - return [ - // Send headers describing the image's size, and MIME-type. - 'Content-Type' => $image->getMimeType(), - 'Content-Length' => $image->getFileSize(), - ]; - } - } - return -1; - } - // If it is the sample image we need to grant access. - $samplePath = \Drupal::config('image.settings')->get('preview_image'); - if ($path === $samplePath) { - $image = \Drupal::service('image.factory')->get($samplePath); - return [ - // Send headers describing the image's size, and MIME-type. - 'Content-Type' => $image->getMimeType(), - 'Content-Length' => $image->getFileSize(), - ]; - } - return NULL; - } - /** * Implements hook_file_move(). */ diff --git a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php index 2cae36dc6cf68bd7829f400aad363a0e8fd8a761..038f8e080cabb51206affbcef47a0ad1a40e634d 100644 --- a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php +++ b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php @@ -60,6 +60,11 @@ class ImageItem extends FileItem { use LoggerChannelTrait; + /** + * The default image directory. + */ + public const string DEFAULT_IMAGE_DIRECTORY = 'default_images'; + /** * {@inheritdoc} */ @@ -459,7 +464,7 @@ protected function defaultImageForm(array &$element, array $settings) { '#title' => $this->t('Image'), '#description' => $this->t('Image to be shown if no image is uploaded.'), '#default_value' => $fids, - '#upload_location' => $settings['uri_scheme'] . '://default_images/', + '#upload_location' => $settings['uri_scheme'] . '://' . static::DEFAULT_IMAGE_DIRECTORY . '/', '#element_validate' => [ '\Drupal\file\Element\ManagedFile::validateManagedFile', [static::class, 'validateDefaultImageForm'], diff --git a/core/modules/image/tests/modules/image_field_display_test/config/schema/image_field_display_test.schema.yml b/core/modules/image/tests/modules/image_field_display_test/config/schema/image_field_display_test.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..1f42e8e780609690758c4593501fd3f07b080165 --- /dev/null +++ b/core/modules/image/tests/modules/image_field_display_test/config/schema/image_field_display_test.schema.yml @@ -0,0 +1,7 @@ +field.field.*.*.*.third_party.image_field_display_test: + type: mapping + label: 'Image field display test settings' + mapping: + access_denied: + type: boolean + label: 'Access denied' diff --git a/core/modules/image/tests/modules/image_field_display_test/image_field_display_test.info.yml b/core/modules/image/tests/modules/image_field_display_test/image_field_display_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..a7952d0183fc05d6471a6204c73132533035cee7 --- /dev/null +++ b/core/modules/image/tests/modules/image_field_display_test/image_field_display_test.info.yml @@ -0,0 +1,5 @@ +name: 'Image field display test for default images in private file storage' +type: module +description: 'Provides an entity field access hook to deny view access to a field for the current user.' +package: Testing +version: VERSION diff --git a/core/modules/image/tests/modules/image_field_display_test/src/Hook/ImageFieldDisplayTestHooks.php b/core/modules/image/tests/modules/image_field_display_test/src/Hook/ImageFieldDisplayTestHooks.php new file mode 100644 index 0000000000000000000000000000000000000000..e03d58d57f1be17b518769bedcc5f667df2e2d28 --- /dev/null +++ b/core/modules/image/tests/modules/image_field_display_test/src/Hook/ImageFieldDisplayTestHooks.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\image_field_display_test\Hook; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Field\FieldConfigInterface; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Session\AccountInterface; + +/** + * Implements hook_entity_field_access(). + */ +#[Hook('entity_field_access')] +class ImageFieldDisplayTestHooks { + + /** + * Implements hook_entity_field_access(). + */ + public function __invoke(string $operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL): AccessResultInterface { + if ($operation === 'view' + && $field_definition instanceof FieldConfigInterface + && $field_definition->getThirdPartySetting('image_field_display_test', 'access_denied', FALSE)) { + return AccessResult::forbidden()->addCacheableDependency($field_definition); + } + return AccessResult::neutral(); + } + +} diff --git a/core/modules/image/tests/src/Functional/ImageFieldDisplayTest.php b/core/modules/image/tests/src/Functional/ImageFieldDisplayTest.php index efeacd6ff1651ec3ff49fc7620bbda0da1627cfd..3a410bec76ec9c394195c4ad180fe06bce70adca 100644 --- a/core/modules/image/tests/src/Functional/ImageFieldDisplayTest.php +++ b/core/modules/image/tests/src/Functional/ImageFieldDisplayTest.php @@ -7,7 +7,9 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\StreamWrapper\StreamWrapperManager; use Drupal\Core\Url; +use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\file\FileInterface; use Drupal\image\Entity\ImageStyle; use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; use Drupal\Tests\TestFileCreationTrait; @@ -567,7 +569,7 @@ public function testImageFieldDefaultImage(): void { // Create an image field that uses the private:// scheme and test that the // default image works as expected. $private_field_name = $this->randomMachineName(); - $this->createImageField($private_field_name, 'node', 'article', ['uri_scheme' => 'private']); + $private_field = $this->createImageField($private_field_name, 'node', 'article', ['uri_scheme' => 'private']); // Add a default image to the new field. $edit = [ // Get the path of the 'image-test.gif' file. @@ -583,6 +585,7 @@ public function testImageFieldDefaultImage(): void { $private_field_storage = FieldStorageConfig::loadByName('node', $private_field_name); $default_image = $private_field_storage->getSetting('default_image'); $file = \Drupal::service('entity.repository')->loadEntityByUuid('file', $default_image['uuid']); + $this->assertInstanceOf(FileInterface::class, $file); $this->assertEquals('private', StreamWrapperManager::getScheme($file->getFileUri()), 'Default image uses private:// scheme.'); $this->assertTrue($file->isPermanent(), 'The default image status is permanent.'); @@ -606,6 +609,48 @@ public function testImageFieldDefaultImage(): void { // Default private image should be displayed when no user supplied image // is present. $this->assertSession()->responseContains($default_output); + + // Check if the default image set for the field storage can be downloaded; + // i.e., not just the HTML markup. + $this->container->get('module_installer')->install(['image_field_display_test']); + $private_default_image_url = $file->createFileUrl(); + $this->drupalGet($private_default_image_url); + $this->assertSession()->statusCodeEquals(200); + // Now disable access to the field and check that the + // same user now receives a 403 Access Denied. + $private_field->setThirdPartySetting('image_field_display_test', 'access_denied', TRUE)->save(); + $this->drupalGet($private_default_image_url); + $this->assertSession()->statusCodeEquals(403); + + // Override the default image with the field configuration for + // the next access check. + $edit = [ + // Get the path of the 'image-test-transparent-out-of-range.gif' file. + 'files[settings_default_image_uuid]' => $this->container->get('file_system')->realpath($images[3]->uri), + 'settings[default_image][alt]' => $alt, + 'settings[default_image][title]' => $title, + ]; + $this->drupalGet("admin/structure/types/manage/article/fields/node.article.$private_field_name"); + $this->submitForm($edit, 'Save'); + // Clear the cache and reload the field configuration + // to retrieve the UUID of the default image file. + $this->container->get('entity_field.manager')->clearCachedFieldDefinitions(); + $private_field = FieldConfig::loadByName('node', 'article', $private_field_name); + $file_uuid = $private_field->getSetting('default_image')['uuid']; + $file = $this->container->get('entity.repository')->loadEntityByUuid('file', $file_uuid); + $this->assertInstanceOf(FileInterface::class, $file); + + // Check if the default image overridden by the field configuration + // can be downloaded. + $private_default_image_url = $file->createFileUrl(); + $private_field->setThirdPartySetting('image_field_display_test', 'access_denied', FALSE)->save(); + $this->drupalGet($private_default_image_url); + $this->assertSession()->statusCodeEquals(200); + // Now disable access to the field and check that the + // same user now receives a 403 Access Denied. + $private_field->setThirdPartySetting('image_field_display_test', 'access_denied', TRUE)->save(); + $this->drupalGet($private_default_image_url); + $this->assertSession()->statusCodeEquals(403); } }