Buy Access to Course
23.

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

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.

43 lines | templates/article_admin/edit.html.twig
// ... 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.

43 lines | templates/article_admin/edit.html.twig
// ... 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.

49 lines | templates/article_admin/edit.html.twig
// ... 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.

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

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

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

Tip

If you're using version 4 of oneup/flysystem-bundle (so, flysystem v2), you don't need to code defensively anymore! All methods will throw an exception automatically if the operation fails.

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.

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