NEW Add better HTTP cache-control manipulation (#8086)

This commit is contained in:
Daniel Hensby 2018-06-08 00:56:31 +01:00 committed by Damian Mooyman
parent 1d0cffd0e4
commit 2b4954035f
20 changed files with 1299 additions and 164 deletions

View File

@ -13,13 +13,19 @@ MySQLDatabase:
collation: utf8_general_ci collation: utf8_general_ci
HTTP: HTTP:
cache_control: cache_control:
max-age: 0 no-cache: "true"
no-store: "true"
must-revalidate: "true" must-revalidate: "true"
no-transform: "true" vary: "X-Requested-With, X-Forwarded-Protocol"
vary: "Cookie, X-Forwarded-Protocol, User-Agent, Accept"
LeftAndMain: LeftAndMain:
dependencies: dependencies:
versionProvider: %$SilverStripeVersionProvider versionProvider: %$SilverStripeVersionProvider
SilverStripeVersionProvider: SilverStripeVersionProvider:
modules: modules:
silverstripe/framework: Framework silverstripe/framework: Framework
---
Only:
environment: dev
---
HTTP:
disable_http_cache: true

View File

@ -204,10 +204,7 @@ class RSSFeed extends ViewableData {
HTTP::register_etag($this->etag); HTTP::register_etag($this->etag);
} }
if(!headers_sent()) { $response->addHeader("Content-Type", "application/rss+xml; charset=utf-8");
HTTP::add_cache_headers();
$response->addHeader("Content-Type", "application/rss+xml; charset=utf-8");
}
Config::inst()->update('SSViewer', 'source_file_comments', $prevState); Config::inst()->update('SSViewer', 'source_file_comments', $prevState);

View File

@ -169,10 +169,7 @@ class Controller extends RequestHandler implements TemplateGlobalProvider {
$response->setBody($body); $response->setBody($body);
} }
ContentNegotiator::process($response); ContentNegotiator::process($response);
HTTP::add_cache_headers($response);
$this->popCurrent(); $this->popCurrent();
return $response; return $response;
} }
@ -502,7 +499,7 @@ class Controller extends RequestHandler implements TemplateGlobalProvider {
*/ */
public function redirectBack() { public function redirectBack() {
// Don't cache the redirect back ever // Don't cache the redirect back ever
HTTP::set_cache_age(0); HTTPCacheControl::singleton()->disableCache(true);
$url = null; $url = null;

View File

@ -386,7 +386,14 @@ class Director implements TemplateGlobalProvider {
} catch(SS_HTTPResponse_Exception $responseException) { } catch(SS_HTTPResponse_Exception $responseException) {
$result = $responseException->getResponse(); $result = $responseException->getResponse();
} }
if(!is_object($result) || $result instanceof SS_HTTPResponse) return $result; // Ensure cache headers are added
if ($result instanceof SS_HTTPResponse) {
HTTP::add_cache_headers($result);
return $result;
}
if(!is_object($result)) {
return $result;
}
user_error("Bad result from url " . $request->getURL() . " handled by " . user_error("Bad result from url " . $request->getURL() . " handled by " .
get_class($controllerObj)." controller: ".get_class($result), E_USER_WARNING); get_class($controllerObj)." controller: ".get_class($result), E_USER_WARNING);

View File

@ -18,6 +18,9 @@ class FlushRequestFilter implements RequestFilter {
} }
public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) { public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) {
if(array_key_exists('flush', $request->getVars())) {
HTTPCacheControl::singleton()->disableCache(true);
}
return true; return true;
} }

View File

@ -6,6 +6,7 @@
* *
* @package framework * @package framework
* @subpackage misc * @subpackage misc
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/
*/ */
class HTTP { class HTTP {
@ -15,7 +16,7 @@ class HTTP {
protected static $cache_age = 0; protected static $cache_age = 0;
/** /**
* @var timestamp $modification_date * @var int $modification_date
*/ */
protected static $modification_date = null; protected static $modification_date = null;
@ -29,15 +30,48 @@ class HTTP {
*/ */
private static $cache_ajax_requests = true; private static $cache_ajax_requests = true;
/**
* @config
* @var bool
*/
private static $disable_http_cache = false;
/**
* Mapping of extension to mime types
*
* @var array
* @config
*/
private static $MimeTypes = array();
/**
* List of names to add to the Cache-Control header.
*
* @see HTTPCacheControl::__construct()
* @config
* @var array Keys are cache control names, values are boolean flags
*/
private static $cache_control = array();
/**
* Vary string; A comma separated list of var header names
*
* @config
* @var string|null
*/
private static $vary = null;
/** /**
* Turns a local system filename into a URL by comparing it to the script * Turns a local system filename into a URL by comparing it to the script
* filename. * filename.
* *
* @param string * @param string
* @return string
*/ */
public static function filename2url($filename) { public static function filename2url($filename) {
$slashPos = -1; $slashPos = -1;
$commonLength = null;
while(($slashPos = strpos($filename, "/", $slashPos+1)) !== false) { while(($slashPos = strpos($filename, "/", $slashPos+1)) !== false) {
if(substr($filename, 0, $slashPos) == substr($_SERVER['SCRIPT_FILENAME'],0,$slashPos)) { if(substr($filename, 0, $slashPos) == substr($_SERVER['SCRIPT_FILENAME'],0,$slashPos)) {
$commonLength = $slashPos; $commonLength = $slashPos;
@ -63,6 +97,9 @@ class HTTP {
/** /**
* Turn all relative URLs in the content to absolute URLs * Turn all relative URLs in the content to absolute URLs
*
* @param string $html
* @return string
*/ */
public static function absoluteURLs($html) { public static function absoluteURLs($html) {
$html = str_replace('$CurrentPageURL', $_SERVER['REQUEST_URI'], $html); $html = str_replace('$CurrentPageURL', $_SERVER['REQUEST_URI'], $html);
@ -99,7 +136,7 @@ class HTTP {
* @param string|callable $code Either a string that can evaluate to an expression * @param string|callable $code Either a string that can evaluate to an expression
* to rewrite links (depreciated), or a callable that takes a single * to rewrite links (depreciated), or a callable that takes a single
* parameter and returns the rewritten URL * parameter and returns the rewritten URL
* @return The content with all links rewritten as per the logic specified in $code * @return string The content with all links rewritten as per the logic specified in $code
*/ */
public static function urlRewriter($content, $code) { public static function urlRewriter($content, $code) {
if(!is_callable($code)) { if(!is_callable($code)) {
@ -107,6 +144,7 @@ class HTTP {
} }
// Replace attributes // Replace attributes
$regExps = array();
$attribs = array("src","background","a" => "href","link" => "href", "base" => "href"); $attribs = array("src","background","a" => "href","link" => "href", "base" => "href");
foreach($attribs as $tag => $attrib) { foreach($attribs as $tag => $attrib) {
if(!is_numeric($tag)) $tagPrefix = "$tag "; if(!is_numeric($tag)) $tagPrefix = "$tag ";
@ -132,6 +170,7 @@ class HTTP {
} else { } else {
// Expose the $URL variable to be used by the $code expression // Expose the $URL variable to be used by the $code expression
$URL = $matches[2]; $URL = $matches[2];
array($URL); // Ensure $URL is available to scope of below code
$rewritten = eval("return ($code);"); $rewritten = eval("return ($code);");
} }
return $matches[1] . $rewritten . $matches[3]; return $matches[1] . $rewritten . $matches[3];
@ -285,9 +324,12 @@ class HTTP {
/** /**
* Set the maximum age of this page in web caches, in seconds * Set the maximum age of this page in web caches, in seconds
*
* @param int $age
*/ */
public static function set_cache_age($age) { public static function set_cache_age($age) {
self::$cache_age = $age; self::$cache_age = $age;
HTTPCacheControl::singleton()->setMaxAge($age);
} }
public static function register_modification_date($dateString) { public static function register_modification_date($dateString) {
@ -318,139 +360,129 @@ class HTTP {
* deprecated; in these cases, the headers are output directly. * deprecated; in these cases, the headers are output directly.
*/ */
public static function add_cache_headers($body = null) { public static function add_cache_headers($body = null) {
$cacheAge = self::$cache_age;
// Validate argument // Validate argument
if($body && !($body instanceof SS_HTTPResponse)) { if($body && !($body instanceof SS_HTTPResponse)) {
user_error("HTTP::add_cache_headers() must be passed an SS_HTTPResponse object", E_USER_WARNING); user_error("HTTP::add_cache_headers() must be passed an SS_HTTPResponse object", E_USER_WARNING);
$body = null; $body = null;
} }
// Development sites have frequently changing templates; this can get stuffed up by the code
// below.
if(Director::isDev()) $cacheAge = 0;
// The headers have been sent and we don't have an SS_HTTPResponse object to attach things to; no point in // The headers have been sent and we don't have an SS_HTTPResponse object to attach things to; no point in
// us trying. // us trying.
if(headers_sent() && !$body) return; if(headers_sent() && !$body) {
return;
}
// Warn if already assigned cache-control headers
if ($body && $body->getHeader('Cache-Control')) {
trigger_error(
'Cache-Control header has already been set. '
. 'Please use HTTPCacheControl API to set caching options instead.',
E_USER_WARNING
);
return;
}
$config = Config::inst()->forClass(__CLASS__);
// Get current cache control state
$cacheControl = HTTPCacheControl::singleton();
// if http caching is disabled by config, disable it - used on dev environments due to frequently changing
// templates and other data. will be overridden by forced publicCache() or privateCache() calls
if ($config->get('disable_http_cache')) {
$cacheControl->disableCache();
}
// Populate $responseHeaders with all the headers that we want to build // Populate $responseHeaders with all the headers that we want to build
$responseHeaders = array(); $responseHeaders = array();
$config = Config::inst(); // if no caching ajax requests, disable ajax if is ajax request
$cacheControlHeaders = Config::inst()->get('HTTP', 'cache_control'); if (!$config->get('cache_ajax_requests') && Director::is_ajax()) {
$cacheControl->disableCache();
// currently using a config setting to cancel this, seems to be so that the CMS caches ajax requests
if(function_exists('apache_request_headers') && $config->get(get_called_class(), 'cache_ajax_requests')) {
$requestHeaders = array_change_key_case(apache_request_headers(), CASE_LOWER);
if(isset($requestHeaders['x-requested-with']) && $requestHeaders['x-requested-with']=='XMLHttpRequest') {
$cacheAge = 0;
}
} }
if($cacheAge > 0) { // Errors disable cache (unless some errors are cached intentionally by usercode)
$cacheControlHeaders['max-age'] = self::$cache_age; if ($body && $body->isError()) {
// Even if publicCache(true) is specfied, errors will be uncachable
// Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information, $cacheControl->disableCache(true);
// defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter).
// Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should
// prefer the caching information indicated through the "Cache-Control" header.
$responseHeaders["Pragma"] = "";
// To do: User-Agent should only be added in situations where you *are* actually
// varying according to user-agent.
$vary = $config->get('HTTP', 'vary');
if ($vary && strlen($vary)) {
$responseHeaders['Vary'] = $vary;
}
}
else {
if($body) {
// Grab header for checking. Unfortunately HTTPRequest uses a mistyped variant.
$contentDisposition = $body->getHeader('Content-disposition');
if (!$contentDisposition) $contentDisposition = $body->getHeader('Content-Disposition');
}
if(
$body &&
Director::is_https() &&
isset($_SERVER['HTTP_USER_AGENT']) &&
strstr($_SERVER['HTTP_USER_AGENT'], 'MSIE')==true &&
strstr($contentDisposition, 'attachment;')==true
) {
// IE6-IE8 have problems saving files when https and no-cache are used
// (http://support.microsoft.com/kb/323308)
// Note: this is also fixable by ticking "Do not save encrypted pages to disk" in advanced options.
$cacheControlHeaders['max-age'] = 3;
// Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information,
// defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter).
// Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should
// prefer the caching information indicated through the "Cache-Control" header.
$responseHeaders["Pragma"] = "";
} else {
$cacheControlHeaders['no-cache'] = "true";
$cacheControlHeaders['no-store'] = "true";
}
} }
foreach($cacheControlHeaders as $header => $value) { // If sessions exist we assume that the responses should not be cached by CDNs / proxies as we are
if(is_null($value)) { // likely to be supplying information relevant to the current user only
unset($cacheControlHeaders[$header]); if (Session::get_all()) {
} elseif((is_bool($value) && $value) || $value === "true") { // Don't force in case user code chooses to opt in to public caching
$cacheControlHeaders[$header] = $header; $cacheControl->privateCache();
} else {
$cacheControlHeaders[$header] = $header."=".$value;
}
} }
$responseHeaders['Cache-Control'] = implode(', ', $cacheControlHeaders); // split the current vary header into it's parts and merge it with the config settings
unset($cacheControlHeaders, $header, $value); // to create a list of unique vary values
$configVary = $config->get('vary');
$bodyVary = $body ? $body->getHeader('Vary') : '';
$vary = self::combineVary($configVary, $bodyVary);
if ($vary) {
$responseHeaders['Vary'] = $vary;
}
if(self::$modification_date && $cacheAge > 0) { // deal with IE6-IE8 problems with https and no-cache
$contentDisposition = null;
if($body) {
// Grab header for checking. Unfortunately HTTPRequest uses a mistyped variant.
$contentDisposition = $body->getHeader('Content-Disposition', true);
}
if(
$body &&
Director::is_https() &&
isset($_SERVER['HTTP_USER_AGENT']) &&
strstr($_SERVER['HTTP_USER_AGENT'], 'MSIE') == true &&
strstr($contentDisposition, 'attachment;') == true &&
($cacheControl->hasDirective('no-cache') || $cacheControl->hasDirective('no-store'))
) {
// IE6-IE8 have problems saving files when https and no-cache/no-store are used
// (http://support.microsoft.com/kb/323308)
// Note: this is also fixable by ticking "Do not save encrypted pages to disk" in advanced options.
$cacheControl->privateCache(true);
}
if (self::$modification_date) {
$responseHeaders["Last-Modified"] = self::gmt_date(self::$modification_date); $responseHeaders["Last-Modified"] = self::gmt_date(self::$modification_date);
}
// if we can store the cache responses we should generate and send etags
if (!$cacheControl->hasDirective('no-store')) {
// Chrome ignores Varies when redirecting back (http://code.google.com/p/chromium/issues/detail?id=79758) // Chrome ignores Varies when redirecting back (http://code.google.com/p/chromium/issues/detail?id=79758)
// which means that if you log out, you get redirected back to a page which Chrome then checks against // which means that if you log out, you get redirected back to a page which Chrome then checks against
// last-modified (which passes, getting a 304) // last-modified (which passes, getting a 304)
// when it shouldn't be trying to use that page at all because it's the "logged in" version. // when it shouldn't be trying to use that page at all because it's the "logged in" version.
// By also using and etag that includes both the modification date and all the varies // By also using and etag that includes both the modification date and all the varies
// values which we also check against we can catch this and not return a 304 // values which we also check against we can catch this and not return a 304
$etagParts = array(self::$modification_date, serialize($_COOKIE)); $etag = self::generateETag($body);
$etagParts[] = Director::is_https() ? 'https' : 'http'; if ($etag) {
if (isset($_SERVER['HTTP_USER_AGENT'])) $etagParts[] = $_SERVER['HTTP_USER_AGENT']; $responseHeaders['ETag'] = $etag;
if (isset($_SERVER['HTTP_ACCEPT'])) $etagParts[] = $_SERVER['HTTP_ACCEPT'];
$etag = sha1(implode(':', $etagParts)); // 304 response detection
$responseHeaders["ETag"] = $etag; if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
// As above, only 304 if the last request had all the same varies values
// (or the etag isn't passed as part of the request - but with chrome it always is)
$matchesEtag = $_SERVER['HTTP_IF_NONE_MATCH'] == $etag;
// 304 response detection if ($matchesEtag) {
if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { if ($body) {
$ifModifiedSince = strtotime(stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE'])); $body->setStatusCode(304);
$body->setBody('');
// As above, only 304 if the last request had all the same varies values } else {
// (or the etag isn't passed as part of the request - but with chrome it always is) // this is wrong, we need to send the same vary headers and so on
$matchesEtag = !isset($_SERVER['HTTP_IF_NONE_MATCH']) || $_SERVER['HTTP_IF_NONE_MATCH'] == $etag; header('HTTP/1.0 304 Not Modified');
die();
if($ifModifiedSince >= self::$modification_date && $matchesEtag) { }
if($body) {
$body->setStatusCode(304);
$body->setBody('');
} else {
header('HTTP/1.0 304 Not Modified');
die();
} }
} }
} }
$expires = time() + $cacheAge;
$responseHeaders["Expires"] = self::gmt_date($expires);
} }
if(self::$etag) { if ($cacheControl->hasDirective('max-age')) {
$responseHeaders['ETag'] = self::$etag; $expires = time() + $cacheControl->getDirective('max-age');
$responseHeaders["Expires"] = self::gmt_date($expires);
} }
// etag needs to be a quoted string according to HTTP spec // etag needs to be a quoted string according to HTTP spec
@ -458,6 +490,9 @@ class HTTP {
$responseHeaders['ETag'] = sprintf('"%s"', $responseHeaders['ETag']); $responseHeaders['ETag'] = sprintf('"%s"', $responseHeaders['ETag']);
} }
// Merge with cache control headers
$responseHeaders = array_merge($responseHeaders, $cacheControl->generateHeaders());
// Now that we've generated them, either output them or attach them to the SS_HTTPResponse as appropriate // Now that we've generated them, either output them or attach them to the SS_HTTPResponse as appropriate
foreach($responseHeaders as $k => $v) { foreach($responseHeaders as $k => $v) {
if($body) { if($body) {
@ -471,23 +506,61 @@ class HTTP {
} }
} }
/**
* @param SS_HTTPResponse|string $response
*
* @return string|false
*/
protected static function generateETag($response)
{
if (self::$etag) {
return self::$etag;
}
if ($response instanceof SS_HTTPResponse) {
return $response->getHeader('ETag') ?: sprintf('"%s"', md5($response->getBody()));
}
if ($response) {
return sprintf('"%s"', md5($response));
}
return false;
}
/** /**
* Return an {@link http://www.faqs.org/rfcs/rfc2822 RFC 2822} date in the * Return an {@link http://www.faqs.org/rfcs/rfc2822 RFC 2822} date in the
* GMT timezone (a timestamp is always in GMT: the number of seconds * GMT timezone (a timestamp is always in GMT: the number of seconds
* since January 1 1970 00:00:00 GMT) * since January 1 1970 00:00:00 GMT)
*
* @param int $timestamp
* @return string
*/ */
public static function gmt_date($timestamp) { public static function gmt_date($timestamp) {
return gmdate('D, d M Y H:i:s', $timestamp) . ' GMT'; return gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
} }
/* /**
* Return static variable cache_age in second * Return static variable cache_age in second
*
* @return int
*/ */
public static function get_cache_age() { public static function get_cache_age() {
return self::$cache_age; return self::$cache_age;
} }
/**
* Combine vary strings
*
* @param string $vary,... Each vary as a separate arg
* @return string
*/
protected static function combineVary($vary)
{
$varies = array();
foreach (func_get_args() as $arg) {
$argVaries = array_filter(preg_split("/\s*,\s*/", trim($arg)));
if ($argVaries) {
$varies = array_merge($varies, $argVaries);
}
}
return implode(', ', array_unique($varies));
}
} }

View File

@ -0,0 +1,442 @@
<?php
/**
* Class HTTPCacheControl
*
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/
*/
class HTTPCacheControl extends SS_Object {
/**
* @var static
*/
private static $inst;
/**
* Store for all the current directives and their values
* Starts with an implicit config for disabled caching
*
* @var array
*/
private $state = array();
/**
* Forcing level of previous setting; higher number wins
* Combination of consts belo
*w
* @var int
*/
protected $forcingLevel = 0;
/**
* Forcing level forced, optionally combined with one of the below.
*/
const LEVEL_FORCED = 10;
/**
* Forcing level caching disabled. Overrides public/private.
*/
const LEVEL_DISABLED = 3;
/**
* Forcing level private-cached. Overrides public.
*/
const LEVEL_PRIVATE = 2;
/**
* Forcing level public cached. Lowest priority.
*/
const LEVEL_PUBLIC = 1;
/**
* Forcing level caching enabled.
*/
const LEVEL_ENABLED = 0;
/**
* A list of allowed cache directives for HTTPResponses
*
* This doesn't include any experimental directives,
* use the config system to add to these if you want to enable them
*
* @config
* @var array
*/
private static $allowed_directives = array(
'public',
'private',
'no-cache',
'max-age',
's-maxage',
'must-revalidate',
'proxy-revalidate',
'no-store',
'no-transform',
);
public function __construct()
{
parent::__construct();
// If we've not been provided an initial state, then grab HTTP.cache_contrpl from config
if (!$this->state) {
$this->setDirectivesFromArray(Config::inst()->get('HTTP', 'cache_control'));
}
}
/**
* Instruct the cache to apply a change with a given level, optionally
* modifying it with a force flag to increase priority of this action.
*
* If the apply level was successful, the change is made and the internal level
* threshold is incremented.
*
* @param int $level Priority of the given change
* @param bool $force If usercode has requested this action is forced to a higher priority.
* Note: Even if $force is set to true, other higher-priority forced changes can still
* cause a change to be rejected if it is below the required threshold.
* @return bool True if the given change is accepted, and that the internal
* level threshold is updated (if necessary) to the new minimum level.
*/
protected function applyChangeLevel($level, $force)
{
$forcingLevel = $level + ($force ? self::LEVEL_FORCED : 0);
if ($forcingLevel < $this->forcingLevel) {
return false;
}
$this->forcingLevel = $forcingLevel;
return true;
}
/**
* Low level method for setting directives include any experimental or custom ones added via config
*
* @param string $directive
* @param string|bool $value
*
* @return $this
*/
public function setDirective($directive, $value = null)
{
// make sure the directive is in the list of allowed directives
$allowedDirectives = $this->config()->get('allowed_directives');
$directive = strtolower($directive);
if (in_array($directive, $allowedDirectives)) {
$this->state[$directive] = $value;
} else {
throw new InvalidArgumentException('Directive ' . $directive . ' is not allowed');
}
return $this;
}
/**
* Low level method to set directives from an associative array
*
* @param array $directives
*
* @return $this
*/
public function setDirectivesFromArray($directives)
{
foreach ($directives as $directive => $value) {
// null values mean remove
if (is_null($value)) {
$this->removeDirective($directive);
} else {
// for legacy reasons we accept the string literal "true" as a bool
// a bool value of true means there is no explicit value for the directive
if ($value && (is_bool($value) || strtolower($value) === 'true')) {
$value = null;
}
$this->setDirective($directive, $value);
}
}
return $this;
}
/**
* Low level method for removing directives
*
* @param string $directive
*
* @return $this
*/
public function removeDirective($directive)
{
unset($this->state[strtolower($directive)]);
return $this;
}
/**
* Low level method to check if a directive is currently set
*
* @param string $directive
*
* @return bool
*/
public function hasDirective($directive)
{
return array_key_exists(strtolower($directive), $this->state);
}
/**
* Low level method to get the value of a directive
*
* Note that `null` value is acceptable for a directive
*
* @param string $directive
*
* @return string|false|null
*/
public function getDirective($directive)
{
if ($this->hasDirective($directive)) {
return $this->state[strtolower($directive)];
}
return false;
}
/**
* The cache should not store anything about the client request or server response.
*
* Set the no-store directive (also removes max-age and s-maxage for consistency purposes)
*
* @param bool $noStore
*
* @return $this
*/
public function setNoStore($noStore = true)
{
if ($noStore) {
$this->setDirective('no-store');
$this->removeDirective('max-age');
$this->removeDirective('s-maxage');
} else {
$this->removeDirective('no-store');
}
return $this;
}
/**
* Forces caches to submit the request to the origin server for validation before releasing a cached copy.
*
* @param bool $noCache
*
* @return $this
*/
public function setNoCache($noCache = true)
{
if ($noCache) {
$this->setDirective('no-cache');
} else {
$this->removeDirective('no-cache');
}
return $this;
}
/**
* Specifies the maximum amount of time (seconds) a resource will be considered fresh.
* This directive is relative to the time of the request.
*
* @param int $age
*
* @return $this
*/
public function setMaxAge($age)
{
$this->setDirective('max-age', $age);
return $this;
}
/**
* Overrides max-age or the Expires header, but it only applies to shared caches (e.g., proxies)
* and is ignored by a private cache.
*
* @param int $age
*
* @return $this
*/
public function setSharedMaxAge($age)
{
$this->setDirective('s-maxage', $age);
return $this;
}
/**
* The cache must verify the status of the stale resources before using it and expired ones should not be used.
*
* @param bool $mustRevalidate
*
* @return $this
*/
public function setMustRevalidate($mustRevalidate = true)
{
if ($mustRevalidate) {
$this->setDirective('must-revalidate');
} else {
$this->removeDirective('must-revalidate');
}
return $this;
}
/**
* Simple way to set cache control header to a cacheable state.
* Use this method over `publicCache()` if you are unsure about caching details.
*
* Removes `no-store` and `no-cache` directives; other directives will remain in place.
* Use alongside `setMaxAge()` to indicate caching.
*
* Does not set `public` directive. Usually, `setMaxAge()` is sufficient. Use `publicCache()` if this is explicitly required.
* See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private
*
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/
* @param bool $force Force the cache to public even if its unforced private or public
* @return $this
*/
public function enableCache($force = false)
{
// Only execute this if its forcing level is high enough
if (!$this->applyChangeLevel(self::LEVEL_ENABLED, $force)) {
SS_Log::log("Call to enableCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG);
return $this;
}
$this->removeDirective('no-store');
$this->removeDirective('no-cache');
return $this;
}
/**
* Simple way to set cache control header to a non-cacheable state.
* Use this method over `privateCache()` if you are unsure about caching details.
* Takes precendence over unforced `enableCache()`, `privateCache()` or `publicCache()` calls.
*
* Removes all state and replaces it with `no-cache, no-store, must-revalidate`. Although `no-store` is sufficient
* the others are added under recommendation from Mozilla (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Examples)
*
* Does not set `private` directive, use `privateCache()` if this is explicitly required.
* See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private
*
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/
* @param bool $force Force the cache to diabled even if it's forced private or public
* @return $this
*/
public function disableCache($force = false)
{
// Only execute this if its forcing level is high enough
if (!$this->applyChangeLevel(self::LEVEL_DISABLED, $force )) {
SS_Log::log("Call to disableCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG);
return $this;
}
$this->state = array(
'no-cache' => null,
'no-store' => null,
'must-revalidate' => null,
);
return $this;
}
/**
* Advanced way to set cache control header to a non-cacheable state.
* Indicates that the response is intended for a single user and must not be stored by a shared cache.
* A private cache (e.g. Web Browser) may store the response. Also removes `public` as this is a contradictory directive.
*
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/
* @param bool $force Force the cache to private even if it's forced public
* @return $this
*/
public function privateCache($force = false)
{
// Only execute this if its forcing level is high enough
if (!$this->applyChangeLevel(self::LEVEL_PRIVATE, $force)) {
SS_Log::log("Call to privateCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG);
return $this;
}
// Update the directives
$this->setDirective('private');
$this->removeDirective('public');
$this->removeDirective('no-cache');
$this->removeDirective('no-store');
return $this;
}
/**
* Advanced way to set cache control header to a cacheable state.
* Indicates that the response may be cached by any cache. (eg: CDNs, Proxies, Web browsers)
* Also removes `private` as this is a contradictory directive
*
* @see https://docs.silverstripe.org/en/developer_guides/performance/http_cache_headers/
* @param bool $force Force the cache to public even if it's private, unless it's been forced private
* @return $this
*/
public function publicCache($force = false)
{
// Only execute this if its forcing level is high enough
if (!$this->applyChangeLevel(self::LEVEL_PUBLIC, $force)) {
SS_Log::log("Call to publicCache($force) didn't execute as it's lower priority than a previous call", SS_Log::DEBUG);
return $this;
}
$this->setDirective('public');
$this->removeDirective('private');
$this->removeDirective('no-cache');
$this->removeDirective('no-store');
return $this;
}
/**
* Generate and add the `Cache-Control` header to a response object
*
* @param SS_HTTPResponse $response
*
* @return $this
*/
public function applyToResponse($response)
{
$headers = $this->generateHeaders();
foreach ($headers as $name => $value) {
$response->addHeader($name, $value);
}
return $this;
}
/**
* Generate the cache header
*
* @return string
*/
protected function generateCacheHeader()
{
$cacheControl = array();
foreach ($this->state as $directive => $value) {
if (is_null($value)) {
$cacheControl[] = $directive;
} else {
$cacheControl[] = $directive . '=' . $value;
}
}
return implode(', ', $cacheControl);
}
/**
* Generate all headers to output
*
* @return array
*/
public function generateHeaders()
{
return array(
'Cache-Control' => $this->generateCacheHeader(),
);
}
/**
* Reset registered http cache control and force a fresh instance to be built
*/
public static function reset() {
Injector::inst()->unregisterNamedObject(__CLASS__);
}
}

View File

@ -189,9 +189,15 @@ class SS_HTTPResponse {
* @param string $header * @param string $header
* @returns null|string * @returns null|string
*/ */
public function getHeader($header) { public function getHeader($header, $anyCase = false) {
if(isset($this->headers[$header])) if ($anyCase) {
return $this->headers[$header]; $headers = array_change_key_case($this->headers, CASE_LOWER);
$header = strtolower($header);
} else {
$headers = $this->headers;
}
if(isset($headers[$header]))
return $headers[$header];
} }
/** /**

View File

@ -34,6 +34,7 @@ class VersionedRequestFilter implements RequestFilter {
if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) { if(class_exists('SapphireTest', false) && SapphireTest::is_running_test()) {
throw new SS_HTTPResponse_Exception($response); throw new SS_HTTPResponse_Exception($response);
} }
HTTP::add_cache_headers($response);
$response->output(); $response->output();
die; die;
} }
@ -44,6 +45,9 @@ class VersionedRequestFilter implements RequestFilter {
} }
public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) { public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) {
if (Versioned::current_stage() !== Versioned::LIVE && !HTTPCacheControl::singleton()->hasDirective('no-store')) {
HTTPCacheControl::singleton()->privateCache(true);
}
return true; return true;
} }

View File

@ -131,8 +131,8 @@ class SS_Log {
/** /**
* Add a writer instance to the logger. * Add a writer instance to the logger.
* @param object $writer Zend_Log_Writer_Abstract instance * @param object $writer Zend_Log_Writer_Abstract instance
* @param const $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE * @param int $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE
* @param $comparison Priority comparison operator. Acts on the integer values of the error * @param string $comparison Priority comparison operator. Acts on the integer values of the error
* levels, where more serious errors are lower numbers. By default this is "=", which means only * levels, where more serious errors are lower numbers. By default this is "=", which means only
* the given priority will be logged. Set to "<=" if you want to track errors of *at least* * the given priority will be logged. Set to "<=" if you want to track errors of *at least*
* the given priority. * the given priority.
@ -151,7 +151,7 @@ class SS_Log {
* error code, error line, error context (backtrace). * error code, error line, error context (backtrace).
* *
* @param mixed $message Exception object or array of error context variables * @param mixed $message Exception object or array of error context variables
* @param const $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE * @param int $priority Priority. Possible values: SS_Log::ERR, SS_Log::WARN or SS_Log::NOTICE
* @param mixed $extras Extra information to log in event * @param mixed $extras Extra information to log in event
*/ */
public static function log($message, $priority, $extras = null) { public static function log($message, $priority, $extras = null) {

View File

@ -67,6 +67,17 @@ functionality is available as an additional [Spam Protection](https://github.com
module if required. The module provides an consistent API for allowing third-party spam protection handlers such as module if required. The module provides an consistent API for allowing third-party spam protection handlers such as
[Recaptcha](http://www.google.com/recaptcha/intro/) and [Mollom](https://mollom.com/) to work within the `Form` API. [Recaptcha](http://www.google.com/recaptcha/intro/) and [Mollom](https://mollom.com/) to work within the `Form` API.
## Data disclosure through HTTP Caching (since 3.7.0)
Forms, and particularly their responses, can contain sensitive data (such as when data is pre-populated or re-posted due
to validation errors). This data can inadvertently be stored either in a user's browser cache or in an intermediary
cache such as a CDN or other caching-proxy. If incorrect `Cache-Conrol` headers are used, private data may be cached and
accessible publicly through the CDN.
To ensure this doesn't happen SilverStripe adds `Cache-Control: no-store, no-cache, must-revalidate` headers to any
forms that have validators or security tokens (all of them by default) applied to them; this ensures that CDNs
(and browsers) will not cache these pages.
## Related Documentation ## Related Documentation
* [Security](../security) * [Security](../security)

View File

@ -1,7 +1,175 @@
title: HTTP Cache Headers title: HTTP Cache Headers
summary: Set the correct HTTP cache headers for your responses. summary: Set the correct HTTP cache headers for your responses.
# Caching Headers # HTTP Cache Headers
## Overview
By default, SilverStripe sends headers which signal to HTTP caches
that the response should be not considered cacheable.
HTTP caches can either be intermediary caches (e.g. CDNs and proxies), or clients (e.g. browsers).
The cache headers sent are `Cache-Control: no-store, no-cache, must-revalidate`;
HTTP caching can be a great way to speed up your website, but needs to be properly applied.
Getting it wrong can accidentally expose draft pages or other protected content.
The [Google Web Fundamentals](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private)
are a great way to learn about HTTP caching.
## Cache Control Headers
### Overview
In order to support developers in making safe choices around HTTP caching,
we're using a `HTTPCacheControl` class to control if a response
should be considered public or private. This is an abstraction on existing
lowlevel APIs like `HTTP::add_cache_headers()` and `SS_HTTPResponse->addHeader()`.
The `HTTPCacheControl` API makes it easier to express your caching preferences
without running the risk of overriding essential core safety measures.
Most commonly, these APIs will prevent HTTP caching of draft content.
It will also prevent caching of content generated with an active session,
since the system can't tell whether session data was used to vary the output.
In this case, it's up to the developer to opt-in to caching,
after ensuring that certain execution paths are safe despite of using sessions.
The system behaviour does not guard against accidentally caching "private" content,
since there are too many variations under which output could be considered private
(e.g. a custom "approval" flag on a comment object). It is up to
the developer to ensure caching is used appropriately there.
The `HTTPCacheControl` class supplements the `HTTP` helper class.
It comes with methods which let developers safely interact with the `Cache-Control` header.
### disableCache()
Simple way to set cache control header to a non-cacheable state.
Use this method over `privateCache()` if you are unsure about caching details.
Takes precendence over unforced `enableCache()`, `privateCache()` or `publicCache()` calls.
Removes all state and replaces it with `no-cache, no-store, must-revalidate`. Although `no-store` is sufficient
the others are added under [recommendation from Mozilla](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Examples)
Does not set `private` directive, use `privateCache()` if this is explicitly required
([details](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private))
### enableCache()
Simple way to set cache control header to a cacheable state.
Use this method over `publicCache()` if you are unsure about caching details.
Removes `no-store` and `no-cache` directives; other directives will remain in place.
Use alongside `setMaxAge()` to indicate caching.
Does not set `public` directive. Usually, `setMaxAge()` is sufficient. Use `publicCache()` if this is explicitly required
([details](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#public_vs_private))
### privateCache()
Advanced way to set cache control header to a non-cacheable state.
Indicates that the response is intended for a single user and must not be stored by a shared cache.
A private cache (e.g. Web Browser) may store the response. Also removes `public` as this is a contradictory directive.
### publicCache()
Advanced way to set cache control header to a cacheable state.
Indicates that the response may be cached by any cache. (eg: CDNs, Proxies, Web browsers)
Also removes `private` as this is a contradictory directive
### Priority
Each of these highlevel methods has a boolean `$force` parameter which determines
their application priority regardless of execution order.
The priority order is as followed, sorted in descending order
(earlier items will overrule later items):
* `disableCache($force=true)`
* `privateCache($force=true)`
* `publicCache($force=true)`
* `enableCache($force=true)`
* `disableCache()`
* `privateCache()`
* `publicCache()`
* `enableCache()`
## Cache Control Examples
### Global opt-in for page content
Enable caching for all page content (through `Page_Controller`).
```php
class Page_Controller extends ContentController
{
public function init()
{
HTTPCacheControl::inst()
->enableCache()
->setMaxAge(60); // 1 minute
parent::init();
}
}
```
Note: SilverStripe will still override this preference when a session is active,
a [CSRF token](/developer_guides/forms/form_security) token is present,
or draft content has been requested.
### Opt-out for a particular controller action
If a controller output relies on session data, cookies,
permission checks or other triggers for conditional output,
you can disable caching either on a controller level
(through `init()`) or for a particular action.
```php
class MyPage_Controller extends Page_Controller
{
public function myprivateaction($request)
{
$response = $this->myPrivateResponse();
HTTPCacheControl::inst()
->disableCache();
return $response;
}
}
```
Note: SilverStripe will still override this preference when a session is active,
a [CSRF token](/developer_guides/forms/form_security) token is present,
or draft content has been requested.
### Global opt-in, ignoring session (advanced)
This can be helpful in situations where forms are embedded on the website.
SilverStripe will still override this preference when draft content has been requested.
CAUTION: This mode relies on a developer examining each execution path to ensure
that no session data is used to vary output.
Use case: By default, forms include a [CSRF token](/developer_guides/forms/form_security)
which starts a session with a value that's unique to the visitor, which makes the output uncacheable.
But any subsequent requests by this visitor will also carry a session, leading to uncacheable output
for this visitor. This is the case even if the output does not contain any forms,
and does not vary for this particular visitor.
```php
class Page_Controller extends ContentController
{
public function init()
{
HTTPCacheControl::inst()
->enableCache($force=true) // DANGER ZONE
->setMaxAge(60); // 1 minute
parent::init();
}
}
```
## Defaults
By default, PHP adds caching headers that make the page appear purely dynamic. This isn't usually appropriate for most By default, PHP adds caching headers that make the page appear purely dynamic. This isn't usually appropriate for most
sites, even ones that are updated reasonably frequently. SilverStripe overrides the default settings with the following sites, even ones that are updated reasonably frequently. SilverStripe overrides the default settings with the following
@ -14,40 +182,39 @@ headers:
* Since a visitor cookie is set, the site won't be cached by proxies. * Since a visitor cookie is set, the site won't be cached by proxies.
* Ajax requests are never cached. * Ajax requests are never cached.
## Customizing Cache Headers ## Max Age
### HTTP::set_cache_age The cache age determines the lifetime of your cache, in seconds.
It only takes effect if you instruct the cache control
that your response is public in the first place (via `enableCache()` or via modifying the `HTTP.cache_control` defaults).
:::php :::php
HTTP::set_cache_age(0); HTTPCacheControl::singleton()
->setMaxAge(60)
Used to set the max-age component of the cache-control line, in seconds. Set it to 0 to disable caching; the "no-cache" Note that `setMaxAge(0)` is NOT sufficient to disable caching in all cases.
clause in `Cache-Control` and `Pragma` will be included.
### HTTP::register_modification_date ### Last Modified
:::php
HTTP::register_modification_date('2014-10-10');
Used to set the modification date to something more recent than the default. [api:DataObject::__construct] calls Used to set the modification date to something more recent than the default. [api:DataObject::__construct] calls
[api:HTTP::register_modification_date(] whenever a record comes from the database ensuring the newest date is present. [api:HTTP::register_modification_date(] whenever a record comes from the database ensuring the newest date is present.
### Vary: cache header :::php
HTTP::register_modification_date('2014-10-10');
By default, SilverStripe will output a `Vary` header (used by upstream caches for determining uniqueness) ### Vary
that looks like
A `Vary` header tells caches which aspects of the response should be considered
when calculating a cache key, usually in addition to the full URL path.
By default, SilverStripe will output a `Vary` header with the following content:
``` ```
Cookie, X-Forwarded-Protocol, User-Agent, Accept Vary: X-Requested-With, X-Forwarded-Protocol
``` ```
To change the value of the `Vary` header, you can change this value by specifying the header in configuration To change the value of the `Vary` header, you can change this value by specifying the header in configuration.
```yml ```yml
HTTP: HTTP:
vary: "" vary: ""
``` ```

View File

@ -607,6 +607,15 @@ In a future release this behaviour will be changed to be on by default, and this
variable will be no longer necessary, thus it will be necessary to always set variable will be no longer necessary, thus it will be necessary to always set
SS_TRUSTED_PROXY_IPS if using a proxy. SS_TRUSTED_PROXY_IPS if using a proxy.
## HTTP Caching Headers
Caching is hard. If you get it wrong, private or draft content might leak
to unauthenticated users. We have created an abstraction which allows you to express
your intent around HTTP caching without worrying too much about the details.
See [/developer_guides/performances/http_cache_headers](Developer Guides > Performance > HTTP Cache Headers)
for details on how to apply caching safely, and read Google's
[Web Fundamentals on Caching](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching).
## Related ## Related
* [http://silverstripe.org/security-releases/](http://silverstripe.org/security-releases/) * [http://silverstripe.org/security-releases/](http://silverstripe.org/security-releases/)

View File

@ -1,4 +1,4 @@
# 3.7.0 (unreleased) # 3.7.0
## SilverStripe 3.7 and PHP 7.2 and Object subclasses ## SilverStripe 3.7 and PHP 7.2 and Object subclasses
@ -67,3 +67,132 @@ Data that is not content sensitive can be cached across stages by simply opting
$cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true)); $cache = SS_Cache::factory('myapp', 'Output', array('disable-segmentation' => true));
``` ```
## HTTP Cache Header changes
### Overview
In order to support developers in making safe choices around HTTP caching,
we've introduced a `HTTPCacheControl` class to control if a response
should be considered public or private. This is an abstraction on existing
lowlevel APIs like `HTTP::add_cache_headers()` and `SS_HTTPResponse->addHeader()`.
This change introduces smaller but necessary changes to HTTP caching headers
sent by SilverStripe. If you are relying on HTTP caching in your implementation,
or use modules such as [silverstripe/controllerpolicy](https://github.com/silverstripe/silverstripe-controllerpolicy),
please review the implications of these changes below.
In short, these APIs make it easier to express your caching preferences
without running the risk of overriding essential core safety measures.
Most commonly, these APIs will prevent HTTP caching of draft content.
It will also prevent caching of content generated with an active session,
since the system can't tell whether session data was used to vary the output.
In this case, it's up to the developer to opt-in to caching,
after ensuring that certain execution paths are safe despite of using sessions.
The system behaviour does not guard against accidentally caching "private" content,
since there are too many variations under which output could be considered private
(e.g. a custom "approval" flag on a comment object). It is up to
the developer to ensure caching is used appropriately there.
By default, SilverStripe sends headers which signal to HTTP caches
that the response should be considered not cacheable.
See [Developer Guide: Performance > HTTP Cache Headers](/developer_guide/performance/http_cache_headers)
for details on the new API.
### Example Usage
#### Global opt-in for page content
Enable caching for all page content (through `Page_Controller`).
```diff
class Page_Controller extends ContentController
{
public function init()
{
- HTTP::set_cache_age(60);
+ HTTPCacheControl::inst()
+ ->enableCache()
+ ->setMaxAge(60); // 1 minute
parent::init();
}
}
```
Note: SilverStripe will still override this preference when a session is active,
a [CSRF token](/developer_guides/forms/form_security) token is present,
or draft content has been requested.
#### Opt-out for a particular controller action
If a controller output relies on session data, cookies,
permission checks or other triggers for conditional output,
you can disable caching either on a controller level
(through `init()`) or for a particular action.
```diff
class MyPage_Controller extends Page_Controller
{
public function myprivateaction($request)
{
$response = $this->myPrivateResponse();
- HTTP::set_cache_age(0);
+ HTTPCacheControl::inst()
+ ->disableCache();
return $response;
}
}
```
Note: SilverStripe will still override this preference when a session is active,
a [CSRF token](/developer_guides/forms/form_security) token is present,
or draft content has been requested.
#### Global opt-in, ignoring session (advanced)
This can be helpful in situations where forms are embedded on the website.
SilverStripe will still override this preference when draft content has been requested.
CAUTION: This mode relies on a developer examining each execution path to ensure
that no session data is used to vary output.
Use case: By default, forms include a [CSRF token](/developer_guides/forms/form_security)
which starts a session with a value that's unique to the visitor, which makes the output uncacheable.
But any subsequent requests by this visitor will also carry a session, leading to uncacheable output
for this visitor. This is the case even if the output does not contain any forms,
and does not vary for this particular visitor.
```diff
class Page_Controller extends ContentController
{
public function init()
{
- HTTP::set_cache_age(60);
+ HTTPCacheControl::inst()
+ ->enableCache($force=true) // DANGER ZONE
+ ->setMaxAge(60); // 1 minute
parent::init();
}
}
```
### Detailed Changes
* Added `Cache-Control: no-store` header to default responses,
to prevent intermediary HTTP proxies (e.g. CDNs) from caching unless developers opt-in
* Removed `Cache-Control: no-transform` header from default responses
* Removed `Vary: Cookie` as an unreliable cache buster,
rely on the existing `Cache-Control: no-store` defaults instead
* Removed `Vary: Accept`, since it's very uncommon to vary content on
the `Content-Type` headers submitted through the request,
and it can significantly decrease the likelyhood of a cache hit.
Note this is different from `Vary: Accept-Encoding`,
which is important for compression (e.g. gzip), and usually added by
other layers such as Apache's mod_gzip.

View File

@ -854,7 +854,9 @@ class Form extends RequestHandler {
} }
// If we need to disable cache, do it // If we need to disable cache, do it
if ($needsCacheDisabled) HTTP::set_cache_age(0); if ($needsCacheDisabled) {
HTTPCacheControl::singleton()->disableCache(true);
}
$attrs = $this->getAttributes(); $attrs = $this->getAttributes();

View File

@ -57,6 +57,9 @@ if (version_compare(phpversion(), '5.3.3', '<')) {
*/ */
require_once('core/Constants.php'); require_once('core/Constants.php');
// we handle our own cache headers in this application
session_cache_limiter('');
// IIS will sometimes generate this. // IIS will sometimes generate this.
if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) { if(!empty($_SERVER['HTTP_X_ORIGINAL_URL'])) {
$_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL']; $_SERVER['REQUEST_URI'] = $_SERVER['HTTP_X_ORIGINAL_URL'];

View File

@ -240,6 +240,8 @@ class Security extends Controller implements TemplateGlobalProvider {
if(!$controller) $controller = Controller::curr(); if(!$controller) $controller = Controller::curr();
HTTPCacheControl::singleton()->disableCache(true);
if(Director::is_ajax()) { if(Director::is_ajax()) {
$response = ($controller) ? $controller->getResponse() : new SS_HTTPResponse(); $response = ($controller) ? $controller->getResponse() : new SS_HTTPResponse();
$response->setStatusCode(403); $response->setStatusCode(403);

View File

@ -0,0 +1,190 @@
<?php
class HTTPCacheControlIntegrationTest extends FunctionalTest {
public function setUp() {
parent::setUp();
Config::inst()->remove('HTTP', 'disable_http_cache');
HTTPCacheControl::reset();
}
public function testFormCSRF() {
// CSRF sets caching to disabled
$response = $this->get('HTTPCacheControlIntegrationTest_SessionController/showform');
$header = $response->getHeader('Cache-Control');
$this->assertFalse($response->isError());
$this->assertNotContains('public', $header);
$this->assertNotContains('private', $header);
$this->assertContains('no-cache', $header);
$this->assertContains('no-store', $header);
$this->assertContains('must-revalidate', $header);
}
public function testPublicForm() {
// Public forms (http get) allow public caching
$response = $this->get('HTTPCacheControlIntegrationTest_SessionController/showpublicform');
$header = $response->getHeader('Cache-Control');
$this->assertFalse($response->isError());
$this->assertContains('public', $header);
$this->assertContains('must-revalidate', $header);
$this->assertNotContains('no-cache', $response->getHeader('Cache-Control'));
$this->assertNotContains('no-store', $response->getHeader('Cache-Control'));
}
public function testPrivateActionsError()
{
// disallowed private actions don't cache
$response = $this->get('HTTPCacheControlIntegrationTest_SessionController/privateaction');
$header = $response->getHeader('Cache-Control');
$this->assertTrue($response->isError());
$this->assertContains('no-cache', $header);
$this->assertContains('no-store', $header);
$this->assertContains('must-revalidate', $header);
}
public function testPrivateActionsAuthenticated()
{
$this->logInWithPermission('ADMIN');
// Authenticated actions are private cache
$response = $this->get('HTTPCacheControlIntegrationTest_SessionController/privateaction');
$header = $response->getHeader('Cache-Control');
$this->assertFalse($response->isError());
$this->assertContains('private', $header);
$this->assertContains('must-revalidate', $header);
$this->assertNotContains('no-cache', $header);
$this->assertNotContains('no-store', $header);
}
public function testPrivateCache() {
$response = $this->get('HTTPCacheControlIntegrationTest_RuleController/privateaction');
$header = $response->getHeader('Cache-Control');
$this->assertFalse($response->isError());
$this->assertContains('private', $header);
$this->assertContains('must-revalidate', $header);
$this->assertNotContains('no-cache', $header);
$this->assertNotContains('no-store', $header);
}
public function testPublicCache() {
$response = $this->get('HTTPCacheControlIntegrationTest_RuleController/publicaction');
$header = $response->getHeader('Cache-Control');
$this->assertFalse($response->isError());
$this->assertContains('public', $header);
$this->assertContains('must-revalidate', $header);
$this->assertNotContains('no-cache', $header);
$this->assertNotContains('no-store', $header);
$this->assertContains('max-age=9000', $header);
}
public function testDisabledCache() {
$response = $this->get('HTTPCacheControlIntegrationTest_RuleController/disabledaction');
$header = $response->getHeader('Cache-Control');
$this->assertFalse($response->isError());
$this->assertNotContains('public', $header);
$this->assertNotContains('private', $header);
$this->assertContains('no-cache', $header);
$this->assertContains('no-store', $header);
$this->assertContains('must-revalidate', $header);
}
}
/**
* Test caching based on session
*/
class HTTPCacheControlIntegrationTest_SessionController extends Controller implements TestOnly
{
private static $allowed_actions = array(
'showform',
'privateaction',
'publicaction',
'showpublicform',
'Form',
);
public function init()
{
parent::init();
// Prefer public by default
HTTPCacheControl::singleton()->publicCache();
}
public function getContent()
{
return '<p>Hello world</p>';
}
public function showform()
{
// Form should be set to private due to CSRF
SecurityToken::enable();
return $this->renderWith('BlankPage');
}
public function showpublicform()
{
// Public form doesn't use CSRF and thus no session usage
SecurityToken::disable();
return $this->renderWith('BlankPage');
}
public function privateaction()
{
if (!Permission::check('ANYCODE')) {
$this->httpError(403, 'Not allowed');
}
return 'ok';
}
public function publicaction()
{
return 'Hello!';
}
public function Form()
{
$form = new Form(
$this,
'Form',
new FieldList(new TextField('Name')),
new FieldList(new FormAction('submit', 'Submit'))
);
$form->setFormMethod('GET');
return $form;
}
}
/**
* Test caching based on specific http caching directives
*/
class HTTPCacheControlIntegrationTest_RuleController extends Controller implements TestOnly
{
private static $allowed_actions = array(
'privateaction',
'publicaction',
'disabledaction',
);
public function init()
{
parent::init();
// Prefer public by default
HTTPCacheControl::singleton()->publicCache();
}
public function privateaction() {
HTTPCacheControl::singleton()->privateCache();
return 'private content';
}
public function publicaction() {
HTTPCacheControl::singleton()
->publicCache()
->setMaxAge(9000);
return 'public content';
}
public function disabledaction() {
HTTPCacheControl::singleton()->disableCache();
return 'uncached content';
}
}

View File

@ -0,0 +1,66 @@
<?php
/**
* Testing of HTTPCacheControl class
*/
class HTTPCacheControlTest extends SapphireTest
{
public function testCachingPriorities()
{
$hcc = new HTTPCacheControl();
$this->assertTrue($this->isDisabled($hcc), 'caching starts as disabled');
$hcc->enableCache();
$this->assertFalse($this->isDisabled($hcc));
$hcc->publicCache();
$this->assertTrue($this->isPublic($hcc), 'public can be set at start');
$hcc->privateCache();
$this->assertTrue($this->isPrivate($hcc), 'private overrides public');
$hcc->publicCache();
$this->assertFalse($this->isPublic($hcc), 'public does not overrides private');
$hcc->disableCache();
$this->assertTrue($this->isDisabled($hcc), 'disabled overrides private');
$hcc->privateCache();
$this->assertFalse($this->isPrivate($hcc), 'private does not override disabled');
$hcc->enableCache(true);
$this->assertFalse($this->isDisabled($hcc));
$hcc->publicCache(true);
$this->assertTrue($this->isPublic($hcc), 'force-public overrides disabled');
$hcc->privateCache();
$this->assertFalse($this->isPrivate($hcc), 'private does not overrdie force-public');
$hcc->privateCache(true);
$this->assertTrue($this->isPrivate($hcc), 'force-private overrides force-public');
$hcc->publicCache(true);
$this->assertFalse($this->isPublic($hcc), 'force-public does not override force-private');
$hcc->disableCache(true);
$this->assertTrue($this->isDisabled($hcc), 'force-disabled overrides force-private');
$hcc->publicCache(true);
$this->assertFalse($this->isPublic($hcc), 'force-public does not overrides force-disabled');
}
protected function isPrivate(HTTPCacheControl $hcc)
{
return $hcc->hasDirective('private') && !$hcc->hasDirective('public') && !$hcc->hasDirective('no-cache');
}
protected function isPublic(HTTPCacheControl $hcc)
{
return $hcc->hasDirective('public') && !$hcc->hasDirective('private') && !$hcc->hasDirective('no-cache');
}
protected function isDisabled(HTTPCacheControl $hcc)
{
return $hcc->hasDirective('no-cache') && !$hcc->hasDirective('private') && !$hcc->hasDirective('public');
}
}

View File

@ -7,27 +7,41 @@
*/ */
class HTTPTest extends FunctionalTest { class HTTPTest extends FunctionalTest {
public function setUp()
{
parent::setUp();
// Remove dev-only config
Config::inst()->remove('HTTP', 'disable_http_cache');
HTTPCacheControl::reset();
}
public function testAddCacheHeaders() { public function testAddCacheHeaders() {
$body = "<html><head></head><body><h1>Mysite</h1></body></html>"; $body = "<html><head></head><body><h1>Mysite</h1></body></html>";
$response = new SS_HTTPResponse($body, 200); $response = new SS_HTTPResponse($body, 200);
$this->assertEmpty($response->getHeader('Cache-Control')); HTTPCacheControl::singleton()->publicCache();
HTTP::set_cache_age(30); HTTP::set_cache_age(30);
HTTP::add_cache_headers($response); HTTP::add_cache_headers($response);
$this->assertNotEmpty($response->getHeader('Cache-Control')); $this->assertNotEmpty($response->getHeader('Cache-Control'));
// Ensure max-age is zero for development. // Ensure cache headers are set correctly when disabled via config (e.g. when dev)
Config::inst()->update('Director', 'environment_type', 'dev'); Config::inst()->update('HTTP', 'disable_http_cache', true);
HTTPCacheControl::reset();
HTTPCacheControl::singleton()->publicCache();
HTTP::set_cache_age(30);
$response = new SS_HTTPResponse($body, 200); $response = new SS_HTTPResponse($body, 200);
HTTP::add_cache_headers($response); HTTP::add_cache_headers($response);
$this->assertContains('max-age=0', $response->getHeader('Cache-Control')); $this->assertContains('no-cache', $response->getHeader('Cache-Control'));
$this->assertContains('no-store', $response->getHeader('Cache-Control'));
$this->assertContains('must-revalidate', $response->getHeader('Cache-Control'));
// Ensure max-age setting is respected in production. // Ensure max-age setting is respected in production.
Config::inst()->update('Director', 'environment_type', 'live'); Config::inst()->remove('HTTP', 'disable_http_cache');
HTTPCacheControl::reset();
HTTPCacheControl::singleton()->publicCache();
HTTP::set_cache_age(30);
$response = new SS_HTTPResponse($body, 200); $response = new SS_HTTPResponse($body, 200);
HTTP::add_cache_headers($response); HTTP::add_cache_headers($response);
$this->assertContains('max-age=30', explode(', ', $response->getHeader('Cache-Control'))); $this->assertContains('max-age=30', $response->getHeader('Cache-Control'));
$this->assertNotContains('max-age=0', $response->getHeader('Cache-Control')); $this->assertNotContains('max-age=0', $response->getHeader('Cache-Control'));
// Still "live": Ensure header's aren't overridden if already set (using purposefully different values). // Still "live": Ensure header's aren't overridden if already set (using purposefully different values).
@ -36,33 +50,40 @@ class HTTPTest extends FunctionalTest {
'Pragma' => 'no-cache', 'Pragma' => 'no-cache',
'Cache-Control' => 'max-age=0, no-cache, no-store', 'Cache-Control' => 'max-age=0, no-cache, no-store',
); );
HTTPCacheControl::reset();
HTTPCacheControl::singleton()->publicCache();
HTTP::set_cache_age(30);
$response = new SS_HTTPResponse($body, 200); $response = new SS_HTTPResponse($body, 200);
foreach($headers as $name => $value) { foreach($headers as $name => $value) {
$response->addHeader($name, $value); $response->addHeader($name, $value);
} }
HTTP::add_cache_headers($response);
foreach($headers as $name => $value) {
$this->assertEquals($value, $response->getHeader($name));
}
}
// Expect a warning if the header is already set
$this->setExpectedException(
'PHPUnit_Framework_Error_Warning',
'Cache-Control header has already been set. '
. 'Please use HTTPCacheControl API to set caching options instead.'
);
HTTP::add_cache_headers($response);
}
public function testConfigVary() { public function testConfigVary() {
$body = "<html><head></head><body><h1>Mysite</h1></body></html>"; $body = "<html><head></head><body><h1>Mysite</h1></body></html>";
$response = new SS_HTTPResponse($body, 200); $response = new SS_HTTPResponse($body, 200);
Config::inst()->update('Director', 'environment_type', 'live');
HTTP::set_cache_age(30); HTTP::set_cache_age(30);
HTTP::add_cache_headers($response); HTTP::add_cache_headers($response);
$v = $response->getHeader('Vary'); $v = $response->getHeader('Vary');
$this->assertNotEmpty($v); $this->assertNotEmpty($v);
$this->assertContains("Cookie", $v);
$this->assertContains("X-Forwarded-Protocol", $v); $this->assertContains("X-Forwarded-Protocol", $v);
$this->assertContains("User-Agent", $v); $this->assertContains("X-Requested-With", $v);
$this->assertContains("Accept", $v); $this->assertNotContains("Cookie", $v);
$this->assertNotContains("User-Agent", $v);
$this->assertNotContains("Accept", $v);
Config::inst()->update('HTTP', 'vary', ''); Config::inst()->update('HTTP', 'vary', '');
HTTPCacheControl::reset();
$response = new SS_HTTPResponse($body, 200); $response = new SS_HTTPResponse($body, 200);
HTTP::add_cache_headers($response); HTTP::add_cache_headers($response);