Skip to content

Commit 908a91f

Browse files
bug #58376 [HttpKernel] Correctly merge max-age/s-maxage and Expires headers (aschempp)
This PR was squashed before being merged into the 5.4 branch. Discussion ---------- [HttpKernel] Correctly merge `max-age`/`s-maxage` and `Expires` headers | Q | A | ------------- | --- | Branch? | 5.4 | Bug fix? | yes | New feature? | no | Deprecations? | no | Issues | Fix contao/contao#7494 | License | MIT The `ResponseCacheStrategy` does not currently merge `Expires` and `Cache-Control: max-age/s-maxage` headers. Before #41665 this was not an issue, because if not all respones had all headers, they were not added to the final reponse. And we could assume a response itself is consistent between `Expires` and `max-age`. `@mpdude` added _heuristic caching_ of public responses in #41665. Unfortunately, it only looks at `Cache-Control: public` but if should also check if no cache information (max-age/s-maxage/Expires) is present. If that were the case, the behavior would not have changed. But it now leads to inconsistent header values because it independently keeps `Expires` and `max-age/s-maxage`. This PR does not only fix the _heuristic caching_, but also merges `Expires` and `Cache-Control` headers to make sure only the lowest value is retained across all headers. For semi-BC reasons I also made sure to only add an `Expires` header if any of the responses contains one. Commits ------- d3e65d6 [HttpKernel] Correctly merge `max-age`/`s-maxage` and `Expires` headers
2 parents 7fe5bba + d3e65d6 commit 908a91f

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