I have a Controller which generates a text/html BinaryFileResponse:

$response = new BinaryFileResponse($temp_file);
$response->headers->set('Content-Type', 'text/html');
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->setPrivate();
return $response;

ActiveLinkResponseFilter->onResponse() runs on every response and does:

if (stripos($response->headers->get('Content-Type'), 'text/html') === FALSE) {
  return;
}
if ($this->currentUser->isAuthenticated()) {
  return;
}
$response->setContent(...

BinaryFileResponse->setContent() does:

public function setContent($content)
{
    if (null !== $content) {
        throw new \LogicException('The content cannot be set on a BinaryFileResponse instance.');
    }
}

So if anyone ever sends a BinaryFileResponse with the content type set to text/html, AND while anonymous then they get a LogicException.

I think the fix here is just to check if $response is_a BinaryFileResponse in ActiveLinkResponseFilter->onResponse(). Might be worth reporting this upstream as a Symfony bug too since children of Response shouldn't be throwing on setContent() if that's a valid call on Response (but I'm guessing they can't change it for backwards compat reasons).

Patch to follow.

CommentFileSizeAuthor
#28 2929798-28.patch6.43 KBalexpott
#28 26-28-interdiff.txt720 bytesalexpott
#27 2929798-26.patch6.82 KBalexpott
#26 23-26-interdiff.txt4.18 KBalexpott
#26 23-26-interdiff.txt4.18 KBalexpott
#23 2929798-21.patch7 KBneclimdul
#21 2929798-21.interdiff.txt1.87 KBneclimdul
#21 2929798-21.patch6.52 KBneclimdul
#16 2929798-16.interdiff.txt4.03 KBneclimdul
#16 2929798-16.patch6.52 KBneclimdul
#16 2929798-16.test.patch4.82 KBneclimdul
#15 2929798-15.tests_.patch3.61 KBneclimdul
#15 2929798-15.patch5.3 KBneclimdul
#15 2929798-15.interdiff.txt6.36 KBneclimdul
#12 2929798-10-test-only.patch3.38 KBSut3kh
#12 2929798-10.patch5.07 KBSut3kh
#12 interdiff_3-10.txt4.81 KBSut3kh
#2 core.2929798.dont_crash_on_a_text_html_BinaryFileResponse.patch1.99 KBGeorge Bills
#3 core.2929798.dont_crash_on_a_text_html_BinaryFileResponse.patch1.93 KBGeorge Bills
#9 2929798-9-test-only.patch3.3 KBSut3kh
#9 2929798-9.patch4.99 KBSut3kh
#9 interdiff_3-9.txt4.73 KBSut3kh
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

George Bills created an issue. See original summary.

George Bills’s picture

George Bills’s picture

George Bills’s picture

Rerolled the patch so it would apply cleanly - any chance of getting this rolled in? It's a fairly obscure bug (only hits you if you're sending out HTML as a BinaryFileResponse while anonymous) but it completely breaks that functionality (e.g. the ability to send out a HTML attachment for the user to save).

George Bills’s picture

Status: Active » Needs review
Wim Leers’s picture

Status: Needs review » Postponed (maintainer needs more info)

sends a BinaryFileResponse with the content type set to text/html,

What is the use case for doing this?

George Bills’s picture

I have a site where users can build up an "enquiry list" of products they'd like to buy.

Users can click a button to send that enquiry list through (as an entity) to the Drupal backend, and then site admins can edit the enquiry ("yes we have 3 of those available at $9.95 each"), click a button, and that emails a quote back to the customer.

Users can also click a "download my enquiry list" button to save a copy for themselves (in addition to the automatic email they get). That renders a very simple page.html.twig template with all of their enquiry information, and sends it out as an attachment for the browser to download. In order to send that HTML out as an "attachment" (i.e. browser will save instead of navigating to) then I need the "setContentDisposition()" method, and that's only available on BinaryFileResponse.

George Bills’s picture

Status: Postponed (maintainer needs more info) » Needs review
Sut3kh’s picture

Title: ActiveLinkResponseFilter breaks any text/html BinaryFileResponse for anonymous users » ActiveLinkResponseFilter breaks any text/html BinaryFileResponse or StreamedResponse for anonymous users
Version: 8.4.2 » 8.6.x-dev
FileSize
4.73 KB
4.99 KB
3.3 KB

This is also an issue for StreamedResponse as it does not support setContent() either.

Our use case was for some html that we cache gzip & brotili compressed versions of, on cache miss we generate the html, gzip and return it but then create the brotli compressed version and update the cache after flushing to the client (brotli is very slow!). I'm sure this could be done with an event listener or something as well, but this should work imo.

I can see the possible concern with this fix being that a developer has no warning that active links will not get is-active classes on links (and as this is only for anon users, could easily be missed) but i do not see any good way around this? I imagine it should be pretty rare to be serving full drupal rendered html pages to anon users in a streamed or binary file response so unless anyone has a better solution then personally i vote standard symfony responses working > extreme edge case DX.

Have updated to include StreamedResponse and added some tests.

The last submitted patch, 9: 2929798-9-test-only.patch, failed testing. View results

The last submitted patch, 9: 2929798-9.patch, failed testing. View results

Sut3kh’s picture

Not sure why the output assertion fails in CI environment, possibly something to do with the buffer?

Whatever it was the output is not relevant to the test, updated to simply pass if no exception and improved the test file name while i was at it.

Status: Needs review » Needs work

The last submitted patch, 12: 2929798-10-test-only.patch, failed testing. View results

Sut3kh’s picture

Status: Needs work » Needs review

Test proven, fix passed.

neclimdul’s picture

@wim I won't claim there are "good" reasons but if you've got a html file on the disk you want to deliver say from a private file store or outside the doc root somewhere then you'd use BinaryFileResponse() and the content type would be 'text/html'.

A developer doing this would have surely backed themselves into a terrible corner because there are likely better solution to the problem but... it could happen.

  1. +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/ActiveLinkResponseFilterTest.php
    @@ -397,4 +407,58 @@ public function testSetLinkActiveClass($html_markup, $current_path, $is_front, $
    +    $language = new LanguageDefault([
    +      'id' => $this->randomMachineName(2),
    +      'name' => $this->randomMachineName(),
    +      'uuid' => $this->randomMachineName(),
    +    ]);
    +    $language_manager = new LanguageManager($language);
    

    Why mock everything else but make a concrete language? I don't know why I highlighted this other then there is a lot of code for mocking that could be a lot smaller and lighter.

  2. +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/ActiveLinkResponseFilterTest.php
    @@ -397,4 +407,58 @@ public function testSetLinkActiveClass($html_markup, $current_path, $is_front, $
    +    $file = __DIR__ . '/../../../../fixtures/active_link_response_filter_test.html';
    +    $responses[] = new BinaryFileResponse($file, 200, $headers);
    

    __FILE__ will work, we don't care about the content's as they won't go anywhere and we won't have to add a fixture.

  3. +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/ActiveLinkResponseFilterTest.php
    @@ -397,4 +407,58 @@ public function testSetLinkActiveClass($html_markup, $current_path, $is_front, $
    +      // If no exception is thrown in the event handler then it is a pass.
    +      // @see \Symfony\Component\HttpFoundation\StreamedResponse::setContent().
    +      ob_start();
    +      $subscriber->onResponse($event);
    +      $response->send();
    +      ob_end_clean();
    +      $this->assertTrue(TRUE, 'No exception was thrown');
    

    I'm not sure we need to send the response, that just makes extra complexity especially since we just toss the output.

    Also, assertTrue(TRUE) is almost always a bad idea even when just testing the absence of an exception. There is generally something else you should be asserting at the same time like a return value. There is no return here but we can use the mocks to make that better assertion. It means mocking a service but we where already doing that and the path stack has some gnarly dependencies so its a prime candidate.

Just updating the tests as noted and using instance_of since its slightly faster than is_a and meets our requirements.

neclimdul’s picture

bah, i used the wrong session for the test... split the tests so they could be documented better.

The last submitted patch, 16: 2929798-16.test.patch, failed testing. View results

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

mr.baileys’s picture

Status: Needs review » Needs work

In order to send that HTML out as an "attachment" (i.e. browser will save instead of navigating to) then I need the "setContentDisposition()" method, and that's only available on BinaryFileResponse.

While setDisposition() is only available on BinaryFileResponse, nothing prevents you from manually setting the Content-Disposition header on your response, which I think is preferred over (ab-)using BinaryFileResponse just to make the output downloadable. Here is a snippet that triggers a download for a regular HTML response:

 public function download() {
    $build['#markup'] = 'I\'m a downloadable HTML response.';
    $build['#attached']['http_header'][] = ['Content-Disposition', 'attachment; my-file-name.html'];
    return $build;
  }
... if you've got a html file on the disk you want to deliver say from a private file store or outside the doc root somewhere then you'd use BinaryFileResponse() and the content type would be 'text/html'.

IMO, this is a valid use case and reason to adjust ActiveLinkResponseFilter::onRespond() so it does not attempt invoke setContent() when this would trigger an exception. The following currently would not work and throws an exception:

public function download() {
  $response = new BinaryFileResponse('/var/www/html/modules/custom/html_download/download.html');
  $response->headers->set('Content-Type', 'text/html');
  return $response;
}

Reviewing the current proposed patch:

+    // Ignore response objects that do not support setContent.
+    $not_supported = $response instanceof BinaryFileResponse;
+    $not_supported = $not_supported || $response instanceof StreamedResponse;
+    if ($not_supported) {
+      return;
+    }

Rather than explictly checking on instanceof StreamedResponse | BinaryFileResponse, I think we should rely on the fact that classes inheriting from Response can opt to override ::getContent() and return FALSE. Both StreamedResponse::getContent() and BinaryFileResponse::getContent() will return FALSE, as might other derived classes not mentioned in this issue.

So the code at the end of ActiveLinkResponseFilter::onRespond() would become:

$response = $event->getResponse();
if ($content = $response->getContent()) {
  $response->setContent(static::setLinkActiveClass(
    $content,
    ltrim($this->currentPath->getPath(), '/'),
    $this->pathMatcher->isFrontPage(),
    $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(),
    $event->getRequest()->query->all()
  ));
}
neclimdul’s picture

It would have to be === FALSE so it handles a 204 but that might work.

neclimdul’s picture

Status: Needs work » Needs review
FileSize
6.52 KB
1.87 KB

Lets see how it works.

I'm still not sure how I feel about it. On the one hand we're relying on undocumented complexity and assumptions about how two methods interact(because there is no interface) in external libraries. On the other we're hard coding a list of classes that limits other libraries and contrib in how it can interact with this behavior. Its probably fine I'm just not 100% comfortable backdooring an interface where there isn't one. :-/

mr.baileys’s picture

Status: Needs review » Needs work

It looks like the uploaded patch in #21 is the same patch as #16?

It would have to be === FALSE so it handles a 204 but that might work.

Would it? If getContent() were to return an empty string instead of FALSE, ActiveLinkResponseFilter::setLinkActiveClass() would not do anything (there is nothing to replace), so it feels like we might as well do if ($content) {...} instead of if ($content !== FALSE) {...}?

neclimdul’s picture

Status: Needs work » Needs review
FileSize
7 KB

hm... I have the right patch still. Here it is.

mglaman’s picture

Status: Needs review » Reviewed & tested by the community

This resolves the issue for me, which breaks jsonapi_explorer.

If getContent() were to return an empty string instead of FALSE

True. But an empty string wouldn't even be processed by the setLinkActiveClass function since the while loop would break immediately and process nothing. The real check here is for responses which explicitly return FALSE because the content is streamed.

neclimdul’s picture

Thanks, that's interesting. Sounds like its blocking a contrib project then and that technically makes it "Major"

alexpott’s picture

FileSize
4.18 KB
4.18 KB

Fixing up some of the code commentary. Some of it was wrong - for example - the filter only works for anonymous users not logged in users and some of it was overly wordy and not as per standards.

alexpott’s picture

alexpott’s picture

Removing unused use statements.

alexpott’s picture

Version: 8.9.x-dev » 8.8.x-dev
Status: Reviewed & tested by the community » Patch (to be ported)

Committed and pushed ba9eff6b48 to 9.0.x and a2c8fd1361 to 8.9.x. Thanks!

Asking other committers for a +1 for backport to 8.8.x

  • alexpott committed ba9eff6 on 9.0.x
    Issue #2929798 by neclimdul, Sut3kh, alexpott, George Bills, mr.baileys...

  • alexpott committed a2c8fd1 on 8.9.x
    Issue #2929798 by neclimdul, Sut3kh, alexpott, George Bills, mr.baileys...
mglaman’s picture

🙌 thank you alexpott

alexpott’s picture

Status: Patch (to be ported) » Fixed

@catch +1'd the backport

  • alexpott committed 3bd5d47 on 8.8.x
    Issue #2929798 by neclimdul, Sut3kh, alexpott, George Bills, mr.baileys...
Wim Leers’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.