diff --git a/.upgrade.yml b/.upgrade.yml index e4e2c392a..42584cb49 100644 --- a/.upgrade.yml +++ b/.upgrade.yml @@ -222,6 +222,7 @@ mappings: CookieJar: SilverStripe\Control\CookieJar Director: SilverStripe\Control\Director FlushRequestFilter: SilverStripe\Control\Middleware\FlushMiddleware + HTTPCacheControl: SilverStripe\Control\Middleware\HTTPCacheControlMiddleware HTTP: SilverStripe\Control\HTTP SS_HTTPRequest: SilverStripe\Control\HTTPRequest SS_HTTPResponse: SilverStripe\Control\HTTPResponse diff --git a/_config/config.yml b/_config/config.yml index 7109ca939..4f1e04983 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,12 +1,15 @@ --- Name: coreconfig --- -SilverStripe\Control\HTTP: - cache_control: - max-age: 0 - must-revalidate: "true" - no-transform: "true" - vary: "Cookie, X-Forwarded-Protocol, X-Forwarded-Proto, User-Agent, Accept" SilverStripe\Core\Manifest\VersionProvider: modules: silverstripe/framework: Framework +--- +Name: httpconfig-dev +Only: + environment: dev +--- +# Set dev level to disabled with a higher forcing level +SilverStripe\Control\Middleware\HTTPCacheControlMiddleware: + defaultState: 'disabled' + defaultForcingLevel: 3 diff --git a/_config/requestprocessors.yml b/_config/requestprocessors.yml index 65aa88ef8..13cb7cdf6 100644 --- a/_config/requestprocessors.yml +++ b/_config/requestprocessors.yml @@ -11,6 +11,8 @@ SilverStripe\Core\Injector\Injector: SessionMiddleware: '%$SilverStripe\Control\Middleware\SessionMiddleware' RequestProcessorMiddleware: '%$SilverStripe\Control\RequestProcessor' FlushMiddleware: '%$SilverStripe\Control\Middleware\FlushMiddleware' + ChangeDetectionMiddleware: '%$SilverStripe\Control\Middleware\ChangeDetectionMiddleware' + HTTPCacheControleMiddleware: '%$SilverStripe\Control\Middleware\HTTPCacheControlMiddleware' CanonicalURLMiddleware: '%$SilverStripe\Control\Middleware\CanonicalURLMiddleware' SilverStripe\Control\Middleware\AllowedHostsMiddleware: properties: @@ -46,4 +48,3 @@ SilverStripe\Core\Injector\Injector: properties: ForceSSL: false ForceWWW: false - diff --git a/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md b/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md index 93c4c6cf9..a6e21cfe2 100644 --- a/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md +++ b/docs/en/02_Developer_Guides/03_Forms/04_Form_Security.md @@ -76,6 +76,20 @@ 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 [Recaptcha](http://www.google.com/recaptcha/intro/) and [Mollom](https://mollom.com/) to work within the `Form` API. +## Data disclosure through HTTP Caching (since 4.2.0) + +Forms, and particularly their responses, can contain sensitive or user-specific data. +Forms can prepopulate submissions when a form is redisplayed with validation errors, +and they by default contain CSRF tokens unique to the user's session. +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-Control` 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. +See [/developer_guides/performance/http_cache_headers](Performance: HTTP Cache Headers). + ## Related Documentation * [Security](../security) diff --git a/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md b/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md index bb3df73c5..d1274d7c6 100644 --- a/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md +++ b/docs/en/02_Developer_Guides/08_Performance/02_HTTP_Cache_Headers.md @@ -1,7 +1,190 @@ title: HTTP Cache Headers 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 `HTTPCacheControlMiddleware` class to control if a response +should be considered public or private. This is an abstraction on the +`HTTPResponse->addHeader()` lowlevel API. + +The `HTTPCacheControlMiddleware` 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 [api:SilverStripe\Control\Middleware\HTTPCacheControlMiddleware] class replaces +(deprecated) caching methods in 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 `PageController`). + +```php +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 +disableCache(); + + return $this->myPrivateResponse(); + } +} +``` + +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. Forms can also contain submission data +when they're redisplayed after a validation error. + +```php +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 sites, even ones that are updated reasonably frequently. SilverStripe overrides the default settings with the following @@ -14,39 +197,41 @@ headers: * Since a visitor cookie is set, the site won't be cached by proxies. * 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 cacheable in the first place (via `enableCache()` or via modifying the `HTTP.cache_control` defaults). + +```php +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; +HTTPCacheControlMiddleware::singleton() + ->setMaxAge(60) +``` + +Note that `setMaxAge(0)` is NOT sufficient to disable caching in all cases. + +### Last Modified + +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. ```php use SilverStripe\Control\HTTP; - -HTTP::set_cache_age(0); -``` - -Used to set the max-age component of the cache-control line, in seconds. Set it to 0 to disable caching; the "no-cache" -clause in `Cache-Control` and `Pragma` will be included. - -### HTTP::register_modification_date - - -```php HTTP::register_modification_date('2014-10-10'); ``` -Used to set the modification date to something more recent than the default. [DataObject::__construct](api:SilverStripe\ORM\DataObject::__construct) calls -[HTTP::register_modification_date(](api:SilverStripe\Control\HTTP::register_modification_date() whenever a record comes from the database ensuring the newest date is present. +### Vary -### Vary: cache header - -By default, SilverStripe will output a `Vary` header (used by upstream caches for determining uniqueness) -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. +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 SilverStripe\Control\HTTP: diff --git a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md index d2a892464..eebb28ec9 100644 --- a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md +++ b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md @@ -770,6 +770,15 @@ class MySecureController extends Controller } ``` +## 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 * [http://silverstripe.org/security-releases/](http://silverstripe.org/security-releases/) diff --git a/docs/en/04_Changelogs/4.2.0.md b/docs/en/04_Changelogs/4.2.0.md index 9d53b78ad..8543e735e 100644 --- a/docs/en/04_Changelogs/4.2.0.md +++ b/docs/en/04_Changelogs/4.2.0.md @@ -201,3 +201,170 @@ SilverStripe\Core\Injector\Injector: args: disable-container: true ``` + +### HTTP Cache Header changes + +#### Overview + +In order to support developers in making safe choices around HTTP caching, +we're using a `HTTPCacheControlMiddleware` class to control if a response +should be considered public or private. This is an abstraction on the +`HTTPResponse->addHeader()` lowlevel API. + +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. + +#### Disabling legacy cache headers + +In order to forcibly disable all deprecated HTTP APIs you can set the below config: + +```yaml +SilverStripe\Control\HTTP: + ignoreDeprecatedCaching: true +``` + +This will ensure that any code paths that use the old API will not interefere with upgraded code +that interferes with the new behaviour. + +#### Example Usage + +##### Global opt-in for page content + +Enable caching for all page content (through `PageController`). + +```diff +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 +disableCache(); + + return $this->myPrivateResponse(); + } +} +``` + +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 +enableCache($force=true) // DANGER ZONE ++ ->setMaxAge(60); // 1 minute + + parent::init(); + } +} +``` + +#### Detailed Cache-Control 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 `Accept` 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. + * No longer sets `Last-Modified` date in HTTP response headers in `DataObject::__construct()`. + Uses `ETag` calculation based on response body which is more accurate, + and resilient against partial and object caching which can produce stale `Last-Modified` values. + * Deprecated `HTTP::add_cache_headers()`. Headers are added automatically by `HTTPCacheControlMiddleware` instead. + * Deprecated `HTTP::set_cache_age()`. Use `HTTPCacheControlMiddleware::singleton()->setMaxAge($age)` + * Deprecated `HTTP.cache_ajax_requests`. Use `HTTPCacheControlMiddleware::disableCache()` instead + * Deprecated `HTTP.modification_date`. Handled by `HTTPCacheControlMiddleware` + * Deprecated `HTTP.disable_http_cache`. Use `HTTPCacheControlMiddleware.defaultState` and `defaultForcingLevel` instead + * Deprecated `HTTP::register_modification_date()`. Use `HTTPCacheControlMiddleware::registerModificationDate()` instead + * Deprecated `HTTP::register_modification_timestamp()`. Use `HTTPCacheControlMiddleware::registerModificationDate()` instead + * Deprecated `HTTP::register_etag()`. Use `HTTPCacheControlMiddleware::ETagMiddleware()` instead \ No newline at end of file diff --git a/src/Control/Controller.php b/src/Control/Controller.php index 2b2932aaa..6b73f00c1 100644 --- a/src/Control/Controller.php +++ b/src/Control/Controller.php @@ -258,9 +258,6 @@ class Controller extends RequestHandler implements TemplateGlobalProvider //deal with content if appropriate ContentNegotiator::process($this->getResponse()); - - //add cache headers - HTTP::add_cache_headers($this->getResponse()); } /** diff --git a/src/Control/Director.php b/src/Control/Director.php index 77a2312a8..79622d213 100644 --- a/src/Control/Director.php +++ b/src/Control/Director.php @@ -932,7 +932,6 @@ class Director implements TemplateGlobalProvider // Redirect to installer $response = new HTTPResponse(); $response->redirect($destURL, 301); - HTTP::add_cache_headers($response); throw new HTTPResponse_Exception($response); } diff --git a/src/Control/HTTP.php b/src/Control/HTTP.php index 08531c38b..1e5bcb25a 100644 --- a/src/Control/HTTP.php +++ b/src/Control/HTTP.php @@ -3,10 +3,15 @@ namespace SilverStripe\Control; use SilverStripe\Assets\File; +use SilverStripe\Control\Middleware\ChangeDetectionMiddleware; +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; +use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Convert; use InvalidArgumentException; use finfo; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Dev\Deprecation; /** * A class with HTTP-related helpers. Like Debug, this is more a bundle of methods than a class. @@ -16,26 +21,70 @@ class HTTP use Configurable; /** + * @deprecated 4.2..5.0 Use HTTPCacheControlMiddleware::singleton()->setMaxAge($age) instead * @var int */ protected static $cache_age = 0; /** + * @deprecated 4.2..5.0 Handled by HTTPCacheControlMiddleware * @var int */ protected static $modification_date = null; /** + * @deprecated 4.2..5.0 Handled by ChangeDetectionMiddleware * @var string */ protected static $etag = null; /** * @config + * @var bool + * @deprecated 4.2..5.0 'HTTP.cache_ajax_requests config is deprecated. + * Use HTTPCacheControlMiddleware::disableCache() instead' + */ + private static $cache_ajax_requests = false; + + /** + * @config + * @var bool + * @deprecated 4.2..5.0 Use HTTPCacheControlMiddleware.defaultState/.defaultForcingLevel instead + */ + private static $disable_http_cache = false; + + /** + * Set to true to disable all deprecated HTTP Cache settings * * @var bool */ - private static $cache_ajax_requests = true; + private static $ignoreDeprecatedCaching = false; + + /** + * Mapping of extension to mime types + * + * @var array + * @config + */ + private static $MimeTypes = []; + + /** + * List of names to add to the Cache-Control header. + * + * @see HTTPCacheControlMiddleware::__construct() + * @config + * @var array Keys are cache control names, values are boolean flags + */ + private static $cache_control = []; + + /** + * Vary string; A comma separated list of var header names + * + * @deprecated 4.2..5.0 Handled by HTTPCacheMiddleware instead + * @config + * @var string|null + */ + private static $vary = null; /** * Turns a local system filename into a URL by comparing it to the script filename. @@ -113,7 +162,7 @@ class HTTP } // Replace attributes - $attribs = array("src", "background", "a" => "href", "link" => "href", "base" => "href"); + $attribs = ["src", "background", "a" => "href", "link" => "href", "base" => "href"]; $regExps = []; foreach ($attribs as $tag => $attrib) { if (!is_numeric($tag)) { @@ -128,7 +177,7 @@ class HTTP } // Replace css styles // @todo - http://www.css3.info/preview/multiple-backgrounds/ - $styles = array('background-image', 'background', 'list-style-image', 'list-style', 'content'); + $styles = ['background-image', 'background', 'list-style-image', 'list-style', 'content']; foreach ($styles as $style) { $regExps[] = "/($style:[^;]*url *\\(\")([^\"]+)(\"\\))/i"; $regExps[] = "/($style:[^;]*url *\\(')([^']+)('\\))/i"; @@ -189,7 +238,7 @@ class HTTP } // Parse params and add new variable - $params = array(); + $params = []; if (isset($parts['query'])) { parse_str($parts['query'], $params); } @@ -197,7 +246,7 @@ class HTTP // Generate URI segments and formatting $scheme = (isset($parts['scheme'])) ? $parts['scheme'] : 'http'; - $user = (isset($parts['user']) && $parts['user'] != '') ? $parts['user'] : ''; + $user = (isset($parts['user']) && $parts['user'] != '') ? $parts['user'] : ''; if ($user != '') { // format in either user:pass@host.com or user@host.com @@ -209,13 +258,13 @@ class HTTP $path = (isset($parts['path']) && $parts['path'] != '') ? $parts['path'] : ''; // handle URL params which are existing / new - $params = ($params) ? '?' . http_build_query($params, null, $separator) : ''; + $params = ($params) ? '?' . http_build_query($params, null, $separator) : ''; // keep fragments (anchors) intact. - $fragment = (isset($parts['fragment']) && $parts['fragment'] != '') ? '#' . $parts['fragment'] : ''; + $fragment = (isset($parts['fragment']) && $parts['fragment'] != '') ? '#' . $parts['fragment'] : ''; // Recompile URI segments - $newUri = $scheme . '://' . $user . $host . $port . $path . $params . $fragment; + $newUri = $scheme . '://' . $user . $host . $port . $path . $params . $fragment; if ($isRelative) { return Director::makeRelative($newUri); @@ -248,14 +297,14 @@ class HTTP */ public static function findByTagAndAttribute($content, $attributes) { - $regexes = array(); + $regexes = []; foreach ($attributes as $tag => $attribute) { $regexes[] = "/<{$tag} [^>]*$attribute *= *([\"'])(.*?)\\1[^>]*>/i"; $regexes[] = "/<{$tag} [^>]*$attribute *= *([^ \"'>]+)/i"; } - $result = array(); + $result = []; if ($regexes) { foreach ($regexes as $regex) { @@ -275,7 +324,7 @@ class HTTP */ public static function getLinksIn($content) { - return self::findByTagAndAttribute($content, array("a" => "href")); + return self::findByTagAndAttribute($content, ["a" => "href"]); } /** @@ -285,7 +334,7 @@ class HTTP */ public static function getImagesIn($content) { - return self::findByTagAndAttribute($content, array("img" => "src")); + return self::findByTagAndAttribute($content, ["img" => "src"]); } /** @@ -294,7 +343,6 @@ class HTTP * commonly known MIME types. * * @param string $filename - * * @return string */ public static function get_mime_type($filename) @@ -323,41 +371,45 @@ class HTTP /** * Set the maximum age of this page in web caches, in seconds. * + * @deprecated 4.2..5.0 Use HTTPCacheControlMiddleware::singleton()->setMaxAge($age) instead * @param int $age */ public static function set_cache_age($age) { + Deprecation::notice('5.0', 'Use HTTPCacheControlMiddleware::singleton()->setMaxAge($age) instead'); self::$cache_age = $age; + HTTPCacheControlMiddleware::singleton()->setMaxAge($age); } /** * @param string $dateString + * @deprecated 4.2..5.0 Use HTTPCacheControlMiddleware::registerModificationDate() instead */ public static function register_modification_date($dateString) { - $timestamp = strtotime($dateString); - if ($timestamp > self::$modification_date) { - self::$modification_date = $timestamp; - } + Deprecation::notice('5.0', 'Use HTTPCacheControlMiddleware::registerModificationDate() instead'); + HTTPCacheControlMiddleware::singleton()->registerModificationDate($dateString); } /** * @param int $timestamp + * @deprecated 4.2..5.0 Use HTTPCacheControlMiddleware::registerModificationDate() instead */ public static function register_modification_timestamp($timestamp) { - if ($timestamp > self::$modification_date) { - self::$modification_date = $timestamp; - } + Deprecation::notice('5.0', 'Use HTTPCacheControlMiddleware::registerModificationDate() instead'); + HTTPCacheControlMiddleware::singleton()->registerModificationDate($timestamp); } /** + * @deprecated 4.2..5.0 Use ChangeDetectionMiddleware instead * @param string $etag */ public static function register_etag($etag) { - if (0 !== strpos($etag, '"')) { - $etag = sprintf('"%s"', $etag); + Deprecation::notice('5.0', 'Use ChangeDetectionMiddleware instead'); + if (strpos($etag, '"') !== 0) { + $etag = "\"{$etag}\""; } self::$etag = $etag; } @@ -372,179 +424,110 @@ class HTTP * Omitting the $body argument or passing a string is deprecated; in these cases, the headers are * output directly. * - * @param HTTPResponse $body + * @param HTTPResponse $response + * @deprecated 4.2..5.0 Headers are added automatically by HTTPCacheControlMiddleware instead. */ - public static function add_cache_headers($body = null) + public static function add_cache_headers($response = null) { - $cacheAge = self::$cache_age; + Deprecation::notice('5.0', 'Headers are added automatically by HTTPCacheControlMiddleware instead.'); - // Validate argument - if ($body && !($body instanceof HTTPResponse)) { - user_error("HTTP::add_cache_headers() must be passed an HTTPResponse object", E_USER_WARNING); - $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 HTTPResponse object to attach things to; no point in - // us trying. - if (headers_sent() && !$body) { + // Skip if deprecated API is disabled + if (Config::inst()->get(HTTP::class, 'ignoreDeprecatedCaching')) { return; } - // Populate $responseHeaders with all the headers that we want to build - $responseHeaders = array(); - - $cacheControlHeaders = HTTP::config()->uninherited('cache_control'); - - - // currently using a config setting to cancel this, seems to be so that the CMS caches ajax requests - if (function_exists('apache_request_headers') && static::config()->uninherited('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; - } + // Ensure a valid response object is provided + if (!$response instanceof HTTPResponse) { + user_error("HTTP::add_cache_headers() must be passed an HTTPResponse object", E_USER_WARNING); + return; } - if ($cacheAge > 0) { - $cacheControlHeaders['max-age'] = self::$cache_age; - - // 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"] = ""; - - // To do: User-Agent should only be added in situations where you *are* actually - // varying according to user-agent. - $vary = HTTP::config()->uninherited('vary'); - if ($vary && strlen($vary)) { - $responseHeaders['Vary'] = $vary; - } - } else { - $contentDisposition = null; - 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"; - } + // Warn if already assigned cache-control headers + if ($response->getHeader('Cache-Control')) { + trigger_error( + 'Cache-Control header has already been set. ' + . 'Please use HTTPCacheControlMiddleware API to set caching options instead.', + E_USER_WARNING + ); + return; } - foreach ($cacheControlHeaders as $header => $value) { - if (is_null($value)) { - unset($cacheControlHeaders[$header]); - } elseif ((is_bool($value) && $value) || $value === "true") { - $cacheControlHeaders[$header] = $header; - } else { - $cacheControlHeaders[$header] = $header . "=" . $value; - } + // Ensure a valid request object exists in the current context + if (!Injector::inst()->has(HTTPRequest::class)) { + user_error("HTTP::add_cache_headers() cannot work without a current HTTPRequest object", E_USER_WARNING); + return; } - $responseHeaders['Cache-Control'] = implode(', ', $cacheControlHeaders); - unset($cacheControlHeaders, $header, $value); + /** @var HTTPRequest $request */ + $request = Injector::inst()->get(HTTPRequest::class); - if (self::$modification_date && $cacheAge > 0) { - $responseHeaders["Last-Modified"] = self::gmt_date(self::$modification_date); - - // 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 - // 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. - // 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 - $etagParts = array(self::$modification_date, serialize($_COOKIE)); - $etagParts[] = Director::is_https() ? 'https' : 'http'; - if (isset($_SERVER['HTTP_USER_AGENT'])) { - $etagParts[] = $_SERVER['HTTP_USER_AGENT']; - } - if (isset($_SERVER['HTTP_ACCEPT'])) { - $etagParts[] = $_SERVER['HTTP_ACCEPT']; - } - - $etag = sha1(implode(':', $etagParts)); - $responseHeaders["ETag"] = $etag; - - // 304 response detection - if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { - $ifModifiedSince = strtotime(stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE'])); - - // 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 = !isset($_SERVER['HTTP_IF_NONE_MATCH']) || $_SERVER['HTTP_IF_NONE_MATCH'] == $etag; - - 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) { - $responseHeaders['ETag'] = self::$etag; - } - - // etag needs to be a quoted string according to HTTP spec - if (!empty($responseHeaders['ETag']) && 0 !== strpos($responseHeaders['ETag'], '"')) { - $responseHeaders['ETag'] = sprintf('"%s"', $responseHeaders['ETag']); - } - - // Now that we've generated them, either output them or attach them to the HTTPResponse as appropriate - foreach ($responseHeaders as $k => $v) { - if ($body) { - // Set the header now if it's not already set. - if ($body->getHeader($k) === null) { - $body->addHeader($k, $v); - } - } elseif (!headers_sent()) { - header("$k: $v"); - } - } + // Run middleware + ChangeDetectionMiddleware::singleton()->process($request, function (HTTPRequest $request) use ($response) { + return HTTPCacheControlMiddleware::singleton()->process($request, function (HTTPRequest $request) use ($response) { + return $response; + }); + }); } + /** + * Ensure that all deprecated HTTP cache settings are respected + * + * @deprecated 4.2..5.0 Use HTTPCacheControlMiddleware instead + * @param HTTPRequest $request + * @param HTTPResponse $response + */ + public static function augmentState(HTTPRequest $request, HTTPResponse $response) + { + // Skip if deprecated API is disabled + $config = Config::forClass(HTTP::class); + if ($config->get('ignoreDeprecatedCaching')) { + return; + } + + $cacheControlMiddleware = HTTPCacheControlMiddleware::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(true) or privateCache(true) calls + if ($config->get('disable_http_cache')) { + Deprecation::notice('5.0', 'Use HTTPCacheControlMiddleware.defaultState/.defaultForcingLevel instead'); + $cacheControlMiddleware->disableCache(); + } + + // if no caching ajax requests, disable ajax if is ajax request + if (!$config->get('cache_ajax_requests') && Director::is_ajax()) { + Deprecation::notice( + '5.0', + 'HTTP.cache_ajax_requests config is deprecated. Use HTTPCacheControlMiddleware::disableCache() instead' + ); + $cacheControlMiddleware->disableCache(); + } + + // Pass vary to middleware + $configVary = $config->get('vary'); + if ($configVary) { + Deprecation::notice('5.0', 'Use HTTPCacheControlMiddleware.defaultVary instead'); + $cacheControlMiddleware->addVary($configVary); + } + + // Set modification date + if (self::$modification_date) { + Deprecation::notice('5.0', 'Use HTTPCacheControlMiddleware::registerModificationDate() instead'); + $cacheControlMiddleware->registerModificationDate(self::$modification_date); + } + + // Ensure deprecated $etag property is assigned + if (self::$etag && !$cacheControlMiddleware->hasDirective('no-store') && !$response->getHeader('ETag')) { + Deprecation::notice('5.0', 'Etag should not be set explicitly'); + $response->addHeader('ETag', self::$etag); + } + } /** * 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 since January 1 1970 00:00:00 GMT) * * @param int $timestamp - * + * @deprecated 4.2..5.0 Inline if you need this * @return string */ public static function gmt_date($timestamp) diff --git a/src/Control/HTTPResponse.php b/src/Control/HTTPResponse.php index f26a5f50a..3ec7006c4 100644 --- a/src/Control/HTTPResponse.php +++ b/src/Control/HTTPResponse.php @@ -19,7 +19,7 @@ class HTTPResponse /** * @var array */ - protected static $status_codes = array( + protected static $status_codes = [ 100 => 'Continue', 101 => 'Switching Protocols', 200 => 'OK', @@ -61,20 +61,25 @@ class HTTPResponse 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 505 => 'HTTP Version Not Supported', - ); + ]; /** * @var array */ - protected static $redirect_codes = array( + protected static $redirect_codes = [ 301, 302, 303, 304, 305, 307, - 308 - ); + 308, + ]; + + /** + * @var string + */ + protected $protocolVersion = '1.0'; /** * @var int @@ -92,9 +97,9 @@ class HTTPResponse * @see http://en.wikipedia.org/wiki/List_of_HTTP_headers * @var array */ - protected $headers = array( + protected $headers = [ "content-type" => "text/html; charset=utf-8", - ); + ]; /** * @var string @@ -108,13 +113,35 @@ class HTTPResponse * @param int $statusCode The numeric status code - 200, 404, etc * @param string $statusDescription The text to be given alongside the status code. * See {@link setStatusCode()} for more information. + * @param string $protocolVersion */ - public function __construct($body = null, $statusCode = null, $statusDescription = null) + public function __construct($body = null, $statusCode = null, $statusDescription = null, $protocolVersion = null) { $this->setBody($body); if ($statusCode) { $this->setStatusCode($statusCode, $statusDescription); } + if (!$protocolVersion) { + if (preg_match('/HTTP\/(?\d+(\.\d+)?)/i', $_SERVER['SERVER_PROTOCOL'], $matches)) { + $protocolVersion = $matches['version']; + } + } + if ($protocolVersion) { + $this->setProtocolVersion($protocolVersion); + } + } + + /** + * The HTTP version used to respond to this request (typically 1.0 or 1.1) + * + * @param string $protocolVersion + * + * @return $this + */ + public function setProtocolVersion($protocolVersion) + { + $this->protocolVersion = $protocolVersion; + return $this; } /** @@ -123,6 +150,7 @@ class HTTPResponse * No newlines are allowed in the description. * If omitted, will default to the standard HTTP description * for the given $code value (see {@link $status_codes}). + * * @return $this */ public function setStatusCode($code, $description = null) @@ -146,6 +174,7 @@ class HTTPResponse * Caution: Will be overwritten by {@link setStatusCode()}. * * @param string $description + * * @return $this */ public function setStatusDescription($description) @@ -154,6 +183,14 @@ class HTTPResponse return $this; } + /** + * @return string + */ + public function getProtocolVersion() + { + return $this->protocolVersion; + } + /** * @return int */ @@ -167,7 +204,7 @@ class HTTPResponse */ public function getStatusDescription() { - return str_replace(array("\r","\n"), '', $this->statusDescription); + return str_replace(["\r", "\n"], '', $this->statusDescription); } /** @@ -183,11 +220,12 @@ class HTTPResponse /** * @param string $body + * * @return $this */ public function setBody($body) { - $this->body = $body ? (string) $body : $body; // Don't type-cast false-ish values, eg null is null not '' + $this->body = $body ? (string)$body : $body; // Don't type-cast false-ish values, eg null is null not '' return $this; } @@ -204,6 +242,7 @@ class HTTPResponse * * @param string $header Example: "content-type" * @param string $value Example: "text/xml" + * * @return $this */ public function addHeader($header, $value) @@ -217,7 +256,8 @@ class HTTPResponse * Return the HTTP header of the given name. * * @param string $header - * @returns string + * + * @return string */ public function getHeader($header) { @@ -241,6 +281,7 @@ class HTTPResponse * e.g. "Content-Type". * * @param string $header + * * @return $this */ public function removeHeader($header) @@ -253,6 +294,7 @@ class HTTPResponse /** * @param string $dest * @param int $code + * * @return $this */ public function redirect($dest, $code = 302) @@ -322,7 +364,7 @@ EOT ); header($method); foreach ($this->getHeaders() as $header => $value) { - header("{$header}: {$value}", true, $this->getStatusCode()); + header("{$header}: {$value}", true, $this->getStatusCode()); } } elseif ($this->getStatusCode() >= 300) { // It's critical that these status codes are sent; we need to report a failure if not. @@ -351,9 +393,9 @@ EOT /** @var HandlerInterface $handler */ $handler = Injector::inst()->get(HandlerInterface::class); $formatter = $handler->getFormatter(); - echo $formatter->format(array( - 'code' => $this->statusCode - )); + echo $formatter->format([ + 'code' => $this->statusCode, + ]); } else { echo $this->body; } @@ -379,4 +421,23 @@ EOT { return in_array($this->getStatusCode(), self::$redirect_codes); } + + /** + * The HTTP response represented as a raw string + * + * @return string + */ + public function __toString() + { + $headers = []; + foreach ($this->getHeaders() as $header => $values) { + foreach ((array)$values as $value) { + $headers[] = sprintf('%s: %s', $header, $value); + } + } + return + sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->getStatusCode(), $this->getStatusDescription()) . "\r\n" . + implode("\r\n", $headers) . "\r\n" . "\r\n" . + $this->getBody(); + } } diff --git a/src/Control/Middleware/CanonicalURLMiddleware.php b/src/Control/Middleware/CanonicalURLMiddleware.php index c007f0742..006eda696 100644 --- a/src/Control/Middleware/CanonicalURLMiddleware.php +++ b/src/Control/Middleware/CanonicalURLMiddleware.php @@ -407,7 +407,6 @@ class CanonicalURLMiddleware implements HTTPMiddleware // Force redirect $response = HTTPResponse::create(); $response->redirect($url, $this->getRedirectType()); - HTTP::add_cache_headers($response); return $response; } diff --git a/src/Control/Middleware/ChangeDetectionMiddleware.php b/src/Control/Middleware/ChangeDetectionMiddleware.php new file mode 100644 index 000000000..4980a95b7 --- /dev/null +++ b/src/Control/Middleware/ChangeDetectionMiddleware.php @@ -0,0 +1,94 @@ +getHeader('Cache-Control'); + if ($cacheControl && strstr($cacheControl, 'no-store')) { + return $response; + } + + // Generate, assign, and conditionally check etag + $etag = $this->generateETag($response); + if ($etag) { + $response->addHeader('ETag', $etag); + + // Check if we have a match + $ifNoneMatch = $request->getHeader('If-None-Match'); + if ($ifNoneMatch === $etag) { + return $this->sendNotModified($request, $response); + } + } + + // Check If-Modified-Since + $ifModifiedSince = $request->getHeader('If-Modified-Since'); + $lastModified = $response->getHeader('Last-Modified'); + if ($ifModifiedSince && $lastModified && strtotime($ifModifiedSince) >= strtotime($lastModified)) { + return $this->sendNotModified($request, $response); + } + + return $response; + } + + /** + * @param HTTPResponse|string $response + * @return string|false + */ + protected function generateETag(HTTPResponse $response) + { + // Existing e-tag + $etag = $response->getHeader('ETag'); + if ($etag) { + return $etag; + } + + // Generate etag from body + return sprintf('"%s"', md5($response->getBody())); + } + + /** + * Sent not-modified response + * + * @param HTTPRequest $request + * @param HTTPResponse $response + * @return mixed + */ + protected function sendNotModified(HTTPRequest $request, HTTPResponse $response) + { + // 304 is invalid for destructive requests + if (in_array($request->httpMethod(), ['POST', 'DELETE', 'PUT'])) { + $response->setStatusCode(412); + } else { + $response->setStatusCode(304); + } + $response->setBody(''); + return $response; + } +} diff --git a/src/Control/Middleware/FlushMiddleware.php b/src/Control/Middleware/FlushMiddleware.php index 0d8f8b482..fbe37f2a9 100644 --- a/src/Control/Middleware/FlushMiddleware.php +++ b/src/Control/Middleware/FlushMiddleware.php @@ -17,6 +17,9 @@ class FlushMiddleware implements HTTPMiddleware public function process(HTTPRequest $request, callable $delegate) { if (array_key_exists('flush', $request->getVars())) { + // Disable cache when flushing + HTTPCacheControlMiddleware::singleton()->disableCache(true); + foreach (ClassInfo::implementorsOf(Flushable::class) as $class) { /** @var Flushable|string $class */ $class::flush(); diff --git a/src/Control/Middleware/HTTPCacheControlMiddleware.php b/src/Control/Middleware/HTTPCacheControlMiddleware.php new file mode 100644 index 000000000..048303eb1 --- /dev/null +++ b/src/Control/Middleware/HTTPCacheControlMiddleware.php @@ -0,0 +1,779 @@ +getResponse(); + } + if (!$response) { + return null; + } + + // Update state based on current request and response objects + $this->augmentState($request, $response); + + // Update state based on deprecated HTTP settings + HTTP::augmentState($request, $response); + + // Add all headers to this response object + $this->applyToResponse($response); + + if (isset($ex)) { + throw $ex; + } + return $response; + } + + /** + * List of states, each of which contains a key of standard directives. + * Each directive should either be a numeric value, true to enable, + * or (bool)false or null to disable. + * Top level key states include `disabled`, `private`, `public`, `enabled` + * in descending order of precedence. + * + * This allows directives to be set independently for individual states. + * + * @var array + */ + protected $stateDirectives = [ + self::STATE_DISABLED => [ + 'no-cache' => true, + 'no-store' => true, + 'must-revalidate' => true, + ], + self::STATE_PRIVATE => [ + 'private' => true, + 'must-revalidate' => true, + ], + self::STATE_PUBLIC => [ + 'public' => true, + 'must-revalidate' => true, + ], + self::STATE_ENABLED => [ + 'must-revalidate' => true, + ], + ]; + + /** + * Set default state + * + * @config + * @var string + */ + protected static $defaultState = self::STATE_DISABLED; + + /** + * Current state + * + * @var string + */ + protected $state = null; + + /** + * Forcing level of previous setting; higher number wins + * Combination of consts below + * + * @var int + */ + protected $forcingLevel = null; + + /** + * List of vary keys + * + * @var array|null + */ + protected $vary = null; + + /** + * Latest modification date for this response + * + * @var int + */ + protected $modificationDate; + + /** + * Default vary + * + * @var array + */ + private static $defaultVary = [ + "X-Requested-With" => true, + "X-Forwarded-Protocol" => true, + ]; + + /** + * Default forcing level + * + * @config + * @var int + */ + private static $defaultForcingLevel = 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 = [ + 'public', + 'private', + 'no-cache', + 'max-age', + 's-maxage', + 'must-revalidate', + 'proxy-revalidate', + 'no-store', + 'no-transform', + ]; + + /** + * Get current vary keys + * + * @return array + */ + public function getVary() + { + // Explicitly set vary + if (isset($this->vary)) { + return $this->vary; + } + + // Load default from config + $defaultVary = $this->config()->get('defaultVary'); + return array_keys(array_filter($defaultVary)); + } + + /** + * Add a vary + * + * @param string|array $vary + * @return $this + */ + public function addVary($vary) + { + $combied = $this->combineVary($this->getVary(), $vary); + $this->setVary($combied); + return $this; + } + + /** + * Set vary + * + * @param array|string $vary + * @return $this + */ + public function setVary($vary) + { + $this->vary = $this->combineVary($vary); + return $this; + } + + /** + * Combine vary strings/arrays into a single array, or normalise a single vary + * + * @param string|array[] $varies Each vary as a separate arg + * @return array + */ + protected function combineVary(...$varies) + { + $merged = []; + foreach ($varies as $vary) { + if ($vary && is_string($vary)) { + $vary = array_filter(preg_split("/\s*,\s*/", trim($vary))); + } + if ($vary && is_array($vary)) { + $merged = array_merge($merged, $vary); + } + } + return array_unique($merged); + } + + + /** + * Register a modification date. Used to calculate the "Last-Modified" HTTP header. + * Can be called multiple times, and will automatically retain the most recent date. + * + * @param string|int $date Date string or timestamp + * @return HTTPCacheControlMiddleware + */ + public function registerModificationDate($date) + { + $timestamp = is_numeric($date) ? $date : strtotime($date); + if ($timestamp > $this->modificationDate) { + $this->modificationDate = $timestamp; + } + return $this; + } + + /** + * Set current state. Should only be invoked internally after processing precedence rules. + * + * @param string $state + * @return $this + */ + protected function setState($state) + { + if (!array_key_exists($state, $this->stateDirectives)) { + throw new InvalidArgumentException("Invalid state {$state}"); + } + $this->state = $state; + return $this; + } + + /** + * Get current state + * + * @return string + */ + public function getState() + { + return $this->state ?: $this->config()->get('defaultState'); + } + + /** + * 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->getForcingLevel()) { + return false; + } + $this->forcingLevel = $forcingLevel; + return true; + } + + /** + * Low level method for setting directives include any experimental or custom ones added via config. + * You need to specify the state (or states) to apply this directive to. + * Can also remove directives with false + * + * @param array|string $states State(s) to apply this directive to + * @param string $directive + * @param int|string|bool $value Flag to set for this value. Set to false to remove, or true to set. + * String or int value assign a specific value. + * @return $this + */ + public function setStateDirective($states, $directive, $value = true) + { + if ($value === null) { + throw new InvalidArgumentException("Invalid directive value"); + } + // 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)) { + throw new InvalidArgumentException('Directive ' . $directive . ' is not allowed'); + } + foreach ((array)$states as $state) { + if (!array_key_exists($state, $this->stateDirectives)) { + throw new InvalidArgumentException("Invalid state {$state}"); + } + // Set or unset directive + if ($value === false) { + unset($this->stateDirectives[$state][$directive]); + } else { + $this->stateDirectives[$state][$directive] = $value; + } + } + return $this; + } + + /** + * Low level method to set directives from an associative array + * + * @param array|string $states State(s) to apply this directive to + * @param array $directives + * @return $this + */ + public function setStateDirectivesFromArray($states, $directives) + { + foreach ($directives as $directive => $value) { + $this->setStateDirective($states, $directive, $value); + } + return $this; + } + + /** + * Low level method for removing directives + * + * @param array|string $states State(s) to remove this directive from + * @param string $directive + * @return $this + */ + public function removeStateDirective($states, $directive) + { + $this->setStateDirective($states, $directive, false); + return $this; + } + + /** + * Low level method to check if a directive is currently set + * + * @param string $state State(s) to apply this directive to + * @param string $directive + * @return bool + */ + public function hasStateDirective($state, $directive) + { + $directive = strtolower($directive); + return isset($this->stateDirectives[$state][$directive]); + } + + /** + * Check if the current state has the given directive. + * + * @param string $directive + * @return bool + */ + public function hasDirective($directive) + { + return $this->hasStateDirective($this->getState(), $directive); + } + + /** + * Low level method to get the value of a directive for a state. + * Returns false if there is no directive. + * True means the flag is set, otherwise the value of the directive. + * + * @param string $state + * @param string $directive + * @return int|string|bool + */ + public function getStateDirective($state, $directive) + { + $directive = strtolower($directive); + if (isset($this->stateDirectives[$state][$directive])) { + return $this->stateDirectives[$state][$directive]; + } + return false; + } + + /** + * Get the value of the given directive for the current state + * + * @param string $directive + * @return bool|int|string + */ + public function getDirective($directive) + { + return $this->getStateDirective($this->getState(), $directive); + } + + /** + * Get directives for the given state + * + * @param string $state + * @return array + */ + public function getStateDirectives($state) + { + return $this->stateDirectives[$state]; + } + + /** + * Get all directives for the currently active state + * + * @return array + */ + public function getDirectives() + { + return $this->getStateDirectives($this->getState()); + } + + /** + * The cache should not store anything about the client request or server response. + * Affects all non-disabled states. Use setStateDirective() instead to set for a single state. + * 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) + { + // Affect all non-disabled states + $applyTo = [self::STATE_ENABLED, self::STATE_PRIVATE, self::STATE_PUBLIC]; + if ($noStore) { + $this->setStateDirective($applyTo, 'no-store'); + $this->removeStateDirective($applyTo, 'max-age'); + $this->removeStateDirective($applyTo, 's-maxage'); + } else { + $this->removeStateDirective($applyTo, 'no-store'); + } + return $this; + } + + /** + * Forces caches to submit the request to the origin server for validation before releasing a cached copy. + * Affects all non-disabled states. Use setStateDirective() instead to set for a single state. + * + * @param bool $noCache + * @return $this + */ + public function setNoCache($noCache = true) + { + // Affect all non-disabled states + $applyTo = [self::STATE_ENABLED, self::STATE_PRIVATE, self::STATE_PUBLIC]; + $this->setStateDirective($applyTo, 'no-cache', $noCache); + 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. + * Affects all non-disabled states. Use setStateDirective() instead to set for a single state. + * + * @param int $age + * @return $this + */ + public function setMaxAge($age) + { + // Affect all non-disabled states + $applyTo = [self::STATE_ENABLED, self::STATE_PRIVATE, self::STATE_PUBLIC]; + $this->setStateDirective($applyTo, '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. + * Affects all non-disabled states. Use setStateDirective() instead to set for a single state. + * + * @param int $age + * @return $this + */ + public function setSharedMaxAge($age) + { + // Affect all non-disabled states + $applyTo = [self::STATE_ENABLED, self::STATE_PRIVATE, self::STATE_PUBLIC]; + $this->setStateDirective($applyTo, '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. + * Affects all non-disabled states. Use setStateDirective() instead to set for a single state. + * + * @param bool $mustRevalidate + * @return $this + */ + public function setMustRevalidate($mustRevalidate = true) + { + $applyTo = [self::STATE_ENABLED, self::STATE_PRIVATE, self::STATE_PUBLIC]; + $this->setStateDirective($applyTo, 'must-revalidate', $mustRevalidate); + return $this; + } + + /** + * Simple way to set cache control header to a cacheable state. + * + * The resulting cache-control headers will be chosen from the 'enabled' set of directives. + * + * 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)) { + $this->setState(self::STATE_ENABLED); + } + 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. + * + * The resulting cache-control headers will be chosen from the 'disabled' set of directives. + * + * 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)) { + $this->setState(self::STATE_DISABLED); + } + 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. + * + * The resulting cache-control headers will be chosen from the 'private' set of directives. + * + * @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)) { + $this->setState(self::STATE_PRIVATE); + } + 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) + * + * The resulting cache-control headers will be chosen from the 'private' set of directives. + * + * @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)) { + $this->setState(self::STATE_PUBLIC); + } + return $this; + } + + /** + * Generate all headers to add to this object + * + * @param HTTPResponse $response + * + * @return $this + */ + public function applyToResponse($response) + { + $headers = $this->generateHeadersFor($response); + foreach ($headers as $name => $value) { + if (!$response->getHeader($name)) { + $response->addHeader($name, $value); + } + } + return $this; + } + + /** + * Generate the cache header + * + * @return string + */ + protected function generateCacheHeader() + { + $cacheControl = []; + foreach ($this->getDirectives() as $directive => $value) { + if ($value === true) { + $cacheControl[] = $directive; + } else { + $cacheControl[] = $directive . '=' . $value; + } + } + return implode(', ', $cacheControl); + } + + /** + * Generate all headers to output + * + * @param HTTPResponse $response + * @return array + */ + public function generateHeadersFor(HTTPResponse $response) + { + return array_filter([ + 'Last-Modified' => $this->generateLastModifiedHeader(), + 'Vary' => $this->generateVaryHeader($response), + 'Cache-Control' => $this->generateCacheHeader(), + 'Expires' => $this->generateExpiresHeader(), + ]); + } + + /** + * Reset registered http cache control and force a fresh instance to be built + */ + public static function reset() + { + Injector::inst()->unregisterNamedObject(__CLASS__); + } + + /** + * @return int + */ + protected function getForcingLevel() + { + if (isset($this->forcingLevel)) { + return $this->forcingLevel; + } + return $this->config()->get('defaultForcingLevel'); + } + + /** + * Generate vary http header + * + * @param HTTPResponse $response + * @return string|null + */ + protected function generateVaryHeader(HTTPResponse $response) + { + // split the current vary header into it's parts and merge it with the config settings + // to create a list of unique vary values + $vary = $this->getVary(); + if ($response->getHeader('Vary')) { + $vary = $this->combineVary($vary, $response->getHeader('Vary')); + } + if ($vary) { + return implode(', ', $vary); + } + return null; + } + + /** + * Generate Last-Modified header + * + * @return string|null + */ + protected function generateLastModifiedHeader() + { + if (!$this->modificationDate) { + return null; + } + return gmdate('D, d M Y H:i:s', $this->modificationDate) . ' GMT'; + } + + /** + * Generate Expires http header + * + * @return null|string + */ + protected function generateExpiresHeader() + { + $maxAge = $this->getDirective('max-age'); + if ($maxAge === false) { + return null; + } + + // Add now to max-age to generate expires + $expires = DBDatetime::now()->getTimestamp() + $maxAge; + return gmdate('D, d M Y H:i:s', $expires) . ' GMT'; + } + + /** + * Update state based on current request and response objects + * + * @param HTTPRequest $request + * @param HTTPResponse $response + */ + protected function augmentState(HTTPRequest $request, HTTPResponse $response) + { + // If sessions exist we assume that the responses should not be cached by CDNs / proxies as we are + // likely to be supplying information relevant to the current user only + if ($request->getSession()->getAll()) { + // Don't force in case user code chooses to opt in to public caching + $this->privateCache(); + } + + // Errors disable cache (unless some errors are cached intentionally by usercode) + if ($response->isError()) { + // Even if publicCache(true) is specified, errors will be uncacheable + $this->disableCache(true); + } + + // Don't cache redirects + if ($response->isRedirect()) { + $this->disableCache(true); + } + } +} diff --git a/src/Control/RSS/RSSFeed.php b/src/Control/RSS/RSSFeed.php index e6806129e..804c7c756 100644 --- a/src/Control/RSS/RSSFeed.php +++ b/src/Control/RSS/RSSFeed.php @@ -2,6 +2,7 @@ namespace SilverStripe\Control\RSS; +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; use SilverStripe\ORM\SS_List; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\FieldType\DBHTMLText; @@ -226,17 +227,14 @@ class RSSFeed extends ViewableData $response = Controller::curr()->getResponse(); if (is_int($this->lastModified)) { - HTTP::register_modification_timestamp($this->lastModified); + HTTPCacheControlMiddleware::singleton()->registerModificationDate($this->lastModified); $response->addHeader("Last-Modified", gmdate("D, d M Y H:i:s", $this->lastModified) . ' GMT'); } if (!empty($this->etag)) { - HTTP::register_etag($this->etag); + $response->addHeader('ETag', "\"{$this->etag}\""); } - if (!headers_sent()) { - HTTP::add_cache_headers(); - $response->addHeader("Content-Type", "application/rss+xml; charset=utf-8"); - } + $response->addHeader("Content-Type", "application/rss+xml; charset=utf-8"); SSViewer::config()->update('source_file_comments', $prevState); return $this->renderWith($this->getTemplates()); diff --git a/src/Control/RequestHandler.php b/src/Control/RequestHandler.php index 2958b9d0b..369799f4c 100644 --- a/src/Control/RequestHandler.php +++ b/src/Control/RequestHandler.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Exception; use InvalidArgumentException; use ReflectionClass; +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; use SilverStripe\Dev\Debug; @@ -656,9 +657,6 @@ class RequestHandler extends ViewableData */ public function redirectBack() { - // Don't cache the redirect back ever - HTTP::set_cache_age(0); - // Prefer to redirect to ?BackURL, but fall back to Referer header // As a last resort redirect to base url $url = $this->getBackURL() diff --git a/src/Control/Session.php b/src/Control/Session.php index 9cba852b9..d0e68f35e 100644 --- a/src/Control/Session.php +++ b/src/Control/Session.php @@ -128,6 +128,15 @@ class Session */ private static $cookie_secure = false; + /** + * Name of session cache limiter to use. + * Defaults to '' to disable cache limiter entirely. + * + * @see https://secure.php.net/manual/en/function.session-cache-limiter.php + * @var string|null + */ + private static $sessionCacheLimiter = ''; + /** * Session data. * Will be null if session has not been started @@ -275,6 +284,11 @@ class Session session_name('SECSESSID'); } + $limiter = $this->config()->get('sessionCacheLimiter'); + if (isset($limiter)) { + session_cache_limiter($limiter); + } + session_start(); $this->data = isset($_SESSION) ? $_SESSION : array(); diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 84d6574a4..0619f78f5 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -5,8 +5,8 @@ namespace SilverStripe\Forms; use BadMethodCallException; use SilverStripe\Control\Controller; use SilverStripe\Control\HasRequestHandler; -use SilverStripe\Control\HTTP; use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; use SilverStripe\Control\NullHTTPRequest; use SilverStripe\Control\RequestHandler; use SilverStripe\Control\Session; @@ -868,25 +868,6 @@ class Form extends ViewableData implements HasRequestHandler { $exclude = (is_string($attrs)) ? func_get_args() : null; - // Figure out if we can cache this form - // - forms with validation shouldn't be cached, cos their error messages won't be shown - // - forms with security tokens shouldn't be cached because security tokens expire - $needsCacheDisabled = false; - if ($this->getSecurityToken()->isEnabled()) { - $needsCacheDisabled = true; - } - if ($this->FormMethod() != 'GET') { - $needsCacheDisabled = true; - } - if (!($this->validator instanceof RequiredFields) || count($this->validator->getRequired())) { - $needsCacheDisabled = true; - } - - // If we need to disable cache, do it - if ($needsCacheDisabled) { - HTTP::set_cache_age(0); - } - $attrs = $this->getAttributes(); // Remove empty @@ -1568,6 +1549,10 @@ class Form extends ViewableData implements HasRequestHandler */ public function forTemplate() { + if (!$this->canBeCached()) { + HTTPCacheControlMiddleware::singleton()->disableCache(); + } + $return = $this->renderWith($this->getTemplates()); // Now that we're rendered, clear message @@ -1823,4 +1808,30 @@ class Form extends ViewableData implements HasRequestHandler { return FormRequestHandler::create($this); } + + /** + * Can the body of this form be cached? + * + * @return bool + */ + protected function canBeCached() + { + if ($this->getSecurityToken()->isEnabled()) { + return false; + } + if ($this->FormMethod() !== 'GET') { + return false; + } + + // Don't cache if there are required fields, or some other complex validator + $validator = $this->getValidator(); + if ($validator instanceof RequiredFields) { + if (count($this->validator->getRequired())) { + return false; + } + } else { + return false; + } + return true; + } } diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index a72c55879..d66a25d2b 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -7,6 +7,7 @@ use Exception; use InvalidArgumentException; use LogicException; use SilverStripe\Control\HTTP; +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; @@ -379,12 +380,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $this->original = $this->record; - // Keep track of the modification date of all the data sourced to make this page - // From this we create a Last-Modified HTTP header - if (isset($record['LastEdited'])) { - HTTP::register_modification_date($record['LastEdited']); - } - // Must be called after parent constructor if (!$isSingleton && (!isset($this->record['ID']) || !$this->record['ID'])) { $this->populateDefaults(); diff --git a/tests/php/Control/HTTPCacheControlIntegrationTest.php b/tests/php/Control/HTTPCacheControlIntegrationTest.php new file mode 100644 index 000000000..94b0f1527 --- /dev/null +++ b/tests/php/Control/HTTPCacheControlIntegrationTest.php @@ -0,0 +1,111 @@ +set('defaultState', 'disabled') + ->set('defaultForcingLevel', 0); + HTTPCacheControlMiddleware::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); + } +} diff --git a/tests/php/Control/HTTPCacheControlIntegrationTest/RuleController.php b/tests/php/Control/HTTPCacheControlIntegrationTest/RuleController.php new file mode 100644 index 000000000..f5e72fc08 --- /dev/null +++ b/tests/php/Control/HTTPCacheControlIntegrationTest/RuleController.php @@ -0,0 +1,45 @@ +publicCache(); + } + + public function privateaction() + { + HTTPCacheControlMiddleware::singleton()->privateCache(); + return 'private content'; + } + + public function publicaction() + { + HTTPCacheControlMiddleware::singleton() + ->publicCache() + ->setMaxAge(9000); + return 'public content'; + } + + public function disabledaction() + { + HTTPCacheControlMiddleware::singleton()->disableCache(); + return 'uncached content'; + } +} diff --git a/tests/php/Control/HTTPCacheControlIntegrationTest/SessionController.php b/tests/php/Control/HTTPCacheControlIntegrationTest/SessionController.php new file mode 100644 index 000000000..cc55aafbd --- /dev/null +++ b/tests/php/Control/HTTPCacheControlIntegrationTest/SessionController.php @@ -0,0 +1,81 @@ +publicCache(); + } + + public function getContent() + { + return '

Hello world

'; + } + + 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'); + } + + /** + * @return string + * @throws \SilverStripe\Control\HTTPResponse_Exception + */ + 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; + } +} diff --git a/tests/php/Control/HTTPTest.php b/tests/php/Control/HTTPTest.php index 2945c088d..517a57020 100644 --- a/tests/php/Control/HTTPTest.php +++ b/tests/php/Control/HTTPTest.php @@ -7,8 +7,8 @@ use SilverStripe\Control\Director; use SilverStripe\Control\HTTP; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\Core\Kernel; +use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; +use SilverStripe\Control\Session; use SilverStripe\Dev\FunctionalTest; /** @@ -18,31 +18,49 @@ use SilverStripe\Dev\FunctionalTest; */ class HTTPTest extends FunctionalTest { + protected function setUp() + { + parent::setUp(); + // Set to disabled at null forcing level + HTTPCacheControlMiddleware::config() + ->set('defaultState', 'disabled') + ->set('defaultForcingLevel', 0); + HTTPCacheControlMiddleware::reset(); + } public function testAddCacheHeaders() { $body = "

Mysite

"; $response = new HTTPResponse($body, 200); - $this->assertEmpty($response->getHeader('Cache-Control')); + HTTPCacheControlMiddleware::singleton()->publicCache(); + HTTPCacheControlMiddleware::singleton()->setMaxAge(30); - HTTP::set_cache_age(30); - - HTTP::add_cache_headers($response); + $this->addCacheHeaders($response); $this->assertNotEmpty($response->getHeader('Cache-Control')); - // Ensure max-age is zero for development. - /** @var Kernel $kernel */ - $kernel = Injector::inst()->get(Kernel::class); - $kernel->setEnvironment(Kernel::DEV); + // Ensure cache headers are set correctly when disabled via config (e.g. when dev) + HTTPCacheControlMiddleware::config() + ->set('defaultState', 'disabled') + ->set('defaultForcingLevel', HTTPCacheControlMiddleware::LEVEL_DISABLED); + HTTPCacheControlMiddleware::reset(); + HTTPCacheControlMiddleware::singleton()->publicCache(); + HTTPCacheControlMiddleware::singleton()->setMaxAge(30); $response = new HTTPResponse($body, 200); - HTTP::add_cache_headers($response); - $this->assertContains('max-age=0', $response->getHeader('Cache-Control')); + $this->addCacheHeaders($response); + $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. - $kernel->setEnvironment(Kernel::LIVE); + HTTPCacheControlMiddleware::config() + ->set('defaultState', 'disabled') + ->set('defaultForcingLevel', 0); + HTTPCacheControlMiddleware::reset(); + HTTPCacheControlMiddleware::singleton()->publicCache(); + HTTPCacheControlMiddleware::singleton()->setMaxAge(30); $response = new HTTPResponse($body, 200); - HTTP::add_cache_headers($response); - $this->assertContains('max-age=30', explode(', ', $response->getHeader('Cache-Control'))); + $this->addCacheHeaders($response); + $this->assertContains('max-age=30', $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). @@ -51,39 +69,45 @@ class HTTPTest extends FunctionalTest 'Pragma' => 'no-cache', 'Cache-Control' => 'max-age=0, no-cache, no-store', ); - $response = new HTTPResponse($body, 200); - foreach ($headers as $name => $value) { - $response->addHeader($name, $value); + foreach ($headers as $header => $value) { + $response->addHeader($header, $value); } - HTTP::add_cache_headers($response); - foreach ($headers as $name => $value) { - $this->assertEquals($value, $response->getHeader($name)); + HTTPCacheControlMiddleware::reset(); + HTTPCacheControlMiddleware::singleton()->publicCache(); + HTTPCacheControlMiddleware::singleton()->setMaxAge(30); + $this->addCacheHeaders($response); + foreach ($headers as $header => $value) { + $this->assertEquals($value, $response->getHeader($header)); } } public function testConfigVary() { - /** @var Kernel $kernel */ - $kernel = Injector::inst()->get(Kernel::class); $body = "

Mysite

"; $response = new HTTPResponse($body, 200); - $kernel->setEnvironment(Kernel::LIVE); - HTTP::set_cache_age(30); - HTTP::add_cache_headers($response); + HTTPCacheControlMiddleware::singleton() + ->setMaxAge(30) + ->setVary('X-Requested-With, X-Forwarded-Protocol'); + $this->addCacheHeaders($response); + // Vary set properly $v = $response->getHeader('Vary'); - $this->assertNotEmpty($v); - - $this->assertContains("Cookie", $v); $this->assertContains("X-Forwarded-Protocol", $v); - $this->assertContains("User-Agent", $v); - $this->assertContains("Accept", $v); + $this->assertContains("X-Requested-With", $v); + $this->assertNotContains("Cookie", $v); + $this->assertNotContains("User-Agent", $v); + $this->assertNotContains("Accept", $v); - HTTP::config()->update('vary', ''); + // No vary + HTTPCacheControlMiddleware::singleton() + ->setMaxAge(30) + ->setVary(null); + HTTPCacheControlMiddleware::reset(); + HTTPCacheControlMiddleware::config() + ->set('defaultVary', []); $response = new HTTPResponse($body, 200); - HTTP::add_cache_headers($response); - + $this->addCacheHeaders($response); $v = $response->getHeader('Vary'); $this->assertEmpty($v); } @@ -370,4 +394,23 @@ class HTTPTest extends FunctionalTest } ); } + + /** + * Process cache headers on a response + * + * @param HTTPResponse $response + */ + protected function addCacheHeaders(HTTPResponse $response) + { + // Mock request + $session = new Session([]); + $request = new HTTPRequest('GET', '/'); + $request->setSession($session); + + // Run middleware + HTTPCacheControlMiddleware::singleton() + ->process($request, function (HTTPRequest $request) use ($response) { + return $response; + }); + } } diff --git a/tests/php/Control/Middleware/HTTPCacheControlMiddlewareTest.php b/tests/php/Control/Middleware/HTTPCacheControlMiddlewareTest.php new file mode 100644 index 000000000..3eeff44f0 --- /dev/null +++ b/tests/php/Control/Middleware/HTTPCacheControlMiddlewareTest.php @@ -0,0 +1,79 @@ +set('defaultState', 'disabled') + ->set('defaultForcingLevel', 0); + HTTPCacheControlMiddleware::reset(); + } + + public function testCachingPriorities() + { + $hcc = new HTTPCacheControlMiddleware(); + $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(HTTPCacheControlMiddleware $hcc) + { + return $hcc->hasDirective('private') && !$hcc->hasDirective('public') && !$hcc->hasDirective('no-cache'); + } + + protected function isPublic(HTTPCacheControlMiddleware $hcc) + { + return $hcc->hasDirective('public') && !$hcc->hasDirective('private') && !$hcc->hasDirective('no-cache'); + } + + protected function isDisabled(HTTPCacheControlMiddleware $hcc) + { + return $hcc->hasDirective('no-cache') && !$hcc->hasDirective('private') && !$hcc->hasDirective('public'); + } +} diff --git a/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php b/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php index d1e4175f8..5cce89d3e 100644 --- a/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php +++ b/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php @@ -2,7 +2,6 @@ namespace SilverStripe\Core\Tests\Startup; -use SilverStripe\Control\Cookie; use SilverStripe\Control\HTTPApplication; use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPResponse;