diff --git a/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php b/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php index 5f372c566dd44..7185c9105be6f 100644 --- a/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php +++ b/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php @@ -50,7 +50,7 @@ class ResponseCacheStrategy implements ResponseCacheStrategyInterface private $ageDirectives = [ 'max-age' => null, 's-maxage' => null, - 'expires' => null, + 'expires' => false, ]; /** @@ -81,15 +81,30 @@ public function add(Response $response) return; } - $isHeuristicallyCacheable = $response->headers->hasCacheControlDirective('public'); $maxAge = $response->headers->hasCacheControlDirective('max-age') ? (int) $response->headers->getCacheControlDirective('max-age') : null; - $this->storeRelativeAgeDirective('max-age', $maxAge, $age, $isHeuristicallyCacheable); $sharedMaxAge = $response->headers->hasCacheControlDirective('s-maxage') ? (int) $response->headers->getCacheControlDirective('s-maxage') : $maxAge; - $this->storeRelativeAgeDirective('s-maxage', $sharedMaxAge, $age, $isHeuristicallyCacheable); - $expires = $response->getExpires(); $expires = null !== $expires ? (int) $expires->format('U') - (int) $response->getDate()->format('U') : null; - $this->storeRelativeAgeDirective('expires', $expires >= 0 ? $expires : null, 0, $isHeuristicallyCacheable); + + // See https://datatracker.ietf.org/doc/html/rfc7234#section-4.2.2 + // If a response is "public" but does not have maximum lifetime, heuristics might be applied. + // Do not store NULL values so the final response can have more limiting value from other responses. + $isHeuristicallyCacheable = $response->headers->hasCacheControlDirective('public') + && null === $maxAge + && null === $sharedMaxAge + && null === $expires; + + if (!$isHeuristicallyCacheable || null !== $maxAge || null !== $expires) { + $this->storeRelativeAgeDirective('max-age', $maxAge, $expires, $age); + } + + if (!$isHeuristicallyCacheable || null !== $sharedMaxAge || null !== $expires) { + $this->storeRelativeAgeDirective('s-maxage', $sharedMaxAge, $expires, $age); + } + + if (null !== $expires) { + $this->ageDirectives['expires'] = true; + } } /** @@ -102,7 +117,7 @@ public function update(Response $response) return; } - // Remove validation related headers of the master response, + // Remove validation related headers of the final response, // because some of the response content comes from at least // one embedded response (which likely has a different caching strategy). $response->setEtag(null); @@ -145,9 +160,9 @@ public function update(Response $response) } } - if (is_numeric($this->ageDirectives['expires'])) { + if ($this->ageDirectives['expires'] && null !== $maxAge) { $date = clone $response->getDate(); - $date = $date->modify('+'.($this->ageDirectives['expires'] + $this->age).' seconds'); + $date = $date->modify('+'.$maxAge.' seconds'); $response->setExpires($date); } } @@ -200,33 +215,16 @@ private function willMakeFinalResponseUncacheable(Response $response): bool * we have to subtract the age so that the value is normalized for an age of 0. * * If the value is lower than the currently stored value, we update the value, to keep a rolling - * minimal value of each instruction. - * - * If the value is NULL and the isHeuristicallyCacheable parameter is false, the directive will - * not be set on the final response. In this case, not all responses had the directive set and no - * value can be found that satisfies the requirements of all responses. The directive will be dropped - * from the final response. - * - * If the isHeuristicallyCacheable parameter is true, however, the current response has been marked - * as cacheable in a public (shared) cache, but did not provide an explicit lifetime that would serve - * as an upper bound. In this case, we can proceed and possibly keep the directive on the final response. + * minimal value of each instruction. If the value is NULL, the directive will not be set on the final response. */ - private function storeRelativeAgeDirective(string $directive, ?int $value, int $age, bool $isHeuristicallyCacheable) + private function storeRelativeAgeDirective(string $directive, ?int $value, ?int $expires, int $age): void { - if (null === $value) { - if ($isHeuristicallyCacheable) { - /* - * See https://datatracker.ietf.org/doc/html/rfc7234#section-4.2.2 - * This particular response does not require maximum lifetime; heuristics might be applied. - * Other responses, however, might have more stringent requirements on maximum lifetime. - * So, return early here so that the final response can have the more limiting value set. - */ - return; - } + if (null === $value && null === $expires) { $this->ageDirectives[$directive] = false; } if (false !== $this->ageDirectives[$directive]) { + $value = min($value ?? PHP_INT_MAX, $expires ?? PHP_INT_MAX); $value -= $age; $this->ageDirectives[$directive] = null !== $this->ageDirectives[$directive] ? min($this->ageDirectives[$directive], $value) : $value; } diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php b/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php index ce9f5ba1a158a..8a4cce53bffa8 100644 --- a/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php @@ -76,6 +76,64 @@ public function testSharedMaxAgeNotSetIfNotSetInMainRequest() $this->assertFalse($response->headers->hasCacheControlDirective('s-maxage')); } + public function testExpiresHeaderUpdatedFromMaxAge() + { + $cacheStrategy = new ResponseCacheStrategy(); + + $response1 = new Response(); + $response1->setExpires(new \DateTime('+ 1 hour')); + $response1->setPublic(); + $cacheStrategy->add($response1); + + $response = new Response(); + $response->setMaxAge(0); + $response->setSharedMaxAge(86400); + $cacheStrategy->update($response); + + $this->assertSame('0', $response->headers->getCacheControlDirective('max-age')); + $this->assertSame('3600', $response->headers->getCacheControlDirective('s-maxage')); + + // Expires header must be same as Date header because "max-age" is 0. + $this->assertSame($response->headers->get('Date'), $response->headers->get('Expires')); + } + + public function testMaxAgeUpdatedFromExpiresHeader() + { + $cacheStrategy = new ResponseCacheStrategy(); + + $response1 = new Response(); + $response1->setExpires(new \DateTime('+ 1 hour')); + $response1->setPublic(); + $cacheStrategy->add($response1); + + $response = new Response(); + $response->setMaxAge(86400); + $cacheStrategy->update($response); + + $this->assertSame('3600', $response->headers->getCacheControlDirective('max-age')); + $this->assertNull($response->headers->getCacheControlDirective('s-maxage')); + $this->assertSame((new \DateTime('+ 1 hour'))->format('D, d M Y H:i:s').' GMT', $response->headers->get('Expires')); + } + + public function testMaxAgeAndSharedMaxAgeUpdatedFromExpiresHeader() + { + $cacheStrategy = new ResponseCacheStrategy(); + + $response1 = new Response(); + $response1->setExpires(new \DateTime('+ 1 day')); + $response1->setPublic(); + $cacheStrategy->add($response1); + + $response = new Response(); + $response->setMaxAge(3600); + $response->setSharedMaxAge(86400); + $cacheStrategy->update($response); + + $this->assertSame('3600', $response->headers->getCacheControlDirective('max-age')); + $this->assertSame('86400', $response->headers->getCacheControlDirective('s-maxage')); + $this->assertSame((new \DateTime('+ 1 hour'))->format('D, d M Y H:i:s').' GMT', $response->headers->get('Expires')); + } + public function testMainResponseNotCacheableWhenEmbeddedResponseRequiresValidation() { $cacheStrategy = new ResponseCacheStrategy(); @@ -243,7 +301,7 @@ public function testResponseIsExpirableButNotValidateableWhenMainResponseCombine * * @dataProvider cacheControlMergingProvider */ - public function testCacheControlMerging(array $expects, array $master, array $surrogates) + public function testCacheControlMerging(array $expects, array $main, array $surrogates) { $cacheStrategy = new ResponseCacheStrategy(); $buildResponse = function ($config) { @@ -289,7 +347,7 @@ public function testCacheControlMerging(array $expects, array $master, array $su $cacheStrategy->add($buildResponse($config)); } - $response = $buildResponse($master); + $response = $buildResponse($main); $cacheStrategy->update($response); foreach ($expects as $key => $value) { @@ -371,7 +429,7 @@ public static function cacheControlMergingProvider() ]; yield 'merge max-age and s-maxage' => [ - ['public' => true, 'max-age' => '60'], + ['public' => true, 'max-age' => null, 's-maxage' => '60'], ['public' => true, 's-maxage' => 3600], [ ['public' => true, 'max-age' => 60],