diff --git a/core/core.services.yml b/core/core.services.yml index 9213c11b1f919e30665467930fc48bd1c1787a97..77d6ccea0158b3ad907419371f7d100ea88c2854 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -914,6 +914,11 @@ services: argument_resolver.default: class: Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver public: false + http_middleware.content_length: + class: Drupal\Core\StackMiddleware\ContentLength + tags: + # Must run before the page_cache and big_pipe middleware. + - { name: http_middleware, priority: 140 } http_middleware.ajax_page_state: class: Drupal\Core\StackMiddleware\AjaxPageState tags: diff --git a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php index b29ed6f4128095a1f76932d0e68be056c97b5d23..da96c9dbfc06eb857d2cb1914cbd82e047b04934 100644 --- a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php @@ -300,40 +300,6 @@ protected function setExpiresNoCache(Response $response) { $response->setExpires(\DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 UTC')); } - /** - * Sets the Content-Length header on the response. - * - * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event - * The event to process. - * - * @see \Symfony\Component\HttpFoundation\Response::prepare() - * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length - */ - public function setContentLengthHeader(ResponseEvent $event): void { - $response = $event->getResponse(); - - if ($response->isInformational() || $response->isEmpty()) { - return; - } - - if ($response->headers->has('Transfer-Encoding')) { - return; - } - - // Drupal cannot set the correct content length header when there is a - // server error. - if ($response->isServerError()) { - return; - } - - $content = $response->getContent(); - if ($content === FALSE) { - return; - } - - $response->headers->set('Content-Length', strlen($content), TRUE); - } - /** * Registers the methods in this class that should be listeners. * @@ -345,10 +311,6 @@ public static function getSubscribedEvents(): array { // There is no specific reason for choosing 16 beside it should be executed // before ::onRespond(). $events[KernelEvents::RESPONSE][] = ['onAllResponds', 16]; - // Run very late, after all other response subscribers have run. However, - // any response subscribers that convert a response to a streamed response - // must run after this and undo what this does. - $events[KernelEvents::RESPONSE][] = ['setContentLengthHeader', -1024]; return $events; } diff --git a/core/lib/Drupal/Core/StackMiddleware/ContentLength.php b/core/lib/Drupal/Core/StackMiddleware/ContentLength.php new file mode 100644 index 0000000000000000000000000000000000000000..6f3172b810a9e5f89ea75d38733d0edbcb64a6a1 --- /dev/null +++ b/core/lib/Drupal/Core/StackMiddleware/ContentLength.php @@ -0,0 +1,52 @@ +<?php + +namespace Drupal\Core\StackMiddleware; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * Adds a Content-Length HTTP header to responses. + */ +class ContentLength implements HttpKernelInterface { + + /** + * Constructs a new ContentLength instance. + * + * @param \Symfony\Component\HttpKernel\HttpKernelInterface $httpKernel + * The wrapped HTTP kernel. + */ + public function __construct( + protected readonly HttpKernelInterface $httpKernel, + ) {} + + /** + * {@inheritdoc} + */ + public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = TRUE): Response { + $response = $this->httpKernel->handle($request, $type, $catch); + if ($response->isInformational() || $response->isEmpty()) { + return $response; + } + + if ($response->headers->has('Transfer-Encoding')) { + return $response; + } + + // Drupal cannot set the correct content length header when there is a + // server error. + if ($response->isServerError()) { + return $response; + } + + $content = $response->getContent(); + if ($content === FALSE) { + return $response; + } + + $response->headers->set('Content-Length', strlen($content), TRUE); + return $response; + } + +} diff --git a/core/modules/big_pipe/big_pipe.services.yml b/core/modules/big_pipe/big_pipe.services.yml index 764a0461e9417ccb0f02049a35781bde213de43e..950d5a11ad93703412cf869031cd38e14c5ff048 100644 --- a/core/modules/big_pipe/big_pipe.services.yml +++ b/core/modules/big_pipe/big_pipe.services.yml @@ -23,3 +23,8 @@ services: class: Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber tags: - { name: event_subscriber } + http_middleware.big_pipe: + class: \Drupal\big_pipe\StackMiddleware\ContentLength + tags: + # Must run after the content_length middleware. + - { name: http_middleware, priority: 150 } diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php index 03e1d4261cd955addefbc202b17f740545a7217c..85397961d33a9054fb19c8bad72ab1348bf52f0b 100644 --- a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php +++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php @@ -82,8 +82,6 @@ public function onRespond(ResponseEvent $event) { $content = $response->getContent(); $content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content); $response->setContent($content); - // FinishResponseSubscriber::setContentLengthHeader() already ran. - $response->headers->set('Content-Length', strlen($content), TRUE); } // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders, @@ -96,9 +94,6 @@ public function onRespond(ResponseEvent $event) { $big_pipe_response = new BigPipeResponse($response); $big_pipe_response->setBigPipeService($this->getBigPipeService($event)); - // A BigPipe response's length is impossible to predict. - $big_pipe_response->headers->remove('Content-Length'); - $event->setResponse($big_pipe_response); } diff --git a/core/modules/big_pipe/src/StackMiddleware/ContentLength.php b/core/modules/big_pipe/src/StackMiddleware/ContentLength.php new file mode 100644 index 0000000000000000000000000000000000000000..bc24914170e0d2d57688b97b2a058a07cc2c0295 --- /dev/null +++ b/core/modules/big_pipe/src/StackMiddleware/ContentLength.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\big_pipe\StackMiddleware; + +use Drupal\big_pipe\Render\BigPipeResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * Defines a big pipe middleware that removes Content-Length headers. + */ +final class ContentLength implements HttpKernelInterface { + + /** + * Constructs a new ContentLength instance. + * + * @param \Symfony\Component\HttpKernel\HttpKernelInterface $httpKernel + * The wrapped HTTP kernel. + */ + public function __construct( + protected readonly HttpKernelInterface $httpKernel, + ) { + } + + /** + * {@inheritdoc} + */ + public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = TRUE): Response { + $response = $this->httpKernel->handle($request, $type, $catch); + if (!$response instanceof BigPipeResponse) { + return $response; + } + $response->headers->remove('Content-Length'); + return $response; + } + +} diff --git a/core/modules/big_pipe/tests/src/Unit/StackMiddleware/ContentLengthTest.php b/core/modules/big_pipe/tests/src/Unit/StackMiddleware/ContentLengthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..08682e400c7823fe5852a07a36a2f9189966220e --- /dev/null +++ b/core/modules/big_pipe/tests/src/Unit/StackMiddleware/ContentLengthTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\big_pipe\Unit\StackMiddleware; + +use Drupal\big_pipe\Render\BigPipeResponse; +use Drupal\big_pipe\StackMiddleware\ContentLength; +use Drupal\Core\Render\HtmlResponse; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * Defines a test for ContentLength middleware. + * + * @group big_pipe + * @coversDefaultClass \Drupal\big_pipe\StackMiddleware\ContentLength + */ +final class ContentLengthTest extends UnitTestCase { + + /** + * @covers ::handle + * @dataProvider providerTestSetContentLengthHeader + */ + public function testHandle(false|int $expected_header, Response $response) { + $kernel = $this->prophesize(HttpKernelInterface::class); + $request = Request::create('/'); + $kernel->handle($request, HttpKernelInterface::MAIN_REQUEST, TRUE)->willReturn($response); + $middleware = new ContentLength($kernel->reveal()); + $response = $middleware->handle($request); + if ($expected_header === FALSE) { + $this->assertFalse($response->headers->has('Content-Length')); + return; + } + $this->assertSame((string) $expected_header, $response->headers->get('Content-Length')); + } + + public function providerTestSetContentLengthHeader() { + $response = new Response('Test content', 200); + $response->headers->set('Content-Length', (string) strlen('Test content')); + return [ + '200 ok' => [ + 12, + $response, + ], + 'Big pipe' => [ + FALSE, + new BigPipeResponse(new HtmlResponse('Test content')), + ], + ]; + } + +} diff --git a/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.info.yml b/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..9525558c7a636d166b93223e9f47427ed9bb71e3 --- /dev/null +++ b/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.info.yml @@ -0,0 +1,5 @@ +name: 'Test HTTP Middleware' +type: module +description: 'Provides a test http middleware for automated tests.' +package: Testing +version: VERSION diff --git a/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.routing.yml b/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..ee5cb24f4bdae67f8bb8d7069c6f75262604f2d0 --- /dev/null +++ b/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.routing.yml @@ -0,0 +1,7 @@ +http_middleware_test.test_response: + path: '/test-response' + defaults: + _title: 'Test response' + _controller: '\Drupal\http_middleware_test\Controller\TestResponseController::testResponse' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.services.yml b/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..4df1905ce1021e7087a880bf085c943286b8cd7d --- /dev/null +++ b/core/modules/system/tests/modules/http_middleware_test/http_middleware_test.services.yml @@ -0,0 +1,5 @@ +services: + http_middleware.alter_content_middleware: + class: Drupal\http_middleware_test\StackMiddleware\AlterContentMiddleware + tags: + - { name: http_middleware, priority: 100, responder: true } diff --git a/core/modules/system/tests/modules/http_middleware_test/src/Controller/TestResponseController.php b/core/modules/system/tests/modules/http_middleware_test/src/Controller/TestResponseController.php new file mode 100644 index 0000000000000000000000000000000000000000..11819035b6e618e9a3b7d187c78a8feac0b3b138 --- /dev/null +++ b/core/modules/system/tests/modules/http_middleware_test/src/Controller/TestResponseController.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\http_middleware_test\Controller; + +use Symfony\Component\HttpFoundation\Response; + +/** + * Controller routines for http_middleware_test routes. + */ +final class TestResponseController { + + /** + * Returns a test response. + */ + public function testResponse(): Response { + return new Response('<html><body><p>Mangoes</p></body></html>'); + } + +} diff --git a/core/modules/system/tests/modules/http_middleware_test/src/StackMiddleware/AlterContentMiddleware.php b/core/modules/system/tests/modules/http_middleware_test/src/StackMiddleware/AlterContentMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..1bb7fff3d146f08756e5b561d83797b0b08b5fbd --- /dev/null +++ b/core/modules/system/tests/modules/http_middleware_test/src/StackMiddleware/AlterContentMiddleware.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\http_middleware_test\StackMiddleware; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * Alters the response before content length is calculated. + */ +final class AlterContentMiddleware implements HttpKernelInterface { + + public function __construct( + private readonly HttpKernelInterface $httpKernel, + ) {} + + /** + * {@inheritdoc} + */ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = TRUE): Response { + $response = $this->httpKernel->handle($request, $type, $catch); + if (\Drupal::getContainer()->hasParameter('no-alter-content-length') && \Drupal::getContainer()->getParameter('no-alter-content-length')) { + $response->setContent('<html><body><p>Avocados</p></body></html>'); + } + return $response; + } + +} diff --git a/core/tests/Drupal/FunctionalTests/HttpKernel/ContentLengthTest.php b/core/tests/Drupal/FunctionalTests/HttpKernel/ContentLengthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8400494c05f41c578e0379044bbed08a635a046f --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/HttpKernel/ContentLengthTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\FunctionalTests\HttpKernel; + +use Drupal\Core\Url; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests Content-Length set by Drupal. + * + * @group Http + */ +class ContentLengthTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'http_middleware_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + public function testContentLength(): void { + // Fire off a request. + $this->drupalGet(Url::fromRoute('http_middleware_test.test_response')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->responseHeaderEquals('Content-Length', '40'); + + $this->setContainerParameter('no-alter-content-length', TRUE); + $this->rebuildContainer(); + + // Fire the same exact request but this time length is different. + $this->drupalGet(Url::fromRoute('http_middleware_test.test_response')); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->responseHeaderEquals('Content-Length', '41'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/FinishResponserSubscriberTest.php b/core/tests/Drupal/Tests/Core/StackMiddleware/ContentLengthTest.php similarity index 51% rename from core/tests/Drupal/Tests/Core/EventSubscriber/FinishResponserSubscriberTest.php rename to core/tests/Drupal/Tests/Core/StackMiddleware/ContentLengthTest.php index aefe20609c6bfc13792c9e60a93028c271588ba5..59212d739790bf8d3840cf7e046304c927892cc2 100644 --- a/core/tests/Drupal/Tests/Core/EventSubscriber/FinishResponserSubscriberTest.php +++ b/core/tests/Drupal/Tests/Core/StackMiddleware/ContentLengthTest.php @@ -1,52 +1,37 @@ <?php -namespace Drupal\Tests\Core\EventSubscriber; +declare(strict_types=1); -use Drupal\Core\Cache\Context\CacheContextsManager; -use Drupal\Core\EventSubscriber\FinishResponseSubscriber; -use Drupal\Core\Language\LanguageManagerInterface; -use Drupal\Core\PageCache\RequestPolicyInterface; -use Drupal\Core\PageCache\ResponsePolicyInterface; +namespace Drupal\Tests\Core\StackMiddleware; + +use Drupal\Core\StackMiddleware\ContentLength; use Drupal\Tests\UnitTestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; /** - * @coversDefaultClass \Drupal\Core\EventSubscriber\FinishResponseSubscriber - * @group EventSubscriber + * @coversDefaultClass \Drupal\Core\StackMiddleware\ContentLength + * @group Middleware */ -class FinishResponserSubscriberTest extends UnitTestCase { +class ContentLengthTest extends UnitTestCase { /** - * @covers ::setContentLengthHeader + * @covers ::handle * @dataProvider providerTestSetContentLengthHeader */ - public function testSetContentLengthHeader(false|int $expected_header, Response $response) { - $event_subscriber = new FinishResponseSubscriber( - $this->prophesize(LanguageManagerInterface::class)->reveal(), - $this->getConfigFactoryStub(), - $this->prophesize(RequestPolicyInterface::class)->reveal(), - $this->prophesize(ResponsePolicyInterface::class)->reveal(), - $this->prophesize(CacheContextsManager::class)->reveal() - ); - - $event = new ResponseEvent( - $this->prophesize(HttpKernelInterface::class)->reveal(), - $this->prophesize(Request::class)->reveal(), - HttpKernelInterface::MAIN_REQUEST, - $response - ); - - $event_subscriber->setContentLengthHeader($event); + public function testHandle(false|int $expected_header, Response $response) { + $kernel = $this->prophesize(HttpKernelInterface::class); + $request = Request::create('/'); + $kernel->handle($request, HttpKernelInterface::MAIN_REQUEST, TRUE)->willReturn($response); + $middleware = new ContentLength($kernel->reveal()); + $response = $middleware->handle($request); if ($expected_header === FALSE) { - $this->assertFalse($event->getResponse()->headers->has('Content-Length')); - } - else { - $this->assertSame((string) $expected_header, $event->getResponse()->headers->get('Content-Length')); + $this->assertFalse($response->headers->has('Content-Length')); + return; } + $this->assertSame((string) $expected_header, $response->headers->get('Content-Length')); } public function providerTestSetContentLengthHeader() {