Enregistrer une image recadrée avec Symfony et Croppie.js

Rédigé par Norore 2 commentaires
Un tas de papier découpé à l’emporte-pièce rond

Ne vous êtes-vous jamais demandé comment faire pour cadrer et sauver une image, avec de l’interactivité pour l’utilisateur ? Après tout, la plupart des sites le font maintenant, mais le jour où ce sera à votre tour de le faire comment vous y prendrez-vous ? C’est une question que j’ai dû me poser pour pouvoir avoir un trombinoscope de nos étudiants, afin de ne pas avoir un serveur qui croule sous le poids d’images en haute définition et avec des dimensions démesurées et non adaptées pour nos besoins. Je vous propose donc de retrouver ici la solution que j’ai adopté et adapté. Comme pour le billet sur l’internationalisation sous Symfony, le code sera surtout posé là comme une base sur laquelle vous pourrez travailler, sans chercher à vous apprendre comment faire tout le développement !

Préparation du formulaire

Dans un premier temps, il faut préparer un formulaire Symfony qui va nous permettre de récupérer le fichier. Voici un exemple pour mon entité Individus :

# /src/MonBundle/Form/PhotoEtudiantType.php
 
namespace MonBundle\Form;
 
use Symfony\Component\OptionsResolver\OptionsResolver;
 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
 
use Symfony\Component\Form\Extension\Core\Type\FileType;
 
class PhotoEtudiantType extends AbstractType
{
	public function buildForm(FormBuilderInterface $builder, array $options)
	{
		$builder->add('photo', FileType::class, array(
				'data_class' => null,
				'label' => 'label.photo.etudiant',
				'attr' => array(
					'class' => 'uploadPhoto',
					'accept' => 'image/*',
				)
			));
	}
 
	public function configureOptions(OptionsResolver $resolver)
	{
		$resolver->setDefaults(array(
			'data_class' => 'MonBundle\Entity\Individus',
		));
	}
 
	public function getBlockPrefix()
	{
		return 'photoetudiant';
	}
}

Pour ce formulaire, je vais avoir besoin d’ajouter une photo au niveau du champ photo pour l’entité Individus. Comme il s’agira d’un fichier à télécharger sur le serveur, je dois utiliser la classe FileType dans la construction du formulaire. J’en profite pour limiter le champ de formulaire au MIME-Type image pour éviter qu’une personne mal intentionnée en profite pour essayer d’injecter du code.

Préparation de la vue

La vue va me permettre deux choses :

  • proposer un formulaire d’envoi de la photographie ;
  • afficher un aperçu de la photo en jouant avec le système de crop (découpe) du scripts Croppie.js que j’utilise.
<!-- /src/MonBundle/Ressources/views/PhotoEtudiant.html.twig -->
{{ form_start(form_etudiant) }}
{{ form_errors(form_etudiant) }}
<div align="center">
    <img id="photo" 
         src="{{ asset('uploads/photos/' ~ etudiant.individu.photo|e) }}"
         alt="{{ 'texte.alt.photo.etudiant'|trans(
             {
                 '%nom%': etudiant.individu.nom,
                 '%prenom%': etudiant.individu.prenom
             }) }}">
</div>
{{ form_widget(form_etudiant.photo) }}
<div class="center-block text-warning">
    <div id="prev_photo"></div>
    <p align="center">{{ 'p.photo.formulaire'|trans }}</p>
    <img id="prep_photo">
</div>
<br>
<input type="hidden" id="photocoupee" name="photocoupee">

<div align="center">
    <input id="btnLoad" 
           type="submit"
           value="{{ 'bouton.valider'|trans }}">
    &nbsp;
    <a href="{{ path(
        'vue_etudiant',
        {
            'id': etudiant.id
        }
    ) }}" 
       title="{{ 'lien.fiche.etudiant'|trans(
           {
               '%nom%': etudiant.individu.nom,
               '%prenom%': etudiant.individu.prenom
           }) }}">
        {{ 'bouton.annuler'|trans }}
    </a>
    {{ form_end(form_etudiant) }}
</div>

Comment va se comporter la page ?

  1. la première balise d’image #photo affiche la photo déjà existante ;
  2. la balise div #prev_photo va permettre d’afficher la prévisualisation de la photo qui sera à découper ;
  3. la deuxième balise image #prep_photo va permettre d’afficher la photo telle qu’elle sera enregistrée dans notre application ;
  4. enfin, le champ caché hidden #photocoupee contiendra les informations de la photo découpée qui seront enregistrée.

Préparation du JavaScript

Ce script va vous permettre de personnaliser la façon dont Croppie.js va interagir avec vos images (source : https://github.com/foliotek/croppie).

// /src/MonBundle/Ressources/public/js/ajustement_photo.js
var uploadCrop = $('#prev_photo').croppie({
    enableExif: true,
    viewport: {
        width: 300,
        height: 400
    },
    boundary: {
        width: 400,
        height: 400
    }
});

$('#photoetudiant_photo').on('change', function () {
    var input = $(this)[0];
    if (input.files && input.files[0]) {
        var reader = new FileReader();
                
        reader.onload = function (e) {
                            uploadCrop.croppie('bind', {
                                url: e.target.result
                            }).then(function(){
                                console.log('jQuery bind complete');
                            });
                        }
        reader.readAsDataURL(input.files[0]);
    }
    else {
        alert("Sorry - you're browser doesn't support the FileReader API");
    }
});

function showResult(result) {
    if (result.src) {
        var img = result.src;
        $('#prep_photo').attr('src', img);
    }
    if (result.blob) {
        var img = result.blob;
        $('#photocoupee').attr('value', img);
    }
}

$('#prev_photo').on('update.croppie', function(ev, cropData) {
    uploadCrop.croppie('result', {
                            type: 'canvas', 
                            size: 'viewport',
                        }).then(function (value) {
                            showResult({src: value});
                        });
});

$('#prev_photo').on('update.croppie', function(ev, cropData) {
    uploadCrop.croppie('result', {
                            type: 'canvas', 
                            size: 'viewport',
                        }).then(function (value) {
                            showResult({blob: value});
                        });
});

OK, ça fait un gros pavé de code JavaScript pas forcément très simple à comprendre ou à appréhender. Voyons ensemble quelle est la logique derrière pour que vous puissiez reproduire ou améliorer ça sur vos projets.

Dans un premier temps, nous définissons la variable uploadCrop qui va nous permettre de définir, dans la balise image #prev_photo le cadre qui va recevoir l’image à afficher dans les dimensions définies par boundary, mais également une fenêtre d'aperçu du découpage de la photo dans les dimensions définies par viewport. Le cadre du viewport pourra aussi être déplacé à l’aide de la souris pour ajuster la zone qui sera à découper.

Ensuite, on regarde au niveau du formulaire si une photo va être envoyée par l’utilisateur par le biais de la variable $('#photoetudiant_photo'). On vérifie que le navigateur supporte l’API JavaScript pour lire le fichier, et si c’est le cas, on demande à Croppie de lier l’image à la variable uploadCrop, afin de pouvoir continuer à interagir avec elle.

La fonction showResult sera appelée par interaction avec la balise #prev_photo et permettra deux choses :

  1. afficher l’aperçu de la photo prédécoupée dans la balise image #prep_photo ;
  2. envoyer les données sur la photo prédécoupée au champ caché hidden #photocoupee.

Ajout de l’interaction avec JavaScript

Pour commencer, téléchargez Croppie.js et déposez-le dans le répertoire idoine de votre projet. Puis appelez-le entre les balises head de votre application pour pré-charger ses fonctions. Une fois ceci fait, il vous restera à appeler votre fonction de découpage ajustement_photo.js en insérant ce bout de code juste avant la fermeture de la balise body :

<script type="text/javascript" 
        src="{{ asset('bundles/monbundle/js/ajustement_photo.js') }}">
</script>

Récupération de la photo recadrée

On y est presque, c’est la dernière étape, courage !

# /src/MonBundle/Controller/PhotoEtudiantController.php

if ($form_etudiant->isSubmitted() && 
    $form_etudiant->isValid()) {
    $file = $individu->getPhoto();
    $base64 = $request->request->get('photocoupee');
    list($type, $data) = explode(';', $base64);
    list(, $data)      = explode(',', $base64);
    $data = base64_decode($data);
    $getFlashBag = $request->getSession()->getFlashBag();

    if (!preg_match('/image\/.*/', $file->getClientMimeType())) {
        $getFlashBag->add(
            'danger',
            'Le fichier doit être une image !'
        );
    }
    if (!preg_match('/^data:image\/(\w+);base64,/', $base64)) {
        $getFlashBag->add(
            'danger',
            'Les données transmises doivent avoir le bon MIME-Type !'
        );
    }
    if (!preg_match('/jpg$/i', $type) and 
        !preg_match('/jpeg$/i', $type) and 
        !preg_match('/png$/i', $type)) {
        $getFlashBag->add(
            'danger',
            'Le fichier doit être une image JPEG ou PNG !' . $type
        );
    }
    else {
        try {
            $fileName = md5(uniqid()) . '.' . explode('/', $type)[1];
            file_put_contents(
                $this->getParameter('photos_directory').'/'.$fileName,
                $data
            );
            $individu->setPhoto($fileName);

            $em = $this->getDoctrine()->getManager();
            $em->persist($individu);
            $em->flush();

            $getFlashBag->add(
                'success',
                'Photo enregistrée.'
            );

            return $this->redirectToRoute(
                'scolarite_biologie_view',
                array(
                    'etudiant' => $etudiant
                )
            );
        } catch (Exception $e) {
            $msg  = "L'exception suivante vient d'avoir lieu :<br>";
            $msg .= "<pre>" . $e->getMessage() . "</pre><br>";
            $msg .= "Si l'exception persiste, merci d'en informer les administrateurs.";

            $getFlashBag->add(
                'warning',
                $msg
            );
            $getFlashBag->add(
                'alert',
                "La photo n'a pas pu être enregistrée"
            );
        }
    }
}

Encore un pavé ? Oui, mais un pavé utile. C’est lui va nous permettre de récupérer la photo découpée par Croppie.js et de l’envoyer sur le serveur ! Décortiquons un peu cette portion pour mieux comprendre sa logique.

Une fois que le formulaire a été soumis et que celui-ci est validé, nous allons dans un premier temps récupérer :

  • le fichier $file renseigné dans l’entité Individus via le formulaire ;
  • la photo découpée par Croppie.js, via la variable d’environnement Request (cf. la documentation officielle) dans la variable $base64 ;
  • le type de fichier $type et les données $data en éclatant la chaîne de caractères contenue dans $base64 en se basant sur le point-virgule ';' ;
  • les données $data de la photo en éclatant la chaîne de caractères $data en se basant sur la virgule ',' ;
  • les données $data en décodant les données qui sont encodées en base 64 à l’aide de la fonction base64_decode().

À partir de là, on effectue différentes vérifications :

  • Est-ce que le MIME-Type de $fichier est bien de type image ?
  • Est-ce que la variable $base64 commence bien par la chaîne de caractères data:image et est bien en base 64 ?
  • Est-ce que le type de $type est bien du JPEG ou du PNG ?

Si on a répondu oui à chacune de ces trois questions, on peut enregistrer notre image sur le serveur.

On définit le nom du fichier dans la variable $fileName, dans mon cas, nous avons choisi de mettre un nom aléatoire à l’aide de la fonction md5(), vous pouvez choisir n’importe quel autre nom, sans oublier de préciser l’extension du fichier contenu dans la variable $type.

On utilise la fonction file_put_contents() pour créer le fichier $fileName dans le répertoire idoine à l’aide des données $data.

Enfin, on met à jour notre Individus au niveau du champ Photo avec le nom du fichier $fileName, et on n’oublie pas de comiter avec flush() !

Il ne vous reste plus qu’à faire vos propres essais. Il existe sûrement d’autres approches et d’autres stratégies, mais c’est celle que j’ai mis en place il y a… Pfou, plus d’un an et demi ?

N’hésitez pas à me faire d’autres suggestions de stratégie pour d’éventuels futurs projets, et en attendant : Bon code !


Source de l'image d'accroche : Un tas de papier découpé à l’emporte-pièce rond. Photographié par Monsterkoi, sous licence Pixabay sur Pixabay

2 commentaires

#1  - tserofcj a dit :

salut, merci pour l'article. Par contre vérifier que le fichier est valide en contrôlant le mimetype et l'extension n'est pas sécurisé car le contenu de la requête peu facilement être falsifié et on se retrouve avec un backdoor sur son serveur.
Une solution plus fiable est de recréer l'image avec la fonction imagecreatefromstring.
$img = imagecreatefromstring(base64_decode($data)); //renvoit false si ce n'est pas une image
if (!$img) {
//DANGER
}

Répondre
#2  - Simon a dit :

Bonjour,

Il faudrait gérer le formulaire autrement via l'ajout d'erreur sur les champs pour gérer correctement sa validation :

```
$formEtudiant = $this->createForm(...);

if ($request->isMethod('POST')) {
$formEtudiant-->handleRequest($request);

if (test pas bon) {
$error = new FormError('Truc n\'est pas bon !');
$formEtudiant->get('truc')->addError($error);
}

if ($formEtudiant) {
// upload et persistance des données
}
}

```

Ensuite, tes 3 preg_match ne servent à rien car tu ne bloquent pas l'envoi du fichier. il faudrait utiliser des elseif. En l'état, je pense qu'il y a une faille de sécurité.

À bientôt,

Répondre

Écrire un commentaire

Quelle est la dernière lettre du mot ljpaqs ?

Fil RSS des commentaires de cet article