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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHere'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.
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