diff --git a/composer.json b/composer.json index 27eece48d0..540640fa47 100644 --- a/composer.json +++ b/composer.json @@ -38,10 +38,12 @@ "patchwork/utf8": "^1.2", "phpspec/php-diff": "^1.0", "phpunit/php-token-stream": "^1.4", + "php-http/guzzle6-adapter": "^1.1", "psr/log": "^1.0", "simplepie/simplepie": "^1.3", "tecnickcom/tcpdf": "^6.0", "true/punycode": "^2.0", + "terminal42/header-replay-bundle": "^1.0", "twig/twig": "^1.26", "webmozart/path-util": "^2.0", "contao/image": "^0.3.1", @@ -78,6 +80,7 @@ "lexik/maintenance-bundle": "^2.0", "monolog/monolog": "^1.22", "phpunit/phpunit": "^5.0", + "php-http/message-factory": "^1.0.2", "satooshi/php-coveralls": "^1.0", "symfony/phpunit-bridge": "^3.2" }, diff --git a/src/ContaoManager/Plugin.php b/src/ContaoManager/Plugin.php index 155e2dae23..f6114de013 100644 --- a/src/ContaoManager/Plugin.php +++ b/src/ContaoManager/Plugin.php @@ -29,6 +29,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Config\Loader\LoaderResolverInterface; use Symfony\Component\HttpKernel\KernelInterface; +use Terminal42\HeaderReplay\HeaderReplayBundle; /** * Plugin for the Contao Manager. @@ -45,6 +46,7 @@ public function getBundles(ParserInterface $parser) return [ BundleConfig::create(KnpMenuBundle::class), BundleConfig::create(KnpTimeBundle::class), + BundleConfig::create(HeaderReplayBundle::class), BundleConfig::create(ContaoCoreBundle::class) ->setReplace(['core']) ->setLoadAfter( diff --git a/src/EventListener/HeaderReplay/BackendSessionListener.php b/src/EventListener/HeaderReplay/BackendSessionListener.php new file mode 100644 index 0000000000..27339647a1 --- /dev/null +++ b/src/EventListener/HeaderReplay/BackendSessionListener.php @@ -0,0 +1,91 @@ + + */ +class BackendSessionListener +{ + /** + * @var ScopeMatcher + */ + private $scopeMatcher; + + /** + * @var bool + */ + private $disableIpCheck; + + /** + * Constructor. + * + * @param ScopeMatcher $scopeMatcher + * @param bool $disableIpCheck + */ + public function __construct(ScopeMatcher $scopeMatcher, $disableIpCheck) + { + $this->scopeMatcher = $scopeMatcher; + $this->disableIpCheck = $disableIpCheck; + } + + /** + * Sets the "force no cache" header on the replay response to disable reverse proxy + * caching if a back end user is logged in (front end preview mode). + * + * @param HeaderReplayEvent $event + */ + public function onReplay(HeaderReplayEvent $event) + { + $request = $event->getRequest(); + + if (!$this->scopeMatcher->isBackendRequest($request) + || null === $request->getSession() + || !$this->hasAuthenticatedBackendUser($request) + ) { + return; + } + + $headers = $event->getHeaders(); + $headers->set(HeaderReplayListener::FORCE_NO_CACHE_HEADER_NAME, 'true'); + } + + /** + * Checks if there is an authenticated back end user. + * + * @param Request $request + * + * @return bool + */ + private function hasAuthenticatedBackendUser(Request $request) + { + if (!$request->cookies->has('BE_USER_AUTH')) { + return false; + } + + $sessionHash = sha1( + sprintf( + '%s%sBE_USER_AUTH', + $request->getSession()->getId(), + $this->disableIpCheck ? '' : $request->getClientIp() + ) + ); + + return $request->cookies->get('BE_USER_AUTH') === $sessionHash; + } +} diff --git a/src/EventListener/HeaderReplay/PageLayoutListener.php b/src/EventListener/HeaderReplay/PageLayoutListener.php new file mode 100644 index 0000000000..53820ca027 --- /dev/null +++ b/src/EventListener/HeaderReplay/PageLayoutListener.php @@ -0,0 +1,77 @@ + + */ +class PageLayoutListener +{ + /** + * @var ScopeMatcher + */ + private $scopeMatcher; + + /** + * @var ContaoFrameworkInterface + */ + private $framework; + + /** + * Constructor. + * + * @param ScopeMatcher $scopeMatcher + * @param ContaoFrameworkInterface $framework + */ + public function __construct(ScopeMatcher $scopeMatcher, ContaoFrameworkInterface $framework) + { + $this->scopeMatcher = $scopeMatcher; + $this->framework = $framework; + } + + /** + * Adds the "Contao-Page-Layout" header to the replay response based on either the TL_VIEW cookie + * or the current browser user agent string, so that the reverse proxy gains the ability to vary on + * it. This is needed so that the reverse proxy generates two entries for the same URL when you are + * using mobile and desktop page layouts. + * + * @param HeaderReplayEvent $event + */ + public function onReplay(HeaderReplayEvent $event) + { + $request = $event->getRequest(); + + if (!$this->scopeMatcher->isFrontendRequest($request)) { + return; + } + + if ($request->cookies->has('TL_VIEW')) { + $mobile = 'mobile' === $request->cookies->get('TL_VIEW'); + } else { + $this->framework->initialize(); + + /** @var Environment $environment */ + $environment = $this->framework->getAdapter(Environment::class); + + $mobile = $environment->get('agent')->mobile; + } + + $headers = $event->getHeaders(); + $headers->set('Contao-Page-Layout', $mobile ? 'mobile' : 'desktop'); + } +} diff --git a/src/Resources/config/listener.yml b/src/Resources/config/listener.yml index 15db998862..fba7b1c524 100644 --- a/src/Resources/config/listener.yml +++ b/src/Resources/config/listener.yml @@ -69,6 +69,22 @@ services: tags: - { name: kernel.event_listener, event: kernel.exception, method: onKernelException, priority: 96 } + contao.listener.header_replay.backend_session: + class: Contao\CoreBundle\EventListener\HeaderReplay\BackendSessionListener + arguments: + - "@contao.routing.scope_matcher" + - "%contao.security.disable_ip_check%" + tags: + - { name: kernel.event_listener, event: terminal42.header_replay, method: onReplay } + + contao.listener.header_replay.page_layout: + class: Contao\CoreBundle\EventListener\HeaderReplay\PageLayoutListener + arguments: + - "@contao.routing.scope_matcher" + - "@contao.framework" + tags: + - { name: kernel.event_listener, event: terminal42.header_replay, method: onReplay } + contao.listener.insecure_installation: class: Contao\CoreBundle\EventListener\InsecureInstallationListener tags: diff --git a/src/Resources/contao/classes/FrontendTemplate.php b/src/Resources/contao/classes/FrontendTemplate.php index af8be85f8a..2720bbe03d 100644 --- a/src/Resources/contao/classes/FrontendTemplate.php +++ b/src/Resources/contao/classes/FrontendTemplate.php @@ -371,9 +371,13 @@ private function setCacheHeaders(Response $response) return $response->setPrivate(); } - // Do not cache the response if a user is logged in or the page is protected or uses a mobile layout - // TODO: Add support for proxies so they can vary on member context and page layout - if (FE_USER_LOGGED_IN === true || BE_USER_LOGGED_IN === true || $objPage->isMobile || $objPage->protected || $this->hasAuthenticatedBackendUser()) + // Vary on page layout + $response->setVary(array('Contao-Page-Layout'), false); + $response->headers->set('Contao-Page-Layout', $objPage->isMobile ? 'mobile' : 'desktop'); + + // Do not cache the response if a user is logged in or the page is protected + // TODO: Add support for proxies so they can vary on member context + if (FE_USER_LOGGED_IN === true || BE_USER_LOGGED_IN === true || $objPage->protected || $this->hasAuthenticatedBackendUser()) { return $response->setPrivate(); } diff --git a/tests/ContaoManager/PluginTest.php b/tests/ContaoManager/PluginTest.php index f8857c4149..a11f821abb 100644 --- a/tests/ContaoManager/PluginTest.php +++ b/tests/ContaoManager/PluginTest.php @@ -30,11 +30,13 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderResolverInterface; use Symfony\Component\HttpKernel\KernelInterface; +use Terminal42\HeaderReplay\HeaderReplayBundle; /** * Tests the Plugin class. * * @author Leo Feyer + * @author Yanick Witschi */ class PluginTest extends TestCase { @@ -58,18 +60,22 @@ public function testGetBundles() /** @var BundleConfig[] $bundles */ $bundles = $plugin->getBundles(new DelegatingParser()); - $this->assertCount(3, $bundles); + $this->assertCount(4, $bundles); $this->assertSame(KnpMenuBundle::class, $bundles[0]->getName()); - $this->assertSame([], $bundles[0]->getReplace()); - $this->assertSame([], $bundles[0]->getLoadAfter()); - - $this->assertSame(KnpTimeBundle::class, $bundles[1]->getName()); $this->assertSame([], $bundles[1]->getReplace()); $this->assertSame([], $bundles[1]->getLoadAfter()); - $this->assertSame(ContaoCoreBundle::class, $bundles[2]->getName()); - $this->assertSame(['core'], $bundles[2]->getReplace()); + $this->assertSame(KnpTimeBundle::class, $bundles[1]->getName()); + $this->assertSame([], $bundles[2]->getReplace()); + $this->assertSame([], $bundles[2]->getLoadAfter()); + + $this->assertSame(HeaderReplayBundle::class, $bundles[2]->getName()); + $this->assertSame([], $bundles[0]->getReplace()); + $this->assertSame([], $bundles[0]->getLoadAfter()); + + $this->assertSame(ContaoCoreBundle::class, $bundles[3]->getName()); + $this->assertSame(['core'], $bundles[3]->getReplace()); $this->assertSame( [ @@ -86,7 +92,7 @@ public function testGetBundles() SensioFrameworkExtraBundle::class, ContaoManagerBundle::class, ], - $bundles[2]->getLoadAfter() + $bundles[3]->getLoadAfter() ); } diff --git a/tests/EventListener/HeaderReplay/BackendSessionListenerTest.php b/tests/EventListener/HeaderReplay/BackendSessionListenerTest.php new file mode 100644 index 0000000000..2a547ebb8b --- /dev/null +++ b/tests/EventListener/HeaderReplay/BackendSessionListenerTest.php @@ -0,0 +1,145 @@ + + */ +class BackendSessionListenerTest extends TestCase +{ + /** + * Tests the object instantiation. + */ + public function testInstantiation() + { + $listener = new BackendSessionListener($this->mockScopeMatcher(), false); + + $this->assertInstanceOf('Contao\CoreBundle\EventListener\HeaderReplay\BackendSessionListener', $listener); + } + + /** + * Tests that no header is added outside the Contao back end scope. + */ + public function testOnReplayWithNoBackendScope() + { + $event = new HeaderReplayEvent(new Request(), new ResponseHeaderBag()); + + $listener = new BackendSessionListener($this->mockScopeMatcher(), false); + $listener->onReplay($event); + + $this->assertArrayNotHasKey( + strtolower(HeaderReplayListener::FORCE_NO_CACHE_HEADER_NAME), + $event->getHeaders()->all() + ); + } + + /** + * Tests that no header is added when the request has no session. + */ + public function testOnReplayWithNoSession() + { + $request = new Request(); + $request->attributes->set('_scope', 'backend'); + + $event = new HeaderReplayEvent($request, new ResponseHeaderBag()); + + $listener = new BackendSessionListener($this->mockScopeMatcher(), false); + $listener->onReplay($event); + + $this->assertArrayNotHasKey( + strtolower(HeaderReplayListener::FORCE_NO_CACHE_HEADER_NAME), + $event->getHeaders()->all() + ); + } + + /** + * Tests that no header is added when the request has no back end user authentication cookie. + */ + public function testOnReplayWithNoAuthCookie() + { + $request = new Request(); + $request->attributes->set('_scope', 'backend'); + $request->setSession(new Session()); + + $event = new HeaderReplayEvent($request, new ResponseHeaderBag()); + + $listener = new BackendSessionListener($this->mockScopeMatcher(), false); + $listener->onReplay($event); + + $this->assertArrayNotHasKey( + strtolower(HeaderReplayListener::FORCE_NO_CACHE_HEADER_NAME), + $event->getHeaders()->all() + ); + + $this->assertNotNull($request->getSession()); + } + + /** + * Tests that no header is added if the auth cookie has an invalid value. + */ + public function testOnReplayWithNoValidCookie() + { + $request = new Request(); + $request->attributes->set('_scope', 'backend'); + $request->cookies->set('BE_USER_AUTH', 'foobar'); + $request->setSession(new Session()); + + $event = new HeaderReplayEvent($request, new ResponseHeaderBag()); + + $listener = new BackendSessionListener($this->mockScopeMatcher(), false); + $listener->onReplay($event); + + $this->assertArrayNotHasKey( + strtolower(HeaderReplayListener::FORCE_NO_CACHE_HEADER_NAME), + $event->getHeaders()->all() + ); + + $this->assertNotNull($request->getSession()); + $this->assertTrue($request->cookies->has('BE_USER_AUTH')); + } + + /** + * Tests that the header is correctly added when scope and auth cookie are correct. + */ + public function testOnReplay() + { + $session = new Session(); + $session->setId('foobar-id'); + + $request = new Request(); + $request->attributes->set('_scope', 'backend'); + $request->cookies->set('BE_USER_AUTH', 'f6d5c422c903288859fb5ccf03c8af8b0fb4b70a'); + $request->setSession($session); + + $event = new HeaderReplayEvent($request, new ResponseHeaderBag()); + + $listener = new BackendSessionListener($this->mockScopeMatcher(), false); + $listener->onReplay($event); + + $this->assertArrayHasKey( + strtolower(HeaderReplayListener::FORCE_NO_CACHE_HEADER_NAME), + $event->getHeaders()->all() + ); + + $this->assertNotNull($request->getSession()); + $this->assertTrue($request->cookies->has('BE_USER_AUTH')); + } +} diff --git a/tests/EventListener/HeaderReplay/PageLayoutListenerTest.php b/tests/EventListener/HeaderReplay/PageLayoutListenerTest.php new file mode 100644 index 0000000000..e13c5259c7 --- /dev/null +++ b/tests/EventListener/HeaderReplay/PageLayoutListenerTest.php @@ -0,0 +1,118 @@ + + */ +class PageLayoutListenerTest extends TestCase +{ + /** + * Tests the object instantiation. + */ + public function testInstantiation() + { + $listener = new PageLayoutListener($this->mockScopeMatcher(), $this->mockContaoFramework()); + + $this->assertInstanceOf('Contao\CoreBundle\EventListener\HeaderReplay\PageLayoutListener', $listener); + } + + /** + * Tests that no header is added outside the Contao front end scope. + */ + public function testOnReplayWithNoFrontendScope() + { + $event = new HeaderReplayEvent(new Request(), new ResponseHeaderBag()); + + $listener = new PageLayoutListener($this->mockScopeMatcher(), $this->mockContaoFramework()); + $listener->onReplay($event); + + $this->assertArrayNotHasKey('contao-page-layout', $event->getHeaders()->all()); + } + + /** + * Tests all combinations of user agent result, TL_VIEW cookie value and checks if the + * header value is set correctly. + * + * @param bool $agentIsMobile + * @param string|null $tlViewCookie + * @param string $expectedHeaderValue + * + * @dataProvider onReplayProvider + */ + public function testOnReplay($agentIsMobile, $tlViewCookie, $expectedHeaderValue) + { + $envAdapter = $this + ->getMockBuilder(Adapter::class) + ->disableOriginalConstructor() + ->setMethods(['get']) + ->getMock() + ; + + $envAdapter + ->method('get') + ->willReturnCallback(function ($key) use ($agentIsMobile) { + switch ($key) { + case 'agent': + return (object) ['mobile' => $agentIsMobile]; + + default: + return null; + } + }) + ; + + $request = new Request(); + $request->attributes->set('_scope', 'frontend'); + + if (null !== $tlViewCookie) { + $request->cookies->set('TL_VIEW', $tlViewCookie); + } + + $event = new HeaderReplayEvent($request, new ResponseHeaderBag()); + + $listener = new PageLayoutListener( + $this->mockScopeMatcher(), + $this->mockContaoFramework(null, null, [Environment::class => $envAdapter]) + ); + + $listener->onReplay($event); + + $this->assertSame($expectedHeaderValue, $event->getHeaders()->get('Contao-Page-Layout')); + } + + /** + * Provides the data for the testOnReplayWithNoFrontendScope test. + * + * @return array + */ + public function onReplayProvider() + { + return [ + 'No cookie -> desktop' => [false, null, 'desktop'], + 'No cookie -> mobile' => [true, null, 'mobile'], + 'Cookie mobile -> mobile when agent match' => [true, 'mobile', 'mobile'], + 'Cookie mobile -> mobile when agent does not match' => [false, 'mobile', 'mobile'], + 'Cookie desktop -> desktop when agent match' => [true, 'desktop', 'desktop'], + 'Cookie desktop -> desktop when agent does not match' => [false, 'desktop', 'desktop'], + ]; + } +}