Buy Access to Course
21.

Storing Private Files

Share this awesome video!

|

Keep on Learning!

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.

17 lines | config/packages/oneup_flysystem.yaml
// ... 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.

17 lines | config/packages/oneup_flysystem.yaml
// ... 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.

52 lines | config/services.yaml
// ... 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

110 lines | src/Service/UploaderHelper.php
// ... 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.

110 lines | src/Service/UploaderHelper.php
// ... 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

110 lines | src/Service/UploaderHelper.php
// ... 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.

96 lines | src/Service/UploaderHelper.php
// ... 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.

96 lines | src/Service/UploaderHelper.php
// ... 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.

96 lines | src/Service/UploaderHelper.php
// ... 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.

96 lines | src/Service/UploaderHelper.php
// ... 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.

96 lines | src/Service/UploaderHelper.php
// ... 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.

96 lines | src/Service/UploaderHelper.php
// ... 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.