Skip to content

Commit 893118f

Browse files
aschemppfabpot
authored andcommitted
[HttpKernel] Correctly merging cache directives in HttpCache/ResponseCacheStrategy
1 parent 3cfb558 commit 893118f

File tree

2 files changed

+391
-36
lines changed

2 files changed

+391
-36
lines changed

src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php

Lines changed: 162 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
*
66
* (c) Fabien Potencier <fabien@symfony.com>
77
*
8-
* This code is partially based on the Rack-Cache library by Ryan Tomayko,
9-
* which is released under the MIT license.
10-
* (based on commit 02d2b48d75bcb63cf1c0c7149c077ad256542801)
11-
*
128
* For the full copyright and license information, please view the LICENSE
139
* file that was distributed with this source code.
1410
*/
@@ -28,30 +24,69 @@
2824
*/
2925
class ResponseCacheStrategy implements ResponseCacheStrategyInterface
3026
{
31-
private $cacheable = true;
27+
/**
28+
* Cache-Control headers that are sent to the final response if they appear in ANY of the responses.
29+
*/
30+
private static $overrideDirectives = ['private', 'no-cache', 'no-store', 'no-transform', 'must-revalidate', 'proxy-revalidate'];
31+
32+
/**
33+
* Cache-Control headers that are sent to the final response if they appear in ALL of the responses.
34+
*/
35+
private static $inheritDirectives = ['public', 'immutable'];
36+
3237
private $embeddedResponses = 0;
33-
private $ttls = [];
34-
private $maxAges = [];
3538
private $isNotCacheableResponseEmbedded = false;
39+
private $age = 0;
40+
private $flagDirectives = [
41+
'no-cache' => null,
42+
'no-store' => null,
43+
'no-transform' => null,
44+
'must-revalidate' => null,
45+
'proxy-revalidate' => null,
46+
'public' => null,
47+
'private' => null,
48+
'immutable' => null,
49+
];
50+
private $ageDirectives = [
51+
'max-age' => null,
52+
's-maxage' => null,
53+
'expires' => null,
54+
];
3655

3756
/**
3857
* {@inheritdoc}
3958
*/
4059
public function add(Response $response)
4160
{
42-
if (!$response->isFresh() || !$response->isCacheable()) {
43-
$this->cacheable = false;
44-
} else {
45-
$maxAge = $response->getMaxAge();
46-
$this->ttls[] = $response->getTtl();
47-
$this->maxAges[] = $maxAge;
48-
49-
if (null === $maxAge) {
50-
$this->isNotCacheableResponseEmbedded = true;
61+
++$this->embeddedResponses;
62+
63+
foreach (self::$overrideDirectives as $directive) {
64+
if ($response->headers->hasCacheControlDirective($directive)) {
65+
$this->flagDirectives[$directive] = true;
5166
}
5267
}
5368

54-
++$this->embeddedResponses;
69+
foreach (self::$inheritDirectives as $directive) {
70+
if (false !== $this->flagDirectives[$directive]) {
71+
$this->flagDirectives[$directive] = $response->headers->hasCacheControlDirective($directive);
72+
}
73+
}
74+
75+
$age = $response->getAge();
76+
$this->age = max($this->age, $age);
77+
78+
if ($this->willMakeFinalResponseUncacheable($response)) {
79+
$this->isNotCacheableResponseEmbedded = true;
80+
81+
return;
82+
}
83+
84+
$this->storeRelativeAgeDirective('max-age', $response->headers->getCacheControlDirective('max-age'), $age);
85+
$this->storeRelativeAgeDirective('s-maxage', $response->headers->getCacheControlDirective('s-maxage') ?: $response->headers->getCacheControlDirective('max-age'), $age);
86+
87+
$expires = $response->getExpires();
88+
$expires = null !== $expires ? $expires->format('U') - $response->getDate()->format('U') : null;
89+
$this->storeRelativeAgeDirective('expires', $expires >= 0 ? $expires : null, 0);
5590
}
5691

5792
/**
@@ -64,33 +99,124 @@ public function update(Response $response)
6499
return;
65100
}
66101

67-
// Remove validation related headers in order to avoid browsers using
68-
// their own cache, because some of the response content comes from
69-
// at least one embedded response (which likely has a different caching strategy).
70-
if ($response->isValidateable()) {
71-
$response->setEtag(null);
72-
$response->setLastModified(null);
102+
// Remove validation related headers of the master response,
103+
// because some of the response content comes from at least
104+
// one embedded response (which likely has a different caching strategy).
105+
$response->setEtag(null);
106+
$response->setLastModified(null);
107+
108+
$this->add($response);
109+
110+
$response->headers->set('Age', $this->age);
111+
112+
if ($this->isNotCacheableResponseEmbedded) {
113+
$response->setExpires($response->getDate());
114+
115+
if ($this->flagDirectives['no-store']) {
116+
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
117+
} else {
118+
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
119+
}
120+
121+
return;
122+
}
123+
124+
$flags = array_filter($this->flagDirectives);
125+
126+
if (isset($flags['must-revalidate'])) {
127+
$flags['no-cache'] = true;
73128
}
74129

75-
if (!$response->isFresh() || !$response->isCacheable()) {
76-
$this->cacheable = false;
130+
$response->headers->set('Cache-Control', implode(', ', array_keys($flags)));
131+
132+
$maxAge = null;
133+
$sMaxage = null;
134+
135+
if (\is_numeric($this->ageDirectives['max-age'])) {
136+
$maxAge = $this->ageDirectives['max-age'] + $this->age;
137+
$response->headers->addCacheControlDirective('max-age', $maxAge);
77138
}
78139

79-
if (!$this->cacheable) {
80-
$response->headers->set('Cache-Control', 'no-cache, must-revalidate');
140+
if (\is_numeric($this->ageDirectives['s-maxage'])) {
141+
$sMaxage = $this->ageDirectives['s-maxage'] + $this->age;
81142

82-
return;
143+
if ($maxAge !== $sMaxage) {
144+
$response->headers->addCacheControlDirective('s-maxage', $sMaxage);
145+
}
146+
}
147+
148+
if (\is_numeric($this->ageDirectives['expires'])) {
149+
$date = clone $response->getDate();
150+
$date->modify('+'.($this->ageDirectives['expires'] + $this->age).' seconds');
151+
$response->setExpires($date);
83152
}
153+
}
84154

85-
$this->ttls[] = $response->getTtl();
86-
$this->maxAges[] = $response->getMaxAge();
155+
/**
156+
* RFC2616, Section 13.4.
157+
*
158+
* @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4
159+
*
160+
* @return bool
161+
*/
162+
private function willMakeFinalResponseUncacheable(Response $response)
163+
{
164+
// RFC2616: A response received with a status code of 200, 203, 300, 301 or 410
165+
// MAY be stored by a cache […] unless a cache-control directive prohibits caching.
166+
if ($response->headers->hasCacheControlDirective('no-cache')
167+
|| $response->headers->getCacheControlDirective('no-store')
168+
) {
169+
return true;
170+
}
87171

88-
if ($this->isNotCacheableResponseEmbedded) {
89-
$response->headers->removeCacheControlDirective('s-maxage');
90-
} elseif (null !== $maxAge = min($this->maxAges)) {
91-
$response->setSharedMaxAge($maxAge);
92-
$response->headers->set('Age', $maxAge - min($this->ttls));
172+
// Last-Modified and Etag headers cannot be merged, they render the response uncacheable
173+
// by default (except if the response also has max-age etc.).
174+
if (\in_array($response->getStatusCode(), [200, 203, 300, 301, 410])
175+
&& null === $response->getLastModified()
176+
&& null === $response->getEtag()
177+
) {
178+
return false;
179+
}
180+
181+
// RFC2616: A response received with any other status code (e.g. status codes 302 and 307)
182+
// MUST NOT be returned in a reply to a subsequent request unless there are
183+
// cache-control directives or another header(s) that explicitly allow it.
184+
$cacheControl = ['max-age', 's-maxage', 'must-revalidate', 'proxy-revalidate', 'public', 'private'];
185+
foreach ($cacheControl as $key) {
186+
if ($response->headers->hasCacheControlDirective($key)) {
187+
return false;
188+
}
189+
}
190+
191+
if ($response->headers->has('Expires')) {
192+
return false;
193+
}
194+
195+
return true;
196+
}
197+
198+
/**
199+
* Store lowest max-age/s-maxage/expires for the final response.
200+
*
201+
* The response might have been stored in cache a while ago. To keep things comparable,
202+
* we have to subtract the age so that the value is normalized for an age of 0.
203+
*
204+
* If the value is lower than the currently stored value, we update the value, to keep a rolling
205+
* minimal value of each instruction. If the value is NULL, the directive will not be set on the final response.
206+
*
207+
* @param string $directive
208+
* @param int|null $value
209+
* @param int $age
210+
*/
211+
private function storeRelativeAgeDirective($directive, $value, $age)
212+
{
213+
if (null === $value) {
214+
$this->ageDirectives[$directive] = false;
215+
}
216+
217+
if (false !== $this->ageDirectives[$directive]) {
218+
$value = $value - $age;
219+
$this->ageDirectives[$directive] = null !== $this->ageDirectives[$directive] ? min($this->ageDirectives[$directive], $value) : $value;
93220
}
94-
$response->setMaxAge(0);
95221
}
96222
}

0 commit comments

Comments
 (0)