-
-
Notifications
You must be signed in to change notification settings - Fork 329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[LiveComponent] Add support for downloading files from LiveActions (Experimental) #2483
base: 2.x
Are you sure you want to change the base?
Changes from all commits
bd6b8bd
61cee45
2f34d71
f24dea6
d7d6bb5
dd75be0
fe92cc7
f81dcff
4f6f869
36143e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,4 +13,8 @@ export default class { | |
|
||
return this.body; | ||
} | ||
|
||
async getBlob(): Promise<Blob> { | ||
return this.response.blob(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -300,15 +300,59 @@ export default class Component { | |||||||||||||
|
||||||||||||||
this.backendRequest.promise.then(async (response) => { | ||||||||||||||
const backendResponse = new BackendResponse(response); | ||||||||||||||
const html = await backendResponse.getBody(); | ||||||||||||||
|
||||||||||||||
// clear sent files inputs | ||||||||||||||
for (const input of Object.values(this.pendingFiles)) { | ||||||||||||||
input.value = ''; | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
// if the response does not contain a component, render as an error | ||||||||||||||
const headers = backendResponse.response.headers; | ||||||||||||||
if (headers.get('X-Live-Download')) { | ||||||||||||||
const headerContentDisposition = headers.get('Content-Disposition'); | ||||||||||||||
if ( | ||||||||||||||
!headerContentDisposition | ||||||||||||||
|| !(headerContentDisposition?.includes('attachment') || headerContentDisposition?.includes('inline')) | ||||||||||||||
|| !headerContentDisposition?.includes('filename=') | ||||||||||||||
) { | ||||||||||||||
throw new Error('Invalid LiveDownload response'); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
const fileSize = Number.parseInt(headers.get('Content-Length') || '0'); | ||||||||||||||
if (fileSize > 10000000) { | ||||||||||||||
throw new Error('File is too large to download (10MB limit)'); | ||||||||||||||
} | ||||||||||||||
Comment on lines
+321
to
+323
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More correct and easier to understand:
Suggested change
Also, why do you want to limit the file size? It can be a nice feature, but shouldn't it be configurable by the developer? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the part we need to discuss. Don't forget the user has NOT accepted this download in any way for now. Also, you cannot store 1GB in the DOM before saving the file. We have options after:
|
||||||||||||||
|
||||||||||||||
const fileName = headerContentDisposition.split('filename=')[1]; | ||||||||||||||
if (!fileName) { | ||||||||||||||
throw new Error('No filename found in Content-Disposition header'); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
const blob = await backendResponse.getBlob(); | ||||||||||||||
const link = Object.assign(window.document.createElement('a'), { | ||||||||||||||
target: '_blank', | ||||||||||||||
style: 'display: none', | ||||||||||||||
href: window.URL.createObjectURL(blob), | ||||||||||||||
download: fileName, | ||||||||||||||
}); | ||||||||||||||
this.element.appendChild(link); | ||||||||||||||
link.click(); | ||||||||||||||
this.element.removeChild(link); | ||||||||||||||
|
||||||||||||||
this.backendRequest = null; | ||||||||||||||
thisPromiseResolve(backendResponse); | ||||||||||||||
|
||||||||||||||
// do we already have another request pending? | ||||||||||||||
if (this.isRequestPending) { | ||||||||||||||
this.isRequestPending = false; | ||||||||||||||
this.performRequest(); | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
return response; | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
const html = await backendResponse.getBody(); | ||||||||||||||
|
||||||||||||||
// if the response does not contain a component, render as an error | ||||||||||||||
if ( | ||||||||||||||
!headers.get('Content-Type')?.includes('application/vnd.live-component+html') && | ||||||||||||||
!headers.get('X-Live-Redirect') | ||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,6 +13,7 @@ | |
|
||
use Psr\Container\ContainerInterface; | ||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
use Symfony\Component\HttpFoundation\BinaryFileResponse; | ||
use Symfony\Component\HttpFoundation\Exception\JsonException; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Component\HttpFoundation\Response; | ||
|
@@ -43,7 +44,9 @@ | |
class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscriberInterface | ||
{ | ||
private const HTML_CONTENT_TYPE = 'application/vnd.live-component+html'; | ||
|
||
private const REDIRECT_HEADER = 'X-Live-Redirect'; | ||
private const DOWNLOAD_HEADER = 'X-Live-Download'; | ||
|
||
public function __construct( | ||
private ContainerInterface $container, | ||
|
@@ -255,6 +258,13 @@ public function onKernelView(ViewEvent $event): void | |
return; | ||
} | ||
|
||
if ($event->getControllerResult() instanceof BinaryFileResponse) { | ||
if (!$event->getControllerResult()->headers->has(self::DOWNLOAD_HEADER)) { | ||
|
||
} | ||
Comment on lines
+262
to
+264
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dead code |
||
$event->setResponse(new Response()); | ||
} | ||
|
||
$event->setResponse($this->createResponse($request->attributes->get('_mounted_component'))); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
<?php | ||
|
||
namespace Symfony\UX\LiveComponent; | ||
|
||
use SplFileInfo; | ||
use SplTempFileObject; | ||
use Symfony\Component\HttpFoundation\BinaryFileResponse; | ||
use Symfony\Component\HttpFoundation\HeaderUtils; | ||
|
||
/** | ||
* @author Simon André <[email protected]> | ||
*/ | ||
final class LiveDownloadResponse extends BinaryFileResponse | ||
{ | ||
public const HEADER_LIVE_DOWNLOAD = 'X-Live-Download'; | ||
|
||
public function __construct(string|SplFileInfo $file, ?string $filename = null) | ||
Kocal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
if (\is_string($file)) { | ||
$file = new SplFileInfo($file); | ||
} | ||
|
||
if ((!$file instanceof SplFileInfo)) { | ||
throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $file)); | ||
} | ||
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure to understand when this code can be executed, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe the condition is wrong? |
||
|
||
if ($file instanceof SplTempFileObject) { | ||
$file->rewind(); | ||
} | ||
|
||
parent::__construct($file, 200, [ | ||
self::HEADER_LIVE_DOWNLOAD => 1, | ||
'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename ?? basename($file)), | ||
'Content-Type' => 'application/octet-stream', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I hesitated (keep dep light) and chose not to... But in fact it's a good idea and Mime is a very small component. So let's make it a requirement for LiveComponent ? Or only when using downloads / uploads ? wdyt ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest to put it in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pretty sure there is no suggest in Symfony composer packages.. We have other situations in UX where we do not require a package (i.e. in Autocomplete for Form ..) My question was more: should we require it for everyone, or keep it as an "runtime" dependency ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. runtime dependency |
||
'Content-Length' => $file instanceof SplTempFileObject ? 0 : $file->getSize(), | ||
], false, HeaderUtils::DISPOSITION_ATTACHMENT); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; | ||
|
||
use Symfony\Component\HttpFoundation\BinaryFileResponse; | ||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; | ||
use Symfony\UX\LiveComponent\Attribute\LiveAction; | ||
use Symfony\UX\LiveComponent\Attribute\LiveArg; | ||
use Symfony\UX\LiveComponent\DefaultActionTrait; | ||
use Symfony\UX\LiveComponent\LiveDownloadResponse; | ||
|
||
/** | ||
* @author Simon André <[email protected]> | ||
*/ | ||
#[AsLiveComponent('download_file', template: 'components/download_file.html.twig')] | ||
class DownloadFileComponent | ||
{ | ||
use DefaultActionTrait; | ||
|
||
private const FILE_DIRECTORY = __DIR__.'/../files/'; | ||
|
||
#[LiveAction] | ||
public function download(): BinaryFileResponse | ||
{ | ||
$file = new \SplFileInfo(self::FILE_DIRECTORY.'/foo.json'); | ||
|
||
return new LiveDownloadResponse($file); | ||
} | ||
|
||
#[LiveAction] | ||
public function generate(): BinaryFileResponse | ||
{ | ||
$file = new \SplTempFileObject(); | ||
$file->fwrite(file_get_contents(self::FILE_DIRECTORY.'/foo.json')); | ||
|
||
return new LiveDownloadResponse($file, 'foo.json'); | ||
} | ||
|
||
#[LiveAction] | ||
public function heavyFile(#[LiveArg] int $size): BinaryFileResponse | ||
{ | ||
$file = new \SplFileInfo(self::FILE_DIRECTORY.'heavy.txt'); | ||
|
||
$response = new BinaryFileResponse($file); | ||
$response->headers->set('Content-Length', 10000000); // 10MB | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<title>Foo</title> | ||
</head> | ||
<body> | ||
<h1>Bar</h1> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"foo": "bar" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Foo | ||
|
||
## Bar |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
<div {{ attributes }}> | ||
|
||
</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really want to assume that if
Content-Length
header is absent, then it is equal to0
?In which case the header can be absent? (I didn't see the rest of the PR yet!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should not be. This method is not made to stream movies, but files generated using the liveprops values.
Elsewhen, developers should send an URL to the JS and then open some stream, or redirect to another page, etc...
No, absolutely not. But if it is unknown, we need to compute it on the fly then. Not implemented yet, but linked to the other questions below :)