Unique (but not Insane) Filenames

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

I told the UploadedFile object to move the file into public/uploads. And it did... but I kinda get the feeling it wasn't trying very hard. I mean, that is a horrible filename. Well, to be fair, this is the temporary filename that PHP decided to use.

Using the Original Filename

Fortunately, the move() method has a second argument: the name to give to the file. The easiest name to use is: $uploadedFile->getClientOriginalName(). This is the name that the file had on my computer: it's one of the pieces of data that is sent up on the request, along with the file contents.

... lines 1 - 15
class ArticleAdminController extends BaseController
{
... lines 18 - 73
public function temporaryUploadAction(Request $request)
{
... lines 76 - 78
dd($uploadedFile->move(
$destination,
$uploadedFile->getClientOriginalName()
));
}
... lines 84 - 121
}

Move over and resubmit the form again. There it is: astronaut.jpg!

Security Concerns

But there are a few problems with this. Number one is security. Boo security! I know, I know, if the world were more butterflies and ice cream cones, we wouldn't need to worry about this stuff. But when it comes to file uploads, security concerns are real.

Right now, our upload form has no validation at all. So even though we are intending for this to be an image, the user could upload anything. And to make things worse, the file will then be publicly accessible. Someone could basically use our site as a private file storage, even storing viruses and trying to trick people into downloading it from our trusted domain. We'll talk about validation a bit later: it is critical that you do not allow your users to upload any file type.

Side note: no matter how you build your app or what safeguards you put it place, you should always make sure that your web server will only parse your main public/index.php file through PHP. If your server is configured to execute any file ending in .php through PHP, that is a huge security risk. Ok, back to butterflies and ice cream.

Even after we add validation to guarantee that the uploaded file is actually an image, the user could still successfully upload an image with a .exe or .php file extension! Even if we validate the file type, allowing fake extensions is weird... and could be risky.

So problem number one is security and we'll tackle part of it in a minute and the other part when we talk about validation.

Problem number two is that the filename is not guaranteed to be unique! If someone else uploads a file called astronaut.jpg, boom! My schweet photo is gone!

Making Filenames Unique

There are a few ways to handle the unique problem - but the easiest one is just to add some sort of unique id to the filename. Set $newFilename to uniqid(), a '-' then $uploadedFile->getClientOriginalName(). Below, use $newFilename.

... lines 1 - 73
public function temporaryUploadAction(Request $request)
{
... lines 76 - 78
$newFilename = uniqid().'-'.$uploadedFile->getClientOriginalName();
dd($uploadedFile->move(
$destination,
$newFilename
));
}
... lines 86 - 125

Let's try that! Better. It's kind of an ugly hash on the beginning of the filename, but it does solve the unique problem. You can also use a shorter hash or, when we actually save this data to our Article object, you could use the Article id instead of the hash. Or, if you really want to keep the original filename exactly as it was, well... we'll talk about that later when we upload "references" to our Article.

Correcting the File Extension

The other thing I want to solve is the possibility that someone uploads an image with a totally insane file extension - like .potato. We can fix this really nicely. Create a new variable called $originalFilename set to pathinfo() with $uploadedFile->getClientOriginalName() and the constant PATHINFO_FILENAME.

This will give us the original filename - astronaut.jpg - but without the file extension: so, just astronaut. Then, for the filename, use $originalFilename, a dash, the uniqid(), a period, and now the real extension of the file: $uploadedFile->guessExtension(). Oh, see how there are two methods: ->guessClientExtension() and ->guessExtension()? The difference is important: the guessExtension() method looks at the file contents, determines the mime type, and returns the file extension for that. But the guessClientExtension() uses the mime type the user sent... which can't be trusted.

... lines 1 - 73
public function temporaryUploadAction(Request $request)
{
... lines 76 - 79
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$newFilename = $originalFilename.'-'.uniqid().'.'.$uploadedFile->guessExtension();
... lines 82 - 86
}
... lines 88 - 127

So, we're not validating that this is an image file yet, but no matter what they upload, we should now get the correct file extension.

Give it a try! Nice! We've got a .jpeg ending.

Optional: Normalizing Filenames

There's one last thing you might want to do... and it's really optional. Go back to the form. One of my files has uppercase letters and spaces inside. Let's try uploading that. It works! There is no problem with storing spaces or... most weird characters on a filesystem. But if you want to guarantee cleaner filenames, there's an easy way to do that. I'll use a class called Urlizer: this comes from the gedmo/doctrine-extensions library. It has a nice method called urlize() and we can wrap our $originalFilename in that to make it a bit cleaner.

... lines 1 - 8
use Gedmo\Sluggable\Util\Urlizer;
... lines 10 - 74
public function temporaryUploadAction(Request $request)
{
... lines 77 - 81
$newFilename = Urlizer::urlize($originalFilename).'-'.uniqid().'.'.$uploadedFile->guessExtension();
... lines 83 - 87
}
... lines 89 - 128

Try that out. Nice! So now we have a unique, normalized filename that at least looks a bit like the original filename. Later, we'll see how we can keep the exact original filename in all cases... if you care. But unless your users are downloading these files, the exact filenames aren't usually that important.

Next: it's time to put this upload field properly into our Symfony form and save the filename to the Article entity.

Leave a comment!

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
        "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.0", // v1.6.2
        "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
    }
}