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.
Comment | File | Size | Author |
---|---|---|---|
#28 | 2929798-28.patch | 6.43 KB | alexpott |
#28 | 26-28-interdiff.txt | 720 bytes | alexpott |
#27 | 2929798-26.patch | 6.82 KB | alexpott |
#26 | 23-26-interdiff.txt | 4.18 KB | alexpott |
#26 | 23-26-interdiff.txt | 4.18 KB | alexpott |
Comments
Comment #2
George Bills CreditAttribution: George Bills commentedComment #3
George Bills CreditAttribution: George Bills commentedComment #4
George Bills CreditAttribution: George Bills commentedRerolled 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).
Comment #5
George Bills CreditAttribution: George Bills commentedComment #6
Wim LeersWhat is the use case for doing this?
Comment #7
George Bills CreditAttribution: George Bills commentedI 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.
Comment #8
George Bills CreditAttribution: George Bills commentedComment #9
Sut3kh CreditAttribution: Sut3kh as a volunteer commentedThis 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.
Comment #12
Sut3kh CreditAttribution: Sut3kh as a volunteer commentedNot 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.
Comment #14
Sut3kh CreditAttribution: Sut3kh as a volunteer commentedTest proven, fix passed.
Comment #15
neclimdul@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.
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.
__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.
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.
Comment #16
neclimdulbah, i used the wrong session for the test... split the tests so they could be documented better.
Comment #19
mr.baileysWhile
setDisposition()
is only available onBinaryFileResponse
, nothing prevents you from manually setting theContent-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: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:Reviewing the current proposed patch:
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:Comment #20
neclimdulIt would have to be
=== FALSE
so it handles a 204 but that might work.Comment #21
neclimdulLets 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. :-/
Comment #22
mr.baileysIt looks like the uploaded patch in #21 is the same patch as #16?
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 doif ($content) {...}
instead ofif ($content !== FALSE) {...}
?Comment #23
neclimdulhm... I have the right patch still. Here it is.
Comment #24
mglamanThis resolves the issue for me, which breaks jsonapi_explorer.
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.
Comment #25
neclimdulThanks, that's interesting. Sounds like its blocking a contrib project then and that technically makes it "Major"
Comment #26
alexpottFixing 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.
Comment #27
alexpottComment #28
alexpottRemoving unused use statements.
Comment #29
alexpottCommitted 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
Comment #32
mglaman🙌 thank you alexpott
Comment #33
alexpott@catch +1'd the backport
Comment #35
Wim LeersNice work here! I added
ActiveLinkResponseFilter
in #2478443: Set the 'is-active' class for anonymous users in a Response Filter instead of a #post_render_cache callback 4.5 years ago, this clearly was an oversight. Thanks! 🙏