diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index c65517c41c4..69e9ed662cd 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -27,6 +27,7 @@ }, "require": { "php": ">=8.0", + "symfony/mime": "^5.4|^6.0", "symfony/property-access": "^5.4|^6.0", "symfony/serializer": "^5.4|^6.0", "symfony/ux-twig-component": "^2.1" diff --git a/src/LiveComponent/src/Controller/UploadController.php b/src/LiveComponent/src/Controller/UploadController.php new file mode 100644 index 00000000000..85de29ec7db --- /dev/null +++ b/src/LiveComponent/src/Controller/UploadController.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 Symfony\UX\LiveComponent\Controller; + +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * @author Kevin Bond + */ +final class UploadController +{ + private string $uploadDir; + + public function __construct(string $uploadDir) + { + $this->uploadDir = rtrim($uploadDir, '/'); + } + + public function uploadAction(Request $request): JsonResponse + { + $files = []; + + foreach ($request->files->all() as $file) { + if (!$file instanceof UploadedFile) { + continue; + } + + // TODO: use UUID? + $name = sprintf('%s.%s', uniqid('live-', true), strtolower($file->getClientOriginalExtension())); + + $file->move($this->uploadDir, $name); + + $files[$file->getClientOriginalName()] = $name; + } + + return new JsonResponse($files); + } + + public function previewAction(string $filename): BinaryFileResponse + { + try { + $file = new File("{$this->uploadDir}/{$filename}"); + } catch (FileNotFoundException) { + throw new NotFoundHttpException(sprintf('File "%s" not found.', $filename)); + } + + if (!str_starts_with((string) $file->getMimeType(), 'image/')) { + throw new NotFoundHttpException('Only images can be previewed.'); + } + + return new BinaryFileResponse($file); + } +} diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 946886ca3b6..cf6cc797c0a 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -20,6 +20,7 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\ComponentValidator; use Symfony\UX\LiveComponent\ComponentValidatorInterface; +use Symfony\UX\LiveComponent\Controller\UploadController; use Symfony\UX\LiveComponent\EventListener\AddLiveAttributesSubscriber; use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; @@ -108,5 +109,12 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('form.type') ->setPublic(false) ; + + $container->setParameter('ux.live_component.upload_dir', '%kernel.project_dir%/var/live-tmp'); // TODO customizable? + + $container->register('ux.live_component.upload_controller', UploadController::class) + ->setArguments(['%ux.live_component.upload_dir%']) + ->setPublic(true) + ; } } diff --git a/src/LiveComponent/src/Resources/config/routing/live_component.xml b/src/LiveComponent/src/Resources/config/routing/live_component.xml index ac87d9a8c13..4973445663d 100644 --- a/src/LiveComponent/src/Resources/config/routing/live_component.xml +++ b/src/LiveComponent/src/Resources/config/routing/live_component.xml @@ -7,4 +7,10 @@ get + + + + + [\w\-\.]+ + diff --git a/src/LiveComponent/tests/Fixtures/files/image1.png b/src/LiveComponent/tests/Fixtures/files/image1.png new file mode 100644 index 00000000000..c8278fc1b65 Binary files /dev/null and b/src/LiveComponent/tests/Fixtures/files/image1.png differ diff --git a/src/LiveComponent/tests/Fixtures/files/image2.png b/src/LiveComponent/tests/Fixtures/files/image2.png new file mode 100644 index 00000000000..c065647d526 Binary files /dev/null and b/src/LiveComponent/tests/Fixtures/files/image2.png differ diff --git a/src/LiveComponent/tests/Fixtures/files/text.txt b/src/LiveComponent/tests/Fixtures/files/text.txt new file mode 100644 index 00000000000..2c3e89d43da --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/files/text.txt @@ -0,0 +1 @@ +some text... diff --git a/src/LiveComponent/tests/Functional/Controller/UploadControllerTest.php b/src/LiveComponent/tests/Functional/Controller/UploadControllerTest.php new file mode 100644 index 00000000000..44059d673ce --- /dev/null +++ b/src/LiveComponent/tests/Functional/Controller/UploadControllerTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional\Controller; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Zenstruck\Browser\Test\HasBrowser; + +/** + * @author Kevin Bond + */ +final class UploadControllerTest extends KernelTestCase +{ + use HasBrowser; + + private const FIXTURE_FILE_DIR = __DIR__.'/../../Fixtures/files'; + private const UPLOAD_FILE_DIR = __DIR__.'/../../../var/live-tmp'; + private const TEMP_DIR = __DIR__.'/../../../var/tmp'; + + /** + * @before + */ + public static function prepareTempDirs(): void + { + (new Filesystem())->remove(self::TEMP_DIR); + (new Filesystem())->remove(self::UPLOAD_FILE_DIR); + (new Filesystem())->mirror(self::FIXTURE_FILE_DIR, self::TEMP_DIR); + } + + public function testCanUploadASingleFile(): void + { + $json = $this->browser() + ->post('/live/upload', ['files' => [new UploadedFile(self::TEMP_DIR.'/image1.png', 'image1.png', test: true)]]) + ->assertSuccessful() + ->response() + ->assertJson() + ->json() + ; + + $this->assertIsArray($json); + $this->assertCount(1, $json); + $this->assertArrayHasKey('image1.png', $json); + $this->assertFileExists(self::UPLOAD_FILE_DIR.'/'.$json['image1.png']); + $this->assertFileDoesNotExist(self::TEMP_DIR.'/'.$json['image1.png']); + } + + public function testCanUploadMultipleFiles(): void + { + $json = $this->browser() + ->post('/live/upload', ['files' => [ + new UploadedFile(self::TEMP_DIR.'/image1.png', 'image1.png', test: true), + new UploadedFile(self::TEMP_DIR.'/image2.png', 'image2.png', test: true), + ]]) + ->assertSuccessful() + ->response() + ->assertJson() + ->json() + ; + + $this->assertIsArray($json); + $this->assertCount(2, $json); + $this->assertArrayHasKey('image1.png', $json); + $this->assertArrayHasKey('image2.png', $json); + $this->assertFileExists(self::UPLOAD_FILE_DIR.'/'.$json['image1.png']); + $this->assertFileDoesNotExist(self::TEMP_DIR.'/'.$json['image1.png']); + $this->assertFileExists(self::UPLOAD_FILE_DIR.'/'.$json['image2.png']); + $this->assertFileDoesNotExist(self::TEMP_DIR.'/'.$json['image2.png']); + } + + public function testUploadEndpointMustBePost(): void + { + $this->markTestIncomplete(); + } + + public function testUploadEndpointMustBeSigned(): void + { + $this->markTestIncomplete(); + } + + public function testUploadEndpointIsTemporary(): void + { + $this->markTestIncomplete(); + } + + public function testCanPreviewImages(): void + { + (new Filesystem())->copy(self::FIXTURE_FILE_DIR.'/image1.png', self::UPLOAD_FILE_DIR.'/image1.png'); + + $this->browser() + ->visit('/live/preview/image1.png') + ->assertSuccessful() + ->assertHeaderContains('Content-Type', 'image/png') + ->assertContains(file_get_contents(self::FIXTURE_FILE_DIR.'/image1.png')) + ; + } + + public function testCannotPreviewNonImages(): void + { + (new Filesystem())->copy(self::FIXTURE_FILE_DIR.'/text.txt', self::UPLOAD_FILE_DIR.'/text.txt'); + + $this->browser() + ->visit('/live/preview/text.txt') + ->assertStatus(404) + ; + } + + public function testMissingPreviewFileThrows404(): void + { + $this->browser() + ->visit('/live/preview/missing.png') + ->assertStatus(404) + ; + } + + public function testInvalidPreviewFilenameThrows404(): void + { + (new Filesystem())->mkdir(self::UPLOAD_FILE_DIR); + + $this->browser() + ->visit('/live/preview/../../tests/Fixtures/files/image1.png') + ->assertStatus(404) + ; + } +}