Hugo Soltys
Hugo Soltys

My name is Hugo, I'm 27 and I'm a Symfony developer since 2013. I love to create websites by myself to learn new technologies or increase my skills.

M6 Web
2018-10-16 - Today Symfony developer
Decathlon
2016-01-01 - 2018-10-15 Symfony developer
IT Room
2014-09-01 - 2015-12-31 Symfony developer
Noogaa
2013-09-01 - 2014-08-31 Student symfony developer
Work with me

Download a file after a form validation with Symfony, Redis and Javascript

October 5, 2019 by Hugo - 219 views - 0 Comments


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.

1 - Redis configuration

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

 

2 - The controller

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.

 

3 - The templates

As you could see in the previous part, we will need 2 simple templates.

  • _form.html.twig which will contain our form
  • registration.html.twig which will contain the page structure, the Javascript and include the _form.html.twig template

Here 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

 

4 - The Javascript

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.

  • submit the registration form
  • handle the error case
  • call the download PDF url if there is no error
  • redirect the user where you want 

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.