Skip to content
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

[Live][POC] alternate upload system #395

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/LiveComponent/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions src/LiveComponent/src/Controller/UploadController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?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\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 <[email protected]>
*/
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,10 @@
<route id="live_component" path="/_components/{component}/{action}">
<default key="action">get</default>
</route>

<route id="live_upload" path="/live/upload" methods="POST" controller="ux.live_component.upload_controller::uploadAction" />

<route id="live_preview" path="/live/preview/{filename}" methods="GET" controller="ux.live_component.upload_controller::previewAction">
<requirement key="filename">[\w\-\.]+</requirement>
</route>
</routes>
Binary file added src/LiveComponent/tests/Fixtures/files/image1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/LiveComponent/tests/Fixtures/files/image2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/LiveComponent/tests/Fixtures/files/text.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some text...
134 changes: 134 additions & 0 deletions src/LiveComponent/tests/Functional/Controller/UploadControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?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\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 <[email protected]>
*/
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)
;
}
}