Downloading a file is a common feature that you can find in a lot of websites. But in some case, you may want to allow this download only when your user has completed a form, and protect the access link from being accessible for everyone.
October 5, 2019 / 0 comments
Warning : Please notice this article is more than 1 year old. Some mechanics may have changed due to the newer versions of the used tools.
Downloading a file is a common feature that you can find in a lot of websites. But in some case, you may want to allow this download only when your user has completed a form, and protect the access link from being accessible for everyone.
Let's imagine that your website provides a PDF at the end of the registration process, but you don't want that the unregistered users can access to this file.
We can achieve this easily with Symfony, Javascript, and a bit of Redis.
Note: in this article, we will use the snc/redis-bundle library.
Once you've installed the SNCRedisBundle, we will configure the framework cache to use the Redis adapter instead of the default one. Many adapters are available, like APCU, Doctrine or Memcached...
So open your config/packages/framework.yaml
file and add the following lines.
framework:
# ...
cache:
app: cache.adapter.redis
To achieve what we want, we will need a controller with two actions. The first one will be the registration form action, and the second will provide the PDF to our user if he successfully registered.
The PDF will be available for 5 minutes. After that, our user will not be able to download it again.
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class RegistrationController extends AbstractController
{
/**
* @Route("/registration", name="registration_route")
* @param Request $request
* @return Response
*/
public function form(Request $request)
{
// ...
}
/**
* @Route("/pdf")
* @param Request $request
* @return BinaryFileResponse
*/
public function pdf(Request $request)
{
// ...
}
}
I will assume that you already have the registration FormType and I will focus on the form submission and what happens next.
Note : if you don't know how to write your FormType you can follow this guide.
Let's update our form
action to create our form, and manage the submission.
<?php
// ...
/**
* @Route("/registration", name="registration_route")
* @param Request $request
* @return Response
*/
public function form(Request $request)
{
$form = $this->createForm(RegistrationType::class, null, [
'action' => $this->generateUrl('registration_route'),
]);
$form->handleRequest($request);
if ($form->isSubmitted()) {
if ($form->isValid()) {
// Here we will generate a hash that will be stored into Redis to authentify the user who wants to download the PDF
} else {
// Here we will manage form errors
}
}
return $this->render('registration.html.twig', [
'form' => $form->createView(),
]);
}
In case of invalid form, we will simply return the HTML of the form in a JsonResponse
object. It will be displayed in the Javascript part below.
<?php
// ...
/**
* @Route("/registration", name="registration_route")
* @param Request $request
* @return Response
*/
public function form(Request $request)
{
// ...
if ($form->isSubmitted()) {
if ($form->isValid()) {
// ...
} else {
$data = [
'html' => $this->renderView('_form.html.twig', [
'form' => $form->createView(),
]),
];
}
return new JsonResponse($data);
}
// ...
}
Now, when our user submit the form and there is no errors, we will generate a hash (no matter which algorithm is chosen, here we will use SHA512). This hash will be stored in Redis with a short TTL. The user will be able to download our PDF until it has expired.
I'm aware that is a very specific feature, but I encountered it in my currrent job for a similar context and I thought it could be useful to someone someday.
Then, we just have to generate the absolute url of the pdf action and pass it to the JsonResponse
. We will use it in the Javascript part to redirect the user.
<?php
// ...
/**
* @var AdapterInterface
*/
private $cache;
/**
* @param AdapterInterface $cache
*/
public function __construct(AdapterInterface $cache)
{
$this->cache = $cache;
}
/**
* @Route("/registration")
* @param Request $request
* @return Response
*/
public function form(Request $request)
{
// ...
if ($form->isSubmitted()) {
if ($form->isValid()) {
$hash = \hash('sha512', \random_bytes(30));
$item = $this->cache->getItem($hash);
$item->expiresAfter(300); // time in seconds
$this->cache->save($hash);
$data = [
'fileUrl' => $this->generateUrl('pdf_route', ['hash' => $hash]),
];
} else {
// ...
}
return new JsonResponse($data);
}
// ...
}
Aaaaand that's it for the form action. Now we just have to write our small controller action that will check if the hash is still valid and return the PDF file.
<?php
// ...
/**
* @Route("/pdf")
* @param Request $request
* @return BinaryFileResponse
*/
public function pdf(Request $request)
{
$hash = $request->query->get('hash');
if (!$hash) {
throw new NotFoundHttpException();
}
$item = $this->cache->getItem($hash);
if (!$item->isHit()) {
throw new NotFoundHttpException();
}
$path = \sprintf('%s/path/to/my/pdf/my-file.pdf', $this->getParameter('kernel.project_dir'));
return $this->file($path);
}
That's all with the controller part. Now let's create some templates.
As you could see in the previous part, we will need 2 simple templates.
_form.html.twig
which will contain our formregistration.html.twig
which will contain the page structure, the Javascript and include the _form.html.twig
templateHere is the _form.html.twig
template. It will just contains the fields of your registration FormType.
{{ form_start(form) }}
{{ form_rest(form) }}
{{ form_end(form) }}
And the registration.html.twig
template.
{% extends 'path/to/your/layout.html.twig' %}
{% block body %}
<div class="container">
<div class="form-container">
{% include '_form.html.twig' %}
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('/js/registration.js') }}"></script>
{% endblock %}
Here it is for the templates part. Keep it simple. As you could see above, now we have to write a Javascript file called registration.js
.
I will use only native Javascript in this part. Generally, I'll try to avoid jQuery in my future articles because I think we can reach the same goals with native JS. Moreover, I think that the jQuery syntax is much less readable and source of errors than native JS.
Basically, our JS file will do 4 simple things.
window.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
form.addEventListener('submit', (e) => {
e.preventDefault();
let formData = new FormData(form);
fetch(form.getAttribute('action'), {
method: 'POST',
body: formData
}).then((response) => {
return response.json();
}).then((data) => {
if (data.fileUrl) {
window.open(data.fileUrl);
window.location.href = "https://rickrolled.fr/";
} else if (data.html) {
document.querySelector('.form-container').innerHTML = data.html;
window.dispatchEvent(new Event('DOMContentLoaded'));
} else {
console.error('An error occured.');
}
});
});
});
Let me explain. First, we add an event listener on the DOMContentLoaded
to ensure that all the DOM is loaded before listening to other user actions.
After that, we register a submit
event listener on our form. So when the user will press the sumbit button, we will send a POST
request containing the form data to the form
action of our controller.
To achieve this, we use the Javascript fetch API. The fetch
function returns a Promise containing a json response object. So, in our code, we decode the json to get an array in which we will find the properties that we passed to the JsonResponse in our controller.
If we find the fileUrl
property, it means that the form submission ended well and so we can provide the PDF to our user, then redirect him to the URL of our choice.
The window.open
function will open a new tab leading to our controller pdf action in the user browser. This action returning a file, it will trigger a download and then close the tab.
In the case of the html
property is found in the json response, it means that there was errors in the form validation.
If it happens, then we will replace the .form-container
div content with the json response html
property, so your form view will be refreshed containing the form errors.
I hope you found this article useful. Please give your feedback in the comment section, I will answer you quickly.
Who is talking to you ?
My name is Hugo, I'm 28 and I'm a Symfony developer since 2013. I love to create websites by myself to learn new technologies or increase my skills. I also like to share my knowledge so I created this blog. I hope you enjoy it :)