Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Storing Private Files

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

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, we're 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

Tip

If you're using version 4 of oneup/flysystem-bundle (so, flysystem v2), autowire Filesystem instead of FilesystemInterface from League\Flysystem.

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!

8
Login or Register to join the conversation
Steffen Z. Avatar
Steffen Z. Avatar Steffen Z. | posted 4 years ago

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

1 Reply

Hey Steffen Z.

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!

Reply
Jose carlos C. Avatar
Jose carlos C. Avatar Jose carlos C. | posted 4 years ago

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

1 Reply

Hey Jose carlos C.

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

Salud!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.87.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.3
        "symfony/console": "^4.0", // v4.2.3
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.3
        "symfony/framework-bundle": "^4.0", // v4.2.3
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.3
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.3
        "symfony/validator": "^4.0", // v4.2.3
        "symfony/web-server-bundle": "^4.0", // v4.2.3
        "symfony/yaml": "^4.0", // v4.2.3
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
        "symfony/dotenv": "^4.0", // v4.2.3
        "symfony/maker-bundle": "^1.0", // v1.11.3
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.3
    }
}
userVoice