diff --git a/.travis.yml b/.travis.yml index 1956b3de..3ff20c16 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,8 @@ branches: - /^\d+\.\d+$/ matrix: + allow_failures: + - php: hhvm include: - php: 5.5 env: SYMFONY_VERSION='2.3.*' diff --git a/CHANGELOG.md b/CHANGELOG.md index c86873ce..1f93fa48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= +1.1.0 +----- + +* **2014-10-14** Allow cache headers overwrite. +* **2014-10-29** Added support for the user context lookup with Symfony built-in + reverse proxy, aka `HttpCache`. + 1.0.0 ----- diff --git a/EventListener/UserContextSubscriber.php b/EventListener/UserContextSubscriber.php index 94cc84a6..06c777b0 100644 --- a/EventListener/UserContextSubscriber.php +++ b/EventListener/UserContextSubscriber.php @@ -103,6 +103,7 @@ public function onKernelRequest(GetResponseEvent $event) if ($this->ttl > 0) { $response->setClientTtl($this->ttl); $response->setVary($this->userIdentifierHeaders); + $response->setPublic(); } else { $response->setClientTtl(0); $response->headers->addCacheControlDirective('no-cache'); diff --git a/HttpCache.php b/HttpCache.php new file mode 100644 index 00000000..e6f97f7b --- /dev/null +++ b/HttpCache.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCacheBundle; + +use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache as BaseHttpCache; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * Base class for enhanced Symfony reverse proxy. + * + * @author Jérôme Vieilledent (courtesy of eZ Systems AS) + * + * {@inheritdoc} + */ +abstract class HttpCache extends BaseHttpCache +{ + /** + * Hash for anonymous user. + */ + const ANONYMOUS_HASH = '38015b703d82206ebc01d17a39c727e5'; + + /** + * Accept header value to be used to request the user hash to the backend application. + * It must match the one defined in FOSHttpCacheBundle's configuration. + */ + const USER_HASH_ACCEPT_HEADER = 'application/vnd.fos.user-context-hash'; + + /** + * Name of the header the user context hash will be stored into. + * It must match the one defined in FOSHttpCacheBundle's configuration. + */ + const USER_HASH_HEADER = 'X-User-Context-Hash'; + + /** + * URI used with the forwarded request for user context hash generation. + */ + const USER_HASH_URI = '/_fos_user_context_hash'; + + /** + * HTTP Method used with the forwarded request for user context hash generation. + */ + const USER_HASH_METHOD = 'GET'; + + /** + * Prefix for session names. + * Must match your session configuration. + */ + const SESSION_NAME_PREFIX = 'PHPSESSID'; + + /** + * Generated user hash. + * + * @var string + */ + private $userHash; + + public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true) + { + if (!$this->isInternalRequest($request)) { + // Prevent tampering attacks on the hash mechanism + if ($request->headers->get('accept') === static::USER_HASH_ACCEPT_HEADER + || $request->headers->get(static::USER_HASH_HEADER) !== null) { + return new Response('', 400); + } + + if ($request->isMethodSafe()) { + $request->headers->set(static::USER_HASH_HEADER, $this->getUserHash($request)); + } + } + + return parent::handle($request, $type, $catch); + } + + /** + * Checks if passed request object is to be considered internal (e.g. for user hash lookup). + * + * @param Request $request + * + * @return bool + */ + private function isInternalRequest(Request $request) + { + return $request->attributes->get('internalRequest', false) === true; + } + + /** + * Returns the user context hash for $request. + * + * @param Request $request + * + * @return string + */ + private function getUserHash(Request $request) + { + if (isset($this->userHash)) { + return $this->userHash; + } + + if ($this->isAnonymous($request)) { + return $this->userHash = static::ANONYMOUS_HASH; + } + + // Forward the request to generate the user hash + $forwardReq = $this->generateForwardRequest($request); + $resp = $this->handle($forwardReq); + // Store the user hash in memory for sub-requests (processed in the same thread). + $this->userHash = $resp->headers->get(static::USER_HASH_HEADER); + + return $this->userHash; + } + + /** + * Checks if current request is considered anonymous. + * + * @param Request $request + * + * @return bool + */ + private function isAnonymous(Request $request) + { + foreach ($request->cookies as $name => $value) { + if ($this->isSessionName($name)) { + return false; + } + } + + return true; + } + + /** + * Checks if passed string can be considered as a session name, such as would be used in cookies. + * + * @param string $name + * + * @return bool + */ + private function isSessionName($name) + { + return strpos($name, static::SESSION_NAME_PREFIX) === 0; + } + + /** + * Generates the request object that will be forwarded to get the user context hash. + * + * @param Request $request + * + * @return Request + */ + private function generateForwardRequest(Request $request) + { + $forwardReq = Request::create(static::USER_HASH_URI, static::USER_HASH_METHOD, array(), array(), array(), $request->server->all()); + $forwardReq->attributes->set('internalRequest', true); + $forwardReq->headers->set('Accept', static::USER_HASH_ACCEPT_HEADER); + $this->cleanupForwardRequest($forwardReq, $request); + + return $forwardReq; + } + + /** + * Cleans up request to forward for user hash generation. + * Cleans cookie header to only get proper sessionIds in it. This is to make the hash request cacheable. + * + * @param Request $forwardReq + * @param Request $originalRequest + */ + protected function cleanupForwardRequest(Request $forwardReq, Request $originalRequest) + { + $sessionIds = array(); + foreach ($originalRequest->cookies as $name => $value) { + if ( $this->isSessionName($name)) { + $sessionIds[$name] = $value; + $forwardReq->cookies->set($name, $value); + } + } + $forwardReq->headers->set('Cookie', http_build_query($sessionIds, '', '; ')); + } +} diff --git a/Resources/doc/features.rst b/Resources/doc/features.rst index 6f6005b6..b9cef441 100644 --- a/Resources/doc/features.rst +++ b/Resources/doc/features.rst @@ -12,3 +12,4 @@ corresponding reference section. features/user-context features/helpers features/testing + features/symfony-http-cache diff --git a/Resources/doc/features/symfony-http-cache.rst b/Resources/doc/features/symfony-http-cache.rst new file mode 100644 index 00000000..67a79d3c --- /dev/null +++ b/Resources/doc/features/symfony-http-cache.rst @@ -0,0 +1,82 @@ +Symfony HttpCache +================= + +Symfony comes with a built-in reverse proxy written in PHP, known as +``HttpCache``. It can be useful when one hosts a Symfony application on shared +hosting for instance +(see [HttpCache documentation](http://symfony.com/doc/current/book/http_cache.html#symfony-reverse-proxy). + +If you use Symfony ``HttpCache``, you'll need to make your ``AppCache`` class +extend ``FOS\HttpCacheBundle\HttpCache`` instead of +``Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache``. + +.. warning:: + + Symfony HttpCache support is currently limited to following features: + + * User context + +Class constants +--------------- + +``FOS\HttpCacheBundle\HttpCache`` defines constants that can easily be overriden +in your ``AppCache`` class: + +.. code-block:: php + + use FOS\HttpCacheBundle\HttpCache; + + class AppCache extends HttpCache + { + /** + * Overriding default value for SESSION_NAME_PREFIX + * to use eZSESSID instead. + */ + const SESSION_NAME_PREFIX = 'eZSESSID'; + } + +User context +~~~~~~~~~~~~ + +.. note:: + + For detailed information on user context, please read the + `user context documentation page ` + +* ``SESSION_NAME_PREFIX``: Prefix for session names. Must match your session + configuration. + Needed for caching correctly generated user context hash for each user session. + + **default**: ``PHPSESSID`` + +.. warning:: + + If you have a customized session name, it is **very important** that this + constant matches it. + Session IDs are indeed used as keys to cache the generated use context hash. + + Wrong session name will lead to unexpected results such as having the same + user context hash for every users, + or not having it cached at all (painful for performance. + +* ``USER_HASH_ACCEPT_HEADER``: Accept header value to be used to request the + user hash to the backend application. + It must match the one defined in FOSHttpCacheBundle's configuration (see below). + + **default**: ``application/vnd.fos.user-context-hash`` + +* ``USER_HASH_HEADER``: Name of the header the user context hash will be stored + into. + It must match the one defined in FOSHttpCacheBundle's configuration (see below). + + **default**: ``X-User-Context-Hash`` + +* ``USER_HASH_URI``: URI used with the forwarded request for user context hash + generation. + + **default**: ``/_fos_user_context_hash`` + +* ``USER_HASH_METHOD``: HTTP Method used with the forwarded request for user + context hash generation. + + **default**: ``GET`` diff --git a/Resources/doc/reference/configuration/user-context.rst b/Resources/doc/reference/configuration/user-context.rst index 46cb6273..8fa38b83 100644 --- a/Resources/doc/reference/configuration/user-context.rst +++ b/Resources/doc/reference/configuration/user-context.rst @@ -11,9 +11,17 @@ Configuration Caching Proxy Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -First you need to set up your caching proxy as explained in the +Varnish +""""""" + +Set up Varnish caching proxy as explained in the :ref:`user context documentation `. +Symfony reverse proxy +""""""""""""""""""""" + +Set up Symfony reverse proxy as explained in the :doc:`Symfony HttpCache dedicated documentation page `. + Context Hash Route ~~~~~~~~~~~~~~~~~~ diff --git a/Tests/Unit/EventListener/UserContextSubscriberTest.php b/Tests/Unit/EventListener/UserContextSubscriberTest.php index c298b0e3..53e246f2 100644 --- a/Tests/Unit/EventListener/UserContextSubscriberTest.php +++ b/Tests/Unit/EventListener/UserContextSubscriberTest.php @@ -94,7 +94,7 @@ public function testOnKernelRequestCached() $this->assertInstanceOf('\Symfony\Component\HttpFoundation\Response', $response); $this->assertEquals('hash', $response->headers->get('X-Hash')); $this->assertEquals('X-SessionId', $response->headers->get('Vary')); - $this->assertEquals('max-age=30, private', $response->headers->get('Cache-Control')); + $this->assertEquals('max-age=30, public', $response->headers->get('Cache-Control')); } public function testOnKernelRequestNotMatched() diff --git a/Tests/Unit/HttpCacheTest.php b/Tests/Unit/HttpCacheTest.php new file mode 100644 index 00000000..4dad9b72 --- /dev/null +++ b/Tests/Unit/HttpCacheTest.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\HttpCacheBundle\Tests\Unit; + +use FOS\HttpCacheBundle\HttpCache; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class HttpCacheTest extends \PHPUnit_Framework_TestCase +{ + /** + * @return \FOS\HttpCacheBundle\HttpCache|\PHPUnit_Framework_MockObject_MockObject + */ + protected function getHttpCachePartialMock(array $mockedMethods = null) + { + $mock = $this->getMockBuilder('\FOS\HttpCacheBundle\HttpCache') + ->setMethods( $mockedMethods ) + ->disableOriginalConstructor() + ->getMock(); + + // Force setting options property since we can't use original constructor. + $options = array( + 'debug' => false, + 'default_ttl' => 0, + 'private_headers' => array( 'Authorization', 'Cookie' ), + 'allow_reload' => false, + 'allow_revalidate' => false, + 'stale_while_revalidate' => 2, + 'stale_if_error' => 60, + ); + + $refMock = new \ReflectionObject($mock); + $refHttpCache = $refMock + // \FOS\HttpCacheBundle\HttpCache + ->getParentClass() + // \Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache + ->getParentClass() + // \Symfony\Component\HttpKernel\HttpCache\HttpCache + ->getParentClass(); + // Workaround for Symfony 2.3 where $options property is not defined. + if (!$refHttpCache->hasProperty('options')) { + $mock->options = $options; + } else { + $refOptions = $refHttpCache + ->getProperty('options'); + $refOptions->setAccessible(true); + $refOptions->setValue($mock, $options ); + } + + return $mock; + } + + public function testGenerateUserHashNotAllowed() + { + $request = new Request(); + $request->headers->set('accept', HttpCache::USER_HASH_ACCEPT_HEADER); + $httpCache = $this->getHttpCachePartialMock(); + $response = $httpCache->handle($request); + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame(400, $response->getStatusCode()); + } + + public function testPassingUserHashNotAllowed() + { + $request = new Request(); + $request->headers->set(HttpCache::USER_HASH_HEADER, 'foo'); + $httpCache = $this->getHttpCachePartialMock(); + $response = $httpCache->handle($request); + $this->assertInstanceOf('Symfony\\Component\\HttpFoundation\\Response', $response); + $this->assertSame(400, $response->getStatusCode()); + } + + public function testUserHashAnonymous() + { + $request = new Request(); + $catch = true; + + $httpCache = $this->getHttpCachePartialMock(array('lookup')); + $response = new Response(); + $httpCache + ->expects($this->once()) + ->method('lookup') + ->with($request, $catch) + ->will($this->returnValue($response)); + + $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + $this->assertTrue($request->headers->has(HttpCache::USER_HASH_HEADER)); + $this->assertSame(HttpCache::ANONYMOUS_HASH, $request->headers->get(HttpCache::USER_HASH_HEADER)); + } + + public function testUserHashUserWithSession() + { + $catch = true; + $sessionId1 = 'my_session_id'; + $sessionId2 = 'another_session_id'; + $cookies = array( + 'PHPSESSID' => $sessionId1, + 'PHPSESSIDsdiuhsdf4535d4f' => $sessionId2, + 'foo' => 'bar' + ); + $cookieString = "PHPSESSID=$sessionId1; foo=bar; PHPSESSIDsdiuhsdf4535d4f=$sessionId2"; + $request = Request::create('/foo', 'GET', array(), $cookies, array(), array('Cookie' => $cookieString)); + $response = new Response(); + + $hashRequest = Request::create(HttpCache::USER_HASH_URI, HttpCache::USER_HASH_METHOD, array(), array(), array(), $request->server->all()); + $hashRequest->attributes->set('internalRequest', true); + $hashRequest->headers->set('Accept', HttpCache::USER_HASH_ACCEPT_HEADER); + $hashRequest->headers->set('Cookie', "PHPSESSID=$sessionId1; PHPSESSIDsdiuhsdf4535d4f=$sessionId2"); + $hashRequest->cookies->set('PHPSESSID', $sessionId1); + $hashRequest->cookies->set('PHPSESSIDsdiuhsdf4535d4f', $sessionId2); + // Ensure request properties have been filled up. + $hashRequest->getPathInfo(); + $hashRequest->getMethod(); + + $expectedContextHash = 'my_generated_hash'; + // Just avoid the response to modify the request object, otherwise it's impossible to test objects equality. + /** @var \Symfony\Component\HttpFoundation\Response|\PHPUnit_Framework_MockObject_MockObject $hashResponse */ + $hashResponse = $this->getMockBuilder('\Symfony\Component\HttpFoundation\Response') + ->setMethods(array('prepare')) + ->getMock(); + $hashResponse->headers->set(HttpCache::USER_HASH_HEADER, $expectedContextHash ); + + $httpCache = $this->getHttpCachePartialMock(array('lookup')); + $httpCache + ->expects($this->at(0)) + ->method('lookup') + ->with($hashRequest, $catch) + ->will($this->returnValue($hashResponse)); + $httpCache + ->expects($this->at(1)) + ->method('lookup') + ->with($request) + ->will($this->returnValue($response)); + + $this->assertSame($response, $httpCache->handle($request, HttpKernelInterface::MASTER_REQUEST, $catch)); + $this->assertTrue($request->headers->has(HttpCache::USER_HASH_HEADER)); + $this->assertSame($expectedContextHash, $request->headers->get(HttpCache::USER_HASH_HEADER)); + } +}