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

8.0.x
Alex Pott 2014-09-20 11:14:29 +01:00
parent 1476c56c62
commit fb6c562c9e
33 changed files with 1314 additions and 78 deletions

View File

@ -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']

View File

@ -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.

View File

@ -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();

View File

@ -19,6 +19,7 @@ use Drupal\Core\DependencyInjection\ServiceProviderInterface;
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 @@ class DrupalKernel implements DrupalKernelInterface, TerminableInterface {
$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.

View File

@ -11,6 +11,8 @@ use Drupal\Component\Datetime\DateTimePlus;
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 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
$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);
}

View File

@ -0,0 +1,65 @@
<?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;
}
}

View File

@ -0,0 +1,25 @@
<?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);
}

View File

@ -0,0 +1,56 @@
<?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;
}
}

View File

@ -0,0 +1,24 @@
<?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);
}

View File

@ -0,0 +1,27 @@
<?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());
}
}

View File

@ -0,0 +1,38 @@
<?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';
}
}

View File

@ -0,0 +1,49 @@
<?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;
}
}
}

View File

@ -0,0 +1,54 @@
<?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);
}

View File

@ -0,0 +1,42 @@
<?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;
}
}

View File

@ -0,0 +1,42 @@
<?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);
}

View File

@ -130,9 +130,6 @@ class SessionManager extends NativeSessionStorage implements SessionManagerInter
// 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)) {

View File

@ -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 }

View File

@ -155,7 +155,6 @@ class ImageStyleDownloadController extends FileDownloadController {
}
if ($success) {
drupal_page_is_cacheable(FALSE);
$image = $this->imageFactory->get($derivative_uri);
$uri = $image->getSource();
$headers += array(

View File

@ -0,0 +1,49 @@
<?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;
}
}
}

View File

@ -0,0 +1,90 @@
<?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.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* A response object.
*
* @var \Symfony\Component\HttpFoundation\Response
*/
protected $response;
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatch|\PHPUnit_Framework_MockObject_MockObject
*/
protected $routeMatch;
public function setUp() {
$this->routeMatch = $this->getMock('Drupal\Core\Routing\RouteMatchInterface');
$this->policy = new DenyPrivateImageStyleDownload($this->routeMatch);
$this->response = new Response();
$this->request = new Request();
}
/**
* Asserts that caching is denied on the private image style download route.
*
* @dataProvider providerPrivateImageStyleDownloadPolicy
* @covers ::check
*/
public function testPrivateImageStyleDownloadPolicy($expected_result, $route_name) {
$this->routeMatch->expects($this->once())
->method('getRouteName')
->will($this->returnValue($route_name));
$actual_result = $this->policy->check($this->response, $this->request);
$this->assertSame($expected_result, $actual_result);
}
/**
* Provides data and expected results for the test method.
*
* @return array
* Data and expected results.
*/
public function providerPrivateImageStyleDownloadPolicy() {
return [
[ResponsePolicyInterface::DENY, 'image.style_private'],
[NULL, 'some.other.route'],
[NULL, NULL],
[NULL, FALSE],
[NULL, TRUE],
[NULL, new \StdClass()],
[NULL, [1, 2, 3]],
];
}
}

View File

@ -34,3 +34,8 @@ services:
arguments: ['@user.tempstore']
tags:
- { name: paramconverter }
node.page_cache_request_policy.deny_node_preview:
class: Drupal\node\PageCache\DenyNodePreview
arguments: ['@current_route_match']
tags:
- { name: page_cache_response_policy }

View File

@ -20,9 +20,6 @@ class NodePreviewController extends EntityViewController {
* {@inheritdoc}
*/
public function view(EntityInterface $node_preview, $view_mode_id = 'full', $langcode = NULL) {
// Do not cache this page.
drupal_page_is_cacheable(FALSE);
$node_preview->preview_view_mode = $view_mode_id;
$build = array('nodes' => parent::view($node_preview, $view_mode_id));

View File

@ -0,0 +1,49 @@
<?php
/**
* @file
* Contains \Drupal\node\PageCache\DenyNodePreview.
*/
namespace Drupal\node\PageCache;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Cache policy for node preview page.
*
* This policy rule denies caching of responses generated by the
* entity.node.preview route.
*/
class DenyNodePreview implements ResponsePolicyInterface {
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* Constructs a deny node 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() === 'entity.node.preview') {
return static::DENY;
}
}
}

View File

@ -0,0 +1,90 @@
<?php
/**
* @file
* Contains \Drupal\Tests\node\Unit\PageCache\DenyNodePreviewTest.
*/
namespace Drupal\Tests\node\Unit\PageCache;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\node\PageCache\DenyNodePreview;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @coversDefaultClass \Drupal\node\PageCache\DenyNodePreview
* @group node
*/
class DenyNodePreviewTest extends UnitTestCase {
/**
* The response policy under test.
*
* @var \Drupal\node\PageCache\DenyNodePreview
*/
protected $policy;
/**
* A request object.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* A response object.
*
* @var \Symfony\Component\HttpFoundation\Response
*/
protected $response;
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatch|\PHPUnit_Framework_MockObject_MockObject
*/
protected $routeMatch;
public function setUp() {
$this->routeMatch = $this->getMock('Drupal\Core\Routing\RouteMatchInterface');
$this->policy = new DenyNodePreview($this->routeMatch);
$this->response = new Response();
$this->request = new Request();
}
/**
* Asserts that caching is denied on the node preview route.
*
* @dataProvider providerPrivateImageStyleDownloadPolicy
* @covers ::check
*/
public function testPrivateImageStyleDownloadPolicy($expected_result, $route_name) {
$this->routeMatch->expects($this->once())
->method('getRouteName')
->will($this->returnValue($route_name));
$actual_result = $this->policy->check($this->response, $this->request);
$this->assertSame($expected_result, $actual_result);
}
/**
* Provides data and expected results for the test method.
*
* @return array
* Data and expected results.
*/
public function providerPrivateImageStyleDownloadPolicy() {
return [
[ResponsePolicyInterface::DENY, 'entity.node.preview'],
[NULL, 'some.other.route'],
[NULL, NULL],
[NULL, FALSE],
[NULL, TRUE],
[NULL, new \StdClass()],
[NULL, [1, 2, 3]],
];
}
}

View File

@ -22,10 +22,22 @@ class ToolbarController extends ControllerBase {
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function subtreesJsonp() {
_toolbar_initialize_page_cache();
$subtrees = toolbar_get_rendered_subtrees();
$response = new JsonResponse($subtrees);
$response->setCallback('Drupal.toolbar.setSubtrees.resolve');
// The Expires HTTP header is the heart of the client-side HTTP caching. The
// additional server-side page cache only takes effect when the client
// accesses the callback URL again (e.g., after clearing the browser cache
// or when force-reloading a Drupal page).
$max_age = 365 * 24 * 60 * 60;
$response->setPrivate();
$response->setMaxAge($max_age);
$expires = new \DateTime();
$expires->setTimestamp(REQUEST_TIME + $max_age);
$response->setExpires($expires);
return $response;
}

View File

@ -0,0 +1,32 @@
<?php
/**
* @file
* Contains \Drupal\toolbar\PageCache\AllowToolbarPath.
*/
namespace Drupal\toolbar\PageCache;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Cache policy for the toolbar page cache service.
*
* This policy allows caching of requests directed to /toolbar/subtrees/{hash}
* even for authenticated users.
*/
class AllowToolbarPath implements RequestPolicyInterface {
/**
* {@inheritdoc}
*/
public function check(Request $request) {
// Note that this regular expression matches the end of pathinfo in order to
// support multilingual sites using path prefixes.
if (preg_match('#/toolbar/subtrees/[^/]+(/[^/]+)?$#', $request->getPathInfo())) {
return static::ALLOW;
}
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* @file
* Contains \Drupal\Tests\toolbar\Unit\PageCache\AllowToolbarPathTest.
*/
namespace Drupal\Tests\toolbar\Unit\PageCache;
use Drupal\toolbar\PageCache\AllowToolbarPath;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @coversDefaultClass \Drupal\toolbar\PageCache\AllowToolbarPath
* @group toolbar
*/
class AllowToolbarPathTest extends UnitTestCase {
/**
* The toolbar path policy under test.
*
* @var \Drupal\toolbar\PageCache\AllowToolbarPath
*/
protected $policy;
public function setUp() {
$this->policy = new AllowToolbarPath();
}
/**
* Asserts that caching is allowed if the request goes to toolbar subtree.
*
* @dataProvider providerTestAllowToolbarPath
* @covers ::check
*/
public function testAllowToolbarPath($expected_result, $path) {
$request = Request::create($path);
$result = $this->policy->check($request);
$this->assertSame($expected_result, $result);
}
/**
* Provides data and expected results for the test method.
*
* @return array
* Data and expected results.
*/
public function providerTestAllowToolbarPath() {
return [
[NULL, '/'],
[NULL, '/other-path?q=/toolbar/subtrees/'],
[NULL, '/toolbar/subtrees/'],
[NULL, '/toolbar/subtrees/some-hash/langcode/additional-stuff'],
[RequestPolicyInterface::ALLOW, '/de/toolbar/subtrees/abcd'],
[RequestPolicyInterface::ALLOW, '/en-us/toolbar/subtrees/xyz'],
[RequestPolicyInterface::ALLOW, '/en-us/toolbar/subtrees/xyz/de'],
[RequestPolicyInterface::ALLOW, '/a/b/c/toolbar/subtrees/xyz/de'],
[RequestPolicyInterface::ALLOW, '/toolbar/subtrees/some-hash'],
[RequestPolicyInterface::ALLOW, '/toolbar/subtrees/some-hash/en'],
];
}
}

View File

@ -95,43 +95,6 @@ function toolbar_element_info() {
return $elements;
}
/**
* Use Drupal's page cache for toolbar/subtrees/*, even for authenticated users.
*
* This gets invoked after full bootstrap, so must duplicate some of what's
* done by \Drupal\Core\DrupalKernel::handlePageCache().
*
* @todo Replace this hack with something better integrated with DrupalKernel
* once Drupal's page caching itself is properly integrated.
*/
function _toolbar_initialize_page_cache() {
$GLOBALS['conf']['system.performance']['cache']['page']['enabled'] = TRUE;
drupal_page_is_cacheable(TRUE);
// If we have a cache, serve it.
// @see \Drupal\Core\DrupalKernel::handlePageCache()
$request = \Drupal::request();
$response = drupal_page_get_cache($request);
if ($response) {
$response->headers->set('X-Drupal-Cache', 'HIT');
drupal_serve_page_from_cache($response, $request);
$response->prepare($request);
$response->send();
// We are done.
exit;
}
// The Expires HTTP header is the heart of the client-side HTTP caching. The
// additional server-side page cache only takes effect when the client
// accesses the callback URL again (e.g., after clearing the browser cache or
// when force-reloading a Drupal page).
$max_age = 3600 * 24 * 365;
drupal_add_http_header('Expires', gmdate(DateTimePlus::RFC7231, REQUEST_TIME + $max_age));
drupal_add_http_header('Cache-Control', 'private, max-age=' . $max_age);
}
/**
* Implements hook_page_build().
*

View File

@ -6,3 +6,7 @@ services:
factory_method: get
factory_service: cache_factory
arguments: [toolbar]
toolbar.page_cache_request_policy.allow_toolbar_path:
class: Drupal\toolbar\PageCache\AllowToolbarPath
tags:
- { name: page_cache_request_policy }

View File

@ -0,0 +1,165 @@
<?php
/**
* @file
* Contains \Drupal\Tests\Core\PageCache\ChainRequestPolicyTest.
*/
namespace Drupal\Tests\Core\PageCache;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ChainRequestPolicy;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @coversDefaultClass \Drupal\Core\PageCache\ChainRequestPolicy
* @group PageCache
*/
class ChainRequestPolicyTest extends UnitTestCase {
/**
* The chain request policy under test.
*
* @var \Drupal\Core\PageCache\ChainRequestPolicy
*/
protected $policy;
/**
* A request object.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
public function setUp() {
$this->policy = new ChainRequestPolicy();
$this->request = new Request();
}
/**
* Asserts that check() returns NULL if the chain is empty.
*
* @covers ::check
*/
public function testEmptyChain() {
$result = $this->policy->check($this->request);
$this->assertSame(NULL, $result);
}
/**
* Asserts that check() returns NULL if a rule returns NULL.
*
* @covers ::check
*/
public function testNullRuleChain() {
$rule = $this->getMock('Drupal\Core\PageCache\RequestPolicyInterface');
$rule->expects($this->once())
->method('check')
->with($this->request)
->will($this->returnValue(NULL));
$this->policy->addPolicy($rule);
$result = $this->policy->check($this->request);
$this->assertSame(NULL, $result);
}
/**
* Asserts that check() throws an exception if a rule returns an invalid value.
*
* @expectedException \UnexpectedValueException
* @dataProvider providerChainExceptionOnInvalidReturnValue
* @covers ::check
*/
public function testChainExceptionOnInvalidReturnValue($return_value) {
$rule = $this->getMock('Drupal\Core\PageCache\RequestPolicyInterface');
$rule->expects($this->once())
->method('check')
->with($this->request)
->will($this->returnValue($return_value));
$this->policy->addPolicy($rule);
$this->policy->check($this->request);
}
/**
* Provides test data for testChainExceptionOnInvalidReturnValue.
*
* @return array
* Test input and expected result.
*/
public function providerChainExceptionOnInvalidReturnValue() {
return [
[FALSE],
[0],
[1],
[TRUE],
[[1, 2, 3]],
[new \stdClass()],
];
}
/**
* Asserts that check() returns ALLOW if any of the rules returns ALLOW.
*
* @dataProvider providerAllowIfAnyRuleReturnedAllow
* @covers ::check
*/
public function testAllowIfAnyRuleReturnedAllow($return_values) {
foreach ($return_values as $return_value) {
$rule = $this->getMock('Drupal\Core\PageCache\RequestPolicyInterface');
$rule->expects($this->once())
->method('check')
->with($this->request)
->will($this->returnValue($return_value));
$this->policy->addPolicy($rule);
}
$actual_result = $this->policy->check($this->request);
$this->assertSame(RequestPolicyInterface::ALLOW, $actual_result);
}
/**
* Provides test data for testAllowIfAnyRuleReturnedAllow.
*
* @return array
* Test input and expected result.
*/
public function providerAllowIfAnyRuleReturnedAllow() {
return [
[[RequestPolicyInterface::ALLOW]],
[[NULL, RequestPolicyInterface::ALLOW]],
];
}
/**
* Asserts that check() returns immediately when a rule returned DENY.
*/
public function testStopChainOnFirstDeny() {
$rule1 = $this->getMock('Drupal\Core\PageCache\RequestPolicyInterface');
$rule1->expects($this->once())
->method('check')
->with($this->request)
->will($this->returnValue(RequestPolicyInterface::ALLOW));
$this->policy->addPolicy($rule1);
$deny_rule = $this->getMock('Drupal\Core\PageCache\RequestPolicyInterface');
$deny_rule->expects($this->once())
->method('check')
->with($this->request)
->will($this->returnValue(RequestPolicyInterface::DENY));
$this->policy->addPolicy($deny_rule);
$ignored_rule = $this->getMock('Drupal\Core\PageCache\RequestPolicyInterface');
$ignored_rule->expects($this->never())
->method('check');
$this->policy->addPolicy($ignored_rule);
$actual_result = $this->policy->check($this->request);
$this->assertsame(RequestPolicyInterface::DENY, $actual_result);
}
}

View File

@ -0,0 +1,140 @@
<?php
/**
* @file
* Contains \Drupal\Tests\Core\PageCache\ChainResponsePolicyTest.
*/
namespace Drupal\Tests\Core\PageCache;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Core\PageCache\ChainResponsePolicy;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @coversDefaultClass \Drupal\Core\PageCache\ChainResponsePolicy
* @group PageCache
*/
class ChainResponsePolicyTest extends UnitTestCase {
/**
* The chain response policy under test.
*
* @var \Drupal\Core\PageCache\ChainResponsePolicy
*/
protected $policy;
/**
* A request object.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $request;
/**
* A response object.
*
* @var \Symfony\Component\HttpFoundation\Response
*/
protected $response;
public function setUp() {
$this->policy = new ChainResponsePolicy();
$this->response = new Response();
$this->request = new Request();
}
/**
* Asserts that check() returns NULL if the chain is empty.
*
* @covers ::check
*/
public function testEmptyChain() {
$result = $this->policy->check($this->response, $this->request);
$this->assertSame(NULL, $result);
}
/**
* Asserts that check() returns NULL if a rule returns NULL.
*
* @covers ::check
*/
public function testNullRuleChain() {
$rule = $this->getMock('Drupal\Core\PageCache\ResponsePolicyInterface');
$rule->expects($this->once())
->method('check')
->with($this->response, $this->request)
->will($this->returnValue(NULL));
$this->policy->addPolicy($rule);
$result = $this->policy->check($this->response, $this->request);
$this->assertSame(NULL, $result);
}
/**
* Asserts that check() throws an exception if a rule returns an invalid value.
*
* @expectedException \UnexpectedValueException
* @dataProvider providerChainExceptionOnInvalidReturnValue
* @covers ::check
*/
public function testChainExceptionOnInvalidReturnValue($return_value) {
$rule = $this->getMock('Drupal\Core\PageCache\ResponsePolicyInterface');
$rule->expects($this->once())
->method('check')
->with($this->response, $this->request)
->will($this->returnValue($return_value));
$this->policy->addPolicy($rule);
$actual_result = $this->policy->check($this->response, $this->request);
$this->assertSame(NULL, $actual_result);
}
/**
* Provides test data for testChainExceptionOnInvalidReturnValue.
*
* @return array
* Test input and expected result.
*/
public function providerChainExceptionOnInvalidReturnValue() {
return [
[FALSE],
[0],
[1],
[TRUE],
[[1, 2, 3]],
[new \stdClass()],
];
}
/**
* Asserts that check() returns immediately when a rule returned DENY.
*/
public function testStopChainOnFirstDeny() {
$rule1 = $this->getMock('Drupal\Core\PageCache\ResponsePolicyInterface');
$rule1->expects($this->once())
->method('check')
->with($this->response, $this->request);
$this->policy->addPolicy($rule1);
$deny_rule = $this->getMock('Drupal\Core\PageCache\ResponsePolicyInterface');
$deny_rule->expects($this->once())
->method('check')
->with($this->response, $this->request)
->will($this->returnValue(ResponsePolicyInterface::DENY));
$this->policy->addPolicy($deny_rule);
$ignored_rule = $this->getMock('Drupal\Core\PageCache\ResponsePolicyInterface');
$ignored_rule->expects($this->never())
->method('check');
$this->policy->addPolicy($ignored_rule);
$actual_result = $this->policy->check($this->response, $this->request);
$this->assertsame(ResponsePolicyInterface::DENY, $actual_result);
}
}

View File

@ -0,0 +1,83 @@
<?php
/**
* @file
* Contains \Drupal\Tests\Core\PageCache\CommandLineOrUnsafeMethodTest.
*/
namespace Drupal\Tests\Core\PageCache;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @coversDefaultClass \Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod
* @group PageCache
*/
class CommandLineOrUnsafeMethodTest extends UnitTestCase {
/**
* The request policy under test.
*
* @var \Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod|\PHPUnit_Framework_MockObject_MockObject
*/
protected $policy;
public function setUp() {
// Note that it is necessary to partially mock the class under test in
// order to disable the isCli-check.
$this->policy = $this->getMock('Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod', array('isCli'));
}
/**
* Asserts that check() returns DENY for unsafe HTTP methods.
*
* @dataProvider providerTestHttpMethod
* @covers ::check
*/
public function testHttpMethod($expected_result, $method) {
$this->policy->expects($this->once())
->method('isCli')
->will($this->returnValue(FALSE));
$request = Request::create('/', $method);
$actual_result = $this->policy->check($request);
$this->assertSame($expected_result, $actual_result);
}
/**
* Provides test data and expected results for the HTTP method test.
*
* @return array
* Test data and expected results.
*/
public function providerTestHttpMethod() {
return [
[NULL, 'GET'],
[NULL, 'HEAD'],
[RequestPolicyInterface::DENY, 'POST'],
[RequestPolicyInterface::DENY, 'PUT'],
[RequestPolicyInterface::DENY, 'DELETE'],
[RequestPolicyInterface::DENY, 'OPTIONS'],
[RequestPolicyInterface::DENY, 'TRACE'],
[RequestPolicyInterface::DENY, 'CONNECT'],
];
}
/**
* Asserts that check() returns DENY if running from the command line.
*
* @covers ::check
*/
public function testIsCli() {
$this->policy->expects($this->once())
->method('isCli')
->will($this->returnValue(TRUE));
$request = Request::create('/', 'GET');
$actual_result = $this->policy->check($request);
$this->assertSame(RequestPolicyInterface::DENY, $actual_result);
}
}

View File

@ -0,0 +1,54 @@
<?php
/**
* @file
* Contains \Drupal\Tests\Core\PageCache\NoSessionOpenTest.
*/
namespace Drupal\Tests\Core\PageCache;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\RequestPolicy;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request;
/**
* @coversDefaultClass \Drupal\Core\PageCache\RequestPolicy\NoSessionOpen
* @group PageCache
*/
class NoSessionOpenTest extends UnitTestCase {
/**
* The session cookie name.
*
* @var string
*/
protected $sessionCookieName;
/**
* The request policy under test.
*
* @var \Drupal\Core\PageCache\RequestPolicy\NoSessionOpen
*/
protected $policy;
public function setUp() {
$this->sessionCookieName = 'B1ESkdf3V4F8u27myaSAShuuHc';
$this->policy = new RequestPolicy\NoSessionOpen($this->sessionCookieName);
}
/**
* Asserts that caching is allowed unless there is a session cookie present.
*
* @covers ::check
*/
public function testNoAllowUnlessSessionCookiePresent() {
$request = new Request();
$result = $this->policy->check($request);
$this->assertSame(RequestPolicyInterface::ALLOW, $result);
$request = Request::create('/', 'GET', [], [$this->sessionCookieName => 'some-session-id']);
$result = $this->policy->check($request);
$this->assertSame(NULL, $result);
}
}