core/lib/Drupal/Core/Access/AccessResult.php | 17 ++++- .../Core/Entity/Entity/EntityViewDisplay.php | 10 ++- .../Core/Entity/EntityAccessControlHandler.php | 6 +- core/lib/Drupal/Core/Entity/EntityDisplayBase.php | 9 +++ .../lib/Drupal/Core/Entity/EntityTypeInterface.php | 2 +- core/lib/Drupal/Core/Entity/EntityViewBuilder.php | 5 -- core/lib/Drupal/Core/Field/FormatterBase.php | 11 ++- .../EntityReferenceFormatterBase.php | 84 +++++++++++++++++++--- core/lib/Drupal/Core/Render/BubbleableMetadata.php | 1 + .../aggregator/src/Tests/ItemCacheTagsTest.php | 10 +++ .../src/Tests/BlockContentCacheTagsTest.php | 13 +++- .../comment/src/Tests/CommentCacheTagsTest.php | 10 +++ .../EntityReferenceFormatterTest.php | 21 +++++- .../Field/FieldFormatter/FileFormatterBase.php | 10 ++- .../Field/FieldFormatter/RSSEnclosureFormatter.php | 2 + .../filter/src/Tests/FilterFormatAccessTest.php | 2 +- .../Field/FieldFormatter/ImageFormatterBase.php | 2 +- .../modules/node/src/Tests/Views/FrontPageTest.php | 1 - core/modules/system/entity.api.php | 1 + .../src/Tests/Entity/EntityCacheTagsTestBase.php | 67 ++++++++--------- .../src/Tests/Entity/EntityViewBuilderTest.php | 6 +- .../Entity/EntityWithUriCacheTagsTestBase.php | 8 ++- .../Field/FieldFormatter/AuthorFormatter.php | 31 +++++--- .../Drupal/Tests/Core/Access/AccessResultTest.php | 31 ++++++++ 24 files changed, 280 insertions(+), 80 deletions(-) diff --git a/core/lib/Drupal/Core/Access/AccessResult.php b/core/lib/Drupal/Core/Access/AccessResult.php index 6188b34..4a1dad6 100644 --- a/core/lib/Drupal/Core/Access/AccessResult.php +++ b/core/lib/Drupal/Core/Access/AccessResult.php @@ -340,11 +340,22 @@ public function cacheUntilConfigurationChanges(ConfigBase $configuration) { */ public function orIf(AccessResultInterface $other) { // $other's cacheability metadata is merged if $merge_other gets set to TRUE - // and this happens in two cases: + // and this happens in three cases: // 1. $other's access result is the one that determines the combined access // result. // 2. This access result is not cacheable and $other's access result is the // same. i.e. attempt to return a cacheable access result. + // 3. Neither access result is 'forbidden' and both are cacheable: inherit + // the other's cacheability metadata because it may turn into a + // 'forbidden' for another value of the cache contexts in the + // cacheability metadata. In other words: this is necessary to respect + // the contagious nature of the 'forbidden' access result. + // e.g. we have two access results A and B. Neither is forbidden. A is + // globally cacheable (no cache contexts). B is cacheable per role. If we + // don't have merging case 3, then A->orIf(B) will be globally cacheable, + // which means that even if a user of a different role logs in, the + // cached access result will be used, even though for that other role, B + // is forbidden! $merge_other = FALSE; if ($this->isForbidden() || $other->isForbidden()) { $result = static::forbidden(); @@ -354,13 +365,13 @@ public function orIf(AccessResultInterface $other) { } elseif ($this->isAllowed() || $other->isAllowed()) { $result = static::allowed(); - if (!$this->isAllowed() || ($this->getCacheMaxAge() === 0 && $other->isAllowed())) { + if (!$this->isAllowed() || ($this->getCacheMaxAge() === 0 && $other->isAllowed()) || ($this->getCacheMaxAge() !== 0 && $other instanceof CacheableDependencyInterface && $other->getCacheMaxAge() !== 0)) { $merge_other = TRUE; } } else { $result = static::neutral(); - if (!$this->isNeutral() || ($this->getCacheMaxAge() === 0 && $other->isNeutral())) { + if (!$this->isNeutral() || ($this->getCacheMaxAge() === 0 && $other->isNeutral()) || ($this->getCacheMaxAge() !== 0 && $other instanceof CacheableDependencyInterface && $other->getCacheMaxAge() !== 0)) { $merge_other = TRUE; } } diff --git a/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php b/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php index 79c0f13..8a724a3 100644 --- a/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php +++ b/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php @@ -238,8 +238,14 @@ public function buildMultiple(array $entities) { // Then let the formatter build the output for each entity. foreach ($entities as $id => $entity) { $items = $grouped_items[$id]; - $build_list[$id][$name] = $formatter->view($items); - $build_list[$id][$name]['#access'] = $items->access('view'); + /** @var \Drupal\Core\Access\AccessResultInterface $field_access */ + $field_access = $items->access('view', NULL, TRUE); + $build_list[$id][$name] = []; + if ($field_access->isAllowed()) { + $build_list[$id][$name] = $formatter->view($items); + } + // Apply the field access cacheability metadata to the render array. + $this->renderer->addDependency($build_list[$id][$name], $field_access); } } } diff --git a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php index 8a25c2a..c34cffc 100644 --- a/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php +++ b/core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php @@ -218,9 +218,9 @@ public function createAccess($entity_bundle = NULL, AccountInterface $account = // Invoke hook_entity_create_access() and hook_ENTITY_TYPE_create_access(). // Hook results take precedence over overridden implementations of - // EntityAccessControlHandler::checkAccess(). Entities that have checks that - // need to be done before the hook is invoked should do so by overriding - // this method. + // EntityAccessControlHandler::checkCreateAccess(). Entities that have + // checks that need to be done before the hook is invoked should do so by + // overriding this method. // We grant access to the entity if both of these conditions are met: // - No modules say to deny access. diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php index 96fa0de..ce1740f 100644 --- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php +++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php @@ -115,6 +115,13 @@ protected $pluginManager; /** + * The renderer. + * + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + + /** * {@inheritdoc} */ public function __construct(array $values, $entity_type) { @@ -126,6 +133,8 @@ public function __construct(array $values, $entity_type) { throw new \InvalidArgumentException('EntityDisplay entities can only handle fieldable entity types.'); } + $this->renderer = \Drupal::service('renderer'); + // A plugin manager and a context type needs to be set by extending classes. if (!isset($this->pluginManager)) { throw new \RuntimeException('Missing plugin manager.'); diff --git a/core/lib/Drupal/Core/Entity/EntityTypeInterface.php b/core/lib/Drupal/Core/Entity/EntityTypeInterface.php index 6b68b01..33552cd 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeInterface.php @@ -660,7 +660,7 @@ public function setUriCallback($callback); * * Enables code listing entities of this type to ensure that rendered listings * are varied as necessary, typically to ensure users of role A see other - * entities listed as users of role B. + * entities listed than users of role B. * * @return string[] */ diff --git a/core/lib/Drupal/Core/Entity/EntityViewBuilder.php b/core/lib/Drupal/Core/Entity/EntityViewBuilder.php index 13a7b50..6d2caf7 100644 --- a/core/lib/Drupal/Core/Entity/EntityViewBuilder.php +++ b/core/lib/Drupal/Core/Entity/EntityViewBuilder.php @@ -174,11 +174,6 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco ), ); - // @todo Remove when https://www.drupal.org/node/2099137 lands. - $build['#cache']['contexts'] = Cache::mergeContexts($build['#cache']['contexts'], [ - 'user.roles', - ]); - // Cache the rendered output if permitted by the view mode and global entity // type configuration. if ($this->isViewModeCacheable($view_mode) && !$entity->isNew() && $entity->isDefaultRevision() && $this->entityType->isRenderCacheable()) { diff --git a/core/lib/Drupal/Core/Field/FormatterBase.php b/core/lib/Drupal/Core/Field/FormatterBase.php index f7df601..fa064ba 100644 --- a/core/lib/Drupal/Core/Field/FormatterBase.php +++ b/core/lib/Drupal/Core/Field/FormatterBase.php @@ -8,6 +8,7 @@ namespace Drupal\Core\Field; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element; /** * Base class for 'Field formatter' plugin implementations. @@ -79,7 +80,9 @@ public function view(FieldItemListInterface $items) { $addition = array(); $elements = $this->viewElements($items); - if ($elements) { + + // If there are actual renderable children, use #theme => field. + if (count($elements) !== count(Element::properties($elements))) { $entity = $items->getEntity(); $entity_type = $entity->getEntityTypeId(); $field_name = $this->fieldDefinition->getName(); @@ -101,6 +104,12 @@ public function view(FieldItemListInterface $items) { $addition = array_merge($info, $elements); } + // Otherwise, we are dealing with access cacheability metadata only (i.e. + // only the #cache property is set). Pass this, so that the necessary cache + // contexts and tags are bubbled. + elseif ($elements) { + $addition = $elements; + } return $addition; } diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php index 25c802c..8c843ff 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php @@ -8,8 +8,10 @@ namespace Drupal\Core\Field\Plugin\Field\FieldFormatter; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; use Drupal\Core\Field\FormatterBase; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\TypedData\TranslatableInterface; /** @@ -36,14 +38,17 @@ * * @return \Drupal\Core\Entity\EntityInterface[] * The array of referenced entities to display, keyed by delta. + * + * @see ::prepareView() */ - protected function getEntitiesToView(EntityReferenceFieldItemListInterface $items) { + protected function getEntitiesToView(EntityReferenceFieldItemListInterface &$items) { $entities = array(); $parent_entity_langcode = $items->getEntity()->language()->getId(); foreach ($items as $delta => $item) { // Ignore items where no entity could be loaded in prepareView(). if (!empty($item->_loaded)) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ $entity = $item->entity; // Set the entity in the correct language for display. @@ -51,8 +56,10 @@ protected function getEntitiesToView(EntityReferenceFieldItemListInterface $item $entity = $entity->getTranslation($parent_entity_langcode); } - // Check entity access if needed. - if (!$this->needsAccessCheck($item) || $entity->access('view')) { + $access = $this->checkAccess($item); + // Add the access result's cacheability, ::view() needs it. + $item->_accessCacheability = BubbleableMetadata::createFromObject($access); + if ($access->isAllowed()) { // Add the referring item, in case the formatter needs it. $entity->_referringItem = $items[$delta]; $entities[$delta] = $entity; @@ -66,6 +73,48 @@ protected function getEntitiesToView(EntityReferenceFieldItemListInterface $item /** * {@inheritdoc} * + * @see ::prepareView() + * @see ::getEntitiestoView() + */ + public function view(FieldItemListInterface $items) { + $addition = parent::view($items); + + $field_level_access_cacheability = new BubbleableMetadata(); + + // Try to map the cacheability of the access result that was set at + // _accessCacheability in getEntitiesToView() to the corresponding render + // subtree. If no such subtree is found, then merge it with the field-level + // access cacheability. + foreach ($items as $delta => $item) { + // Ignore items where no entity could be loaded in prepareView(). + if (!empty($item->_loaded)) { + if (isset($addition[$delta])) { + BubbleableMetadata::createFromRenderArray($addition[$delta]) + ->merge($item->_accessCacheability) + ->applyTo($addition[$delta]); + } + else { + $field_level_access_cacheability = $field_level_access_cacheability->merge($item->_accessCacheability); + } + } + } + + // Apply the cacheability metadata for the inaccessible entities and the + // entities for which the corresponding render subtree could not be found. + // This causes the field to be rendered (and cached) according to the cache + // contexts by which the access results vary, to ensure only uses with + // access to this field can view it. It also tags this field with the cache + // tags on which the access results depend, to ensure users that cannot view + // this field at the moment will gain access once any of those cache tags + // are invalidated. + $field_level_access_cacheability->applyTo($addition); + + return $addition; + } + + /** + * {@inheritdoc} + * * Loads the entities referenced in that field across all the entities being * viewed. */ @@ -94,15 +143,26 @@ public function prepareView(array $entities_items) { // For each item, pre-populate the loaded entity in $item->entity, and set // the 'loaded' flag. + $parent_entity_langcode = $items->getEntity()->language()->getId(); foreach ($entities_items as $items) { foreach ($items as $item) { if (isset($target_entities[$item->target_id])) { - $item->entity = $target_entities[$item->target_id]; + $entity = $target_entities[$item->target_id]; + // Set the entity in the correct language for display. + if ($entity instanceof TranslatableInterface && $entity->hasTranslation($parent_entity_langcode)) { + $entity = $entity->getTranslation($parent_entity_langcode); + } + $item->entity = $entity; $item->_loaded = TRUE; } elseif ($item->hasNewEntity()) { $item->_loaded = TRUE; } + + // For each item, pre-populate the entity access result. + if (!empty($item->_loaded)) { + $item->_access = $this->checkAccess($item); + } } } } @@ -121,16 +181,22 @@ protected function needsEntityLoad(EntityReferenceItem $item) { } /** - * Returns whether entity access should be checked. + * Checks access to the given entity reference item. + * + * By default, entity access is checked. However, a subclass can choose to + * exclude certain items from entity access checking by immediately granting + * access. * * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item * The item to check. * - * @return bool - * TRUE if entity access should be checked. + * @return \Drupal\Core\Access\AccessResult + * A cacheable access result. */ - protected function needsAccessCheck(EntityReferenceItem $item) { - return TRUE; + protected function checkAccess(EntityReferenceItem $item) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $item->entity; + return $entity->access('view', NULL, TRUE); } } diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php index 31a1f85..1bbd8ab 100644 --- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php +++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php @@ -8,6 +8,7 @@ namespace Drupal\Core\Render; use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableDependencyInterface; diff --git a/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php b/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php index 925a6ed..fd6fdee 100644 --- a/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php +++ b/core/modules/aggregator/src/Tests/ItemCacheTagsTest.php @@ -9,6 +9,7 @@ use Drupal\aggregator\Entity\Feed; use Drupal\aggregator\Entity\Item; +use Drupal\Core\Entity\EntityInterface; use Drupal\system\Tests\Entity\EntityCacheTagsTestBase; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; @@ -84,4 +85,13 @@ public function testEntityCreation() { $this->assertFalse(\Drupal::cache('render')->get('foo'), 'Creating a new feed item invalidates the cache tag of the feed.'); } + /** + * {@inheritdoc} + */ + protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) { + return [ + 'user.permissions', + ]; + } + } diff --git a/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php b/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php index 8fb80d5..582d832 100644 --- a/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php +++ b/core/modules/block_content/src/Tests/BlockContentCacheTagsTest.php @@ -55,6 +55,15 @@ protected function createEntity() { /** * {@inheritdoc} * + * @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess() + */ + protected function getAccessCacheContextsForEntity(EntityInterface $entity) { + return []; + } + + /** + * {@inheritdoc} + * * Each comment must have a comment body, which always has a text format. */ protected function getAdditionalCacheTagsForEntity(EntityInterface $entity) { @@ -86,12 +95,12 @@ public function testBlock() { // Expected contexts and tags for the BlockContent entity. // @see \Drupal\Core\Entity\EntityViewBuilder::getBuildDefaults(). - $expected_entity_cache_contexts = ['theme', 'user.roles']; + $expected_entity_cache_contexts = ['theme']; $expected_entity_cache_tags = Cache::mergeTags(['block_content_view'], $this->entity->getCacheTags(), $this->getAdditionalCacheTagsForEntity($this->entity)); // Verify that what was render cached matches the above expectations. $cid = $this->createCacheId($expected_block_cache_keys, $expected_block_cache_contexts); $redirected_cid = $this->createCacheId($expected_block_cache_keys, Cache::mergeContexts($expected_block_cache_contexts, $expected_entity_cache_contexts)); - $this->verifyRenderCache($cid, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), $redirected_cid); + $this->verifyRenderCache($cid, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), ($cid !== $redirected_cid) ? $redirected_cid : NULL); } } diff --git a/core/modules/comment/src/Tests/CommentCacheTagsTest.php b/core/modules/comment/src/Tests/CommentCacheTagsTest.php index fa95260..d4710f5 100644 --- a/core/modules/comment/src/Tests/CommentCacheTagsTest.php +++ b/core/modules/comment/src/Tests/CommentCacheTagsTest.php @@ -81,6 +81,16 @@ protected function createEntity() { return $comment; } + + /** + * {@inheritdoc} + */ + protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) { + return [ + 'user.permissions', + ]; + } + /** * {@inheritdoc} * diff --git a/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php b/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php index 9979c0d..7bd22f7 100644 --- a/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php +++ b/core/modules/field/src/Tests/EntityReference/EntityReferenceFormatterTest.php @@ -10,6 +10,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\entity_reference\Tests\EntityReferenceTestTrait; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\field\Entity\FieldStorageConfig; use Drupal\filter\Entity\FilterFormat; use Drupal\system\Tests\Entity\EntityUnitTestBase; @@ -218,26 +219,44 @@ public function testLabelFormatter() { // The 'link' settings is TRUE by default. $build = $this->buildRenderArray([$this->referencedEntity, $this->unsavedReferencedEntity], $formatter); + $expected_field_cacheability = [ + 'contexts' => [], + 'tags' => [], + 'max-age' => Cache::PERMANENT, + ]; + $this->assertEqual($build['#cache'], $expected_field_cacheability, 'The field render array contains the entity access cacheability metadata'); $expected_item_1 = array( '#type' => 'link', '#title' => $this->referencedEntity->label(), '#url' => $this->referencedEntity->urlInfo(), '#options' => $this->referencedEntity->urlInfo()->getOptions(), '#cache' => array( + 'contexts' => [ + 'user.permissions', + ], 'tags' => $this->referencedEntity->getCacheTags(), ), + '#attached' => [], + '#post_render_cache' => [], ); $this->assertEqual(drupal_render($build[0]), drupal_render($expected_item_1), sprintf('The markup returned by the %s formatter is correct for an item with a saved entity.', $formatter)); + $this->assertEqual(BubbleableMetadata::createFromRenderArray($build[0]), BubbleableMetadata::createFromRenderArray($expected_item_1)); // The second referenced entity is "autocreated", therefore not saved and // lacking any URL info. $expected_item_2 = array( '#markup' => $this->unsavedReferencedEntity->label(), '#cache' => array( + 'contexts' => [ + 'user.permissions', + ], 'tags' => $this->unsavedReferencedEntity->getCacheTags(), + 'max-age' => Cache::PERMANENT, ), + '#attached' => [], + '#post_render_cache' => [], ); - $this->assertEqual($build[1], $expected_item_2, sprintf('The markup returned by the %s formatter is correct for an item with a unsaved entity.', $formatter)); + $this->assertEqual($build[1], $expected_item_2, sprintf('The render array returned by the %s formatter is correct for an item with a unsaved entity.', $formatter)); // Test with the 'link' setting set to FALSE. $build = $this->buildRenderArray([$this->referencedEntity, $this->unsavedReferencedEntity], $formatter, array('link' => FALSE)); diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php b/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php index cdd5b7b..3180571 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/FileFormatterBase.php @@ -7,6 +7,7 @@ namespace Drupal\file\Plugin\Field\FieldFormatter; +use Drupal\Core\Access\AccessResult; use Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceFormatterBase; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; @@ -25,11 +26,16 @@ protected function needsEntityLoad(EntityReferenceItem $item) { /** * {@inheritdoc} */ - protected function needsAccessCheck(EntityReferenceItem $item) { + protected function checkAccess(EntityReferenceItem $item) { // Only check access if the current file access control handler explicitly // opts in by implementing FileAccessFormatterControlHandlerInterface. $access_handler_class = $item->entity->getEntityType()->getHandlerClass('access'); - return is_subclass_of($access_handler_class, '\Drupal\file\FileAccessFormatterControlHandlerInterface'); + if (is_subclass_of($access_handler_class, '\Drupal\file\FileAccessFormatterControlHandlerInterface')) { + return parent::checkAccess($item); + } + else { + return AccessResult::allowed(); + } } } diff --git a/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php b/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php index c324702..0a7b98f 100644 --- a/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php +++ b/core/modules/file/src/Plugin/Field/FieldFormatter/RSSEnclosureFormatter.php @@ -26,6 +26,7 @@ class RSSEnclosureFormatter extends FileFormatterBase { * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items) { + $elements = []; $entity = $items->getEntity(); // Add the first file as an enclosure to the RSS item. RSS allows only one // enclosure per item. See: http://en.wikipedia.org/wiki/RSS_enclosure @@ -39,6 +40,7 @@ public function viewElements(FieldItemListInterface $items) { ), ); } + return $elements; } } diff --git a/core/modules/filter/src/Tests/FilterFormatAccessTest.php b/core/modules/filter/src/Tests/FilterFormatAccessTest.php index 8dfe449..32d5d04 100644 --- a/core/modules/filter/src/Tests/FilterFormatAccessTest.php +++ b/core/modules/filter/src/Tests/FilterFormatAccessTest.php @@ -126,7 +126,7 @@ function testFormatPermissions() { $this->assertTrue($this->allowedFormat->access('use', $this->webUser), 'A regular user has access to use a text format they were granted access to.'); $this->assertEqual(AccessResult::allowed()->addCacheContexts(['user.permissions']), $this->allowedFormat->access('use', $this->webUser, TRUE), 'A regular user has access to use a text format they were granted access to.'); $this->assertFalse($this->disallowedFormat->access('use', $this->webUser), 'A regular user does not have access to use a text format they were not granted access to.'); - $this->assertEqual(AccessResult::neutral(), $this->disallowedFormat->access('use', $this->webUser, TRUE)); //, 'A regular user does not have access to use a text format they were not granted access to.'); + $this->assertEqual(AccessResult::neutral()->cachePerPermissions(), $this->disallowedFormat->access('use', $this->webUser, TRUE), 'A regular user does not have access to use a text format they were not granted access to.'); $this->assertTrue($fallback_format->access('use', $this->webUser), 'A regular user has access to use the fallback format.'); $this->assertEqual(AccessResult::allowed(), $fallback_format->access('use', $this->webUser, TRUE), 'A regular user has access to use the fallback format.'); diff --git a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php index dd8348e..076b9d4 100644 --- a/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php +++ b/core/modules/image/src/Plugin/Field/FieldFormatter/ImageFormatterBase.php @@ -19,7 +19,7 @@ /** * {@inheritdoc} */ - protected function getEntitiesToView(EntityReferenceFieldItemListInterface $items) { + protected function getEntitiesToView(EntityReferenceFieldItemListInterface &$items) { // Add the default image if needed. if ($items->isEmpty()) { $default_image = $this->getFieldSetting('default_image'); diff --git a/core/modules/node/src/Tests/Views/FrontPageTest.php b/core/modules/node/src/Tests/Views/FrontPageTest.php index 0e2efbf..83e54b0 100644 --- a/core/modules/node/src/Tests/Views/FrontPageTest.php +++ b/core/modules/node/src/Tests/Views/FrontPageTest.php @@ -290,7 +290,6 @@ protected function assertFrontPageViewCacheTags($do_assert_views_caches) { } $cache_contexts = Cache::mergeContexts($cache_contexts, [ 'timezone', - 'user.roles' ]); // First page. diff --git a/core/modules/system/entity.api.php b/core/modules/system/entity.api.php index e69a92a..223a426 100644 --- a/core/modules/system/entity.api.php +++ b/core/modules/system/entity.api.php @@ -1844,6 +1844,7 @@ function hook_entity_field_access($operation, \Drupal\Core\Field\FieldDefinition if ($field_definition->getName() == 'field_of_interest' && $operation == 'edit') { return AccessResult::allowedIfHasPermission($account, 'update field of interest'); } + return AccessResult::neutral(); } /** diff --git a/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php b/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php index 23d5f79..f85c3d9 100644 --- a/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php +++ b/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php @@ -130,6 +130,21 @@ protected static function generateStandardizedInfo($entity_type_label, $group) { abstract protected function createEntity(); /** + * Returns the access cache contexts for the tested entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to be tested, as created by createEntity(). + * + * @return string[] + * An array of the additional cache contexts. + * + * @see \Drupal\Core\Entity\EntityAccessControlHandlerInterface + */ + protected function getAccessCacheContextsForEntity(EntityInterface $entity) { + return ['user.permissions']; + } + + /** * Returns the additional (non-standard) cache contexts for the tested entity. * * @param \Drupal\Core\Entity\EntityInterface $entity @@ -317,7 +332,7 @@ public function testReferencedEntity() { // The default cache contexts for rendered entities. $default_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']; - $entity_cache_contexts = Cache::mergeContexts($default_cache_contexts, ['user.roles']); + $entity_cache_contexts = $default_cache_contexts; // Cache tags present on every rendered page. $page_cache_tags = Cache::mergeTags( @@ -326,6 +341,7 @@ public function testReferencedEntity() { // which adds the block config entity type's list cache tags. \Drupal::moduleHandler()->moduleExists('block') ? ['config:block_list']: [] ); + $page_cache_tags_referencing_entity = in_array('user.permissions', $this->getAccessCacheContextsForEntity($this->referencingEntity)) ? ['config:user.role.anonymous'] : []; $view_cache_tag = array(); if ($this->entity->getEntityType()->hasHandlerClass('view_builder')) { @@ -366,11 +382,15 @@ public function testReferencedEntity() { $this->pass("Test referencing entity.", 'Debug'); $this->verifyPageCache($referencing_entity_url, 'MISS'); // Verify a cache hit, but also the presence of the correct cache tags. - $this->verifyPageCache($referencing_entity_url, 'HIT', Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags)); + $this->verifyPageCache($referencing_entity_url, 'HIT', Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags, $page_cache_tags_referencing_entity)); // Also verify the existence of an entity render cache entry. $cache_keys = ['entity_view', 'entity_test', $this->referencingEntity->id(), 'full']; $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); - $redirected_cid = $this->createRedirectedCacheId($cache_keys, $entity_cache_contexts); + $access_cache_contexts = $this->getAccessCacheContextsForEntity($this->entity); + $redirected_cid = NULL; + if (count($access_cache_contexts)) { + $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $this->getAdditionalCacheContextsForEntity($this->referencingEntity), $access_cache_contexts)); + } $this->verifyRenderCache($cid, $referencing_entity_cache_tags, $redirected_cid); $this->pass("Test non-referencing entity.", 'Debug'); @@ -387,7 +407,7 @@ public function testReferencedEntity() { // Prime the page cache for the listing of referencing entities. $this->verifyPageCache($listing_url, 'MISS'); // Verify a cache hit, but also the presence of the correct cache tags. - $this->verifyPageCache($listing_url, 'HIT', Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags)); + $this->verifyPageCache($listing_url, 'HIT', Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags, $page_cache_tags_referencing_entity)); $this->pass("Test empty listing.", 'Debug'); @@ -639,32 +659,6 @@ protected function createCacheId(array $keys, array $contexts) { } /** - * Creates the redirected cache ID, if any. - * - * If a subclass overrides ::getAdditionalCacheContextsForEntity(), it can - * specify the additional cache contexts by which the given entity must be - * varied, because those are the cache contexts that are bubbled from the - * field formatters. - * - * @param string[] $keys - * A list of cache keys used for the regular (pre-bubbling) CID. - * @param string[] $contexts - * A set of cache contexts used for the regular (pre-bubbling) CID. - * - * @return string|null - * The redirected (post-bubbling) CID, if any. - */ - protected function createRedirectedCacheId(array $keys, array $contexts) { - $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->referencingEntity); - if (count($additional_cache_contexts)) { - return $this->createCacheId($keys, Cache::mergeContexts($contexts, $additional_cache_contexts)); - } - else { - return NULL; - } - } - - /** * Verify that a given render cache entry exists, with the correct cache tags. * * @param string $cid @@ -681,12 +675,21 @@ protected function verifyRenderCache($cid, array $tags, $redirected_cid = NULL) sort($cache_entry->tags); sort($tags); $this->assertIdentical($cache_entry->tags, $tags); + $is_redirecting_cache_item = isset($cache_entry->data['#cache_redirect']); if ($redirected_cid === NULL) { - $this->assertTrue(!isset($cache_entry->data['#cache_redirect']), 'Render cache entry is not a redirect.'); + $this->assertFalse($is_redirecting_cache_item, 'Render cache entry is not a redirect.'); + // If this is a redirecting cache item unlike we expected, log it. + if ($is_redirecting_cache_item) { + debug($cache_entry->data); + } } else { // Verify that $cid contains a cache redirect. - $this->assertTrue(isset($cache_entry->data['#cache_redirect']), 'Render cache entry is a redirect.'); + $this->assertTrue($is_redirecting_cache_item, 'Render cache entry is a redirect.'); + // If this is not a redirecting cache item unlike we expected, log it. + if (!$is_redirecting_cache_item) { + debug($cache_entry->data); + } // Verify that the cache redirect points to the expected CID. $redirect_cache_metadata = $cache_entry->data['#cache']; $actual_redirection_cid = $this->createCacheId( diff --git a/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php b/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php index e44299b..f077b3e 100644 --- a/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php +++ b/core/modules/system/src/Tests/Entity/EntityViewBuilderTest.php @@ -63,7 +63,7 @@ public function testEntityViewBuilderCache() { // Get a fully built entity view render array. $entity_test->save(); $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'full'); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(Cache::mergeContexts($build['#cache']['contexts'], ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']))); + $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme'])); $cid = implode(':', $cid_parts); $bin = $build['#cache']['bin']; @@ -113,7 +113,7 @@ public function testEntityViewBuilderCacheWithReferences() { // Get a fully built entity view render array for the referenced entity. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test_reference, 'full'); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(Cache::mergeContexts($build['#cache']['contexts'], ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']))); + $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme'])); $cid_reference = implode(':', $cid_parts); $bin_reference = $build['#cache']['bin']; @@ -132,7 +132,7 @@ public function testEntityViewBuilderCacheWithReferences() { // Get a fully built entity view render array. $build = $this->container->get('entity.manager')->getViewBuilder('entity_test')->view($entity_test, 'full'); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(Cache::mergeContexts($build['#cache']['contexts'], ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']))); + $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme'])); $cid = implode(':', $cid_parts); $bin = $build['#cache']['bin']; diff --git a/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php b/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php index 299f811..0b6f430 100644 --- a/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php +++ b/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php @@ -32,7 +32,7 @@ public function testEntityUri() { $view_mode = $this->selectViewMode($entity_type); // The default cache contexts for rendered entities. - $entity_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.roles']; + $entity_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme']; // Generate the standardized entity cache tags. $cache_tag = $this->entity->getCacheTags(); @@ -51,7 +51,11 @@ public function testEntityUri() { if (\Drupal::entityManager()->getDefinition($entity_type)->isRenderCacheable()) { $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode]; $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); - $redirected_cid = $this->createRedirectedCacheId($cache_keys, $entity_cache_contexts); + $redirected_cid = NULL; + $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->entity); + if (count($additional_cache_contexts)) { + $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts)); + } $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag, $this->getAdditionalCacheTagsForEntity($this->entity), array($render_cache_tag)); $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid); } diff --git a/core/modules/user/src/Plugin/Field/FieldFormatter/AuthorFormatter.php b/core/modules/user/src/Plugin/Field/FieldFormatter/AuthorFormatter.php index 328f513..10e488a 100644 --- a/core/modules/user/src/Plugin/Field/FieldFormatter/AuthorFormatter.php +++ b/core/modules/user/src/Plugin/Field/FieldFormatter/AuthorFormatter.php @@ -7,9 +7,11 @@ namespace Drupal\user\Plugin\Field\FieldFormatter; +use Drupal\Core\Access\AccessResult; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceFormatterBase; +use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; /** * Plugin implementation of the 'author' formatter. @@ -31,18 +33,16 @@ class AuthorFormatter extends EntityReferenceFormatterBase { public function viewElements(FieldItemListInterface $items) { $elements = array(); - foreach ($items as $delta => $item) { + foreach ($this->getEntitiesToView($items) as $delta => $entity) { /** @var $referenced_user \Drupal\user\UserInterface */ - if ($referenced_user = $item->entity) { - $elements[$delta] = array( - '#theme' => 'username', - '#account' => $referenced_user, - '#link_options' => array('attributes' => array('rel' => 'author')), - '#cache' => array( - 'tags' => $referenced_user->getCacheTags(), - ), - ); - } + $elements[$delta] = array( + '#theme' => 'username', + '#account' => $entity, + '#link_options' => array('attributes' => array('rel' => 'author')), + '#cache' => array( + 'tags' => $entity->getCacheTags(), + ), + ); } return $elements; @@ -55,4 +55,13 @@ public static function isApplicable(FieldDefinitionInterface $field_definition) return $field_definition->getFieldStorageDefinition()->getSetting('target_type') == 'user'; } + /** + * {@inheritdoc} + */ + protected function checkAccess(EntityReferenceItem $item) { + // Always allow an entity author's username to be read, even if the current + // user does not have permission to view the entity author's profile. + return AccessResult::allowed(); + } + } diff --git a/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php b/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php index c549a3c..77d5e13 100644 --- a/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php +++ b/core/tests/Drupal/Tests/Core/Access/AccessResultTest.php @@ -797,6 +797,37 @@ public function testAndOrCacheabilityPropagation(AccessResultInterface $first, $ } /** + * @covers ::orIf + * + * Tests the special case of ORing non-forbidden access results that are both + * cacheable but have different cacheability metadata. + * This is only the case for non-forbidden access results; we still abort the + * ORing process as soon as a forbidden access result is encountered. This is + * tested in ::testOrIf(). + */ + public function testOrIfCacheabilityMerging() { + $merge_both_directions = function(AccessResult $a, AccessResult $b) { + // A globally cacheable access result. + $a->setCacheMaxAge(3600); + // Another access result that is cacheable per permissions. + $b->setCacheMaxAge(86400)->cachePerPermissions(); + + $r1 = $a->orIf($b); + $this->assertTrue($r1->getCacheMaxAge() !== 0); + $this->assertSame(['user.permissions'], $r1->getCacheContexts()); + $r2 = $b->orIf($a); + $this->assertTrue($r2->getCacheMaxAge() !== 0); + $this->assertSame(['user.permissions'], $r2->getCacheContexts()); + }; + + // Merge either direction, get the same result. + $merge_both_directions(AccessResult::allowed(), AccessResult::allowed()); + $merge_both_directions(AccessResult::allowed(), AccessResult::neutral()); + $merge_both_directions(AccessResult::neutral(), AccessResult::neutral()); + $merge_both_directions(AccessResult::neutral(), AccessResult::allowed()); + } + + /** * Tests allowedIfHasPermissions(). * * @covers ::allowedIfHasPermissions