From bd6b8bdc1083b3d82254f37244ec243850e3cb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:31:05 +0100 Subject: [PATCH 01/12] Add getBlob method on BackendResponse --- src/LiveComponent/assets/src/Backend/BackendResponse.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index 5b1357bd24e..dc6eeb11af4 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -13,4 +13,8 @@ export default class { return this.body; } + + async getBlob(): Promise { + return await this.response.blob(); + } } From 61cee450dc44215046a270b19ac879f8166d6685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:31:29 +0100 Subject: [PATCH 02/12] Handle downloads in Component.js --- .../assets/src/Component/index.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 7db1f564a7b..b53aa0b57d4 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -300,15 +300,60 @@ 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')) { + if ( + !( + headers.get('Content-Disposition')?.includes('attachment') || + headers.get('Content-Disposition')?.includes('inline') + ) || + !headers.get('Content-Disposition')?.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)'); + } + + const fileName = headers.get('Content-Disposition')?.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') From 2f34d71e36c282effd7329bd1c930d16d385f54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:31:51 +0100 Subject: [PATCH 03/12] Build dist files --- .../assets/dist/Backend/BackendResponse.d.ts | 1 + .../assets/dist/live_controller.js | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts index a51a6448707..864a3533545 100644 --- a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts +++ b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts @@ -3,4 +3,5 @@ export default class { private body; constructor(response: Response); getBody(): Promise; + getBlob(): Promise; } diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 375a5b15bd5..294645ff00d 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -111,6 +111,9 @@ class BackendResponse { } return this.body; } + async getBlob() { + return await this.response.blob(); + } } function getElementAsTagText(element) { @@ -2119,11 +2122,43 @@ class Component { this.isRequestPending = false; this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse(response); - const html = await backendResponse.getBody(); for (const input of Object.values(this.pendingFiles)) { input.value = ''; } const headers = backendResponse.response.headers; + if (headers.get('X-Live-Download')) { + if (!(headers.get('Content-Disposition')?.includes('attachment') || + headers.get('Content-Disposition')?.includes('inline')) || + !headers.get('Content-Disposition')?.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)'); + } + const fileName = headers.get('Content-Disposition')?.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); + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + return response; + } + const html = await backendResponse.getBody(); if (!headers.get('Content-Type')?.includes('application/vnd.live-component+html') && !headers.get('X-Live-Redirect')) { const controls = { displayError: true }; From f24dea6379243bbb67f7fa6727233a2dcae694b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:33:46 +0100 Subject: [PATCH 04/12] Add LiveDownloadResponse + checks in Subscriber --- .../EventListener/LiveComponentSubscriber.php | 10 ++ .../src/LiveDownloadResponse.php | 38 ++++++ .../Component/DownloadFileComponent.php | 56 +++++++++ .../tests/Fixtures/files/foo.html | 9 ++ .../tests/Fixtures/files/foo.json | 3 + src/LiveComponent/tests/Fixtures/files/foo.md | 3 + .../components/download_file.html.twig | 3 + .../LiveComponentSubscriberTest.php | 111 ++++++++++++------ 8 files changed, 200 insertions(+), 33 deletions(-) create mode 100644 src/LiveComponent/src/LiveDownloadResponse.php create mode 100644 src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php create mode 100644 src/LiveComponent/tests/Fixtures/files/foo.html create mode 100644 src/LiveComponent/tests/Fixtures/files/foo.json create mode 100644 src/LiveComponent/tests/Fixtures/files/foo.md create mode 100644 src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 58b5df6d111..2a623e45451 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -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, @@ -254,6 +257,13 @@ public function onKernelView(ViewEvent $event): void return; } + + if ($event->getControllerResult() instanceof BinaryFileResponse) { + if (!$event->getControllerResult()->headers->has(self::DOWNLOAD_HEADER)) { + + } + $event->setResponse(new Response()); + } $event->setResponse($this->createResponse($request->attributes->get('_mounted_component'))); } diff --git a/src/LiveComponent/src/LiveDownloadResponse.php b/src/LiveComponent/src/LiveDownloadResponse.php new file mode 100644 index 00000000000..d87608884f1 --- /dev/null +++ b/src/LiveComponent/src/LiveDownloadResponse.php @@ -0,0 +1,38 @@ + + */ +final class LiveDownloadResponse extends BinaryFileResponse +{ + public const HEADER_LIVE_DOWNLOAD = 'X-Live-Download'; + + public function __construct(string|SplFileInfo $file, ?string $filename = null) + { + if (\is_string($file)) { + $file = new SplFileInfo($file); + } + + if ((!$file instanceof SplFileInfo)) { + throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $file)); + } + + 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', + 'Content-Length' => $file instanceof SplTempFileObject ? 0 : $file->getSize(), + ], false, HeaderUtils::DISPOSITION_ATTACHMENT); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php b/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php new file mode 100644 index 00000000000..9506ad742ee --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php @@ -0,0 +1,56 @@ + + * + * 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é + */ +#[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 + } +} diff --git a/src/LiveComponent/tests/Fixtures/files/foo.html b/src/LiveComponent/tests/Fixtures/files/foo.html new file mode 100644 index 00000000000..85f74bd15ed --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/foo.html @@ -0,0 +1,9 @@ + + + + Foo + + +

Bar

+ + diff --git a/src/LiveComponent/tests/Fixtures/files/foo.json b/src/LiveComponent/tests/Fixtures/files/foo.json new file mode 100644 index 00000000000..e63d37b65a8 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/foo.json @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} diff --git a/src/LiveComponent/tests/Fixtures/files/foo.md b/src/LiveComponent/tests/Fixtures/files/foo.md new file mode 100644 index 00000000000..ed69f19dbf3 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/foo.md @@ -0,0 +1,3 @@ +# Foo + +## Bar diff --git a/src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig new file mode 100644 index 00000000000..a337ab59064 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/download_file.html.twig @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 9ac59da9123..24a2d78b2bf 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -12,12 +12,11 @@ namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\DomCrawler\Crawler; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; +use Zenstruck\Browser; use Zenstruck\Browser\Test\HasBrowser; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -70,8 +69,7 @@ public function testCanRenderComponentAsHtml(): void ->assertContains('Prop1: '.$entity->id) ->assertContains('Prop2: 2021-03-05 9:23') ->assertContains('Prop3: value3') - ->assertContains('Prop4: (none)') - ; + ->assertContains('Prop4: (none)'); } public function testCanRenderComponentAsHtmlWithAlternateRoute(): void @@ -89,8 +87,7 @@ public function testCanRenderComponentAsHtmlWithAlternateRoute(): void ]) ->assertSuccessful() ->assertOn('/alt/alternate_route', parts: ['path']) - ->assertContains('From alternate route. (count: 0)') - ; + ->assertContains('From alternate route. (count: 0)'); } public function testCanExecuteComponentActionNormalRoute(): void @@ -127,8 +124,7 @@ public function testCanExecuteComponentActionNormalRoute(): void ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 2') - ->assertSee('Embedded content with access to context, like count=2') - ; + ->assertSee('Embedded content with access to context, like count=2'); } public function testCanExecuteComponentActionWithAlternateRoute(): void @@ -151,24 +147,21 @@ public function testCanExecuteComponentActionWithAlternateRoute(): void ]) ->assertSuccessful() ->assertOn('/alt/alternate_route/increase') - ->assertContains('count: 1') - ; + ->assertContains('count: 1'); } public function testCannotExecuteComponentActionForGetRequest(): void { $this->browser() ->get('/_components/component2/increase') - ->assertStatus(405) - ; + ->assertStatus(405); } public function testCannotExecuteComponentDefaultActionForGetRequestWhenMethodIsPost(): void { $this->browser() ->get('/_components/with_method_post/__invoke') - ->assertStatus(405) - ; + ->assertStatus(405); } public function testPreReRenderHookOnlyExecutedDuringAjax(): void @@ -187,8 +180,7 @@ public function testPreReRenderHookOnlyExecutedDuringAjax(): void ], ]) ->assertSuccessful() - ->assertSee('PreReRenderCalled: Yes') - ; + ->assertSee('PreReRenderCalled: Yes'); } public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void @@ -224,8 +216,7 @@ public function testItAddsEmbeddedTemplateContextToEmbeddedComponents(): void ]) ->assertSuccessful() ->assertSee('PreReRenderCalled: Yes') - ->assertSee('Embedded content with access to context, like count=1') - ; + ->assertSee('Embedded content with access to context, like count=1'); } public function testItWorksWithNamespacedTemplateNamesForEmbeddedComponents(): void @@ -237,8 +228,7 @@ public function testItWorksWithNamespacedTemplateNamesForEmbeddedComponents(): v $this->browser() ->visit('/render-namespaced-template/render_embedded_with_blocks') ->assertSuccessful() - ->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"'.$obscuredName.'"') - ; + ->assertElementAttributeContains('.component2', 'data-live-props-value', '"data-host-template":"'.$obscuredName.'"'); } public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): void @@ -269,8 +259,7 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): voi ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') - ->assertSee('Overridden content from component 2 on same line - count: 2') - ; + ->assertSee('Overridden content from component 2 on same line - count: 2'); } public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNamespacedTemplate(): void @@ -301,8 +290,7 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNam ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') - ->assertSee('Overridden content from component 2 on same line - count: 2') - ; + ->assertSee('Overridden content from component 2 on same line - count: 2'); } public function testCanRedirectFromComponentAction(): void @@ -336,10 +324,71 @@ public function testCanRedirectFromComponentAction(): void ->assertStatus(204) ->assertHeaderEquals('Location', '/') ->assertHeaderContains('X-Live-Redirect', '1') - ->assertHeaderEquals('X-Custom-Header', '1') + ->assertHeaderEquals('X-Custom-Header', '1'); + } + + public function testCanDownloadFileFromComponentAction(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('download_file')); + + $this->browser() + ->throwExceptions() + ->post('/_components/download_file', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + ]), + ], + ]) + + ->interceptRedirects() + ->post('/_components/download_file/download', [ + 'headers' => [ + 'Accept' => 'application/vnd.live-component+html', + ], + 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], + ]) + ->assertStatus(200) + ->assertHeaderContains('X-Live-Download', '1') + ->assertHeaderContains('Content-Type', 'application/octet-stream') + ->assertHeaderContains('Content-Disposition', 'attachment') + ->assertHeaderEquals('Content-Length', '21') ; } + public function testCanDownloadGeneratedFileFromComponentAction(): void + { + $dehydrated = $this->dehydrateComponent($this->mountComponent('download_file')); + + $this->browser() + ->throwExceptions() + ->post('/_components/download_file', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + ]), + ], + ]) + ->interceptRedirects() + ->assertSuccessful() + ->post('/_components/download_file/generate', [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + ]), + ], + ]) + ->assertStatus(200) + ->assertHeaderContains('X-Live-Download', '1') + ->assertHeaderContains('Content-Type', 'application/octet-stream') + ->assertHeaderContains('Content-Disposition', 'attachment') + ->assertHeaderEquals('Content-Length', '21') + ->use(function(Browser $browser) { + self::assertJson($browser->content()); + self::assertSame(['foo' => 'bar'], \json_decode($browser->content(), true)); + }); + } + public function testInjectsLiveArgs(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('component6')); @@ -371,8 +420,7 @@ public function testInjectsLiveArgs(): void ->assertHeaderContains('Content-Type', 'html') ->assertContains('Arg1: hello') ->assertContains('Arg2: 666') - ->assertContains('Arg3: 33.3') - ; + ->assertContains('Arg3: 33.3'); } public function testWithNullableEntity(): void @@ -389,8 +437,7 @@ public function testWithNullableEntity(): void ], ]) ->assertSuccessful() - ->assertContains('Prop1: default') - ; + ->assertContains('Prop1: default'); } public function testCanHaveControllerAttributes(): void @@ -407,8 +454,7 @@ public function testCanHaveControllerAttributes(): void ->actingAs(new InMemoryUser('kevin', 'pass', ['ROLE_USER'])) ->assertAuthenticated('kevin') ->post('/_components/with_security?props='.urlencode(json_encode($dehydrated->getProps()))) - ->assertSuccessful() - ; + ->assertSuccessful(); } public function testCanInjectSecurityUserIntoAction(): void @@ -436,7 +482,6 @@ public function testCanInjectSecurityUserIntoAction(): void ], ]) ->assertSuccessful() - ->assertSee('username: kevin') - ; + ->assertSee('username: kevin'); } } From d7d6bb5651cc1b487c828cc9cc4d776ea5d9969a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:34:02 +0100 Subject: [PATCH 05/12] Add entry in CHANGELOG --- src/LiveComponent/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index a43e9518ee5..9ff4da4160e 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -3,6 +3,8 @@ ## 2.23.0 - Allow configuring the secret used to compute fingerprints and checksums. +- [EXPERIMENTAL] Add `LiveDownloadResponse` and enable file downloads from + a `LiveAction`. ## 2.22.0 From dd75be038c4decda4df932070a02c865ab79ee95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:35:42 +0100 Subject: [PATCH 06/12] Add demo on website --- .../assets/styles/components/_Button.scss | 37 ++++++ .../assets/styles/components/_DemoCard.scss | 11 ++ .../Controller/Demo/LiveDemoController.php | 1 + ux.symfony.com/src/Model/LiveDemo.php | 5 + .../src/Service/DocumentStorage.php | 68 +++++++++++ .../src/Service/LiveDemoRepository.php | 10 ++ .../src/Twig/Components/DownloadFiles.php | 110 ++++++++++++++++++ ux.symfony.com/templates/_header.html.twig | 6 +- .../components/Demo/DemoCard.html.twig | 8 ++ .../components/DownloadFiles.html.twig | 56 +++++++++ .../demos/live_component/download.html.twig | 13 +++ 11 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 ux.symfony.com/src/Service/DocumentStorage.php create mode 100644 ux.symfony.com/src/Twig/Components/DownloadFiles.php create mode 100644 ux.symfony.com/templates/components/DownloadFiles.html.twig create mode 100644 ux.symfony.com/templates/demos/live_component/download.html.twig diff --git a/ux.symfony.com/assets/styles/components/_Button.scss b/ux.symfony.com/assets/styles/components/_Button.scss index 799761294e3..46eda5f100d 100644 --- a/ux.symfony.com/assets/styles/components/_Button.scss +++ b/ux.symfony.com/assets/styles/components/_Button.scss @@ -9,6 +9,8 @@ background: var(--bs-body-bg); color: var(--bs-body-color); + + transition: background-color 0.2s, color 0.2s; } .Button--dark { @@ -17,3 +19,38 @@ color: #dee2e6; border: 1px solid #a6a0a0; } + +.Button--large { + font-size: 1.25rem; + padding: 1rem 2rem; +} + +.Button--blue { + background: #007bff; + color: #fff; + &:hover { + background: #0056b3; + } +} + +.BigButton { + display: grid; + place-content: center; + background: var(--bg-color); + background-blend-mode: color-burn; + color: var(--color); + font-size: 1rem; + text-transform: uppercase; + padding: .5rem 1rem; + border-radius: 1.5rem; + font-weight: 300; + font-stretch: semi-condensed; + opacity: .75; + transition: all 150ms; + border: 2px solid rgba(0, 0, 0, .6); + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; +} diff --git a/ux.symfony.com/assets/styles/components/_DemoCard.scss b/ux.symfony.com/assets/styles/components/_DemoCard.scss index cbcc965c8f2..964647bcb89 100644 --- a/ux.symfony.com/assets/styles/components/_DemoCard.scss +++ b/ux.symfony.com/assets/styles/components/_DemoCard.scss @@ -85,3 +85,14 @@ flex-wrap: wrap; gap: .5rem; } + +.DemoCard__badge { + position: absolute; + top: .75rem; + right: .75rem; + + .Badge { + background: var(--bs-secondary-bg); + border: 1px solid var(--bs-secondary-bg); + } +} diff --git a/ux.symfony.com/src/Controller/Demo/LiveDemoController.php b/ux.symfony.com/src/Controller/Demo/LiveDemoController.php index afbfb9cb0e6..da1bc309d20 100644 --- a/ux.symfony.com/src/Controller/Demo/LiveDemoController.php +++ b/ux.symfony.com/src/Controller/Demo/LiveDemoController.php @@ -100,6 +100,7 @@ public function invoice(LiveDemoRepository $liveDemoRepository, ?Invoice $invoic #[Route('/infinite-scroll-2', name: 'app_demo_live_component_infinite_scroll_2')] #[Route('/product-form', name: 'app_demo_live_component_product_form')] #[Route('/upload', name: 'app_demo_live_component_upload')] + #[Route('/download', name: 'app_demo_live_component_download')] public function demo( LiveDemoRepository $liveDemoRepository, string $demo, diff --git a/ux.symfony.com/src/Model/LiveDemo.php b/ux.symfony.com/src/Model/LiveDemo.php index ccd28acdc91..352a8d7eac6 100644 --- a/ux.symfony.com/src/Model/LiveDemo.php +++ b/ux.symfony.com/src/Model/LiveDemo.php @@ -44,4 +44,9 @@ public function getLongDescription(): string { return $this->longDescription; } + + public function isNew(): bool + { + return \DateTimeImmutable::createFromFormat('Y-m-d', $this->getPublishedAt()) > new \DateTimeImmutable('-30 days'); + } } diff --git a/ux.symfony.com/src/Service/DocumentStorage.php b/ux.symfony.com/src/Service/DocumentStorage.php new file mode 100644 index 00000000000..0c1b8dfccdc --- /dev/null +++ b/ux.symfony.com/src/Service/DocumentStorage.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Service; + +use App\Model\Document; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Filesystem\Path; + +final class DocumentStorage +{ + private readonly Filesystem $filesystem; + + public function __construct( + #[Autowire('%kernel.project_dir%/assets/documents')] + private readonly string $storageDirectory, + ) { + $this->filesystem = new Filesystem(); + + if (!$this->filesystem->exists($this->storageDirectory)) { + $this->filesystem->mkdir($this->storageDirectory); + } + } + + public function readFile(string $path): string + { + if (!$this->hasFile($path)) { + throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $path)); + } + + return $this->filesystem->readFile($this->getAbsolutePath($path)); + } + + public function hasFile(string $path): bool + { + return $this->filesystem->exists($this->getAbsolutePath($path)); + } + + public function getFile(string $path): Document + { + if (!$this->hasFile($path)) { + throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $path)); + } + + return new Document($this->getAbsolutePath($path)); + } + + private function getAbsolutePath(string $path): string + { + try { + $absolutePath = Path::makeAbsolute($path, $this->storageDirectory); + } catch (\Throwable $e) { + throw new \InvalidArgumentException(sprintf('The file "%s" is not valid.', $path), 0, $e); + } + + return $absolutePath; + } + +} diff --git a/ux.symfony.com/src/Service/LiveDemoRepository.php b/ux.symfony.com/src/Service/LiveDemoRepository.php index c65a6a16f14..10616493da6 100644 --- a/ux.symfony.com/src/Service/LiveDemoRepository.php +++ b/ux.symfony.com/src/Service/LiveDemoRepository.php @@ -21,6 +21,16 @@ class LiveDemoRepository public function findAll(): array { return [ + new LiveDemo( + 'download', + name: 'Downloading files', + description: 'Return file as downloadable attachment from your Live Component.', + author: 'smnandre', + publishedAt: '2025-01-01', + tags: ['file', 'upload', 'LiveAction', 'download', 'button'], + longDescription: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur tincidunt vulputate felis a ultricies. + * Morbi at odio nec nulla imperdiet scelerisque a eget nibh. Donec convallis turpis ut nunc egest', + ), new LiveDemo( 'infinite-scroll-2', name: 'Infinite Scroll - 2/2', diff --git a/ux.symfony.com/src/Twig/Components/DownloadFiles.php b/ux.symfony.com/src/Twig/Components/DownloadFiles.php new file mode 100644 index 00000000000..99f745cc3b7 --- /dev/null +++ b/ux.symfony.com/src/Twig/Components/DownloadFiles.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig\Components; + +use App\Service\DocumentStorage; +use DateTimeImmutable; +use SplTempFileObject; +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\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\LiveDownloadResponse; + +#[AsLiveComponent] +// #[AsTaggedItem('controller.service_arguments')] +final class DownloadFiles +{ + use DefaultActionTrait; + + #[LiveProp(writable: true)] + public int $year = 2025; + + public function __construct( + private readonly DocumentStorage $documentStorage, + ) { + } + + #[LiveAction] + public function download(): BinaryFileResponse + { + $file = $this->documentStorage->getFile('demos/empty.html'); + + return new LiveDownloadResponse($file); + } + + #[LiveAction] + public function generate(#[LiveArg] string $format): BinaryFileResponse + { + $report = match($format) { + 'csv' => $this->generateCsvReport($this->year), + 'json' => $this->generateJsonReport($this->year), + 'md' => $this->generateMarkdownReport($this->year), + default => throw new \InvalidArgumentException('Invalid format provided'), + }; + + $file = new SplTempFileObject(); + $file->fwrite($report); + + return new LiveDownloadResponse($file, 'report.'.$format); + } + + private function generateCsvReport(int $year): string + { + $file = new SplTempFileObject(); + // $file->fputcsv(['Month', 'Number', 'Name', 'Number of days']); + foreach ($this->getReportData($year) as $row) { + $file->fputcsv($row); + } + + return $file->fread($file->ftell()); + } + + private function generateMarkdownReport(int $year): string + { + $rows = iterator_to_array($this->getReportData($year)); + + foreach ($rows as $key => $row) { + $rows[$key] = '|'.implode('|', $row).'|'; + } + + return implode("\n", $rows); + } + + private function generateJsonReport(int $year): string + { + $rows = iterator_to_array($this->getReportData($year)); + + return \json_encode($rows, JSON_FORCE_OBJECT | JSON_THROW_ON_ERROR); + } + + /** + * @param int<2000,2025> $year The year to generate the report for (2000-2025) + * + * @return iterable + */ + private function getReportData(int $year): iterable + { + foreach (range(1, 12) as $month) { + $startDate = DateTimeImmutable::createFromFormat('Y', $year)->setDate($year, $month, 1); + $endDate = $startDate->modify('last day of this month'); + yield $month => [ + 'name' => $startDate->format('F'), + 'month' => $startDate->format('F'), + 'number' => $startDate->format('Y-m'), + 'nb_days' => $endDate->diff($startDate)->days, + ]; + } + } +} diff --git a/ux.symfony.com/templates/_header.html.twig b/ux.symfony.com/templates/_header.html.twig index c0ce67fb6c0..90ec7b950d7 100644 --- a/ux.symfony.com/templates/_header.html.twig +++ b/ux.symfony.com/templates/_header.html.twig @@ -50,10 +50,12 @@ Live Components Icons Packages - Demos + + + Demos + Cookbook - Support diff --git a/ux.symfony.com/templates/components/Demo/DemoCard.html.twig b/ux.symfony.com/templates/components/Demo/DemoCard.html.twig index 71789a0dced..0b18a24aab7 100644 --- a/ux.symfony.com/templates/components/Demo/DemoCard.html.twig +++ b/ux.symfony.com/templates/components/Demo/DemoCard.html.twig @@ -8,6 +8,14 @@ loading="lazy" > + + {% if demo.isNew() %} +
+
+ NEW +
+
+ {% endif %}

diff --git a/ux.symfony.com/templates/components/DownloadFiles.html.twig b/ux.symfony.com/templates/components/DownloadFiles.html.twig new file mode 100644 index 00000000000..c4520aa0e69 --- /dev/null +++ b/ux.symfony.com/templates/components/DownloadFiles.html.twig @@ -0,0 +1,56 @@ +
+ +
+
+ +
+
+

Download an existing File

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur tincidunt vulputate felis a ultricies. + Morbi at odio nec nulla imperdiet scelerisque a eget nibh. Donec convallis turpis ut nunc egestas rutrum. +

+
+
+ +
+ +
+ +
+ +
+ +

Or one generated on-demand

+ +

Choose a format:

+ +
+ {% for format in ['csv', 'json', 'md'] %} + + {% endfor %} +
+ +
+
+
+ diff --git a/ux.symfony.com/templates/demos/live_component/download.html.twig b/ux.symfony.com/templates/demos/live_component/download.html.twig new file mode 100644 index 00000000000..df1f3e3544c --- /dev/null +++ b/ux.symfony.com/templates/demos/live_component/download.html.twig @@ -0,0 +1,13 @@ +{% extends 'demos/live_demo.html.twig' %} + +{% block code_block_left %} + +{% endblock %} + +{% block code_block_right %} + +{% endblock %} + +{% block demo_content %} + +{% endblock %} From fe92cc711f80463bfc6e464475bac06bb618938f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 5 Jan 2025 03:37:08 +0100 Subject: [PATCH 07/12] CS fixes --- .../EventListener/LiveComponentSubscriber.php | 6 +-- .../src/LiveDownloadResponse.php | 8 ++-- ux.symfony.com/src/Model/LiveDemo.php | 2 +- .../src/Service/DocumentStorage.php | 25 ++++++------ .../src/Service/LiveDemoRepository.php | 2 +- .../src/Twig/Components/DownloadFiles.php | 40 +++++++++---------- 6 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 2a623e45451..f6693997c6b 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -44,7 +44,7 @@ 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'; @@ -257,10 +257,10 @@ public function onKernelView(ViewEvent $event): void return; } - + if ($event->getControllerResult() instanceof BinaryFileResponse) { if (!$event->getControllerResult()->headers->has(self::DOWNLOAD_HEADER)) { - + } $event->setResponse(new Response()); } diff --git a/src/LiveComponent/src/LiveDownloadResponse.php b/src/LiveComponent/src/LiveDownloadResponse.php index d87608884f1..b89b468461f 100644 --- a/src/LiveComponent/src/LiveDownloadResponse.php +++ b/src/LiveComponent/src/LiveDownloadResponse.php @@ -13,21 +13,21 @@ final class LiveDownloadResponse extends BinaryFileResponse { public const HEADER_LIVE_DOWNLOAD = 'X-Live-Download'; - + public function __construct(string|SplFileInfo $file, ?string $filename = null) { if (\is_string($file)) { $file = new SplFileInfo($file); } - + if ((!$file instanceof SplFileInfo)) { throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $file)); } - + 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)), diff --git a/ux.symfony.com/src/Model/LiveDemo.php b/ux.symfony.com/src/Model/LiveDemo.php index 352a8d7eac6..2d015b7088b 100644 --- a/ux.symfony.com/src/Model/LiveDemo.php +++ b/ux.symfony.com/src/Model/LiveDemo.php @@ -44,7 +44,7 @@ public function getLongDescription(): string { return $this->longDescription; } - + public function isNew(): bool { return \DateTimeImmutable::createFromFormat('Y-m-d', $this->getPublishedAt()) > new \DateTimeImmutable('-30 days'); diff --git a/ux.symfony.com/src/Service/DocumentStorage.php b/ux.symfony.com/src/Service/DocumentStorage.php index 0c1b8dfccdc..77e2fd2007b 100644 --- a/ux.symfony.com/src/Service/DocumentStorage.php +++ b/ux.symfony.com/src/Service/DocumentStorage.php @@ -19,50 +19,49 @@ final class DocumentStorage { private readonly Filesystem $filesystem; - + public function __construct( #[Autowire('%kernel.project_dir%/assets/documents')] private readonly string $storageDirectory, ) { $this->filesystem = new Filesystem(); - + if (!$this->filesystem->exists($this->storageDirectory)) { $this->filesystem->mkdir($this->storageDirectory); } } - + public function readFile(string $path): string { if (!$this->hasFile($path)) { - throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $path)); + throw new \InvalidArgumentException(\sprintf('The file "%s" does not exist.', $path)); } - + return $this->filesystem->readFile($this->getAbsolutePath($path)); } - + public function hasFile(string $path): bool { return $this->filesystem->exists($this->getAbsolutePath($path)); } - + public function getFile(string $path): Document { if (!$this->hasFile($path)) { - throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $path)); + throw new \InvalidArgumentException(\sprintf('The file "%s" does not exist.', $path)); } - + return new Document($this->getAbsolutePath($path)); } - + private function getAbsolutePath(string $path): string { try { $absolutePath = Path::makeAbsolute($path, $this->storageDirectory); } catch (\Throwable $e) { - throw new \InvalidArgumentException(sprintf('The file "%s" is not valid.', $path), 0, $e); + throw new \InvalidArgumentException(\sprintf('The file "%s" is not valid.', $path), 0, $e); } - + return $absolutePath; } - } diff --git a/ux.symfony.com/src/Service/LiveDemoRepository.php b/ux.symfony.com/src/Service/LiveDemoRepository.php index 10616493da6..92b8414adb3 100644 --- a/ux.symfony.com/src/Service/LiveDemoRepository.php +++ b/ux.symfony.com/src/Service/LiveDemoRepository.php @@ -21,7 +21,7 @@ class LiveDemoRepository public function findAll(): array { return [ - new LiveDemo( + new LiveDemo( 'download', name: 'Downloading files', description: 'Return file as downloadable attachment from your Live Component.', diff --git a/ux.symfony.com/src/Twig/Components/DownloadFiles.php b/ux.symfony.com/src/Twig/Components/DownloadFiles.php index 99f745cc3b7..50116150080 100644 --- a/ux.symfony.com/src/Twig/Components/DownloadFiles.php +++ b/ux.symfony.com/src/Twig/Components/DownloadFiles.php @@ -12,8 +12,6 @@ namespace App\Twig\Components; use App\Service\DocumentStorage; -use DateTimeImmutable; -use SplTempFileObject; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; @@ -27,77 +25,77 @@ final class DownloadFiles { use DefaultActionTrait; - + #[LiveProp(writable: true)] public int $year = 2025; - + public function __construct( private readonly DocumentStorage $documentStorage, ) { } - + #[LiveAction] public function download(): BinaryFileResponse { $file = $this->documentStorage->getFile('demos/empty.html'); - + return new LiveDownloadResponse($file); } - + #[LiveAction] public function generate(#[LiveArg] string $format): BinaryFileResponse { - $report = match($format) { + $report = match ($format) { 'csv' => $this->generateCsvReport($this->year), 'json' => $this->generateJsonReport($this->year), 'md' => $this->generateMarkdownReport($this->year), default => throw new \InvalidArgumentException('Invalid format provided'), }; - - $file = new SplTempFileObject(); + + $file = new \SplTempFileObject(); $file->fwrite($report); - + return new LiveDownloadResponse($file, 'report.'.$format); } - + private function generateCsvReport(int $year): string { - $file = new SplTempFileObject(); + $file = new \SplTempFileObject(); // $file->fputcsv(['Month', 'Number', 'Name', 'Number of days']); foreach ($this->getReportData($year) as $row) { $file->fputcsv($row); } - + return $file->fread($file->ftell()); } - + private function generateMarkdownReport(int $year): string { $rows = iterator_to_array($this->getReportData($year)); - + foreach ($rows as $key => $row) { $rows[$key] = '|'.implode('|', $row).'|'; } - + return implode("\n", $rows); } private function generateJsonReport(int $year): string { $rows = iterator_to_array($this->getReportData($year)); - - return \json_encode($rows, JSON_FORCE_OBJECT | JSON_THROW_ON_ERROR); + + return json_encode($rows, \JSON_FORCE_OBJECT | \JSON_THROW_ON_ERROR); } /** * @param int<2000,2025> $year The year to generate the report for (2000-2025) - * + * * @return iterable */ private function getReportData(int $year): iterable { foreach (range(1, 12) as $month) { - $startDate = DateTimeImmutable::createFromFormat('Y', $year)->setDate($year, $month, 1); + $startDate = \DateTimeImmutable::createFromFormat('Y', $year)->setDate($year, $month, 1); $endDate = $startDate->modify('last day of this month'); yield $month => [ 'name' => $startDate->format('F'), From f81dcff542ab83a8e3dce3f612f25ad5eb259920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Mon, 6 Jan 2025 00:09:40 +0100 Subject: [PATCH 08/12] Prefer return instead of return await Co-authored-by: Hugo Alliaume --- src/LiveComponent/assets/src/Backend/BackendResponse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index dc6eeb11af4..1b74c5a727b 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -15,6 +15,6 @@ export default class { } async getBlob(): Promise { - return await this.response.blob(); + return this.response.blob(); } } From 4f6f86980073319851c0624b9f2cf3f5f646b23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Mon, 6 Jan 2025 00:11:26 +0100 Subject: [PATCH 09/12] Reuse 'Content-Disposition' header value Co-authored-by: Hugo Alliaume --- src/LiveComponent/assets/src/Component/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index b53aa0b57d4..502e3436237 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -308,12 +308,11 @@ export default class Component { const headers = backendResponse.response.headers; if (headers.get('X-Live-Download')) { + const headerContentDisposition = headers.get('Content-Disposition'); if ( - !( - headers.get('Content-Disposition')?.includes('attachment') || - headers.get('Content-Disposition')?.includes('inline') - ) || - !headers.get('Content-Disposition')?.includes('filename=') + !headerContentDisposition + || !(headerContentDisposition?.includes('attachment') || headerContentDisposition?.includes('inline')) + || !headerContentDisposition?.includes('filename=') ) { throw new Error('Invalid LiveDownload response'); } From 36143e6f1f1e2a1e97ae59137a4c736e3b11f410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Mon, 6 Jan 2025 00:11:56 +0100 Subject: [PATCH 10/12] Reuse 'Content-Disposition' header value Co-authored-by: Hugo Alliaume --- src/LiveComponent/assets/src/Component/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 502e3436237..293e9e648de 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -322,7 +322,7 @@ export default class Component { throw new Error('File is too large to download (10MB limit)'); } - const fileName = headers.get('Content-Disposition')?.split('filename=')[1]; + const fileName = headerContentDisposition.split('filename=')[1]; if (!fileName) { throw new Error('No filename found in Content-Disposition header'); } From 32cd8244a298c67aeacda3adbcde5b2f1fe3efc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Thu, 9 Jan 2025 01:57:21 +0100 Subject: [PATCH 11/12] Simplify header checks --- .../assets/dist/live_controller.js | 33 +++++---------- .../assets/src/Component/index.ts | 40 +++++-------------- 2 files changed, 22 insertions(+), 51 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 294645ff00d..81476ff9ff3 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -112,7 +112,7 @@ class BackendResponse { return this.body; } async getBlob() { - return await this.response.blob(); + return this.response.blob(); } } @@ -2122,34 +2122,23 @@ class Component { this.isRequestPending = false; this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse(response); + const headers = backendResponse.response.headers; for (const input of Object.values(this.pendingFiles)) { input.value = ''; } - const headers = backendResponse.response.headers; - if (headers.get('X-Live-Download')) { - if (!(headers.get('Content-Disposition')?.includes('attachment') || - headers.get('Content-Disposition')?.includes('inline')) || - !headers.get('Content-Disposition')?.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)'); - } - const fileName = headers.get('Content-Disposition')?.split('filename=')[1]; - if (!fileName) { - throw new Error('No filename found in Content-Disposition header'); - } + const contentDisposition = headers.get('Content-Disposition'); + const fileResponse = contentDisposition?.match(/^(attachment|inline).*filename="?([^;]+)"?/); + if (fileResponse) { const blob = await backendResponse.getBlob(); - const link = Object.assign(window.document.createElement('a'), { - target: '_blank', + const link = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(blob), + download: fileResponse[2], style: 'display: none', - href: window.URL.createObjectURL(blob), - download: fileName, + target: '_blank', }); - this.element.appendChild(link); + document.body.appendChild(link); link.click(); - this.element.removeChild(link); + setTimeout(() => document.body.removeChild(link), 75); this.backendRequest = null; thisPromiseResolve(backendResponse); if (this.isRequestPending) { diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 293e9e648de..553d58f73ea 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -300,48 +300,30 @@ export default class Component { this.backendRequest.promise.then(async (response) => { const backendResponse = new BackendResponse(response); + const headers = backendResponse.response.headers; // clear sent files inputs for (const input of Object.values(this.pendingFiles)) { input.value = ''; } - 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)'); - } - - const fileName = headerContentDisposition.split('filename=')[1]; - if (!fileName) { - throw new Error('No filename found in Content-Disposition header'); - } - + // File Download + const contentDisposition = headers.get('Content-Disposition'); + const fileResponse = contentDisposition?.match(/^(attachment|inline).*filename="?([^;]+)"?/); + if (fileResponse) { const blob = await backendResponse.getBlob(); - const link = Object.assign(window.document.createElement('a'), { - target: '_blank', + const link = Object.assign(document.createElement('a'), { + href: URL.createObjectURL(blob), + download: fileResponse[2], style: 'display: none', - href: window.URL.createObjectURL(blob), - download: fileName, + target: '_blank', }); - this.element.appendChild(link); + document.body.appendChild(link); link.click(); - this.element.removeChild(link); + setTimeout(() => document.body.removeChild(link), 75); this.backendRequest = null; thisPromiseResolve(backendResponse); - - // do we already have another request pending? if (this.isRequestPending) { this.isRequestPending = false; this.performRequest(); From 456c0e2d2161ff8d2c51e9e95e2c09fe82b47031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Thu, 9 Jan 2025 02:56:51 +0100 Subject: [PATCH 12/12] rework --- .../EventListener/LiveComponentSubscriber.php | 9 --- .../src/LiveDownloadResponse.php | 38 ---------- src/LiveComponent/src/LiveResponse.php | 51 +++++++++++++ .../Component/DownloadFileComponent.php | 22 +++--- .../tests/Fixtures/files/test.txt | 1 + .../LiveComponentSubscriberTest.php | 2 - .../tests/Unit/LiveResponseTest.php | 75 +++++++++++++++++++ 7 files changed, 138 insertions(+), 60 deletions(-) delete mode 100644 src/LiveComponent/src/LiveDownloadResponse.php create mode 100644 src/LiveComponent/src/LiveResponse.php create mode 100644 src/LiveComponent/tests/Fixtures/files/test.txt create mode 100644 src/LiveComponent/tests/Unit/LiveResponseTest.php diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index f6693997c6b..4d8d17af4e2 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -44,9 +44,7 @@ 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, @@ -258,13 +256,6 @@ public function onKernelView(ViewEvent $event): void return; } - if ($event->getControllerResult() instanceof BinaryFileResponse) { - if (!$event->getControllerResult()->headers->has(self::DOWNLOAD_HEADER)) { - - } - $event->setResponse(new Response()); - } - $event->setResponse($this->createResponse($request->attributes->get('_mounted_component'))); } diff --git a/src/LiveComponent/src/LiveDownloadResponse.php b/src/LiveComponent/src/LiveDownloadResponse.php deleted file mode 100644 index b89b468461f..00000000000 --- a/src/LiveComponent/src/LiveDownloadResponse.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ -final class LiveDownloadResponse extends BinaryFileResponse -{ - public const HEADER_LIVE_DOWNLOAD = 'X-Live-Download'; - - public function __construct(string|SplFileInfo $file, ?string $filename = null) - { - if (\is_string($file)) { - $file = new SplFileInfo($file); - } - - if ((!$file instanceof SplFileInfo)) { - throw new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $file)); - } - - 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', - 'Content-Length' => $file instanceof SplTempFileObject ? 0 : $file->getSize(), - ], false, HeaderUtils::DISPOSITION_ATTACHMENT); - } -} diff --git a/src/LiveComponent/src/LiveResponse.php b/src/LiveComponent/src/LiveResponse.php new file mode 100644 index 00000000000..b100b168470 --- /dev/null +++ b/src/LiveComponent/src/LiveResponse.php @@ -0,0 +1,51 @@ + + * @author Kevin Bond + */ +final class LiveResponse +{ + /** + * @param string|\SplFileInfo $file The file to send as a response + * @param string|null $filename The name of the file to send (defaults to the basename of the file) + * @param string|null $contentType The content type of the file (defaults to `application/octet-stream`) + */ + public static function file(string|\SplFileInfo $file, ?string $filename = null, ?string $contentType = null, ?int $size = null): BinaryFileResponse + { + return new BinaryFileResponse($file, 200, [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename ?? basename($file)), + 'Content-Type' => $contentType ?? 'application/octet-stream', + 'Content-Length' => $size ?? ($file instanceof \SplFileInfo ? $file->getSize() : null), + ]); + } + + /** + * @param resource|Closure $file The file to stream as a response + * @param string $filename The name of the file to send (defaults to the basename of the file) + * @param string|null $contentType The content type of the file (defaults to `application/octet-stream`) + * @param int|null $size The size of the file + */ + public static function streamFile(mixed $file, string $filename, ?string $contentType = null, ?int $size = null): StreamedResponse + { + if (!is_resource($file) && !$file instanceof \Closure) { + throw new \InvalidArgumentException(sprintf('The file must be a resource or a closure, "%s" given.', get_debug_type($file))); + } + + return new StreamedResponse($file instanceof \Closure ? $file(...) : function () use ($file) { + while (!feof($file)) { + echo fread($file, 1024); + } + }, 200, [ + 'Content-Disposition' => HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename), + 'Content-Type' => $contentType ?? 'application/octet-stream', + 'Content-Length' => $size, + ]); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php b/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php index 9506ad742ee..d6333142324 100644 --- a/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php +++ b/src/LiveComponent/tests/Fixtures/Component/DownloadFileComponent.php @@ -16,7 +16,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveArg; use Symfony\UX\LiveComponent\DefaultActionTrait; -use Symfony\UX\LiveComponent\LiveDownloadResponse; +use Symfony\UX\LiveComponent\LiveResponse; /** * @author Simon André @@ -25,32 +25,32 @@ class DownloadFileComponent { use DefaultActionTrait; - - private const FILE_DIRECTORY = __DIR__.'/../files/'; + + private const FILE_DIRECTORY = __DIR__.'/../files/'; #[LiveAction] public function download(): BinaryFileResponse { $file = new \SplFileInfo(self::FILE_DIRECTORY.'/foo.json'); - - return new LiveDownloadResponse($file); + + return LiveResponse::file($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'); + + return LiveResponse::file($file, 'foo.json', size: 1000); } - + #[LiveAction] public function heavyFile(#[LiveArg] int $size): BinaryFileResponse { $file = new \SplFileInfo(self::FILE_DIRECTORY.'heavy.txt'); - - $response = new BinaryFileResponse($file); + + $response = LiveResponse::file($file); $response->headers->set('Content-Length', 10000000); // 10MB } } diff --git a/src/LiveComponent/tests/Fixtures/files/test.txt b/src/LiveComponent/tests/Fixtures/files/test.txt new file mode 100644 index 00000000000..8e27be7d615 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/test.txt @@ -0,0 +1 @@ +text diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 24a2d78b2bf..72633cc563d 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -349,7 +349,6 @@ public function testCanDownloadFileFromComponentAction(): void 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertStatus(200) - ->assertHeaderContains('X-Live-Download', '1') ->assertHeaderContains('Content-Type', 'application/octet-stream') ->assertHeaderContains('Content-Disposition', 'attachment') ->assertHeaderEquals('Content-Length', '21') @@ -379,7 +378,6 @@ public function testCanDownloadGeneratedFileFromComponentAction(): void ], ]) ->assertStatus(200) - ->assertHeaderContains('X-Live-Download', '1') ->assertHeaderContains('Content-Type', 'application/octet-stream') ->assertHeaderContains('Content-Disposition', 'attachment') ->assertHeaderEquals('Content-Length', '21') diff --git a/src/LiveComponent/tests/Unit/LiveResponseTest.php b/src/LiveComponent/tests/Unit/LiveResponseTest.php new file mode 100644 index 00000000000..81c34946ee9 --- /dev/null +++ b/src/LiveComponent/tests/Unit/LiveResponseTest.php @@ -0,0 +1,75 @@ +assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('attachment; filename=test.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('application/octet-stream', $response->headers->get('Content-Type')); + } + + public function testSendFileWithSplFileInfo(): void + { + $file = new File(__DIR__.'/../fixtures/files/test.txt'); + $response = LiveResponse::file($file, 'custom-name.txt', 'text/plain'); + + $this->assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('attachment; filename=custom-name.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('text/plain', $response->headers->get('Content-Type')); + } + + public function testSendFileWithSplTempFileObject(): void + { + $tempFile = new \SplTempFileObject(); + $tempFile->fwrite('Temporary content'); + $response = LiveResponse::file($tempFile, size: 17); + + $this->assertInstanceOf(BinaryFileResponse::class, $response); + $this->assertEquals('application/octet-stream', $response->headers->get('Content-Type')); + $this->assertEquals(17, $response->headers->get('Content-Length')); + } + + public function testStreamFileWithResource(): void + { + $file = fopen(__DIR__.'/../fixtures/files/test.txt', 'rb'); + $response = LiveResponse::streamFile($file, 'streamed-file.txt'); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertEquals('attachment; filename=streamed-file.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('application/octet-stream', $response->headers->get('Content-Type')); + fclose($file); + } + + public function testStreamFileWithClosure(): void + { + $closure = function () { + echo 'Streaming content'; + }; + + $response = LiveResponse::streamFile($closure, 'streamed-closure.txt', 'text/plain'); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertEquals('attachment; filename=streamed-closure.txt', $response->headers->get('Content-Disposition')); + $this->assertEquals('text/plain', $response->headers->get('Content-Type')); + } + + public function testStreamFileWithInvalidType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The file must be a resource or a closure, "string" given.'); + + LiveResponse::streamFile('invalid-type', 'invalid.txt'); + } +}