From 6e6e9a8851f93c2012b4adcd0ab6f3ac6c407420 Mon Sep 17 00:00:00 2001 From: bjorn <bjorn@swis.nl> Date: Mon, 22 Jul 2024 15:46:57 +0200 Subject: [PATCH] Update vary implementation --- core/core.services.yml | 2 + .../VaryHeaderResponseSubscriber.php | 52 +++++++++++ .../LanguageNegotiationBrowser.php | 15 +-- ...uageBrowserDetectionAcceptLanguageTest.php | 14 +-- .../src/StackMiddleware/PageCache.php | 91 ++++++++++++++++--- .../src/Functional/PageCacheVaryTest.php | 60 ++++++++++++ .../src/Controller/TestVaryController.php | 62 +++++++++++++ .../test_page_test/test_page_test.routing.yml | 7 ++ 8 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 core/lib/Drupal/Core/EventSubscriber/VaryHeaderResponseSubscriber.php create mode 100644 core/modules/page_cache/tests/src/Functional/PageCacheVaryTest.php create mode 100644 core/modules/system/tests/modules/test_page_test/src/Controller/TestVaryController.php diff --git a/core/core.services.yml b/core/core.services.yml index 2ee184054a7b..1ffd18f2354d 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -370,6 +370,8 @@ services: tags: - { name: page_cache_response_policy } - { name: dynamic_page_cache_response_policy } + vary_header_response_subscriber: + class: Drupal\Core\EventSubscriber\VaryHeaderResponseSubscriber config.manager: class: Drupal\Core\Config\ConfigManager arguments: ['@entity_type.manager', '@config.factory', '@config.typed', '@string_translation', '@config.storage', '@event_dispatcher', '@entity.repository', '@extension.path.resolver'] diff --git a/core/lib/Drupal/Core/EventSubscriber/VaryHeaderResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/VaryHeaderResponseSubscriber.php new file mode 100644 index 000000000000..42963b09d412 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/VaryHeaderResponseSubscriber.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\Core\EventSubscriber; + +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\CacheableResponseInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +class VaryHeaderResponseSubscriber implements EventSubscriberInterface { + + protected array $vary = []; + + public function addVaryHeader(string $header): void { + if (!in_array($header, $this->vary)) { + $this->vary[] = $header; + } + } + + public function onRespond(ResponseEvent $event) { + if (!$event->isMainRequest()) { + return; + } + + if ($this->vary) { + $response = $event->getResponse(); + $response->setVary($this->vary, false); + if ($response instanceof CacheableResponseInterface) { + $metadata = new CacheableMetadata(); + $contexts = []; + foreach ($this->vary as $vary_header) { + $contexts[] = 'headers:' . $vary_header; + } + $metadata->addCacheContexts($contexts); + $response->addCacheableDependency($metadata); + } + } + } + + public static function getSubscribedEvents() { + /** + * Either Drupal\Tests\page_cache\Functional\PageCacheVaryTest::testPageCacheWithVary + * fails or Drupal\Tests\language\Functional\LanguageBrowserDetectionAcceptLanguageTest::testAcceptLanguageEmptyDefault + * fails if you remove one of those. Not sure why + */ + $events[KernelEvents::RESPONSE][] = ['onRespond', -100]; + $events[KernelEvents::RESPONSE][] = ['onRespond', 100]; + return $events; + } + +} diff --git a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationBrowser.php b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationBrowser.php index a3a5df8ae63b..da0b63029ffc 100644 --- a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationBrowser.php +++ b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationBrowser.php @@ -3,6 +3,7 @@ namespace Drupal\language\Plugin\LanguageNegotiation; use Drupal\Component\Utility\UserAgent; +use Drupal\Core\EventSubscriber\VaryHeaderResponseSubscriber; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\language\Attribute\LanguageNegotiation; @@ -34,12 +35,18 @@ class LanguageNegotiationBrowser extends LanguageNegotiationMethodBase implement */ protected $pageCacheKillSwitch; + /** + * @var \Drupal\Core\EventSubscriber\VaryHeaderResponseSubscriber $varyResponseSubscriber + */ + private VaryHeaderResponseSubscriber $varyResponseSubscriber; + /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { $instance = new static(); $instance->pageCacheKillSwitch = $container->get('page_cache_kill_switch'); + $instance->varyResponseSubscriber = $container->get('vary_header_response_subscriber'); return $instance; } @@ -49,6 +56,8 @@ public static function create(ContainerInterface $container, array $configuratio public function getLangcode(?Request $request = NULL) { $langcode = NULL; + $this->varyResponseSubscriber->addVaryHeader('Accept-Language'); + $this->varyResponseSubscriber->addVaryHeader('Content-Language'); if ($this->languageManager && $request && $request->server->get('HTTP_ACCEPT_LANGUAGE')) { $http_accept_language = $request->server->get('HTTP_ACCEPT_LANGUAGE'); $langcodes = array_keys($this->languageManager->getLanguages()); @@ -56,12 +65,6 @@ public function getLangcode(?Request $request = NULL) { $langcode = UserAgent::getBestMatchingLangcode($http_accept_language, $langcodes, $mappings); } - // Internal page cache with multiple languages and browser negotiation - // could lead to wrong cached sites. Therefore disabling the internal page - // cache. - // @todo Solve more elegantly in https://www.drupal.org/node/2430335. - $this->pageCacheKillSwitch->trigger(); - return $langcode; } diff --git a/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php b/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php index a6d9d84b2d73..b3f0e0c71987 100644 --- a/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php +++ b/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php @@ -10,7 +10,7 @@ /** * Tests browser language detection with different accept-language headers. * - * @group language + * @group vary */ class LanguageBrowserDetectionAcceptLanguageTest extends BrowserTestBase { @@ -89,32 +89,32 @@ public function testAcceptLanguageEmptyDefault(): void { $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'en']); $this->assertSession()->responseHeaderEquals('Content-Language', 'en'); - $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS'); // Check with UK browser. $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'en-UK,en']); $this->assertSession()->responseHeaderEquals('Content-Language', 'en'); - $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS'); // Check with french browser. $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'fr-FR,fr']); $this->assertSession()->responseHeaderEquals('Content-Language', 'fr'); - $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS'); // Check with browser without language settings - should return fallback language. $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => '']); $this->assertSession()->responseHeaderEquals('Content-Language', 'en'); - $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS'); // Check with french browser again. $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'fr-FR,fr']); $this->assertSession()->responseHeaderEquals('Content-Language', 'fr'); - $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT'); // Check with UK browser. $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'en-UK,en']); $this->assertSession()->responseHeaderEquals('Content-Language', 'en'); - $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache'); + $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'HIT'); // Check if prefixed URLs are still cached. $this->drupalGet('/en/system-test/echo/language test', [], ['Accept-Language' => 'en']); diff --git a/core/modules/page_cache/src/StackMiddleware/PageCache.php b/core/modules/page_cache/src/StackMiddleware/PageCache.php index efe4e3b99c29..16d33f4e48c3 100644 --- a/core/modules/page_cache/src/StackMiddleware/PageCache.php +++ b/core/modules/page_cache/src/StackMiddleware/PageCache.php @@ -48,11 +48,18 @@ class PageCache implements HttpKernelInterface { protected $responsePolicy; /** - * The cache ID for the (master) request. + * The cache IDs for the (main) request. * - * @var string + * @var string[] */ - protected $cid; + protected array $cids = []; + + /** + * The request used for generating the cache ID. + * + * @var \Symfony\Component\HttpFoundation\Request + */ + protected Request $requestUsedForCid; /** * Constructs a PageCache object. @@ -304,10 +311,23 @@ protected function storeResponse(Request $request, Response $response) { */ protected function get(Request $request, $allow_invalid = FALSE) { $cid = $this->getCacheId($request); - if ($cache = $this->cache->get($cid, $allow_invalid)) { - return $cache->data; + + $cache = $this->cache->get($cid, $allow_invalid); + if (!$cache) { + return FALSE; } - return FALSE; + + // Cached response may contain Vary header. + // Try to find exact match by adding relevant request headers to cache ID. + $vary = $cache->data->getVary(); + $cid_vary = $this->getCacheId($request, $vary); + if ($cid_vary !== $cid) { + $cache = $this->cache->get($cid_vary, $allow_invalid); + } + if (!$cache) { + return FALSE; + } + return $cache->data; } /** @@ -335,6 +355,14 @@ protected function get(Request $request, $allow_invalid = FALSE) { protected function set(Request $request, Response $response, $expire, array $tags) { $cid = $this->getCacheId($request); $this->cache->set($cid, $response, $expire, $tags); + // Additionally, set cache item for varied response. + $vary = $response->getVary(); + if ($vary) { + $cid_vary = $this->getCacheId($request, $vary); + if ($cid_vary !== $cid) { + $this->cache->set($cid_vary, $response, $expire, $tags); + } + } } /** @@ -342,25 +370,60 @@ protected function set(Request $request, Response $response, $expire, array $tag * * @param \Symfony\Component\HttpFoundation\Request $request * A request object. + * @param array $vary_headers + * Optional array of vary headers in response object. * * @return string * The cache ID for this request. */ - protected function getCacheId(Request $request) { + protected function getCacheId(Request $request, array $vary_headers = []) { + // Get the cache IDs key. + $key = '|'; + if (!empty($vary_headers)) { + sort($vary_headers); + $key = implode('|', $vary_headers); + } + // Once a cache ID is determined for the request, reuse it for the duration // of the request. This ensures that when the cache is written, it is only // keyed on request data that was available when it was read. For example, // the request format might be NULL during cache lookup and then set during // routing, in which case we want to key on NULL during writing, since that // will be the value during lookups for subsequent requests. - if (!isset($this->cid)) { - $cid_parts = [ - $request->getSchemeAndHttpHost() . $request->getRequestUri(), - $request->getRequestFormat(NULL), - ]; - $this->cid = implode(':', $cid_parts); + if (isset($this->cids[$key])) { + return $this->cids[$key]; + } + + if (!isset($this->requestUsedForCid)) { + $this->requestUsedForCid = clone $request; + } + + $cid_parts = [ + $this->requestUsedForCid->getSchemeAndHttpHost() . $this->requestUsedForCid->getRequestUri(), + $this->requestUsedForCid->getRequestFormat(NULL), + ]; + + // Need to separate cache IDs for responses that contain Vary headers. + foreach ($vary_headers as $vary_header) { + $vary_header = strtolower($vary_header); + // Vary:Cookie is handled separately. + // @see PageCache::get() + if ($vary_header === 'cookie') { + continue; + } + + $request_header = $this->requestUsedForCid->headers->get($vary_header); + + if (!$request_header) { + $request_header = '_'; + } + + $cid_parts[] = $vary_header . ':' . $request_header; } - return $this->cid; + + $this->cids[$key] = implode(':', $cid_parts); + + return $this->cids[$key]; } } diff --git a/core/modules/page_cache/tests/src/Functional/PageCacheVaryTest.php b/core/modules/page_cache/tests/src/Functional/PageCacheVaryTest.php new file mode 100644 index 000000000000..c212c66f4e24 --- /dev/null +++ b/core/modules/page_cache/tests/src/Functional/PageCacheVaryTest.php @@ -0,0 +1,60 @@ +<?php + +namespace Drupal\Tests\page_cache\Functional; + +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; + +/** + * Enables the page cache and tests it with various HTTP requests. + * + * @group vary + */ +class PageCacheVaryTest extends BrowserTestBase { + + use AssertPageCacheContextsAndTagsTrait; + + protected $dumpHeaders = TRUE; + + /** + * Modules to enable. + * + * @var array + */ + protected static $modules = ['test_page_test', 'system_test', 'entity_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->config('system.site') + ->set('name', 'Drupal') + ->set('page.front', '/test-page') + ->save(); + } + + /** + * Tests that custom vary header cached. + */ + public function testPageCacheWithVary(): void { + $config = $this->config('system.performance'); + $config->set('cache.page.max_age', 300); + $config->save(); + + $this->drupalGet('/test-page-vary-cache', [], ['X-Vary-Test' => 'TestVary1']); + $this->assertSession()->pageTextContains('TestVary1'); + $this->drupalGet('/test-page-vary-cache', [], ['X-Vary-Test' => 'TestVary1']); + $this->assertSession()->responseHeaderContains('X-Drupal-Cache', 'HIT'); + $this->drupalGet('/test-page-vary-cache', [], ['X-Vary-Test' => 'TestVary2']); + $this->assertSession()->pageTextNotContains('TestVary1'); + $this->assertSession()->pageTextContains('TestVary2'); + } + +} diff --git a/core/modules/system/tests/modules/test_page_test/src/Controller/TestVaryController.php b/core/modules/system/tests/modules/test_page_test/src/Controller/TestVaryController.php new file mode 100644 index 000000000000..4d7153c77d56 --- /dev/null +++ b/core/modules/system/tests/modules/test_page_test/src/Controller/TestVaryController.php @@ -0,0 +1,62 @@ +<?php + +namespace Drupal\test_page_test\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\EventSubscriber\VaryHeaderResponseSubscriber; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Page controller that is aware of Vary header. + * + * @internal + */ +class TestVaryController extends ControllerBase { + + /** + * The vary policy. + * + * @var VaryHeaderResponseSubscriber + */ + protected $varyResponseSubscriber; + + /** + * Constructs the TestVaryController object. + * + * @param VaryHeaderResponseSubscriber $varyResponseSubscriber + * The vary policy. + */ + public function __construct(VaryHeaderResponseSubscriber $varyResponseSubscriber) { + $this->varyResponseSubscriber = $varyResponseSubscriber; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('vary_header_response_subscriber') + ); + } + + /** + * Page that displays content that depends on custom request header. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return array + * Renderable array expected by renderer service. + */ + public function pageVary(Request $request): array { + $expected = $request->headers->get('x-vary-test'); + $build = [ + '#title' => 'Vary test', + '#markup' => $expected ?: 'default header value', + ]; + $this->varyResponseSubscriber->addVaryHeader('X-Vary-Test'); + return $build; + } + +} diff --git a/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml b/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml index f3744df5ee61..6be26f930579 100644 --- a/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml +++ b/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml @@ -152,3 +152,10 @@ test_page_test.test_page_var_dump: _controller: '\Drupal\test_page_test\Controller\TestPageTestController::testPageVarDump' requirements: _access: 'TRUE' + +test_page_test.test_vary_cache: + path: '/test-page-vary-cache' + defaults: + _controller: '\Drupal\test_page_test\Controller\TestVaryController::pageVary' + requirements: + _access: 'TRUE' -- GitLab