Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[HttpKernel] Correctly merge max-age/s-maxage and Expires headers #58376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class ResponseCacheStrategy implements ResponseCacheStrategyInterface
private $ageDirectives = [
'max-age' => null,
's-maxage' => null,
'expires' => null,
'expires' => false,
];

/**
Expand Down Expand Up @@ -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;
}
}

/**
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If one response has max-age and the other has s-maxage, the final response should only have a s-maxage header and no max-age (since we don't know the "private" value for one of the responses).

['public' => true, 's-maxage' => 3600],
[
['public' => true, 'max-age' => 60],
Expand Down
Loading