Passed
Pull Request — master (#18)
by Robbie
03:29
created

CachingPolicy   A

Complexity

Total Complexity 35

Size/Duplication

Total Lines 208
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 35
dl 0
loc 208
rs 9
c 0
b 0
f 0

5 Methods

Rating   Name   Duplication   Size   Complexity  
A getCacheAge() 0 3 1
A setVary() 0 4 1
A getVary() 0 3 1
A setCacheAge() 0 4 1
F applyToResponse() 0 129 31
1
<?php
2
3
namespace SilverStripe\ControllerPolicy\Policies;
4
5
use SilverStripe\Control\Director;
6
use SilverStripe\Control\HTTP;
7
use SilverStripe\Control\HTTPRequest;
8
use SilverStripe\Control\HTTPResponse;
9
use SilverStripe\ControllerPolicy\ControllerPolicy;
10
11
/**
12
 * A rewrite of the default SilverStripe behaviour allowing more customisation. Consuming code can provide its own
13
 * callbacks for providing custom cacheAge, vary and timestamp parameters.
14
 *
15
 * See PageControlledPolicy as an example implementation of such customisation that applies on top of default.
16
 *
17
 * Extends HTTP to get access to globals describing the Last-Modified and Etag data.
18
 */
19
20
class CachingPolicy extends HTTP implements ControllerPolicy
21
{
22
    /**
23
     * Whether to disable the cache age (set to zero) in dev environments
24
     *
25
     * @config
26
     * @var bool
27
     */
28
    private static $disable_cache_age_in_dev = true;
0 ignored issues
show
introduced by
The private property $disable_cache_age_in_dev is not used, and could be removed.
Loading history...
29
30
    /**
31
     * Max-age seconds to cache for if configuration not available from the originator.
32
     *
33
     * @var int
34
     */
35
    protected $cacheAge = 0;
36
37
    /**
38
     * Vary string to add if configuration is not available from the originator.
39
     *
40
     * Note on vary headers: Do not add user-agent unless you vary on it AND you have configured user-agent
41
     * clustering in some way, otherwise this will be an equivalent to disabling caching as there
42
     * is a lot of different UAs in the wild.
43
     *
44
     * @var string
45
     */
46
    protected $vary = 'Cookie, X-Forwarded-Protocol';
47
48
    /**
49
     * Set the cache age
50
     *
51
     * @param int $cacheAge
52
     * @return $this
53
     */
54
    public function setCacheAge($cacheAge)
55
    {
56
        $this->cacheAge = $cacheAge;
57
        return $this;
58
    }
59
60
    /**
61
     * Get the cache age
62
     *
63
     * @return int
64
     */
65
    public function getCacheAge()
66
    {
67
        return $this->cacheAge;
68
    }
69
70
    /**
71
     * Set the "vary" content header
72
     *
73
     * @param string $vary
74
     * @return $this
75
     */
76
    public function setVary($vary)
77
    {
78
        $this->vary = $vary;
79
        return $this;
80
    }
81
82
    /**
83
     * Get the "vary" content header
84
     *
85
     * @return string
86
     */
87
    public function getVary()
88
    {
89
        return $this->vary;
90
    }
91
92
    /**
93
     * @see HTTP::add_cache_headers()
94
     *
95
     * @param object $originator
96
     * @param HTTPRequest $request
97
     * @param HTTPResponse $response
98
     */
99
    public function applyToResponse($originator, HTTPRequest $request, HTTPResponse $response)
100
    {
101
        $cacheAge = $this->getCacheAge();
102
        $vary = $this->getVary();
103
104
        // Allow overriding max-age from the object hooked up to the policed controller.
105
        if ($originator->hasMethod('getCacheAge')) {
106
            $extendedCacheAge = $originator->getCacheAge($cacheAge);
107
            if ($extendedCacheAge !== null) {
108
                $cacheAge = $extendedCacheAge;
109
            }
110
        }
111
112
        // Same for vary, but probably less useful.
113
        if ($originator->hasMethod('getVary')) {
114
            $extendedVary = $originator->getVary($vary);
115
            if ($extendedVary !== null) {
116
                $vary = $extendedVary;
117
            }
118
        }
119
120
        // Development sites have frequently changing templates; this can get stuffed up by the code
121
        // below.
122
        if (Director::isDev() && $this->config()->get('disable_cache_age_in_dev')) {
123
            $cacheAge = 0;
124
        }
125
126
        // The headers have been sent and we don't have an HTTPResponse object to attach things to; no point in
127
        // us trying.
128
        if (headers_sent() && !$response->getBody()) {
129
            return;
130
        }
131
132
        // Populate $responseHeaders with all the headers that we want to build
133
        $responseHeaders = [];
134
135
        $cacheControlHeaders = HTTP::config()->uninherited('cache_control');
136
137
        if ($cacheAge > 0) {
138
            // Note: must-revalidate means that the cache must revalidate AFTER the entry has gone stale.
139
            $cacheControlHeaders['must-revalidate'] = 'true';
140
141
            $cacheControlHeaders['max-age'] = $cacheAge;
142
143
            // Set empty pragma to avoid PHP's session_cache_limiter adding conflicting caching information,
144
            // defaulting to "nocache" on most PHP configurations (see http://php.net/session_cache_limiter).
145
            // Since it's a deprecated HTTP 1.0 option, all modern HTTP clients and proxies should
146
            // prefer the caching information indicated through the "Cache-Control" header.
147
            $responseHeaders["Pragma"] = "";
148
149
            $responseHeaders['Vary'] = $vary;
150
        }
151
152
        foreach ($cacheControlHeaders as $header => $value) {
153
            if (is_null($value)) {
154
                unset($cacheControlHeaders[$header]);
155
            } elseif ((is_bool($value) && $value) || $value === "true") {
156
                $cacheControlHeaders[$header] = $header;
157
            } else {
158
                $cacheControlHeaders[$header] = $header . "=" . $value;
159
            }
160
        }
161
162
        $responseHeaders['Cache-Control'] = implode(', ', $cacheControlHeaders);
163
        unset($cacheControlHeaders, $header, $value);
164
165
        // Find out when the URI was last modified. Allows customisation, but fall back HTTP timestamp collector.
166
        if ($originator->hasMethod('getModificationTimestamp')) {
167
            $timestamp = $originator->getModificationTimestamp();
168
        } elseif (self::$modification_date && $cacheAge > 0) {
169
            $timestamp = self::$modification_date;
170
        }
171
172
        if (isset($timestamp)) {
173
            $responseHeaders["Last-Modified"] = self::gmt_date($timestamp);
174
175
            // Chrome ignores Varies when redirecting back (http://code.google.com/p/chromium/issues/detail?id=79758)
176
            // which means that if you log out, you get redirected back to a page which Chrome then checks against
177
            // last-modified (which passes, getting a 304)
178
            // when it shouldn't be trying to use that page at all because it's the "logged in" version.
179
            // By also using and etag that includes both the modification date and all the varies
180
            // values which we also check against we can catch this and not return a 304
181
            $etagParts = array(self::$modification_date, serialize($_COOKIE));
182
            $etagParts[] = Director::is_https() ? 'https' : 'http';
183
            if (isset($_SERVER['HTTP_USER_AGENT'])) {
184
                $etagParts[] = $_SERVER['HTTP_USER_AGENT'];
185
            }
186
            if (isset($_SERVER['HTTP_ACCEPT'])) {
187
                $etagParts[] = $_SERVER['HTTP_ACCEPT'];
188
            }
189
190
            $etag = sha1(implode(':', $etagParts));
191
            $responseHeaders["ETag"] = $etag;
192
193
            // 304 response detection
194
            if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
195
                $ifModifiedSince = strtotime(stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE']));
196
197
                // As above, only 304 if the last request had all the same varies values
198
                // (or the etag isn't passed as part of the request - but with chrome it always is)
199
                $matchesEtag = !isset($_SERVER['HTTP_IF_NONE_MATCH']) || $_SERVER['HTTP_IF_NONE_MATCH'] == $etag;
200
201
                if ($ifModifiedSince >= self::$modification_date && $matchesEtag) {
202
                    if ($body) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $body seems to be never defined.
Loading history...
203
                        $body->setStatusCode(304);
204
                        $body->setBody('');
205
                    } else {
206
                        header('HTTP/1.0 304 Not Modified');
207
                        die();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
208
                    }
209
                }
210
            }
211
212
            $expires = time() + $cacheAge;
213
            $responseHeaders["Expires"] = self::gmt_date($expires);
214
        }
215
216
        if (self::$etag) {
217
            $responseHeaders['ETag'] = self::$etag;
218
        }
219
220
        // etag needs to be a quoted string according to HTTP spec
221
        if (!empty($responseHeaders['ETag']) && 0 !== strpos($responseHeaders['ETag'], '"')) {
222
            $responseHeaders['ETag'] = sprintf('"%s"', $responseHeaders['ETag']);
223
        }
224
225
        // Now that we've generated them, either output them or attach them to the HTTPResponse as appropriate
226
        foreach ($responseHeaders as $k => $v) {
227
            $response->addHeader($k, $v);
228
        }
229
    }
230
}
231