Endpoint for Downloading 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

When we upload an article reference file, it successfully gets moved into the var/uploads/article_reference/ directory. That's great. And that means those files are not publicly accessible to anyone... which is what we wanted.

Listing the Uploaded References

Except... how can we allow authors to access them? As a first step, let's at least list the files on the page. In edit.html.twig, add a <ul> with some Bootstrap classes.

... lines 1 - 2
{% block content_body %}
... lines 4 - 7
<div class="row">
... lines 9 - 14
<div class="col-sm-4">
... lines 16 - 17
<ul class="list-group small">
... lines 19 - 23
</ul>
... lines 25 - 33
</div>
</div>
{% endblock %}
... lines 37 - 43

Then loop with {% for reference in article.articleReferences %}. Inside, add an <li>, a bunch of classes to make it look fancy, and then print, how about, reference.originalFilename.

... lines 1 - 2
{% block content_body %}
... lines 4 - 7
<div class="row">
... lines 9 - 14
<div class="col-sm-4">
... lines 16 - 17
<ul class="list-group small">
{% for reference in article.articleReferences %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ reference.originalFilename }}
</li>
{% endfor %}
</ul>
... lines 25 - 33
</div>
</div>
{% endblock %}
... lines 37 - 43

This is pretty cool: when we move the files onto the server, we give them a weird filename. But because we saved the original filename, we can show that here: the author has no idea we're naming their files crazy things internally.

Let's see how this looks. Nice! 2 uploaded PDF's.

The Download Controller

To add a download link, we know that we can't just link to the file directly: it's not public. Instead, we're going to link to a Symfony route and controller and that controller will check security and return the file to the user. Let's do this in ArticleReferenceAdminController. Add a new public function, how about, downloadArticleReference().

... lines 1 - 17
class ArticleReferenceAdminController extends BaseController
{
... lines 20 - 78
public function downloadArticleReference(ArticleReference $reference)
{
... line 81
}
}

Add the @Route() above this with /admin/article/references/{id}/download - where the {id} this time is the id of the ArticleReference object. Then, name="admin_article_download_reference" and methods={"GET"}, just to be extra cool.

... lines 1 - 17
class ArticleReferenceAdminController extends BaseController
{
... lines 20 - 75
/**
* @Route("/admin/article/references/{id}/download", name="admin_article_download_reference", methods={"GET"})
*/
public function downloadArticleReference(ArticleReference $reference)
{
... line 81
}
}

Because the {id} is the id of the ArticleReference, we can add that as an argument: ArticleReference $reference. Just dd($reference) so we can see if this is working.

... lines 1 - 17
class ArticleReferenceAdminController extends BaseController
{
... lines 20 - 75
/**
* @Route("/admin/article/references/{id}/download", name="admin_article_download_reference", methods={"GET"})
*/
public function downloadArticleReference(ArticleReference $reference)
{
dd($reference);
}
}

Love it! Copy the route name and head back into the template. Add a <span> here for styling and an anchor with href="{{ path() }}", the route name, and id: reference.id. For the text, I'll use the Font Awesome download icon.

... lines 1 - 2
{% block content_body %}
... lines 4 - 7
<div class="row">
... lines 9 - 14
<div class="col-sm-4">
... lines 16 - 17
<ul class="list-group small">
{% for reference in article.articleReferences %}
<li class="list-group-item d-flex justify-content-between align-items-center">
... lines 21 - 22
<span>
<a href="{{ path('admin_article_download_reference', {
id: reference.id
}) }}"><span class="fa fa-download"></span></a>
</span>
</li>
{% endfor %}
</ul>
... lines 31 - 39
</div>
</div>
{% endblock %}
... lines 43 - 49

Try it out! Refresh and... download! So far so good.

Creating a Read File Stream

In some ways, our job in the controller is really simple: read the contents of the file and send it to the user. But... we don't actually want to read the contents of the file into a string and then put it in a Response. Because if it's a large file, that will eat up PHP memory.

This is already why, in UploaderHelper, we're using a stream to write the file. And now, we'll use a stream to read it. To keep all this streaming logic centralized in this class, add a new public function readStream() with a string $path argument and bool $isPublic so we know which of these two filesystems to read from.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 70
public function readStream(string $path, bool $isPublic)
{
... lines 73 - 81
}
... lines 83 - 110
}

Above the method, advertise that this will return a resource - PHP doesn't have a resource return type yet. Inside, step 1 is to get the right filesystem using the $isPublic argument.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 67
/**
* @return resource
*/
public function readStream(string $path, bool $isPublic)
{
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
... lines 74 - 81
}
... lines 83 - 110
}

Then, $resource = $filesystem->readStream($path).

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 67
/**
* @return resource
*/
public function readStream(string $path, bool $isPublic)
{
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
$resource = $filesystem->readStream($path);
... lines 76 - 81
}
... lines 83 - 110
}

That's... pretty much it! But hold Cmd or Ctrl and click to see the readStream() method. Ah yes, if this fails, Flysystem will return false. So let's code defensively: if ($resource === false), throw a new \Exception() with a nice message:

Error opening stream for %s

and pass $path. At the bottom, return $resource.

... lines 1 - 12
class UploaderHelper
{
... lines 15 - 67
/**
* @return resource
*/
public function readStream(string $path, bool $isPublic)
{
$filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;
$resource = $filesystem->readStream($path);
if ($resource === false) {
throw new \Exception(sprintf('Error opening stream for "%s"', $path));
}
return $resource;
}
... lines 83 - 110
}

This is great! We now have an easy way to get a stream to read any file in our filesystems... which will work if the file is stored locally or somewhere else.

Checking Security

In the controller add the UploaderHelper argument. Oh, but before we use this, I forgot to check security! That was the whole point! The goal is to allow these files to be downloaded by anyone who has access to edit the article. We've been checking that via the @IsGranted('MANAGE') annotation - which leverages a custom voter we created in the Symfony series. We can use this annotation here because the article in the annotation refers to the $article argument to the controller.

But in this new controller, we don't have an article argument, so we can't use the annotation in the same way. No problem: add $article = $reference->getArticle() and then run the security check manually: $this->denyAccessUnlessGranted() with that same 'MANAGE' string and $article.

... lines 1 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 79
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
$article = $reference->getArticle();
$this->denyAccessUnlessGranted('MANAGE', $article);
... lines 84 - 92
}
}

Refresh to try it. We still have access because we're logged in as an admin.

Next, let's take our file stream and send it to the user! We'll also learn how to control the filename and force the user's browser to download it.

Leave a comment!

  • 2020-06-19 Victor Bocharsky

    Hey Caeema,

    Well, it depends on what you need to do with that file. First of all, readStream() function returns you a resource. But if you need to get the content of that file - you probably may want to use file_get_contents() native PHP function to get the actual content of the file. But it might be memory consuming, working with resources might be more lightweight. But once again, it depends on what you're going to do with that excel file. If you have an Excel reader - a library you installed in your project - and that reader works with resources - probably it would be fine, just pass it the correct resource. But some readers may expect only path to the file, and they they will open it for reading/wringing themselves. It depends on the library API, etc.

    But if you see that the file is not found - that probable mean that you path is incorrect. I'd recommend you to dump the path you specified in your controller and follow your filesystem manually to see that file there. Maybe it's incorrect and miss some folders in it, maybe you get doubled slash ("//") in it, maybe you forgot to pass an exceptions of the file, etc. So, first of all you need to make sure that the file really exists in the path you have in your controller.

    I hope this helps!

    Cheers!

  • 2020-06-18 Caeema

    Hi !

    I used some parts of this tutorial, including to upload some excel files.
    The system of upload is working fine: in my particular case, the file is uploaded in /var/uploads/import_user_file

    But I know need to use this excel file and I struggle for something:
    In a controller or in a service, how can I retrieve the path to this file ?

    I thought I could use the function readStream()

    public function readStream(string $path, bool $isPublic)
    {
    $filesystem = $isPublic ? $this->filesystem : $this->privateFilesystem;

    $resource = $this->filesystem->readStream($path);

    if ($resource === false) {
    throw new \Exception(sprintf('Error when opening stream for "%s" ', $path));
    }

    return $resource;
    }

    And here in my controller:

    if ($file) {

    /** @var string $filename */
    $filename = $uploadHelper->uploadFile($file, UploadHelper::IMPORT_USER_FILE, false);

    // return "import_user_file/import-testing.xlsx"
    $path = UploadHelper::IMPORT_USER_FILE.'/'.$filename;

    $excelFile = $uploadHelper->readStream($path, false);

    dd($excelFile);

    }

    But... the dump return file not found, and of course, it looks like the proccess is not going to the folder /var/upload

    Can somebody explain me, I think I miss something here.

    Thanks!

  • 2020-04-20 Digit Image

    In fact, with my current solution, the one-shot URL is not given to the user as this. It is calculated by the controller just before loading the page and destroyed just after. The *visible* URL in the media player is so unusable for downloading. It works great except the speed...
    Thanks for your answer. I will check into HLS!

  • 2020-04-19 weaverryan

    Hi Digit Image!

    Yes, the problem with "proxying" files of *any* significant size through a Symfony controller is that they will be *slow*. This is a great solution for things like PDF files... or something else. But videos or audio files, it's just not going to scale.

    More generally, audio & video streaming are special "cases" - there are special protocols for doing this stuff - like RTMP or HLS https://en.wikipedia.org/wi... - and if you want a robust streaming services, you really need to do some research into this. I am definitely not an expert on it, so I can't say much unfortunately :).

    But, if you are *generally* ok with streaming from S3 (or CloudFront in front of S3), then you will need to do signed URLs. However, you said this:

    > This system is very cool because I can create a one-shot URL (with a token) as an endpoint to load the mp3 file in my page

    You could *still* totally do this... but then the controller for this page would create a signed URL and redirect the user to that URL. So yes, the URL in the browser would ultimately be the signed URL to S3. But you would *still* be able to generate these one-shot URLs with a token and send them to people.

    > As I don't want users can download the original files, I think I can't use s3 presigned-URL

    If you want to avoid people downloading the files, then you *really* need to check into HLS or something like it - for example https://docs.aws.amazon.com... - streaming data to people... without actually allowing them to download the file is tricky business. Even with your current solution, I could use the one-shot URL to download the music file.

    Cheers!

  • 2020-04-17 Digit Image

    Hi! And thank you for this cast, it really helps!

    I'm trying to make a "music on demand" service such as Spotify (a very very VERY small one ;-). Logged users can listening songs but I don't want them to download the original files. To to that, I tried to use the StreamResponse object as you did in the course:

    $response = new StreamedResponse(function() use ($song, $uploaderHelper) {
    $outputStream = fopen('php://output', 'wb');
    $fileStream = $uploaderHelper->readStream($song->getFilePath(), false);
    stream_copy_to_stream($fileStream, $outputStream);
    });

    This system is very cool because I can create a one-shot URL (with a token) as an endpoint to load the mp3 file in my page. And the real URL stays hidden.

    But the download performances from s3 are very very VERY bad!

    As I don't want users can download the original files, I think I can't use s3 presigned-URL... I really want to have a one-shot URL not one with expiration time.

    Any idea?
    Cyril (from France)

  • 2020-03-15 weaverryan

    Hey Tomasz Gąsior!

    That's really great advice - and I should have done a better job on that - I don't mind being called out on something I could have done it better :). I will keep this in mind more on future tutorials - better accessibility is just a win for everything/everyone.

    Cheers!

  • 2020-03-10 Tomasz Gąsior

    2:42 I know, it is not frontend tutorial but for information: please don't forget about blind people and accessibility. Instead just icon, inside anchor put the whole text "Download %s", and replace %s with full name of the title.First: a11y screen readers are not able to read the icon. Second: "download" alone it's not fine, anchor text must be understandable outside of its context — a11y screen readers are able to generate list of all anchors of the website. If you want to hide the text for standard non-blind people, wrap it inside `span` but don't use display: none, use `sr-only` class instead of look for visually hidden technique if you don't use bootstrap in your project.

  • 2019-04-12 Victor Bocharsky

    Hey cybernet2u

    Thank you for reporting this! We had a bug with Emoji char, now it's fixed and all code blocks should be good. If you notice any problems with code blocks, please, let us know.

    Cheers!

  • 2019-04-09 cybernet2u

    edit template is incomplete :)