Buy

All about Uploading Files in Symfony

0%
Buy

Storing Private Files

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Here's the tricky part: we can't just go into UploaderHelper and use the Flysystem filesystem like we did before to save the uploaded file... because that writes everything into the public/uploads/ directory. If we need to check security before letting a user download a file, then it can't live in the public/ directory.

And that means we need a second Flysystem filesystem: one that can store things somewhere outside the public/ directory. Side note: it is possible to solve the "private" uploads problem with just one filesystem using signed URLs, and we'll talk about it later when we move to S3.

Creating a Private Filesystem

But for now, a great solution is to create a private filesystem. Open the config/packages/oneup_flysystem.yaml file. Copy the public_uploads_adapter, paste and call it private_uploads_adapter. You can store the files anywhere, as long as it's not in public/. But, the var/ directory is sort of meant for this type of thing. So let's say: var/uploads. Oh, and I could re-use my uploads_dir_name parameter here - but it won't give us any benefit. That parameter is really meant to keep the upload directory and public path to assets in sync. But these files won't have a public path anyways... we'll make them downloadable in an entirely different way.

... line 1
oneup_flysystem:
adapters:
... lines 4 - 7
private_uploads_adapter:
local:
directory: '%kernel.project_dir%/var/uploads'
... lines 11 - 17

Next, for filesystems, do the same thing: make a private_uploads_filesystem that will use the private_uploads_adapter.

... line 1
oneup_flysystem:
... lines 3 - 11
filesystems:
... lines 13 - 14
private_uploads_filesystem:
adapter: private_uploads_adapter

Cool! Next, in UploaderHelper, were already passing the $publicUploadFilesystem as an argument. We will also need the private one. Before we add it here, go into services.yaml. Remember, under _defaults, we're binding the $publicUploadFilesystem argument to the public fileystem service. Let's do the same for the private one. Call it $privateUploadFilesystem and change the service id to point to the "private" one.

... lines 1 - 11
services:
... line 13
_defaults:
... lines 15 - 21
bind:
... lines 23 - 25
$privateUploadsFilesystem: '@oneup_flysystem.private_uploads_filesystem_filesystem'
... lines 27 - 52

Now, copy that argument name and, in UploaderHelper, add a second argument: FilesystemInterface $privateUploadFilesystem. Create a new property on top called $privateFilesystem and set it below: $this->privateFilesystem = $privateUploadFilesystem

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 18
private $privateFilesystem;
... lines 20 - 26
public function __construct(FilesystemInterface $publicUploadsFilesystem, FilesystemInterface $privateUploadsFilesystem, RequestStackContext $requestStackContext, LoggerInterface $logger, string $uploadedAssetsBaseUrl)
{
... line 29
$this->privateFilesystem = $privateUploadsFilesystem;
... lines 31 - 33
}
... lines 35 - 110

Re-using the Upload Logic

Ok, we're ready! Most of the logic in uploadArticleImage() should be reusable: we're basically going to do the same thing... just through the private filesystem: we need to figure out the filename and stream it through Flysystem. The only part of this method that we don't need is the $existingFilename. We don't need to delete an existing file... because we're not going to allow files to be "updated" for a specific ArticleReference - we'll just have the user delete them and re-upload the new file.

Refactoring time! Copy all of this code down through the fclose() and, at the bottom, create a new private function called uploadFile(). This will take in the File object that we're uploading... and we also need to pass the directory name - you'll see what that is in a moment. Then add a bool $isPublic flag so that this method knows whether to store things in the public or private filesystem.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 85
private function uploadFile(File $file, string $directory, bool $isPublic)
{
... lines 88 - 107
}
}

To start, paste that exact logic

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 85
private function uploadFile(File $file, string $directory, bool $isPublic)
{
if ($file instanceof UploadedFile) {
$originalFilename = $file->getClientOriginalName();
} else {
$originalFilename = $file->getFilename();
}
$newFilename = Urlizer::urlize(pathinfo($originalFilename, PATHINFO_FILENAME)).'-'.uniqid().'.'.$file->guessExtension();
$stream = fopen($file->getPathname(), 'r');
$result = $this->filesystem->writeStream(
self::ARTICLE_IMAGE.'/'.$newFilename,
$stream
);
if ($result === false) {
throw new \Exception(sprintf('Could not write uploaded file "%s"', $newFilename));
}
if (is_resource($stream)) {
fclose($stream);
}
}
}

and, at the bottom, return $newFilename. Oh, and I should also probably add a return type.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 67
private function uploadFile(File $file, string $directory, bool $isPublic): string
{
... lines 70 - 92
return $newFilename;
}
}

Let's see... the first thing we need to do is handle this $isPublic argument. So Let's say $filesystem = $isPublic ? and, if it is public, use $this->filesystem, otherwise use $this->privateFilesystem. Below, replace $this->filesystem with $filesystem.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 67
private function uploadFile(File $file, string $directory, bool $isPublic): string
{
... lines 70 - 76
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
... lines 78 - 79
$result = $filesystem->writeStream(
... lines 81 - 82
);
... lines 84 - 93
}
}

The other thing we need to update is the directory: it's hardcoded to ARTICLE_IMAGE. Replace that with $directory: this is the directory inside the filesystem where the file will be stored.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 67
private function uploadFile(File $file, string $directory, bool $isPublic): string
{
... lines 70 - 79
$result = $filesystem->writeStream(
$directory.'/'.$newFilename,
$stream
);
... lines 84 - 93
}
}

All done! Back up in uploadArticleImage(), re-select all that code we just copied, delete it, do a happy dance and replace it with $newFilename = $this->uploadFile() passing the $file, the directory - self::ARTICLE_IMAGE - and whether or not this file should be public, which is true.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 36
public function uploadArticleImage(File $file, ?string $existingFilename): string
{
$newFilename = $this->uploadFile($file, self::ARTICLE_IMAGE, true);
... lines 40 - 53
}
... lines 55 - 94
}

Now we can do the same thing down in uploadArticleReference. Oh, but first, we need to create another constant for the directory const ARTICLE_REFERENCE = 'article_reference.

... lines 1 - 12
class UploaderHelper
{
... line 15
const ARTICLE_REFERENCE = 'article_reference';
... lines 17 - 94
}

Back down, all we need is return $this->uploadFile(), with $file, self::ARTICLE_REFERENCE and false so that it uses the private filesystem.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 55
public function uploadArticleReference(File $file): string
{
return $this->uploadFile($file, self::ARTICLE_REFERENCE, false);
}
... lines 60 - 94
}

I think that's it! Let's test this puppy out! Move over and refresh to re-POST the form. No error... but I have no idea if that worked... because we're not rendering anything yet. Check out the var/ directory... var/uploads/article_reference/symfony-best-practices..., we got it!

Of course, there's absolutely no way for anyone to access this file... but we'll fix that up soon enough.

Next: unless we really, really, trust our authors, we probably shouldn't let them upload any file type. Let's tighten up validation.

Leave a comment!

  • 2019-05-23 Diego Aguiar

    Hey Steffen Zeidler

    That's a good question. In this case the service "UploaderHelper" is on charge of using the right file system, we don't want to worry about which file system is being used on every action we add to the system. If using "flags" bothers you, you can split that method in two pieces. One for uploading private files and another one for uploading public files

    Cheers!

  • 2019-05-22 Steffen Zeidler

    Just a slight note / micro optimization: Wouldn't it be better to use the corresponding filesystem as third argument of the method uploadFile() instead of the boolean argument $isPublic, as boolean arguments are not easy readable/understandable? What is your opinion about it?
    Regards Steffen

  • 2019-03-19 Diego Aguiar

    Hey CharlES

    That's awesome! We're so happy to hear that you developed your site using Symfony and you found useful our tutorials!

    Salud!

  • 2019-03-18 CharlES

    Hey, I just published my first website in Symfony with a CMS own about "Mamparas de oficina" https://www.tecnomodular.com/, thanks SymfonyCasts