ux icon indicating copy to clipboard operation
ux copied to clipboard

[LiveComponent] Live Actions cannot handle file downloads

Open richardhj opened this issue 1 year ago ‱ 14 comments

As live actions are treated as regular controller actions, they should also support file responses.


#[LiveAction]
public function submit(): BinaryFileResponse
{
    $this->validate();

    return $this->file($this->exporter->generate($this->items));
}

Currently, file downloads are not downloaded but parsed..?

Screenshot 2024-02-18 at 13 28 23

Edit (current workaround)

public function submit(UriSigner $uriSigner): Response
{
    $this->validate();


    $file = $this->exporter->generate($this->items)

    $url = $this->generateUrl('admin_download', ['file' => base64_encode($file)]);

    return $this->redirect($uriSigner->sign($url));
}

richardhj avatar Feb 18 '24 12:02 richardhj

Hello @richardhj

there is no support of File responses currently in LiveComponent

Couple of solutions:

  • send a redirect response -> GET url of your response (what i'd do)
  • use a standard action/URL instead of a live one (probably not the best there as it seems you need some form data)
  • create a custom action in your Stimulus controller to handle the binary stream (but you may need to duplicate some code)

--

Some quotes cherry-picked from the RFC9110, in favor of the POST -> redirection -> GET solution

If one or more resources has been created on the origin server as a result of successfully processing a POST request, the origin server SHOULD send a 201 (Created) response containing a Location header field that provides an identifier for the primary resource created (Section 10.2.2) and a representation that describes the status of the request while referring to the new resource(s).

If the result of processing a POST would be equivalent to a representation of an existing resource, an origin server MAY redirect the user agent to that resource by sending a 303 (See Other) response with the existing resource's identifier in the Location field. This has the benefits of providing the user agent a resource identifier and transferring the representation via a method more amenable to shared caching

--

Now what seems weird to me is how the response seems handled and displayed in your example/capture... that may need some improvement / bug fixes :)

smnandre avatar Feb 18 '24 21:02 smnandre

Hi @smnandre, thats valuale information, I implemented a redirect to a "download controller" 👍 👍

However, that redirect must be uri-signed, so I can make sure that only "authorized" files can be downloaded. Maybe there is a way, Live component may support content-disposition in the future? 😄

richardhj avatar Feb 19 '24 09:02 richardhj

same need here, so maybe this should be considered to be in core :thinking:

norkunas avatar Mar 18 '24 11:03 norkunas

would be awesome if file response handling would work one day ❀

barbieswimcrew avatar Jul 02 '24 11:07 barbieswimcrew

+1

stefpe avatar Jul 02 '24 11:07 stefpe

+1

x-vlad-x avatar Jul 02 '24 12:07 x-vlad-x

+1

sebbemunich avatar Jul 02 '24 12:07 sebbemunich

Hey guys, this is not how OSS work and you are spamming other people's mailboxes.

richardhj avatar Jul 02 '24 13:07 richardhj

Hi @stefpe, @x-vlad-x, @sebbemunich,

I understand your desire to see this feature implemented. However, as @richardhj mentioned, this is an open-source project, so you can either:

  • Open a pull request (PR) to add the feature.
  • Ask your company to sponsor someone to develop it.
  • Make a polite request for someone else to consider working on it.

If you want to support @barbieswimcrew's suggestion, thumbing up the first message in the issue is the best way, as it allows us to sort by this.


I performed a brief test with this method, which could serve as an implementation idea. This is similar to how Livewire implements this feature.

Here are some considerations for the implementation:

  • Handle readable streams if possible.
  • Ensure files do not remain in memory or the DOM unnecessarily.
  • Prevent the creation of large blobs (e.g., 1TB) in the DOM.
  • Verify the origin of the files (only accept local streams/files, perhaps with a custom header).
  • Enforce that this occurs only after a user action (not on load or after an event).

However, implementing this will take some time, which I currently do not have, as we all work on this in our free time.

(and I still believe that redirecting to a file is a much cleaner solution 😄 )

smnandre avatar Jul 02 '24 22:07 smnandre

This option would really be very useful.

devath0 avatar Dec 20 '24 17:12 devath0

@smnandre, thank you for the following suggestion (and congrats for your place in the Symfony UX Core Team 👍)

there is no support of File responses currently in LiveComponent

Couple of solutions:

* send a redirect response -> GET url of your response (what i'd do)

Just in case someone found this topic and is trying to implement a download from a LiveComponent using a return new RedirectResponse() with something similar to the following code :

#[LiveAction]
    public function initiateDownload(
        UrlGeneratorInterface $urlGenerator,
    ): Response
    {

        $url = $urlGenerator->generate('app_document_download');
        return new RedirectResponse($url);
    }
<div{{ attributes }}>
    <button
            data-action="live#action"
            data-live-action-param="initiateDownload"
    >
        Export
    </button>
</div>

If turbo is enabled (it is if you have initialized your symfony project with --webapp) there is a strange behaviour (or not ?) : the url will be called twice : first from the live component (type fetch) then by turbo (type document)

Image

If you share data between the live component and the controller route with a session and you manipulate this data (add/remove), you will have unexpected results (eg : $session->remove('some_session_key')) because the data will be changed on the first call and not in the state you expect in the second call : the one that return the document.

Adding the attribute data-turbo="false" (turbo doc) to the root of your live component will bypass this behaviour

<div{{ attributes }}
        data-turbo="false"
>
    <button
            data-action="live#action"
            data-live-action-param="initiateDownload"
    >
        Export
    </button>
</div>

The url of RedirectResponse() is now called only once Image

(on symfony 7.2.2 up to date at the time of this post)

zefyx avatar Jan 16 '25 13:01 zefyx

Hi @zefyx! Thank you for sharing this feedback (and the kind words).

We are working on a proper implementation of downloads with LiveComponent on the PR here https://github.com/symfony/ux/pull/2483

But this is something that could be mentioned anyway on the documentation I think.

Probably just after we speak about file uploads, in a dedicated "Download" paragraph

Maybe just a paragraph ending with the HTML you provided here... ... would you have time to open a PR for this ?

smnandre avatar Jan 17 '25 21:01 smnandre

Hi, my solution :

$tempFile = tempnam(sys_get_temp_dir(), 'export_');
        $handle = fopen($tempFile, 'w');

       //DO YOU THINGS
       
        fclose($handle);

        // Utiliser le nom du fichier temporaire comme token
        $token = basename($tempFile);

        // Générer l'URL de téléchargement via la route 'admin_download'
        $url = $router->generate('backoffice_secure_download_csv', ['token' => $token], UrlGeneratorInterface::ABSOLUTE_URL);

        // Signer l'URL pour éviter toute altération
        $signedUrl = $uriSigner->sign($url);

        // Rediriger vers l'URL signée pour lancer le téléchargement
        return new RedirectResponse($signedUrl);

And download :

#[Route('/download_csv', name: 'secure_download_csv')]
    public function downloadCsv(Request $request, UriSigner $uriSigner): Response
    {
        // Vérifier que l'URL est correctement signée
        if (!$uriSigner->check($request->getUri())) {
            throw new AccessDeniedHttpException('Lien invalide ou expiré.');
        }

        // Récupérer le token depuis la query string
        $token = $request->query->get('token');
        if (!$token) {
            throw new NotFoundHttpException('Aucun fichier spécifié.');
        }

        // Reconstruire le chemin du fichier temporaire
        $tempFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $token;

        if (!file_exists($tempFile)) {
            throw new NotFoundHttpException('Le fichier n\'existe pas ou a expiré.');
        }

        // Créer une réponse pour le téléchargement
        $response = new BinaryFileResponse($tempFile);
        $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'export.csv');

         unlink($tempFile);

        return $response;
    }

slavsobuzz avatar Feb 26 '25 09:02 slavsobuzz

In my case, I create an empty component dedicated solely to managing downloads. The advantage is that other components can use a different listener to update their views.

<div {{ attributes }} style="display: none"></div>
#[AsLiveComponent]
class Download extends AbstractController
{
	use ComponentToolsTrait;
	use DefaultActionTrait;

	public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
	{
	}

	#[LiveListener('file:download')]
	public function onFinalize(#[LiveArg] int $id): RedirectResponse
	{
		$url = $this->urlGenerator->generate('app_file_download', ['id'       => $id]);
		return new RedirectResponse($url);
	}
}

CODEheures avatar Oct 30 '25 11:10 CODEheures