diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f0800a..dd1fac80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add error indicator to profiler list view for profiles with errors * Add cURL copy paste button in the Request/Response tab + * Add support for streamed responses in the debug toolbar 8.0 --- diff --git a/EventListener/WebDebugToolbarListener.php b/EventListener/WebDebugToolbarListener.php index 66879eaf..6ff0435f 100644 --- a/EventListener/WebDebugToolbarListener.php +++ b/EventListener/WebDebugToolbarListener.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ServerEvent; 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; @@ -112,7 +113,15 @@ class WebDebugToolbarListener implements EventSubscriberInterface $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()])); + $content = $this->twig->render('@WebProfiler/Profiler/toolbar_redirect.html.twig', ['location' => $response->headers->get('Location'), 'host' => $request->getSchemeAndHttpHost()]); + + if ($response instanceof StreamedResponse) { + $response->setCallback(static function () use ($content): void { + echo $content; + }); + } else { + $response->setContent($content); + } $response->setStatusCode(200); $response->headers->remove('Location'); } @@ -156,24 +165,46 @@ class WebDebugToolbarListener implements EventSubscriberInterface */ protected function injectToolbar(Response $response, Request $request, array $nonces): void { - $content = $response->getContent(); - $pos = strripos($content, ''); + $responseRef = \WeakReference::create($response); + $injectToolbar = function (string $buffer) use ($request, $responseRef, $nonces): string { + if (false !== $pos = strripos($buffer, '')) { + $toolbar = "\n".str_replace("\n", '', $this->getToolbarHTML($request, $responseRef->get()->headers->get('X-Debug-Token'), $nonces))."\n"; + $buffer = substr($buffer, 0, $pos).$toolbar.substr($buffer, $pos); + } - 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); + return $buffer; + }; + + if (!$response instanceof StreamedResponse) { + $response->setContent($injectToolbar($response->getContent())); + + return; } + + $callback = $response->getCallback(); + $response->setCallback(static function () use ($callback, $injectToolbar): void { + ob_start($injectToolbar, 8); // length of '' + try { + $callback(...\func_get_args()); + } finally { + ob_end_flush(); + } + }); + } + + 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 diff --git a/Tests/EventListener/WebDebugToolbarListenerTest.php b/Tests/EventListener/WebDebugToolbarListenerTest.php index a72ca5b4..b0bf88ba 100644 --- a/Tests/EventListener/WebDebugToolbarListenerTest.php +++ b/Tests/EventListener/WebDebugToolbarListenerTest.php @@ -30,7 +30,7 @@ use Twig\Environment; 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 @@ class WebDebugToolbarListenerTest extends TestCase } #[DataProvider('provideRedirects')] - public function testHtmlRedirectionIsIntercepted($statusCode) + public function testHtmlRedirectionIsIntercepted(int $statusCode) { $response = new Response('Some content', $statusCode); $response->headers->set('Location', 'https://example.com/'); @@ -76,7 +76,7 @@ class WebDebugToolbarListenerTest extends TestCase public function testNonHtmlRedirectionIsNotIntercepted() { - $response = new Response('Some content', '301'); + $response = new Response('Some content', 301); $response->headers->set('Location', 'https://example.com/'); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); $event = new ResponseEvent($this->createStub(KernelInterface::class), new Request([], [], ['_format' => 'json']), HttpKernelInterface::MAIN_REQUEST, $response); @@ -131,7 +131,7 @@ class WebDebugToolbarListenerTest extends TestCase #[DataProvider('provideRedirects')] #[Depends('testToolbarIsInjected')] - public function testToolbarIsNotInjectedOnRedirection($statusCode) + public function testToolbarIsNotInjectedOnRedirection(int $statusCode) { $response = new Response('', $statusCode); $response->headers->set('Location', 'https://example.com/'); @@ -485,7 +485,7 @@ class WebDebugToolbarListenerTest extends TestCase $response->send(false); } - protected function getTwigMock($render = 'WDT') + protected function getTwigMock(string $render = 'WDT') { $templating = $this->createStub(Environment::class); $templating diff --git a/Tests/EventListener/WebDebugToolbarStreamedResponseListenerTest.php b/Tests/EventListener/WebDebugToolbarStreamedResponseListenerTest.php new file mode 100644 index 00000000..54701fc2 --- /dev/null +++ b/Tests/EventListener/WebDebugToolbarStreamedResponseListenerTest.php @@ -0,0 +1,130 @@ + + * + * 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\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Kernel; +use Twig\Environment; + +class WebDebugToolbarStreamedResponseListenerTest extends TestCase +{ + #[DataProvider('provideInjectedToolbarHtml')] + public function testInjectToolbar(string $content, string $expected) + { + $listener = new WebDebugToolbarListener($this->getTwigStub()); + $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->assertSame($expected, $this->getContentFromStreamedResponse($response)); + } + + public static function provideInjectedToolbarHtml(): array + { + return [ + ['', "\nWDT\n"], + [' + + + + + ', " + + + + \nWDT\n + "], + ]; + } + + #[DataProvider('provideRedirects')] + public function testHtmlRedirectionIsIntercepted(int $statusCode) + { + $response = new StreamedResponse($this->createCallbackFromContent('Some content'), $statusCode); + $response->headers->set('Location', 'https://example.com/'); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + $event = new ResponseEvent($this->createStub(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigStub('Redirection'), true); + $listener->onKernelResponse($event); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('Redirection', $this->getContentFromStreamedResponse($response)); + } + + public static function provideRedirects(): array + { + return [ + [301], + [302], + ]; + } + + public function testToolbarIsInjected() + { + $response = new StreamedResponse($this->createCallbackFromContent('')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createStub(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigStub()); + $listener->onKernelResponse($event); + + $this->assertSame("\nWDT\n", $this->getContentFromStreamedResponse($response)); + } + + public function testToolbarIsNotInjectedOnIncompleteHtmlResponses() + { + $response = new StreamedResponse($this->createCallbackFromContent('
Some content
')); + $response->headers->set('X-Debug-Token', 'xxxxxxxx'); + + $event = new ResponseEvent($this->createStub(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigStub()); + $listener->onKernelResponse($event); + + $this->assertSame('
Some content
', $this->getContentFromStreamedResponse($response)); + } + + private function getTwigStub(string $render = 'WDT'): Environment + { + $templating = $this->createStub(Environment::class); + $templating->method('render') + ->willReturn($render); + + return $templating; + } + + private function createCallbackFromContent(string $content): callable + { + return static function () use ($content): void { + echo $content; + }; + } + + private function getContentFromStreamedResponse(StreamedResponse $response): string + { + ob_start(); + $response->sendContent(); + $content = ob_get_contents(); + ob_end_clean(); + + return $content; + } +}