Merge pull request #8166 from open-sausages/pulls/4/http-cache-middleware

NEW Add better HTTP cache-control manipulation (4.x branch)
This commit is contained in:
Ingo Schommer 2018-06-14 17:19:54 +12:00 committed by GitHub
commit bd84944c0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1962 additions and 293 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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
<?php
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\CMS\Controllers\ContentController;
class PageController extends ContentController
{
public function init()
{
HTTPCacheControlMiddleware::singleton()
->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
<?php
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\CMS\Controllers\ContentController;
class PageController extends ContentController
{
public function myprivateaction($request)
{
HTTPCacheControlMiddleware::singleton()
->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
<?php
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\CMS\Controllers\ContentController;
class PageController extends ContentController
{
public function init()
{
HTTPCacheControlMiddleware::singleton()
->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:

View File

@ -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/)

View File

@ -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
<?php
-use SilverStripe\Control\HTTP;
+use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\CMS\Controllers\ContentController;
class PageController extends ContentController
{
public function init()
{
- HTTP::set_cache_age(60);
+ HTTPCacheControlMiddleware::singleton()
+ ->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
<?php
-use SilverStripe\Control\HTTP;
+use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\CMS\Controllers\ContentController;
class PageController extends ContentController
{
public function myprivateaction($request)
{
- HTTP::set_cache_age(0);
+ HTTPCacheControlMiddleware::singleton()
+ ->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
<?php
-use SilverStripe\Control\HTTP;
+use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\CMS\Controllers\ContentController;
class PageController extends ContentController
{
public function init()
{
- HTTP::set_cache_age(60);
+ HTTPCacheControlMiddleware::singleton()
+ ->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

View File

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

View File

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

View File

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

View File

@ -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\/(?<version>\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();
}
}

View File

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

View File

@ -0,0 +1,94 @@
<?php
namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Injector\Injectable;
/**
* Handles internal change detection via etag / ifmodifiedsince headers,
* conditonally sending a 304 not modified if possible.
*/
class ChangeDetectionMiddleware implements HTTPMiddleware
{
use Injectable;
/**
* Generate response for the given request
*
* @param HTTPRequest $request
* @param callable $delegate
* @return HTTPResponse
*/
public function process(HTTPRequest $request, callable $delegate)
{
/** @var HTTPResponse $response */
$response = $delegate($request);
if (!$response) {
return null;
}
// Ignore etag for no-store
$cacheControl = $response->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;
}
}

View File

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

View File

@ -0,0 +1,779 @@
<?php
namespace SilverStripe\Control\Middleware;
use InvalidArgumentException;
use SilverStripe\Control\HTTP;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Resettable;
use SilverStripe\ORM\FieldType\DBDatetime;
class HTTPCacheControlMiddleware implements HTTPMiddleware, Resettable
{
use Configurable;
use Injectable;
const STATE_ENABLED = 'enabled';
const STATE_PUBLIC = 'public';
const STATE_PRIVATE = 'private';
const STATE_DISABLED = 'disabled';
/**
* Generate response for the given request
*
* @todo Refactor HTTP::add_cache_headers() (e.g. etag handling) into this middleware
*
* @param HTTPRequest $request
* @param callable $delegate
* @return HTTPResponse
* @throws HTTPResponse_Exception
*/
public function process(HTTPRequest $request, callable $delegate)
{
try {
$response = $delegate($request);
} catch (HTTPResponse_Exception $ex) {
$response = $ex->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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,111 @@
<?php
namespace SilverStripe\Control\Tests;
use SilverStripe\Control\HTTP;
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\Control\Tests\HTTPCacheControlIntegrationTest\RuleController;
use SilverStripe\Control\Tests\HTTPCacheControlIntegrationTest\SessionController;
use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\FunctionalTest;
class HTTPCacheControlIntegrationTest extends FunctionalTest
{
protected static $extra_controllers = [
SessionController::class,
RuleController::class,
];
protected function setUp()
{
parent::setUp();
HTTPCacheControlMiddleware::config()
->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);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace SilverStripe\Control\Tests\HTTPCacheControlIntegrationTest;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\Dev\TestOnly;
class RuleController extends Controller implements TestOnly
{
private static $url_segment = 'HTTPCacheControlIntegrationTest_RuleController';
private static $allowed_actions = [
'privateaction',
'publicaction',
'disabledaction',
];
protected function init()
{
parent::init();
// Prefer public by default
HTTPCacheControlMiddleware::singleton()->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';
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace SilverStripe\Control\Tests\HTTPCacheControlIntegrationTest;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\Dev\TestOnly;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\TextField;
use SilverStripe\Security\Permission;
use SilverStripe\Security\SecurityToken;
class SessionController extends Controller implements TestOnly
{
private static $url_segment = 'HTTPCacheControlIntegrationTest_SessionController';
private static $allowed_actions = [
'showform',
'privateaction',
'publicaction',
'showpublicform',
'Form',
];
protected function init()
{
parent::init();
// Prefer public by default
HTTPCacheControlMiddleware::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');
}
/**
* @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;
}
}

View File

@ -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 = "<html><head></head><body><h1>Mysite</h1></body></html>";
$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 = "<html><head></head><body><h1>Mysite</h1></body></html>";
$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;
});
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace SilverStripe\Control\Tests\Middleware;
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\Dev\SapphireTest;
class HTTPCacheControlMiddlewareTest extends SapphireTest
{
protected function setUp()
{
parent::setUp();
// Set to disabled at null forcing level
HTTPCacheControlMiddleware::config()
->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');
}
}

View File

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