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.
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 |
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.
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!
Hey, I just published my first website in Symfony with a CMS own about "Mamparas de oficina" https://www.tecnomodular.com/, thanks SymfonyCasts
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!
// 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
}
}
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