diff --git a/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php b/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php index 168e9564b0c9..862bac557dd7 100644 --- a/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php +++ b/src/Symfony/Component/HttpKernel/HttpCache/ResponseCacheStrategy.php @@ -5,10 +5,6 @@ * * (c) Fabien Potencier * - * This code is partially based on the Rack-Cache library by Ryan Tomayko, - * which is released under the MIT license. - * (based on commit 02d2b48d75bcb63cf1c0c7149c077ad256542801) - * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ @@ -28,30 +24,69 @@ */ class ResponseCacheStrategy implements ResponseCacheStrategyInterface { - private $cacheable = true; + /** + * Cache-Control headers that are sent to the final response if they appear in ANY of the responses. + */ + private static $overrideDirectives = ['private', 'no-cache', 'no-store', 'no-transform', 'must-revalidate', 'proxy-revalidate']; + + /** + * Cache-Control headers that are sent to the final response if they appear in ALL of the responses. + */ + private static $inheritDirectives = ['public', 'immutable']; + private $embeddedResponses = 0; - private $ttls = []; - private $maxAges = []; private $isNotCacheableResponseEmbedded = false; + private $age = 0; + private $flagDirectives = [ + 'no-cache' => null, + 'no-store' => null, + 'no-transform' => null, + 'must-revalidate' => null, + 'proxy-revalidate' => null, + 'public' => null, + 'private' => null, + 'immutable' => null, + ]; + private $ageDirectives = [ + 'max-age' => null, + 's-maxage' => null, + 'expires' => null, + ]; /** * {@inheritdoc} */ public function add(Response $response) { - if (!$response->isFresh() || !$response->isCacheable()) { - $this->cacheable = false; - } else { - $maxAge = $response->getMaxAge(); - $this->ttls[] = $response->getTtl(); - $this->maxAges[] = $maxAge; - - if (null === $maxAge) { - $this->isNotCacheableResponseEmbedded = true; + ++$this->embeddedResponses; + + foreach (self::$overrideDirectives as $directive) { + if ($response->headers->hasCacheControlDirective($directive)) { + $this->flagDirectives[$directive] = true; } } - ++$this->embeddedResponses; + foreach (self::$inheritDirectives as $directive) { + if (false !== $this->flagDirectives[$directive]) { + $this->flagDirectives[$directive] = $response->headers->hasCacheControlDirective($directive); + } + } + + $age = $response->getAge(); + $this->age = max($this->age, $age); + + if ($this->willMakeFinalResponseUncacheable($response)) { + $this->isNotCacheableResponseEmbedded = true; + + return; + } + + $this->storeRelativeAgeDirective('max-age', $response->headers->getCacheControlDirective('max-age'), $age); + $this->storeRelativeAgeDirective('s-maxage', $response->headers->getCacheControlDirective('s-maxage') ?: $response->headers->getCacheControlDirective('max-age'), $age); + + $expires = $response->getExpires(); + $expires = null !== $expires ? $expires->format('U') - $response->getDate()->format('U') : null; + $this->storeRelativeAgeDirective('expires', $expires >= 0 ? $expires : null, 0); } /** @@ -64,33 +99,124 @@ public function update(Response $response) return; } - // Remove validation related headers in order to avoid browsers using - // their own cache, because some of the response content comes from - // at least one embedded response (which likely has a different caching strategy). - if ($response->isValidateable()) { - $response->setEtag(null); - $response->setLastModified(null); + // Remove validation related headers of the master response, + // because some of the response content comes from at least + // one embedded response (which likely has a different caching strategy). + $response->setEtag(null); + $response->setLastModified(null); + + $this->add($response); + + $response->headers->set('Age', $this->age); + + if ($this->isNotCacheableResponseEmbedded) { + $response->setExpires($response->getDate()); + + if ($this->flagDirectives['no-store']) { + $response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate'); + } else { + $response->headers->set('Cache-Control', 'no-cache, must-revalidate'); + } + + return; + } + + $flags = array_filter($this->flagDirectives); + + if (isset($flags['must-revalidate'])) { + $flags['no-cache'] = true; } - if (!$response->isFresh() || !$response->isCacheable()) { - $this->cacheable = false; + $response->headers->set('Cache-Control', implode(', ', array_keys($flags))); + + $maxAge = null; + $sMaxage = null; + + if (\is_numeric($this->ageDirectives['max-age'])) { + $maxAge = $this->ageDirectives['max-age'] + $this->age; + $response->headers->addCacheControlDirective('max-age', $maxAge); } - if (!$this->cacheable) { - $response->headers->set('Cache-Control', 'no-cache, must-revalidate'); + if (\is_numeric($this->ageDirectives['s-maxage'])) { + $sMaxage = $this->ageDirectives['s-maxage'] + $this->age; - return; + if ($maxAge !== $sMaxage) { + $response->headers->addCacheControlDirective('s-maxage', $sMaxage); + } + } + + if (\is_numeric($this->ageDirectives['expires'])) { + $date = clone $response->getDate(); + $date->modify('+'.($this->ageDirectives['expires'] + $this->age).' seconds'); + $response->setExpires($date); } + } - $this->ttls[] = $response->getTtl(); - $this->maxAges[] = $response->getMaxAge(); + /** + * RFC2616, Section 13.4. + * + * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4 + * + * @return bool + */ + private function willMakeFinalResponseUncacheable(Response $response) + { + // RFC2616: A response received with a status code of 200, 203, 300, 301 or 410 + // MAY be stored by a cache […] unless a cache-control directive prohibits caching. + if ($response->headers->hasCacheControlDirective('no-cache') + || $response->headers->getCacheControlDirective('no-store') + ) { + return true; + } - if ($this->isNotCacheableResponseEmbedded) { - $response->headers->removeCacheControlDirective('s-maxage'); - } elseif (null !== $maxAge = min($this->maxAges)) { - $response->setSharedMaxAge($maxAge); - $response->headers->set('Age', $maxAge - min($this->ttls)); + // Last-Modified and Etag headers cannot be merged, they render the response uncacheable + // by default (except if the response also has max-age etc.). + if (\in_array($response->getStatusCode(), [200, 203, 300, 301, 410]) + && null === $response->getLastModified() + && null === $response->getEtag() + ) { + return false; + } + + // RFC2616: A response received with any other status code (e.g. status codes 302 and 307) + // MUST NOT be returned in a reply to a subsequent request unless there are + // cache-control directives or another header(s) that explicitly allow it. + $cacheControl = ['max-age', 's-maxage', 'must-revalidate', 'proxy-revalidate', 'public', 'private']; + foreach ($cacheControl as $key) { + if ($response->headers->hasCacheControlDirective($key)) { + return false; + } + } + + if ($response->headers->has('Expires')) { + return false; + } + + return true; + } + + /** + * Store lowest max-age/s-maxage/expires for the final response. + * + * The response might have been stored in cache a while ago. To keep things comparable, + * 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, the directive will not be set on the final response. + * + * @param string $directive + * @param int|null $value + * @param int $age + */ + private function storeRelativeAgeDirective($directive, $value, $age) + { + if (null === $value) { + $this->ageDirectives[$directive] = false; + } + + if (false !== $this->ageDirectives[$directive]) { + $value = $value - $age; + $this->ageDirectives[$directive] = null !== $this->ageDirectives[$directive] ? min($this->ageDirectives[$directive], $value) : $value; } - $response->setMaxAge(0); } } diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php b/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php index 6d67a177398c..22cadf712952 100644 --- a/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/HttpCache/ResponseCacheStrategyTest.php @@ -237,4 +237,233 @@ public function testResponseIsExpirableButNotValidateableWhenMasterResponseCombi $this->assertSame('60', $masterResponse->headers->getCacheControlDirective('s-maxage')); $this->assertFalse($masterResponse->isValidateable()); } + + /** + * @dataProvider cacheControlMergingProvider + */ + public function testCacheControlMerging(array $expects, array $master, array $surrogates) + { + $cacheStrategy = new ResponseCacheStrategy(); + $buildResponse = function ($config) { + $response = new Response(); + + foreach ($config as $key => $value) { + switch ($key) { + case 'age': + $response->headers->set('Age', $value); + break; + + case 'expires': + $expires = clone $response->getDate(); + $expires->modify('+'.$value.' seconds'); + $response->setExpires($expires); + break; + + case 'max-age': + $response->setMaxAge($value); + break; + + case 's-maxage': + $response->setSharedMaxAge($value); + break; + + case 'private': + $response->setPrivate(); + break; + + case 'public': + $response->setPublic(); + break; + + default: + $response->headers->addCacheControlDirective($key, $value); + } + } + + return $response; + }; + + foreach ($surrogates as $config) { + $cacheStrategy->add($buildResponse($config)); + } + + $response = $buildResponse($master); + $cacheStrategy->update($response); + + foreach ($expects as $key => $value) { + if ('expires' === $key) { + $this->assertSame($value, $response->getExpires()->format('U') - $response->getDate()->format('U')); + } elseif ('age' === $key) { + $this->assertSame($value, $response->getAge()); + } elseif (true === $value) { + $this->assertTrue($response->headers->hasCacheControlDirective($key), sprintf('Cache-Control header must have "%s" flag', $key)); + } elseif (false === $value) { + $this->assertFalse( + $response->headers->hasCacheControlDirective($key), + sprintf('Cache-Control header must NOT have "%s" flag', $key) + ); + } else { + $this->assertSame($value, $response->headers->getCacheControlDirective($key), sprintf('Cache-Control flag "%s" should be "%s"', $key, $value)); + } + } + } + + public function cacheControlMergingProvider() + { + yield 'result is public if all responses are public' => [ + ['private' => false, 'public' => true], + ['public' => true], + [ + ['public' => true], + ], + ]; + + yield 'result is private by default' => [ + ['private' => true, 'public' => false], + ['public' => true], + [ + [], + ], + ]; + + yield 'combines public and private responses' => [ + ['must-revalidate' => false, 'private' => true, 'public' => false], + ['public' => true], + [ + ['private' => true], + ], + ]; + + yield 'inherits no-cache from surrogates' => [ + ['no-cache' => true, 'public' => false], + ['public' => true], + [ + ['no-cache' => true], + ], + ]; + + yield 'inherits no-store from surrogate' => [ + ['no-store' => true, 'public' => false], + ['public' => true], + [ + ['no-store' => true], + ], + ]; + + yield 'resolve to lowest possible max-age' => [ + ['public' => false, 'private' => true, 's-maxage' => false, 'max-age' => '60'], + ['public' => true, 'max-age' => 3600], + [ + ['private' => true, 'max-age' => 60], + ], + ]; + + yield 'resolves multiple max-age' => [ + ['public' => false, 'private' => true, 's-maxage' => false, 'max-age' => '60'], + ['private' => true, 'max-age' => 100], + [ + ['private' => true, 'max-age' => 3600], + ['public' => true, 'max-age' => 60, 's-maxage' => 60], + ['private' => true, 'max-age' => 60], + ], + ]; + + yield 'merge max-age and s-maxage' => [ + ['public' => true, 's-maxage' => '60', 'max-age' => null], + ['public' => true, 's-maxage' => 3600], + [ + ['public' => true, 'max-age' => 60], + ], + ]; + + yield 'result is private when combining private responses' => [ + ['no-cache' => false, 'must-revalidate' => false, 'private' => true], + ['s-maxage' => 60, 'private' => true], + [ + ['s-maxage' => 60, 'private' => true], + ], + ]; + + yield 'result can have s-maxage and max-age' => [ + ['public' => true, 'private' => false, 's-maxage' => '60', 'max-age' => '30'], + ['s-maxage' => 100, 'max-age' => 2000], + [ + ['s-maxage' => 1000, 'max-age' => 30], + ['s-maxage' => 500, 'max-age' => 500], + ['s-maxage' => 60, 'max-age' => 1000], + ], + ]; + + yield 'does not set headers without value' => [ + ['max-age' => null, 's-maxage' => null, 'public' => null], + ['private' => true], + [ + ['private' => true], + ], + ]; + + yield 'max-age 0 is sent to the client' => [ + ['private' => true, 'max-age' => '0'], + ['max-age' => 0, 'private' => true], + [ + ['max-age' => 60, 'private' => true], + ], + ]; + + yield 'max-age is relative to age' => [ + ['max-age' => '240', 'age' => 60], + ['max-age' => 180], + [ + ['max-age' => 600, 'age' => 60], + ], + ]; + + yield 'retains lowest age of all responses' => [ + ['max-age' => '160', 'age' => 60], + ['max-age' => 600, 'age' => 60], + [ + ['max-age' => 120, 'age' => 20], + ], + ]; + + yield 'max-age can be less than age, essentially expiring the response' => [ + ['age' => 120, 'max-age' => '90'], + ['max-age' => 90, 'age' => 120], + [ + ['max-age' => 120, 'age' => 60], + ], + ]; + + yield 'max-age is 0 regardless of age' => [ + ['max-age' => '0'], + ['max-age' => 60], + [ + ['max-age' => 0, 'age' => 60], + ], + ]; + + yield 'max-age is not negative' => [ + ['max-age' => '0'], + ['max-age' => 0], + [ + ['max-age' => 0, 'age' => 60], + ], + ]; + + yield 'calculates lowest Expires header' => [ + ['expires' => 60], + ['expires' => 60], + [ + ['expires' => 120], + ], + ]; + + yield 'calculates Expires header relative to age' => [ + ['expires' => 210, 'age' => 120], + ['expires' => 90], + [ + ['expires' => 600, 'age' => '120'], + ], + ]; + } }