Skip to content
Snippets Groups Projects
Commit 81a13c64 authored by catch's avatar catch
Browse files

Issue #2521956 by Berdir, Fabianx, effulgentsia, andypost, lauriii, seanB, Wim...

Issue #2521956 by Berdir, Fabianx, effulgentsia, andypost, lauriii, seanB, Wim Leers, Upchuk: Missing contexts prevent caching of block access
parent 00741504
No related branches found
No related tags found
No related merge requests found
<?php
namespace Drupal\Component\Plugin\Exception;
/**
* An exception class thrown when contexts exist but are missing a value.
*/
class MissingValueContextException extends ContextException {
/**
* MissingValueContextException constructor.
*
* @param string[] $contexts_without_value
* List of contexts with missing value.
*/
public function __construct(array $contexts_without_value = []) {
$message = 'Required contexts without a value: ' . implode(', ', $contexts_without_value);
parent::__construct($message);
}
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace Drupal\Core\Plugin\Context; namespace Drupal\Core\Plugin\Context;
use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Exception\ContextException;
use Drupal\Component\Plugin\Exception\MissingValueContextException;
use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface; use Drupal\Core\Plugin\ContextAwarePluginInterface;
...@@ -93,15 +94,17 @@ public function applyContextMapping(ContextAwarePluginInterface $plugin, $contex ...@@ -93,15 +94,17 @@ public function applyContextMapping(ContextAwarePluginInterface $plugin, $contex
} }
} }
// If there are any required contexts without a value, throw an exception.
if ($missing_value) {
throw new ContextException(sprintf('Required contexts without a value: %s.', implode(', ', $missing_value)));
}
// If there are any mappings that were not satisfied, throw an exception. // If there are any mappings that were not satisfied, throw an exception.
// This is a more severe problem than missing values, so check and throw
// this first.
if (!empty($mappings)) { if (!empty($mappings)) {
throw new ContextException('Assigned contexts were not satisfied: ' . implode(',', array_keys($mappings))); throw new ContextException('Assigned contexts were not satisfied: ' . implode(',', array_keys($mappings)));
} }
// If there are any required contexts without a value, throw an exception.
if ($missing_value) {
throw new MissingValueContextException($missing_value);
}
} }
} }
...@@ -74,6 +74,9 @@ public function getMatchingContexts(array $contexts, ContextDefinitionInterface ...@@ -74,6 +74,9 @@ public function getMatchingContexts(array $contexts, ContextDefinitionInterface
* *
* @throws \Drupal\Component\Plugin\Exception\ContextException * @throws \Drupal\Component\Plugin\Exception\ContextException
* Thrown when a context assignment was not satisfied. * Thrown when a context assignment was not satisfied.
* @throws \Drupal\Component\Plugin\Exception\MissingValueContextException
* Thrown when a context is provided but has no value. Only thrown if
* no contexts are missing.
*/ */
public function applyContextMapping(ContextAwarePluginInterface $plugin, $contexts, $mappings = []); public function applyContextMapping(ContextAwarePluginInterface $plugin, $contexts, $mappings = []);
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace Drupal\block; namespace Drupal\block;
use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Exception\ContextException;
use Drupal\Component\Plugin\Exception\MissingValueContextException;
use Drupal\Core\Access\AccessResult; use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableDependencyInterface;
...@@ -83,12 +84,16 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter ...@@ -83,12 +84,16 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter
else { else {
$conditions = []; $conditions = [];
$missing_context = FALSE; $missing_context = FALSE;
$missing_value = FALSE;
foreach ($entity->getVisibilityConditions() as $condition_id => $condition) { foreach ($entity->getVisibilityConditions() as $condition_id => $condition) {
if ($condition instanceof ContextAwarePluginInterface) { if ($condition instanceof ContextAwarePluginInterface) {
try { try {
$contexts = $this->contextRepository->getRuntimeContexts(array_values($condition->getContextMapping())); $contexts = $this->contextRepository->getRuntimeContexts(array_values($condition->getContextMapping()));
$this->contextHandler->applyContextMapping($condition, $contexts); $this->contextHandler->applyContextMapping($condition, $contexts);
} }
catch (MissingValueContextException $e) {
$missing_value = TRUE;
}
catch (ContextException $e) { catch (ContextException $e) {
$missing_context = TRUE; $missing_context = TRUE;
} }
...@@ -99,14 +104,15 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter ...@@ -99,14 +104,15 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter
if ($missing_context) { if ($missing_context) {
// If any context is missing then we might be missing cacheable // If any context is missing then we might be missing cacheable
// metadata, and don't know based on what conditions the block is // metadata, and don't know based on what conditions the block is
// accessible or not. For example, blocks that have a node type // accessible or not. Make sure the result cannot be cached.
// condition will have a missing context on any non-node route like the
// frontpage.
// @todo Avoid setting max-age 0 for some or all cases, for example by
// treating available contexts without value differently in
// https://www.drupal.org/node/2521956.
$access = AccessResult::forbidden()->setCacheMaxAge(0); $access = AccessResult::forbidden()->setCacheMaxAge(0);
} }
elseif ($missing_value) {
// The contexts exist but have no value. Deny access without
// disabling caching. For example the node type condition will have a
// missing context on any non-node route like the frontpage.
$access = AccessResult::forbidden();
}
elseif ($this->resolveConditions($conditions, 'and') !== FALSE) { elseif ($this->resolveConditions($conditions, 'and') !== FALSE) {
// Delegate to the plugin. // Delegate to the plugin.
$block_plugin = $entity->getPlugin(); $block_plugin = $entity->getPlugin();
...@@ -117,12 +123,15 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter ...@@ -117,12 +123,15 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter
} }
$access = $block_plugin->access($account, TRUE); $access = $block_plugin->access($account, TRUE);
} }
catch (MissingValueContextException $e) {
// The contexts exist but have no value. Deny access without
// disabling caching.
$access = AccessResult::forbidden();
}
catch (ContextException $e) { catch (ContextException $e) {
// Setting access to forbidden if any context is missing for the same // If any context is missing then we might be missing cacheable
// reasons as with conditions (described in the comment above). // metadata, and don't know based on what conditions the block is
// @todo Avoid setting max-age 0 for some or all cases, for example by // accessible or not. Make sure the result cannot be cached.
// treating available contexts without value differently in
// https://www.drupal.org/node/2521956.
$access = AccessResult::forbidden()->setCacheMaxAge(0); $access = AccessResult::forbidden()->setCacheMaxAge(0);
} }
} }
......
...@@ -141,15 +141,32 @@ public function testRecentNodeBlock() { ...@@ -141,15 +141,32 @@ public function testRecentNodeBlock() {
$label = $block->label(); $label = $block->label();
$this->assertNoText($label, 'Block was not displayed on the front page.'); $this->assertNoText($label, 'Block was not displayed on the front page.');
$this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'user', 'route']); $this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'user', 'route']);
// Ensure that a page that does not have a node context can still be cached,
// the front page is the user page which is already cached from the login
// request above.
$this->assertSame('HIT', $this->getSession()->getResponseHeader('X-Drupal-Dynamic-Cache'));
$this->drupalGet('node/add/article'); $this->drupalGet('node/add/article');
$this->assertText($label, 'Block was displayed on the node/add/article page.'); $this->assertText($label, 'Block was displayed on the node/add/article page.');
$this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'session', 'theme', 'url.path', 'url.query_args', 'user', 'route']); $this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'session', 'theme', 'url.path', 'url.query_args', 'user', 'route']);
// The node/add/article page is an admin path and currently uncacheable.
$this->assertSame('UNCACHEABLE', $this->getSession()->getResponseHeader('X-Drupal-Dynamic-Cache'));
$this->drupalGet('node/' . $node1->id()); $this->drupalGet('node/' . $node1->id());
$this->assertText($label, 'Block was displayed on the node/N when node is of type article.'); $this->assertText($label, 'Block was displayed on the node/N when node is of type article.');
$this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'user', 'route', 'timezone']); $this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'user', 'route', 'timezone']);
$this->assertSame('MISS', $this->getSession()->getResponseHeader('X-Drupal-Dynamic-Cache'));
$this->drupalGet('node/' . $node1->id());
$this->assertSame('HIT', $this->getSession()->getResponseHeader('X-Drupal-Dynamic-Cache'));
$this->drupalGet('node/' . $node5->id()); $this->drupalGet('node/' . $node5->id());
$this->assertNoText($label, 'Block was not displayed on nodes of type page.'); $this->assertNoText($label, 'Block was not displayed on nodes of type page.');
$this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'user', 'route', 'timezone']); $this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'theme', 'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMAT, 'user', 'route', 'timezone']);
$this->assertSame('MISS', $this->getSession()->getResponseHeader('X-Drupal-Dynamic-Cache'));
$this->drupalGet('node/' . $node5->id());
$this->assertSame('HIT', $this->getSession()->getResponseHeader('X-Drupal-Dynamic-Cache'));
$this->drupalLogin($this->adminUser); $this->drupalLogin($this->adminUser);
$this->drupalGet('admin/structure/block'); $this->drupalGet('admin/structure/block');
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use Drupal\Component\Plugin\ConfigurablePluginInterface; use Drupal\Component\Plugin\ConfigurablePluginInterface;
use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Exception\ContextException;
use Drupal\Component\Plugin\Exception\MissingValueContextException;
use Drupal\Core\Cache\NullBackend; use Drupal\Core\Cache\NullBackend;
use Drupal\Core\DependencyInjection\ClassResolverInterface; use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ContainerBuilder;
...@@ -336,7 +337,7 @@ public function testApplyContextMappingMissingRequired() { ...@@ -336,7 +337,7 @@ public function testApplyContextMappingMissingRequired() {
$plugin->expects($this->never()) $plugin->expects($this->never())
->method('getContext'); ->method('getContext');
$this->setExpectedException(ContextException::class, 'Required contexts without a value: hit.'); $this->setExpectedException(MissingValueContextException::class, 'Required contexts without a value: hit');
$this->contextHandler->applyContextMapping($plugin, $contexts); $this->contextHandler->applyContextMapping($plugin, $contexts);
} }
...@@ -404,7 +405,7 @@ public function testApplyContextMappingNoValueRequired() { ...@@ -404,7 +405,7 @@ public function testApplyContextMappingNoValueRequired() {
$plugin->expects($this->never()) $plugin->expects($this->never())
->method('setContextValue'); ->method('setContextValue');
$this->setExpectedException(ContextException::class, 'Required contexts without a value: hit.'); $this->setExpectedException(MissingValueContextException::class, 'Required contexts without a value: hit');
$this->contextHandler->applyContextMapping($plugin, $contexts); $this->contextHandler->applyContextMapping($plugin, $contexts);
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment