diff --git a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md index 539d814d2a438..50787452ea277 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `ajax_replace` option for replacing toolbar on AJAX requests + * Show debug bar when using a streamed response 7.2 --- diff --git a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php index de7bb7b001ca0..d1eeeee1aa616 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php +++ b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -110,7 +111,14 @@ public function onKernelResponse(ResponseEvent $event): void $session->getFlashBag()->setAll($session->getFlashBag()->peekAll()); } - $response->setContent($this->twig->render('@WebProfiler/Profiler/toolbar_redirect.html.twig', ['location' => $response->headers->get('Location'), 'host' => $request->getSchemeAndHttpHost()])); + if ($response instanceof StreamedResponse) { + $twig = $this->twig; + $response->setCallback(function () use ($twig, $request, $response): void { + echo $twig->render('@WebProfiler/Profiler/toolbar_redirect.html.twig', ['location' => $response->headers->get('Location'), 'host' => $request->getSchemeAndHttpHost()]); + }); + } else { + $response->setContent($this->twig->render('@WebProfiler/Profiler/toolbar_redirect.html.twig', ['location' => $response->headers->get('Location'), 'host' => $request->getSchemeAndHttpHost()])); + } $response->setStatusCode(200); $response->headers->remove('Location'); } @@ -133,26 +141,51 @@ public function onKernelResponse(ResponseEvent $event): void */ protected function injectToolbar(Response $response, Request $request, array $nonces): void { - $content = $response->getContent(); - $pos = strripos($content, ''); - - if (false !== $pos) { - $toolbar = "\n".str_replace("\n", '', $this->twig->render( - '@WebProfiler/Profiler/toolbar_js.html.twig', - [ - 'full_stack' => class_exists(FullStack::class), - 'excluded_ajax_paths' => $this->excludedAjaxPaths, - 'token' => $response->headers->get('X-Debug-Token'), - 'request' => $request, - 'csp_script_nonce' => $nonces['csp_script_nonce'] ?? null, - 'csp_style_nonce' => $nonces['csp_style_nonce'] ?? null, - ] - ))."\n"; - $content = substr($content, 0, $pos).$toolbar.substr($content, $pos); - $response->setContent($content); + if ($response instanceof StreamedResponse) { + $callback = $response->getCallback(); + $toolbarHTMLContent = "\n".str_replace("\n", '', $this->getToolbarHTML($request, $response->headers->get('X-Debug-Token'), $nonces))."\n"; + $injectedCallback = static function () use ($toolbarHTMLContent, $callback): void { + ob_start(function (string $buffer, int $phase) use ($toolbarHTMLContent): string { + $pos = strripos($buffer, ''); + if (false !== $pos) { + $buffer = substr($buffer, 0, $pos).$toolbarHTMLContent.substr($buffer, $pos); + } + + return $buffer; + }, 8); // length of '' + + ($callback)(); + ob_end_flush(); + }; + $response->setCallback($injectedCallback); + } else { + $content = $response->getContent(); + $pos = strripos($content, ''); + + if (false !== $pos) { + $toolbar = "\n".str_replace("\n", '', $this->getToolbarHTML($request, $response->headers->get('X-Debug-Token'), $nonces))."\n"; + + $content = substr($content, 0, $pos).$toolbar.substr($content, $pos); + $response->setContent($content); + } } } + private function getToolbarHTML(Request $request, ?string $debugToken, array $nonces): string + { + return $this->twig->render( + '@WebProfiler/Profiler/toolbar_js.html.twig', + [ + 'full_stack' => class_exists(FullStack::class), + 'excluded_ajax_paths' => $this->excludedAjaxPaths, + 'token' => $debugToken, + 'request' => $request, + 'csp_script_nonce' => $nonces['csp_script_nonce'] ?? null, + 'csp_style_nonce' => $nonces['csp_style_nonce'] ?? null, + ] + ); + } + public static function getSubscribedEvents(): array { return [ diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php index ff9bd096fb13f..bd4ca434596fd 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php @@ -28,7 +28,7 @@ class WebDebugToolbarListenerTest extends TestCase /** * @dataProvider getInjectToolbarTests */ - public function testInjectToolbar($content, $expected) + public function testInjectToolbar(string $content, string $expected) { $listener = new WebDebugToolbarListener($this->getTwigMock()); $m = new \ReflectionMethod($listener, 'injectToolbar'); @@ -60,7 +60,7 @@ public static function getInjectToolbarTests() /** * @dataProvider provideRedirects */ - public function testHtmlRedirectionIsIntercepted($statusCode) + public function testHtmlRedirectionIsIntercepted(int $statusCode) { $response = new Response('Some content', $statusCode); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); @@ -75,7 +75,7 @@ public function testHtmlRedirectionIsIntercepted($statusCode) public function testNonHtmlRedirectionIsNotIntercepted() { - $response = new Response('Some content', '301'); + $response = new Response('Some content', 301); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); $event = new ResponseEvent($this->createMock(Kernel::class), new Request([], [], ['_format' => 'json']), HttpKernelInterface::MAIN_REQUEST, $response); @@ -136,7 +136,7 @@ public function testToolbarIsNotInjectedOnContentDispositionAttachment() * * @dataProvider provideRedirects */ - public function testToolbarIsNotInjectedOnRedirection($statusCode) + public function testToolbarIsNotInjectedOnRedirection(int $statusCode) { $response = new Response('', $statusCode); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); @@ -417,7 +417,7 @@ public function testAjaxReplaceHeaderOnEnabledAndXHRButPreviouslySet() $this->assertSame('0', $response->headers->get('Symfony-Debug-Toolbar-Replace')); } - protected function getTwigMock($render = 'WDT') + protected function getTwigMock(string $render = 'WDT') { $templating = $this->createMock(Environment::class); $templating->expects($this->any()) diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarStreamedResponseListenerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarStreamedResponseListenerTest.php new file mode 100644 index 0000000000000..86f15c52d99ad --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarStreamedResponseListenerTest.php @@ -0,0 +1,386 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\WebProfilerBundle\Tests\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\WebProfilerBundle\Csp\ContentSecurityPolicyHandler; +use Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Twig\Environment; + +class WebDebugToolbarStreamedResponseListenerTest extends TestCase +{ + /** + * @dataProvider getInjectToolbarTests + */ + public function testInjectToolbar(string $content, string $expected) + { + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $m = new \ReflectionMethod($listener, 'injectToolbar'); + + $response = new StreamedResponse($this->createCallbackFromContent($content)); + + $m->invoke($listener, $response, Request::create('/'), ['csp_script_nonce' => 'scripto', 'csp_style_nonce' => 'stylo']); + $this->assertEquals($expected, $this->getContentFromStreamedResponse($response)); + } + + public static function getInjectToolbarTests() + { + return [ + ['', "\nWDT\n"], + [' + + + + + ', " + + + + \nWDT\n + "], + ]; + } + + /** + * @dataProvider provideRedirects + */ + public function testHtmlRedirectionIsIntercepted(int $statusCode) + { + $response = new StreamedResponse($this->createCallbackFromContent('Some content'), $statusCode); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock('Redirection'), true); + $listener->onKernelResponse($event); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Redirection', $this->getContentFromStreamedResponse($response)); + } + + public function testNonHtmlRedirectionIsNotIntercepted() + { + $response = new StreamedResponse($this->createCallbackFromContent('Some content'), 301); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request([], [], ['_format' => 'json']), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock('Redirection'), true); + $listener->onKernelResponse($event); + + $this->assertEquals(301, $response->getStatusCode()); + $this->assertEquals('Some content', $this->getContentFromStreamedResponse($response)); + } + + public function testToolbarIsInjected() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals("\nWDT\n", $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedOnNonHtmlContentType() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + $response->headers->set('Content-Type', 'text/xml'); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedOnContentDispositionAttachment() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + $response->headers->set('Content-Disposition', 'attachment; filename=test.html'); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + * + * @dataProvider provideRedirects + */ + public function testToolbarIsNotInjectedOnRedirection(int $statusCode) + { + $response = new StreamedResponse($this->createCallbackFromContent(''), $statusCode); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + public static function provideRedirects(): array + { + return [ + [301], + [302], + ]; + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedWhenThereIsNoNoXDebugTokenResponseHeader() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedWhenOnSubRequest() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::SUB_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedOnIncompleteHtmlResponses() + { + $response = new StreamedResponse($this->createCallbackFromContent('
Some content
')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('
Some content
', $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedOnXmlHttpRequests() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $request = new Request(); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + + $event = new ResponseEvent($this->createMock(Kernel::class), $request, HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + /** + * @depends testToolbarIsInjected + */ + public function testToolbarIsNotInjectedOnNonHtmlRequests() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request([], [], ['_format' => 'json']), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock()); + $listener->onKernelResponse($event); + + $this->assertEquals('', $this->getContentFromStreamedResponse($response)); + } + + public function testXDebugUrlHeader() + { + $response = new StreamedResponse(); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('_profiler', ['token' => 'xxxxxxxx'], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('http://mydomain.com/_profiler/xxxxxxxx') + ; + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, $urlGenerator); + $listener->onKernelResponse($event); + + $this->assertEquals('http://mydomain.com/_profiler/xxxxxxxx', $response->headers->get('X-Debug-Token-Link')); + } + + public function testThrowingUrlGenerator() + { + $response = new StreamedResponse(); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('_profiler', ['token' => 'xxxxxxxx']) + ->willThrowException(new \Exception('foo')) + ; + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, $urlGenerator); + $listener->onKernelResponse($event); + + $this->assertEquals('Exception: foo', $response->headers->get('X-Debug-Error')); + } + + public function testThrowingErrorCleanup() + { + $response = new StreamedResponse(); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('_profiler', ['token' => 'xxxxxxxx']) + ->willThrowException(new \Exception("This\nmultiline\r\ntabbed text should\tcome out\r on\n \ta single plain\r\nline")) + ; + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, $urlGenerator); + $listener->onKernelResponse($event); + + $this->assertEquals('Exception: This multiline tabbed text should come out on a single plain line', $response->headers->get('X-Debug-Error')); + } + + public function testCspIsDisabledIfDumperWasUsed() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $cspHandler = $this->createMock(ContentSecurityPolicyHandler::class); + $cspHandler->expects($this->once()) + ->method('disableCsp'); + $dumpDataCollector = $this->createMock(DumpDataCollector::class); + $dumpDataCollector->expects($this->once()) + ->method('getDumpsCount') + ->willReturn(1); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', $cspHandler, $dumpDataCollector); + $listener->onKernelResponse($event); + + $this->assertEquals("\nWDT\n", $this->getContentFromStreamedResponse($response)); + } + + public function testCspIsKeptEnabledIfDumperWasNotUsed() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $cspHandler = $this->createMock(ContentSecurityPolicyHandler::class); + $cspHandler->expects($this->never()) + ->method('disableCsp'); + $dumpDataCollector = $this->createMock(DumpDataCollector::class); + $dumpDataCollector->expects($this->once()) + ->method('getDumpsCount') + ->willReturn(0); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', $cspHandler, $dumpDataCollector); + $listener->onKernelResponse($event); + + $this->assertEquals("\nWDT\n", $this->getContentFromStreamedResponse($response)); + } + + public function testNullContentTypeWithNoDebugEnv() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('Content-Type', null); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null); + $listener->onKernelResponse($event); + + $this->expectNotToPerformAssertions(); + } + + protected function getTwigMock(string $render = 'WDT') + { + $templating = $this->createMock(Environment::class); + $templating->expects($this->any()) + ->method('render') + ->willReturn($render); + + return $templating; + } + + private function createCallbackFromContent(string $content): callable + { + return function () use ($content) { + echo $content; + }; + } + + private function getContentFromStreamedResponse(StreamedResponse $response): string + { + ob_start(); + $response->sendContent(); + $content = ob_get_contents(); + ob_end_clean(); + + return $content; + } +}