Skip to content

Commit d3e65d6

Browse files
aschemppnicolas-grekas
authored andcommitted
[HttpKernel] Correctly merge max-age/s-maxage and Expires headers
1 parent d7000e6 commit d3e65d6

File tree

2 files changed

+89
-33
lines changed

2 files changed

+89
-33
lines changed

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

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class ResponseCacheStrategy implements ResponseCacheStrategyInterface
5050
private $ageDirectives = [
5151
'max-age' => null,
5252
's-maxage' => null,
53-
'expires' => null,
53+
'expires' => false,
5454
];
5555

5656
/**
@@ -81,15 +81,30 @@ public function add(Response $response)
8181
return;
8282
}
8383

84-
$isHeuristicallyCacheable = $response->headers->hasCacheControlDirective('public');
8584
$maxAge = $response->headers->hasCacheControlDirective('max-age') ? (int) $response->headers->getCacheControlDirective('max-age') : null;
86-
$this->storeRelativeAgeDirective('max-age', $maxAge, $age, $isHeuristicallyCacheable);
8785
$sharedMaxAge = $response->headers->hasCacheControlDirective('s-maxage') ? (int) $response->headers->getCacheControlDirective('s-maxage') : $maxAge;
88-
$this->storeRelativeAgeDirective('s-maxage', $sharedMaxAge, $age, $isHeuristicallyCacheable);
89-
9086
$expires = $response->getExpires();
9187
$expires = null !== $expires ? (int) $expires->format('U') - (int) $response->getDate()->format('U') : null;
92-
$this->storeRelativeAgeDirective('expires', $expires >= 0 ? $expires : null, 0, $isHeuristicallyCacheable);
88+
89+
// See https://datatracker.ietf.org/doc/html/rfc7234#section-4.2.2
90+
// If a response is "public" but does not have maximum lifetime, heuristics might be applied.
91+
// Do not store NULL values so the final response can have more limiting value from other responses.
92+
$isHeuristicallyCacheable = $response->headers->hasCacheControlDirective('public')
93+
&& null === $maxAge
94+
&& null === $sharedMaxAge
95+
&& null === $expires;
96+
97+
if (!$isHeuristicallyCacheable || null !== $maxAge || null !== $expires) {
98+
$this->storeRelativeAgeDirective('max-age', $maxAge, $expires, $age);
99+
}
100+
101+
if (!$isHeuristicallyCacheable || null !== $sharedMaxAge || null !== $expires) {
102+
$this->storeRelativeAgeDirective('s-maxage', $sharedMaxAge, $expires, $age);
103+
}
104+
105+
if (null !== $expires) {
106+
$this->ageDirectives['expires'] = true;
107+
}
93108
}
94109

95110
/**
@@ -102,7 +117,7 @@ public function update(Response $response)
102117
return;
103118
}
104119

105-
// Remove validation related headers of the master response,
120+
// Remove validation related headers of the final response,
106121
// because some of the response content comes from at least
107122
// one embedded response (which likely has a different caching strategy).
108123
$response->setEtag(null);
@@ -145,9 +160,9 @@ public function update(Response $response)
145160
}
146161
}
147162

148-
if (is_numeric($this->ageDirectives['expires'])) {
163+
if ($this->ageDirectives['expires'] && null !== $maxAge) {
149164
$date = clone $response->getDate();
150-
$date = $date->modify('+'.($this->ageDirectives['expires'] + $this->age).' seconds');
165+
$date = $date->modify('+'.$maxAge.' seconds');
151166
$response->setExpires($date);
152167
}
153168
}
@@ -200,33 +215,16 @@ private function willMakeFinalResponseUncacheable(Response $response): bool
200215
* we have to subtract the age so that the value is normalized for an age of 0.
201216
*
202217
* If the value is lower than the currently stored value, we update the value, to keep a rolling
203-
* minimal value of each instruction.
204-
*
205-
* If the value is NULL and the isHeuristicallyCacheable parameter is false, the directive will
206-
* not be set on the final response. In this case, not all responses had the directive set and no
207-
* value can be found that satisfies the requirements of all responses. The directive will be dropped
208-
* from the final response.
209-
*
210-
* If the isHeuristicallyCacheable parameter is true, however, the current response has been marked
211-
* as cacheable in a public (shared) cache, but did not provide an explicit lifetime that would serve
212-
* as an upper bound. In this case, we can proceed and possibly keep the directive on the final response.
218+
* minimal value of each instruction. If the value is NULL, the directive will not be set on the final response.
213219
*/
214-
private function storeRelativeAgeDirective(string $directive, ?int $value, int $age, bool $isHeuristicallyCacheable)
220+
private function storeRelativeAgeDirective(string $directive, ?int $value, ?int $expires, int $age): void
215221
{
216-
if (null === $value) {
217-
if ($isHeuristicallyCacheable) {
218-
/*
219-
* See https://datatracker.ietf.org/doc/html/rfc7234#section-4.2.2
220-
* This particular response does not require maximum lifetime; heuristics might be applied.
221-
* Other responses, however, might have more stringent requirements on maximum lifetime.
222-
* So, return early here so that the final response can have the more limiting value set.
223-
*/
224-
return;
225-
}
222+
if (null === $value && null === $expires) {
226223
$this->ageDirectives[$directive] = false;
227224
}
228225

229226
if (false !== $this->ageDirectives[$directive]) {
227+
$value = min($value ?? PHP_INT_MAX, $expires ?? PHP_INT_MAX);
230228
$value -= $age;
231229
$this->ageDirectives[$directive] = null !== $this->ageDirectives[$directive] ? min($this->ageDirectives[$directive], $value) : $value;
232230
}

src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,64 @@ public function testSharedMaxAgeNotSetIfNotSetInMainRequest()
7676
$this->assertFalse($response->headers->hasCacheControlDirective('s-maxage'));
7777
}
7878

79+
public function testExpiresHeaderUpdatedFromMaxAge()
80+
{
81+
$cacheStrategy = new ResponseCacheStrategy();
82+
83+
$response1 = new Response();
84+
$response1->setExpires(new \DateTime('+ 1 hour'));
85+
$response1->setPublic();
86+
$cacheStrategy->add($response1);
87+
88+
$response = new Response();
89+
$response->setMaxAge(0);
90+
$response->setSharedMaxAge(86400);
91+
$cacheStrategy->update($response);
92+
93+
$this->assertSame('0', $response->headers->getCacheControlDirective('max-age'));
94+
$this->assertSame('3600', $response->headers->getCacheControlDirective('s-maxage'));
95+
96+
// Expires header must be same as Date header because "max-age" is 0.
97+
$this->assertSame($response->headers->get('Date'), $response->headers->get('Expires'));
98+
}
99+
100+
public function testMaxAgeUpdatedFromExpiresHeader()
101+
{
102+
$cacheStrategy = new ResponseCacheStrategy();
103+
104+
$response1 = new Response();
105+
$response1->setExpires(new \DateTime('+ 1 hour'));
106+
$response1->setPublic();
107+
$cacheStrategy->add($response1);
108+
109+
$response = new Response();
110+
$response->setMaxAge(86400);
111+
$cacheStrategy->update($response);
112+
113+
$this->assertSame('3600', $response->headers->getCacheControlDirective('max-age'));
114+
$this->assertNull($response->headers->getCacheControlDirective('s-maxage'));
115+
$this->assertSame((new \DateTime('+ 1 hour'))->format('D, d M Y H:i:s').' GMT', $response->headers->get('Expires'));
116+
}
117+
118+
public function testMaxAgeAndSharedMaxAgeUpdatedFromExpiresHeader()
119+
{
120+
$cacheStrategy = new ResponseCacheStrategy();
121+
122+
$response1 = new Response();
123+
$response1->setExpires(new \DateTime('+ 1 day'));
124+
$response1->setPublic();
125+
$cacheStrategy->add($response1);
126+
127+
$response = new Response();
128+
$response->setMaxAge(3600);
129+
$response->setSharedMaxAge(86400);
130+
$cacheStrategy->update($response);
131+
132+
$this->assertSame('3600', $response->headers->getCacheControlDirective('max-age'));
133+
$this->assertSame('86400', $response->headers->getCacheControlDirective('s-maxage'));
134+
$this->assertSame((new \DateTime('+ 1 hour'))->format('D, d M Y H:i:s').' GMT', $response->headers->get('Expires'));
135+
}
136+
79137
public function testMainResponseNotCacheableWhenEmbeddedResponseRequiresValidation()
80138
{
81139
$cacheStrategy = new ResponseCacheStrategy();
@@ -243,7 +301,7 @@ public function testResponseIsExpirableButNotValidateableWhenMainResponseCombine
243301
*
244302
* @dataProvider cacheControlMergingProvider
245303
*/
246-
public function testCacheControlMerging(array $expects, array $master, array $surrogates)
304+
public function testCacheControlMerging(array $expects, array $main, array $surrogates)
247305
{
248306
$cacheStrategy = new ResponseCacheStrategy();
249307
$buildResponse = function ($config) {
@@ -289,7 +347,7 @@ public function testCacheControlMerging(array $expects, array $master, array $su
289347
$cacheStrategy->add($buildResponse($config));
290348
}
291349

292-
$response = $buildResponse($master);
350+
$response = $buildResponse($main);
293351
$cacheStrategy->update($response);
294352

295353
foreach ($expects as $key => $value) {
@@ -371,7 +429,7 @@ public static function cacheControlMergingProvider()
371429
];
372430

373431
yield 'merge max-age and s-maxage' => [
374-
['public' => true, 'max-age' => '60'],
432+
['public' => true, 'max-age' => null, 's-maxage' => '60'],
375433
['public' => true, 's-maxage' => 3600],
376434
[
377435
['public' => true, 'max-age' => 60],

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy