Commit fb6c562c authored by alexpott's avatar alexpott

Issue #2263981 by znerol, beejeebus: Introduce a robust and extensible page cache-policy framework.

parent 1476c56c
......@@ -100,6 +100,18 @@ services:
factory_method: get
factory_service: cache_factory
arguments: [discovery]
page_cache_request_policy:
class: Drupal\Core\PageCache\DefaultRequestPolicy
tags:
- { name: service_collector, tag: page_cache_request_policy, call: addPolicy}
page_cache_response_policy:
class: Drupal\Core\PageCache\ChainResponsePolicy
tags:
- { name: service_collector, tag: page_cache_response_policy, call: addPolicy}
page_cache_kill_switch:
class: Drupal\Core\PageCache\ResponsePolicy\KillSwitch
tags:
- { name: page_cache_response_policy }
config.manager:
class: Drupal\Core\Config\ConfigManager
arguments: ['@entity.manager', '@config.factory', '@config.typed', '@string_translation', '@config.storage', '@event_dispatcher']
......@@ -729,7 +741,7 @@ services:
class: Drupal\Core\EventSubscriber\FinishResponseSubscriber
tags:
- { name: event_subscriber }
arguments: ['@language_manager', '@config.factory']
arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy']
redirect_response_subscriber:
class: Drupal\Core\EventSubscriber\RedirectResponseSubscriber
arguments: ['@url_generator']
......
......@@ -389,30 +389,6 @@ function drupal_page_get_cache(Request $request) {
}
}
/**
* Determines the cacheability of the current page.
*
* Note: we do not serve cached pages to authenticated users, or to anonymous
* users when $_SESSION is non-empty. $_SESSION may contain status messages
* from a form submission, the contents of a shopping cart, or other user-
* specific content that should not be cached and displayed to other users.
*
* @param $allow_caching
* Set to FALSE if you want to prevent this page to get cached.
*
* @return
* TRUE if the current page can be cached, FALSE otherwise.
*/
function drupal_page_is_cacheable($allow_caching = NULL) {
$allow_caching_static = &drupal_static(__FUNCTION__, TRUE);
if (isset($allow_caching)) {
$allow_caching_static = $allow_caching;
}
return $allow_caching_static && ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD')
&& PHP_SAPI !== 'cli';
}
/**
* Sets an HTTP response header for the current page.
*
......@@ -931,7 +907,7 @@ function drupal_set_message($message = NULL, $type = 'status', $repeat = FALSE)
}
// Mark this page as being uncacheable.
drupal_page_is_cacheable(FALSE);
\Drupal::service('page_cache_kill_switch')->trigger();
}
// Messages not set when DB connection fails.
......
......@@ -62,8 +62,8 @@ function drupal_rebuild(ClassLoader $classloader, Request $request) {
$bin->deleteAll();
}
// Disable the page cache.
drupal_page_is_cacheable(FALSE);
// Disable recording of cached pages.
\Drupal::service('page_cache_kill_switch')->trigger();
drupal_flush_all_caches();
......
......@@ -19,6 +19,7 @@
use Drupal\Core\DependencyInjection\YamlFileLoader;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\Language\Language;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PhpStorage\PhpStorageFactory;
use Drupal\Core\Site\Settings;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -466,9 +467,8 @@ public function handlePageCache(Request $request) {
$cache_enabled = $config->get('cache.page.use_internal');
}
// If there is no session cookie and cache is enabled (or forced), try to
// serve a cached page.
if (!$request->cookies->has(session_name()) && $cache_enabled && drupal_page_is_cacheable()) {
$request_policy = \Drupal::service('page_cache_request_policy');
if ($cache_enabled && $request_policy->check($request) === RequestPolicyInterface::ALLOW) {
// Get the page from the cache.
$response = drupal_page_get_cache($request);
// If there is a cached page, display it.
......
......@@ -11,6 +11,8 @@
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
......@@ -40,6 +42,20 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
*/
protected $config;
/**
* A policy rule determining the cacheability of a request.
*
* @var \Drupal\Core\PageCache\RequestPolicyInterface
*/
protected $requestPolicy;
/**
* A policy rule determining the cacheability of the response.
*
* @var \Drupal\Core\PageCache\ResponsePolicyInterface
*/
protected $responsePolicy;
/**
* Constructs a FinishResponseSubscriber object.
*
......@@ -47,10 +63,16 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
* The language manager object for retrieving the correct language code.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* A config factory for retrieving required config objects.
* @param \Drupal\Core\PageCache\RequestPolicyInterface $request_policy
* A policy rule determining the cacheability of a request.
* @param \Drupal\Core\PageCache\ResponsePolicyInterface $response_policy
* A policy rule determining the cacheability of a response.
*/
public function __construct(LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory) {
public function __construct(LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory, RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy) {
$this->languageManager = $language_manager;
$this->config = $config_factory->get('system.performance');
$this->requestPolicy = $request_policy;
$this->responsePolicy = $response_policy;
}
/**
......@@ -83,16 +105,21 @@ public function onRespond(FilterResponseEvent $event) {
$response->headers->set($name, $value, FALSE);
}
$is_cacheable = drupal_page_is_cacheable();
$is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);
// Add headers necessary to specify whether the response should be cached by
// proxies and/or the browser.
if ($is_cacheable && $this->config->get('cache.page.max_age') > 0) {
if (!$this->isCacheControlCustomized($response)) {
// Only add the default Cache-Control header if the controller did not
// specify one on the response.
$this->setResponseCacheable($response, $request);
}
}
else {
// If either the policy forbids caching or the sites configuration does
// not allow to add a max-age directive, then enforce a Cache-Control
// header declaring the response as not cacheable.
$this->setResponseNotCacheable($response, $request);
}
......
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\ChainRequestPolicy.
*/
namespace Drupal\Core\PageCache;
use Symfony\Component\HttpFoundation\Request;
/**
* Implements a compound request policy.
*
* When evaluating the compound policy, all of the contained rules are applied
* to the request. The overall result is computed according to the following
* rules:
*
* <ol>
* <li>Returns static::DENY if any of the rules evaluated to static::DENY</li>
* <li>Returns static::ALLOW if at least one of the rules evaluated to
* static::ALLOW and none to static::DENY</li>
* <li>Otherwise returns NULL</li>
* </ol>
*/
class ChainRequestPolicy implements ChainRequestPolicyInterface {
/**
* A list of policy rules to apply when this policy is evaluated.
*
* @var \Drupal\Core\PageCache\RequestPolicyInterface[]
*/
protected $rules = [];
/**
* {@inheritdoc}
*/
public function check(Request $request) {
$final_result = NULL;
foreach ($this->rules as $rule) {
$result = $rule->check($request);
if ($result === static::DENY) {
return $result;
}
elseif ($result === static::ALLOW) {
$final_result = $result;
}
elseif (isset($result)) {
throw new \UnexpectedValueException('Return value of RequestPolicyInterface::check() must be one of RequestPolicyInterface::ALLOW, RequestPolicyInterface::DENY or NULL');
}
}
return $final_result;
}
/**
* {@inheritdoc}
*/
public function addPolicy(RequestPolicyInterface $policy) {
$this->rules[] = $policy;
return $this;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\ChainRequestPolicyInterface.
*/
namespace Drupal\Core\PageCache;
/**
* Defines the interface for compound request policies.
*/
interface ChainRequestPolicyInterface extends RequestPolicyInterface {
/**
* Add a policy to the list of policy rules.
*
* @param \Drupal\Core\PageCache\RequestPolicyInterface $policy
* The request policy rule to add.
*
* @return $this
*/
public function addPolicy(RequestPolicyInterface $policy);
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\ChainResponsePolicy.
*/
namespace Drupal\Core\PageCache;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Implements a compound response policy.
*
* When evaluating the compound policy, all of the contained rules are applied
* to the response. The overall result is computed according to the following
* rules:
*
* <ol>
* <li>Returns static::DENY if any of the rules evaluated to static::DENY</li>
* <li>Otherwise returns NULL</li>
* </ol>
*/
class ChainResponsePolicy implements ChainResponsePolicyInterface {
/**
* A list of policy rules to apply when this policy is checked.
*
* @var \Drupal\Core\PageCache\ResponsePolicyInterface[]
*/
protected $rules = [];
/**
* {@inheritdoc}
*/
public function check(Response $response, Request $request) {
foreach ($this->rules as $rule) {
$result = $rule->check($response, $request);
if ($result === static::DENY) {
return $result;
}
elseif (isset($result)) {
throw new \UnexpectedValueException('Return value of ResponsePolicyInterface::check() must be one of ResponsePolicyInterface::DENY or NULL');
}
}
}
/**
* {@inheritdoc}
*/
public function addPolicy(ResponsePolicyInterface $policy) {
$this->rules[] = $policy;
return $this;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\ChainResponsePolicyInterface.
*/
namespace Drupal\Core\PageCache;
/**
* Defines the interface for compound request policies.
*/
interface ChainResponsePolicyInterface extends ResponsePolicyInterface {
/**
* Add a policy to the list of policy rules.
*
* @param \Drupal\Core\PageCache\ResponsePolicyInterface $policy
* The request policy rule to add.
*
* @return $this
*/
public function addPolicy(ResponsePolicyInterface $policy);
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\DefaultRequestPolicy.
*/
namespace Drupal\Core\PageCache;
/**
* The default page cache request policy.
*
* Delivery of cached pages is denied if either the application is running from
* the command line or the request was not initiated with a safe method (GET or
* HEAD). Also caching is only allowed for requests without a session cookie.
*/
class DefaultRequestPolicy extends ChainRequestPolicy {
/**
* Constructs the default page cache request policy.
*/
public function __construct() {
$this->addPolicy(new RequestPolicy\CommandLineOrUnsafeMethod());
$this->addPolicy(new RequestPolicy\NoSessionOpen());
}
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod.
*/
namespace Drupal\Core\PageCache\RequestPolicy;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Reject when running from the command line or when HTTP method is not safe.
*
* The policy denies caching if the request was initiated from the command line
* interface (drush) or the request method is neither GET nor HEAD (see RFC
* 2616, section 9.1.1 - Safe Methods).
*/
class CommandLineOrUnsafeMethod implements RequestPolicyInterface {
/**
* {@inheritdoc}
*/
public function check(Request $request) {
if ($this->isCli() || !$request->isMethodSafe()) {
return static::DENY;
}
}
/**
* Indicates whether this is a CLI request.
*/
protected function isCli() {
return PHP_SAPI === 'cli';
}
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\RequestPolicy\NoSessionOpen.
*/
namespace Drupal\Core\PageCache\RequestPolicy;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* A policy allowing delivery of cached pages when there is no session open.
*
* Do not serve cached pages to authenticated users, or to anonymous users when
* $_SESSION is non-empty. $_SESSION may contain status messages from a form
* submission, the contents of a shopping cart, or other userspecific content
* that should not be cached and displayed to other users.
*/
class NoSessionOpen implements RequestPolicyInterface {
/**
* The name of the session cookie.
*
* @var string
*/
protected $sessionCookieName;
/**
* Constructs a new page cache session policy.
*
* @param string $session_cookie_name
* (optional) The name of the session cookie. Defaults to session_name().
*/
public function __construct($session_cookie_name = NULL) {
$this->sessionCookieName = $session_cookie_name ?: session_name();
}
/**
* {@inheritdoc}
*/
public function check(Request $request) {
if (!$request->cookies->has($this->sessionCookieName)) {
return static::ALLOW;
}
}
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\RequestPolicyInterface.
*/
namespace Drupal\Core\PageCache;
use Symfony\Component\HttpFoundation\Request;
/**
* Defines the interface for request policy implementations.
*
* The request policy is evaluated in order to determine whether delivery of a
* cached page should be attempted. The caller should do so if static::ALLOW is
* returned from the check() method.
*/
interface RequestPolicyInterface {
/**
* Allow delivery of cached pages.
*/
const ALLOW = 'allow';
/**
* Deny delivery of cached pages.
*/
const DENY = 'deny';
/**
* Determines whether delivery of a cached page should be attempted.
*
* Note that the request-policy check runs very early. In particular it is
* not possible to determine the logged in user. Also the current route match
* is not yet present when the check runs. Therefore, request-policy checks
* need to be designed in a way such that they do not depend on any other
* service and only take in account the information present on the incoming
* request.
*
* When matching against the request path, special attention is needed to
* support path prefixes which are often used on multilingual sites.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request object.
*
* @return string|NULL
* One of static::ALLOW, static::DENY or NULL. Calling code may attempt to
* deliver a cached page if static::ALLOW is returned. Returns NULL if the
* policy is not specified for the given request.
*/
public function check(Request $request);
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\ResponsePolicy\KillSwitch.
*/
namespace Drupal\Core\PageCache\ResponsePolicy;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* A policy evaluating to static::DENY when the kill switch was triggered.
*/
class KillSwitch implements ResponsePolicyInterface {
/**
* A flag indicating whether the kill switch was triggered.
*
* @var bool
*/
protected $kill = FALSE;
/**
* {@inheritdoc}
*/
public function check(Response $response, Request $request) {
if ($this->kill) {
return static::DENY;
}
}
/**
* Deny any page caching on the current request.
*/
public function trigger() {
$this->kill = TRUE;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\PageCache\ResponsePolicyInterface.
*/
namespace Drupal\Core\PageCache;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Defines the interface for response policy implementations.
*
* The response policy is evaluated in order to determine whether a page should
* be stored a in the cache. Calling code should do so unless static::DENY is
* returned from the check() method.
*/
interface ResponsePolicyInterface {
/**
* Deny storage of a page in the cache.
*/
const DENY = 'deny';
/**
* Determines whether it is save to store a page in the cache.
*
* @param \Symfony\Component\HttpFoundation\Response $response
* The response which is about to be sent to the client.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return string|NULL
* Either static::DENY or NULL. Calling code may attempt to store a page in
* the cache unless static::DENY is returned. Returns NULL if the policy
* policy is not specified for the given response.
*/
public function check(Response $response, Request $request);
}
......@@ -130,9 +130,6 @@ public function start() {
// anonymous users not use a session cookie unless something is stored in
// $_SESSION. This allows HTTP proxies to cache anonymous pageviews.
$result = $this->startNow();
if ($user->isAuthenticated() || !$this->isSessionObsolete()) {
drupal_page_is_cacheable(FALSE);
}
}
if (empty($result)) {
......
......@@ -6,3 +6,8 @@ services:
plugin.manager.image.effect:
class: Drupal\image\ImageEffectManager
parent: default_plugin_manager
image.page_cache_request_policy.deny_private_image_style_download:
class: Drupal\image\PageCache\DenyPrivateImageStyleDownload
arguments: ['@current_route_match']
tags:
- { name: page_cache_response_policy }
......@@ -155,7 +155,6 @@ public function deliver(Request $request, $scheme, ImageStyleInterface $image_st
}
if ($success) {
drupal_page_is_cacheable(FALSE);
$image = $this->imageFactory->get($derivative_uri);
$uri = $image->getSource();
$headers += array(
......
<?php
/**
* @file
* Contains \Drupal\image\PageCache\DenyPrivateImageStyleDownload.
*/
namespace Drupal\image\PageCache;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Cache policy for image preview page.
*
* This policy rule denies caching of responses generated by the
* entity.image.preview route.
*/
class DenyPrivateImageStyleDownload implements ResponsePolicyInterface {
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructs a deny image preview page cache policy.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
*/
public function __construct(RouteMatchInterface $route_match) {
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public function check(Response $response, Request $request) {
if ($this->routeMatch->getRouteName() === 'image.style_private') {
return static::DENY;
}
}
}
<?php
/**
* @file
* Contains \Drupal\Tests\image\Unit\PageCache\DenyPrivateImageStyleDownloadTest.
*/
namespace Drupal\Tests\image\Unit\PageCache;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\image\PageCache\DenyPrivateImageStyleDownload;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @coversDefaultClass \Drupal\image\PageCache\DenyPrivateImageStyleDownload
* @group image
*/
class DenyPrivateImageStyleDownloadTest extends UnitTestCase {
/**
* The response policy under test.
*
* @var \Drupal\image\PageCache\DenyPrivateImageStyleDownload
*/
protected $policy;
/**
* A request object.