Issue #3410022 by heddn, larowlan, catch: Regression from #3295790 content-length header set earlier than expected

merge-requests/4579/merge
Dave Long 2024-01-02 17:19:57 +00:00
parent 6a0d80e461
commit 8f0872276e
No known key found for this signature in database
GPG Key ID: ED52AE211E142771
14 changed files with 283 additions and 75 deletions

View File

@ -914,6 +914,11 @@ services:
argument_resolver.default: argument_resolver.default:
class: Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver class: Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver
public: false 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: http_middleware.ajax_page_state:
class: Drupal\Core\StackMiddleware\AjaxPageState class: Drupal\Core\StackMiddleware\AjaxPageState
tags: tags:

View File

@ -300,40 +300,6 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
$response->setExpires(\DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 UTC')); $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. * Registers the methods in this class that should be listeners.
* *
@ -345,10 +311,6 @@ class FinishResponseSubscriber implements EventSubscriberInterface {
// There is no specific reason for choosing 16 beside it should be executed // There is no specific reason for choosing 16 beside it should be executed
// before ::onRespond(). // before ::onRespond().
$events[KernelEvents::RESPONSE][] = ['onAllResponds', 16]; $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; return $events;
} }

View File

@ -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;
}
}

View File

@ -23,3 +23,8 @@ services:
class: Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber class: Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber
tags: tags:
- { name: event_subscriber } - { 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 }

View File

@ -82,8 +82,6 @@ class HtmlResponseBigPipeSubscriber implements EventSubscriberInterface {
$content = $response->getContent(); $content = $response->getContent();
$content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content); $content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content);
$response->setContent($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, // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders,
@ -96,9 +94,6 @@ class HtmlResponseBigPipeSubscriber implements EventSubscriberInterface {
$big_pipe_response = new BigPipeResponse($response); $big_pipe_response = new BigPipeResponse($response);
$big_pipe_response->setBigPipeService($this->getBigPipeService($event)); $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); $event->setResponse($big_pipe_response);
} }

View File

@ -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;
}
}

View File

@ -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')),
],
];
}
}

View File

@ -0,0 +1,5 @@
name: 'Test HTTP Middleware'
type: module
description: 'Provides a test http middleware for automated tests.'
package: Testing
version: VERSION

View File

@ -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'

View File

@ -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 }

View File

@ -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>');
}
}

View File

@ -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;
}
}

View File

@ -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');
}
}

View File

@ -1,52 +1,37 @@
<?php <?php
namespace Drupal\Tests\Core\EventSubscriber; declare(strict_types=1);
use Drupal\Core\Cache\Context\CacheContextsManager; namespace Drupal\Tests\Core\StackMiddleware;
use Drupal\Core\EventSubscriber\FinishResponseSubscriber;
use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\StackMiddleware\ContentLength;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Drupal\Core\PageCache\ResponsePolicyInterface;
use Drupal\Tests\UnitTestCase; use Drupal\Tests\UnitTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\HttpKernelInterface;
/** /**
* @coversDefaultClass \Drupal\Core\EventSubscriber\FinishResponseSubscriber * @coversDefaultClass \Drupal\Core\StackMiddleware\ContentLength
* @group EventSubscriber * @group Middleware
*/ */
class FinishResponserSubscriberTest extends UnitTestCase { class ContentLengthTest extends UnitTestCase {
/** /**
* @covers ::setContentLengthHeader * @covers ::handle
* @dataProvider providerTestSetContentLengthHeader * @dataProvider providerTestSetContentLengthHeader
*/ */
public function testSetContentLengthHeader(false|int $expected_header, Response $response) { public function testHandle(false|int $expected_header, Response $response) {
$event_subscriber = new FinishResponseSubscriber( $kernel = $this->prophesize(HttpKernelInterface::class);
$this->prophesize(LanguageManagerInterface::class)->reveal(), $request = Request::create('/');
$this->getConfigFactoryStub(), $kernel->handle($request, HttpKernelInterface::MAIN_REQUEST, TRUE)->willReturn($response);
$this->prophesize(RequestPolicyInterface::class)->reveal(), $middleware = new ContentLength($kernel->reveal());
$this->prophesize(ResponsePolicyInterface::class)->reveal(), $response = $middleware->handle($request);
$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);
if ($expected_header === FALSE) { if ($expected_header === FALSE) {
$this->assertFalse($event->getResponse()->headers->has('Content-Length')); $this->assertFalse($response->headers->has('Content-Length'));
} return;
else {
$this->assertSame((string) $expected_header, $event->getResponse()->headers->get('Content-Length'));
} }
$this->assertSame((string) $expected_header, $response->headers->get('Content-Length'));
} }
public function providerTestSetContentLengthHeader() { public function providerTestSetContentLengthHeader() {