Prečítajte si niečo zaujímavé

Symfony File Upload with a simple CDN

Symfony File Upload

 

File uploads are a common functionality and all the frameworks out there deal with it differently. Unfortunately, the suggested way in the Symfony docs has many, many side downs and Vich uploader is quite hard to use and configure. Hence we decided to create a little file upload system. The goals are:

  1. To be able to attach files to every entity in our system in 3 simple steps,
  2. to serve all file via a controller as a response,
  3. to be able to use security and access roles with files,
  4. to be able to use multiple attachments.

Requirements

Symfony J .. we will be using the TimestampableEntity trait and Slug functionality from Doctrine extensions

Idea behind our Upload system

Basically, we would like to have one File Entity which will hold all files uploaded to our system. We would like to access these files by a slug in a standardized way e.g. /cdn/my-uploaded-file.png. We also need a service in which we just pass the upload form type and it will do the work. There are also things we would like to avoid like having mxn relations with our file entity. Since would like to use slugs as file identifiers and we also would like to have a CDN controller which will serve our files, we really need a simple way to tell an entity it has files and how to find them.

Steps

In our first step, lets create a File entity interface. In your bundle create a folder called Services and in here a folder called CDN. Here is our Interface:

<?php

 

namespace AppBundle\Services\CDN;

 

/**

 * Interface FileEntityInterface

 */

interface FileEntityInterface

{

    /**

     * Set the file name

     *

     * @param string $name

     *

     * @return mixed

     */

    public function setName($name);

 

    /**

     * Set the tmp_name of uploaded file

     *

     * @param string $tempName

     *

     * @return mixed

     */

    public function setTempName($tempName);

 

    /**

     * Set the mime type of the file

     *

     * @param string $type

     *

     * @return mixed

     */

    public function setType($type);

 

    /**

     * Set the sime in mb

     *

     * @param integer $size

     *

     * @return mixed

     */

    public function setSize($size);

 

    /**

     * Relative path to uploaded file

     *

     * @param string $uploadDir

     *

     * @return mixed

     */

    public function setUploadDir($uploadDir);

 

    /**

     * Get the url name for the file

     *

     * @return string

     */

    public function getSlug();

}

 

Out interface just ensures that our future file entity will have everything needed to be able to hold our files. Since are already in the CDN folder, lets also create a file called UploadedFile. This will simply extend SymfonyUploadFile and set our upload directory:

<?php
namespace AppBundle\Services\CDN;

 
use \Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;

 
/**
 * Class UploadedFile
 *
 * @package AppBundle\Services\CDN
 */
class UploadedFile extends SymfonyUploadedFile
{
    /**
     * @var string relative path to uploaded direcotry
     */
    private $uploadDir;

 
    /**
     * Fill an entity with file data
     *
     * @param FileEntityInterface $file
     */
    public function save(FileEntityInterface $file)
    {
        $file->setUploadDir($this->getUploadDir());
        $file->setName($this->getClientOriginalName());
        $file->setSize($this->getClientSize());
        $file->setTempName($this->getFilename());
        $file->setType($this->getMimeType());
    }

 
    /**
     * @return string
     */
    public function getUploadDir()
    {
        return $this->uploadDir;
    }

 
    /**
     * @param string $uploadDir
     */
    public function setUploadDir($uploadDir)
    {
        $this->uploadDir = $uploadDir;
    }

 
}

 

Now we have everything set up to create our FileEntity:

<?php

 
namespace AppBundle\Entity;

 
use AppBundle\Services\CDN\FileEntityInterface;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Symfony\Component\Validator\Constraints as Assert;
use Gedmo\Mapping\Annotation as Gedmo;

 
/**
 * File
 *
 * @ORM\Table()
 * @ORM\Entity()
 */
class File implements FileEntityInterface
{
    use TimestampableEntity;
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

 
    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

 
    /**
     * @Gedmo\Slug(fields={"name","createdAt"})
     * @ORM\Column(length=128, unique=true)
     */
    private $slug;

 
    /**
     * @var string
     *
     * @ORM\Column(name="temp_name", type="string", length=255)
     */
    private $tempName;

 
    /**
     * @var string
     *
     * @ORM\Column(name="type", type="string", length=255)
     */
    private $type;

 
    /**
     * @var integer
     *
     * @ORM\Column(name="size", type="integer")
     */
    private $size;

 
    /**
     * @var string
     *
     * @ORM\Column(name="upload_dir", type="string", length=255)
     */
    private $uploadDir;

 

So, how do we connect an entity with our FileEntity? Actually, very simply by just storing the slug of the file we need to connect with. Since we will have a CDN controller, the only thing to find a file is the slug it selves. Just to have it a little easier, lets create a Trait which we will use in our entities to save the slugs. Create a folder Traits in AppBundle\Services and then create a File called AttachableEntity:

<?php

 
namespace ScrumBundle\Services\Traits;

 
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;

 
/**
 * Standardize how attachments are added to an entity
 *
 * We use a file entity to store the attachment and we are just saving the slug to be able
 * to access it via url
 *
 * @package AppBundle\Services\Traits
 */
trait AttachableEntity
{
    /**
     * @var string
     * @ORM\Column(name="attachments", type="text", nullable=true)
     *
     */
    private $attachments;

 
    /**
     * @return mixed
     */
    public function getAttachments()
    {
        return unserialize($this->attachments);
    }

 
    /**
     * @param array $attachmentNames
     */
    public function setAttachments(array $attachmentNames)
    {
        $this->attachments = serialize($attachmentNames);
    }
}

 

Notice the serialization, we do expect to have multiple files assigned and therefore we just serialize the array of slugs.

 

So we have everything set up, how do we upload the actual files then?

Let’s create an UploadHelper class in our Services folder which wll help us to upload the files:

<?php

 
namespace AppBundle\Services;

 
use AppBundle\Entity\File;
use Doctrine\ORM\EntityManager;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\File\UploadedFile;

 
/**
 * Class UploadHelper
 *
 * @package AppBundle\Services
 */
class UploadHelper
{
    /**
     * @var Container
     */
    private $container;

 
    /**
     * @var EntityManager
     */
    private $em;

 
    /**
     * @param Container $container
     */
    public function __construct(Container $container, EntityManager $em)
    {
        $this->container = $container;
        $this->em = $em;
    }

 
    /**
     * @param array $files
     *
     * @param bool  $mail
     *
     * @return array
     */
    public function uploadFiles($files)
    {
        $slugs = [];
        foreach ($files as $file) {
            if (!empty($file)) {
                $slugs[] = $this->uploadFile($file);
            }
        }

 
        return $slugs;
    }

 
    /**
     * @param UploadedFile $file
     *
     * @return string
     */
    public function uploadFile(UploadedFile $file)
    {
        $fileEntity = new File();
        $uploadDir = $this->getUploadDir();
        $fileEntity->setUploadDir($uploadDir['dir']);
        $fileEntity->setName($file->getClientOriginalName());
        $fileEntity->setSize($file->getClientSize());
        $fileEntity->setTempName($file->getFilename());
        $fileEntity->setType($file->getMimeType());
        $this->em->persist($fileEntity);
        $this->em->flush();
        $file->move($uploadDir['path']);

 
        return $fileEntity->getSlug();
    }
    /**
     * Get a relatively unique folder name
     *
     * @param bool $onlyName
     *
     * @return string|array folder name or path and directory name ('path'=> '', 'dir'=>'')
     */
    public function getUploadDir($onlyName = false)
    {
        $characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".time();

 
        $unique = md5(str_shuffle($characters));
        if ($onlyName) {
            return $unique;
        }
        $uploadDir = $this->container->getParameter('upload_dir');
        $uploadDir = $uploadDir.DIRECTORY_SEPARATOR.$unique;

 
        if (!is_dir($uploadDir)) {
            @mkdir($uploadDir, 0777, true);
        }

 
        return [
            'path' => $uploadDir,
            'dir'  => $unique,
        ];
    }
}

 

What this file actually does? it receives either a list of uploaded files or a single uploaded file and creates our File entity. It persists the File entity and moves the file to our target directory. Now the target directory is a bit tricky. The slug of the file is unique, so there is no need to have the file in a unique directory, although after a year or two of file uploads, you end up with thousands of files in one directory. This can cause you so much troubles and is really hard to fix, therefore we copy the files into a random directory. Our goal is not to have unique directories with one file in it, but just to prevent thousands of files in one directory.  At the end the upload method returns an array of slugs or a single slug.

 

Let’s also register our new service and inject what’s needed in our “services.yml” .

services:
    upload_helper:
       class: AppBundle\Services\UploadHelper
       arguments: ["@service_container", "@doctrine.orm.entity_manager"]
       lazy: true

 

Let’s get back to our first goal from the beginning:

To be able to attach files to every entity in our system in 3 simple steps

Imagine we have a comment entity and we would like to allow multiple attachments for this comment. We already have a form and the comments are working without attachments so far so good.

1. Use the attachable trait in our Comment entity:

<>1.<>2.<>3.<>4.<>5.<>6.<>7.<>8.<>9.<>10.<>11.<>12.<>13.<>14.<>15.<>16.<>17.<>18. 
namespace AppBundle\Form;

 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

 
class CommentType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array                $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('body', null, [
                'label' => false,
                'attr'  => [
                    'placeholder' => 'Write your comment ...',
                ],
            ])
            ->add('files', 'file', [
                'multiple'   => true,
                'mapped'     => false,
                'required'   => false,
                'data_class' => null,
            ])
            ->add('submit', 'submit', [
                'attr' => [
                    'class' => 'btn btn-raised btn-success',
                ],
            ]);
    }

 
    /**
     * @param OptionsResolver $resolver
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Comment',
        ]);
    }
}

 
3.Use our Upload helper service in the controller to upload the files

 
if ($commentForm->isValid()) {
    $files = $commentForm->get('files')->getData();
    $uploadedFiles = $this->get('upload_helper')->uploadFiles($files);
    $comment->setAttachments($uploadedFiles);
    $this->getDoctrine()->getManager()->persist($comment);
    $this->getDoctrine()->getManager()->flush();
}

 
And that’s it. 3 simple steps and you are able to add uploads to every entity in your system from now on.

 
Nice but we still can’t see or use the attachments. It is time to create our CDN controller:

 
Create a controller in your system and the following action in it
    /**
     * @Route("/load/{slug}", name="cdn_file_load")
     *
     * @param string $slug
     * @Security("has_role('ROLE_USER')")
     *
     * @return Response|JsonResponse
     */
    public function loadAction($slug)
    {
        $response = new JsonResponse();
        $fileEntity = $this->getDoctrine()->getRepository('AppBundle:File')->findOneBy([
            'slug' => $slug,
        ]);

 
        if (null === $fileEntity) {
            return $response->setData(
                [
                    'error' => 'File not found',
                ]
            );
        }
        $uploadDir = $this->container->getParameter('upload_dir');
        $file = $uploadDir.DIRECTORY_SEPARATOR.$fileEntity
                  ->getUploadDir().DIRECTORY_SEPARATOR.$fileEntity->getTempName();

 
        if (!file_exists($file)) {
            return $response->setData(
                [
                    'error' => 'File was deleted',
                ]
            );
        }
        // Generate response
        $response = new Response();

 
        // Set headers
        $response->headers->set('Cache-Control', 'private');
        $response->headers->set('Content-type', mime_content_type($file));
        $response->headers->set('Content-Disposition', 'attachment; 
                 filename="'.$fileEntity->getName().'";');
        $response->headers->set('Content-length', filesize($file));
        // Send headers before outputting anything
        $response->sendHeaders();

 
        $response->setContent(readfile($file));

 
        return $response;
    }

 
Now as you can notice we aur using the @security annotation and allowing files to be seen only for logged in users which was one of our goals at the beginning. Of course you can remove or extend it as it fits your needs.
The second thing to notice is that we our forcing the file to be downloaded as an attachment, you can of course extend this functionality and display images or pdf’s directly. For our scenario, the twig template wold look like this:

 
{% for att in comment.attachments %}
         <p>
                  <i class="material-icons">attach_file</i>
                 <a href="{{ path('cdn_file_load',{slug:att}) }}"                                                         
                          target="_blank">{{ att }}
                 </a>
         </p>
{% endfor %}

 

Zdielaj článok

Komentáre (1)

  • avatar
    Unknown visitor Odpovedať

    Not easy to read.

Pridaj Komentár