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; + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy