Laboratoire 5: Upload d'image
Introduction
Dans ce laboratoire, nous allons voir comment envoyer une image depuis le navigateur web de l’utilisateur (partie React) sur le serveur (partie NodeJS). Vous avez un code d’exemple disponible. Le but de ce document est de fournir une explication de ce code pour que vous soyez capables de l’adapter à votre projet. Le document sera découpé en 3 parties : l’envoi des images, la réception des images et rendre accessible ces images.
Initialisez la partie front du projet avec la commande suivante:
npm create vite@latest front -- --template react
Ensuite, créez le dossier back et exécutez la commande suivante à l'intérieur:
npm init -y

Partie 0 : règles CORS
Pendant le développement de votre projet, vous avez surement été confrontés au problème des règles CORS. En effet, il s’agit d’un mécanisme de protection pour l’utilisateur afin d’éviter des requêtes vers un domaine étranger. Le problème survient lorsque votre application (le script en JavaScript) qui vient d’un domaine X (exemple: localhost:3000) envoie une requête vers le domaine Y (exemple: localhost:3001 ou monsuperdomaine.be). Le navigateur n’autorisera cette requête que si le serveur (dans l’exemple monsuperdomaine.be) autorise le domaine X à effectuer une requête. Si aucune réponse n’est reçue, le navigateur bloquera la requête. Dans la phase de développement, il est possible d’utiliser différentes techniques pour permettre de valider les règles CORS. La plus simple est de valider les règles CORS directement sur le serveur. En NodeJS, il existe un module qui permet de le faire en deux lignes. Cette approche a l’énorme avantage d’être très simple à mettre en place. Cependant, la "meilleure solution" consiste à éviter ce problème en utilisant un proxy (ce qui est réalisable avec Vite).
Une vidéo explicative si vous avez besoin d'informations supplémentaires
Partie 1 : l’envoi d’images (React)
Pour l’envoi des images, nous allons utiliser l’objet FormData. Cet objet nous permettra de facilement gérer les images dans un formulaire. Créez le fichier ./front/image_upload/src/component/Formulaire.jsx et:
- Au niveau du
return, vous créez le formulaire utilisé. - Au niveau des inputs, vous remarquerez qu’il y a deux types :
textetfile. Ce dernier permet au navigateur d’ouvrir une fenêtre pour que l’utilisateur puisse sélectionner un ou plusieurs fichiers.
// Le reste du composant est à faire
return(
{/*Le reste du return est à faire*/}
<label>Images:</label>
<input
type={"file"}
accept={"image/*"}
onChange={(e) => images.current = e.target.files }
required
multiple
/>
{/*Le reste du return est à faire*/}
);
- L’attribut
acceptpermet d’indiquer au navigateur qu’on souhaite avoir seulement des images (peu importe le format). Ainsi, quand le navigateur ouvrira une fenêtre pour que l’utilisateur puisse choisir le ou les fichier(s), il n’affichera que les images. Il s’agit d’une facilité pour l’utilisateur, rien de plus. - L’attribut
onChangepermet de capturer l’évènement lorsque l’utilisateur aura choisi une ou plusieurs images.e.target.filesest un tableau qui contient les fichiers choisis par l’utilisateur. - L’attribut
requiredpermet d’indiquer que ce champ est obligatoire. Il s’agit d’une facilité pour l’utilisateur. - L’attribut
multiplepermet d’indiquer que l’utilisateur peut choisir plusieurs fichiers (par défaut, il ne peut en choisir qu’un seul).
Chaque changement du formulaire se répercute dans l’état du composant (grâce à onChange). Ce qui nous permettra d’envoyer les informations par la suite.
Quand l’utilisateur clique sur le bouton pour envoyer le formulaire, la fonction sendForm() est activée. C’est elle qui va préparer le FormData correctement pour qu’il puisse être envoyé.
async function sendForm (event) {
event.preventDefault();
const formData = new FormData();
formData.append('nom', nom);
formData.append('prenom', prenom);
formData.append('avatar', avatar.current);
for (const image of images.current){
formData.append('images', image);
}
try {
await APISendForm(formData);
console.log("OK");
} catch (e) {
console.log(e);
}
}
La première étape est de bloquer l’envoi du formulaire par le navigateur, car nous souhaitons le faire d’une façon particulière. Un formData est un objet qui utilise un système de clé-valeur pour stocker les données. Il est tout à fait possible de stocker des informations textuelles comme des fichiers. La méthode append permet de rajouter un couple "clé-valeur" sachant que si la clé existe déjà, la valeur sera ajoutée aux valeurs précédentes (il s’agira d’un tableau de valeurs). C’est pour cette raison que nous bouclons sur les images.
Il ne reste plus qu’à voir le code du module d’Axios qui permet d’envoyer l’information au serveur. Pour cela, créez le fichier suivant:
import axios from 'axios';
const URL = "http://localhost:3001/formulaire";
const sendForm = async (formData) => {
return await axios.post(URL, formData, {
headers: {'Content-Type': 'multipart/form-data'}
});
};
export {sendForm};
La particularité de la requête est son header Content-Type. Effectivement, nous devons indiquer que le type du contenu de la requête sera multipart/form-data. Ce type permet d’indiquer que la requête contiendra un mélange de champs "classiques" (texte, nombres, etc) et de champs servant à l’envoi des images. Le serveur pourra ainsi traiter correctement la requête.
Partie 2 : réception d’images (NodeJS)
Dans un premier temps, nous devons activer les règles CORS pour que React puisse envoyer les informations à NodeJS. Le module cors permet justement de faire le nécessaire.
La gestion des requêtes multipart/form-data est un peu plus délicate. En effet, NodeJS ne gère pas ces requêtes de manière native. Heureusement, il existe un module qui permet de gérer cela. Il s’agit du module "multer". Créez le fichier suivant:
import cors from 'cors';
import express from 'express';
import multer from 'multer';
const app = express();
const port = 3001;
const storage = multer.memoryStorage();
const upload = multer({
limits: {
fileSize: 700000 // 700Ko
},
storage: storage
});
// Le reste du code est à faire
Dans un premier temps, nous devons configurer le module pour indiquer où il doit stocker les images reçues et les règles qu’il doit appliquer. multer.memoryStorage() permet d’indiquer que l’image doit être stockée en RAM et non être écrite sur le disque. Ensuite, la partie limits permet d’indiquer une limite de taille ou du nombre de fichiers. Dans l’exemple, il est indiqué qu’un fichier ne peut pas dépasser 700Ko, mais il n’y a aucune limite concernant le nombre maximum de fichiers.
Ceci constitue donc un vecteur d’attaque et vous DEVEZ le gérer !
Vous pourrez facilement faire cela via une des options de multer (voir documentation).
multer agit comme un middleware et donc vous devez le mettre au niveau des routes qui ont besoin de recevoir des fichiers (et nulle part ailleurs !). Avec une série de paramètres, il est possible d’indiquer quels champs sont nécessaires pour la route choisie. Ce qui permet de récupérer uniquement les informations intéressantes (même si le client a envoyé plus d’informations que nécessaire).
//reste du code
app.post('/formulaire', upload.fields([
{name: 'nom', maxCount: 1},
{name: 'prenom', maxCount: 1},
{name: 'avatar', maxCount: 1},
{name: 'images'}
]), Formulaire.formulaire);
//reste du code
Ici, nous lui indiquons que nous souhaitons récupérer les champs : nom, prénom, avatar et images. Le maxCount permet d’indiquer le nombre d’éléments à récupérer (exemple : si plusieurs avatars ont été fournis, seul le premier sera traité). Si maxCount n’est pas précisé, tous les éléments seront récupérés.
Nous allons maintenant nous intéresser au contrôleur pour voir comment multer injecte les différents éléments dans l’objet request. Créez le fichier
import * as uuid from 'uuid'
import {saveImage} from '../modele/imageManager.js';
const destFolderAvatar = "./upload/avatar";
const destFolderImages = "./upload/images";
export function formulaire (req, res){
const {nom, prenom} = req.body;
const avatar = req.files.avatar[0];
const images = req.files.images;
if(nom === undefined
|| prenom === undefined
|| avatar === undefined
|| images === undefined){
res.sendStatus(400);
} else {
const promises = [];
promises.push(
saveImage(avatar.buffer, uuid.v4(), destFolderAvatar)
);
for (const image of images){
promises.push(
saveImage(image.buffer, uuid.v4(), destFolderImages)
);
}
Promise.all(promises).then(() => {
res.sendStatus(201);
})
.catch(error => {
console.error(error);
res.sendStatus(500);
});
}
}
Comme vous pouvez le voir, multer met les différents éléments à deux endroits : req.body (s’il ne s’agit pas de fichier) et req.files (lorsqu’il s’agit de fichiers). Dans le dernier cas, un tableau avec le nom de la clé (avatar ou images) sera créé pour stocker ces fichiers. Le reste du code consiste à attendre que les promesses d’écritures des images se terminent.
Il est nécessaire de s’intéresser à la dernière étape qui consiste à stocker les images sur le disque. Le module sharp s’avère être redoutablement efficace pour réaliser cette tâche. En effet, nous devons réaliser plusieurs étapes : redimensionner l’image pour avoir une taille maximale, convertir l’image en JPEG pour économiser de la place et enregistrer l’image sur le disque. Ce module permet de tout faire en une fois ! Créez le fichier suivant:
import sharp from 'sharp';
export function saveImage (imageBuffer, imageName, destFolder) {
return sharp(imageBuffer)
.jpeg()
.resize({
fit: 'inside',
width: 1920,
height: 1080
})
.toFile(`${destFolder}/${imageName}.jpeg`);
}
La fonction prend le buffer contenant l’image, le nom que l’on souhaite pour l’image ainsi que le dossier où elle sera stockée. Le nom de l’image est un UUID ("universally unique identifier") dans le code qui permet d'être certain qu’il n’y aura pas de conflit au niveau des noms de fichiers. Ensuite, nous utilisons la méthode jpeg() pour indiquer que le fichier de sortie doit être enregistré au format jpeg. La méthode resize() permet de redimensionner l’image (le fit : 'inside' permet d’indiquer que si l’image dépasse les dimensions indiquées, il faut la rétrécir). Enfin, la méthode toFile() permet d’enregistrer le fichier sur le disque au bon endroit et avec le bon nom.
Partie 3 : accessibilité des images (NodeJS)
Maintenant que nos images sont reçues et enregistrées, il faut pouvoir les visualiser. Express vient avec une solution toute faite qui permet de rendre les images facilement accessibles. En rendant le dossier upload statique, on rend son contenu visible et Express se chargera d’envoyer les fichiers pour vous. Pour cela, il faut utiliser le middleware express.static():
//reste du code
app.use(express.static('./upload'));
//reste du code
Le dossier upload devient public et n’a plus à être précisé dans l’URL pour accéder à son contenu. Par exemple, si je souhaite accéder à l’image 5ab18a57-8c91-4004-a6d6-bf5c03c926be.jpeg du dossier avatar, je devrai juste utiliser l’URL : http://localhost:3001/avatar/5ab18a57-8c91-4004-a6d6-bf5c03c926be.jpeg. Express se chargera d’envoyer le fichier et vous gagnerez un temps précieux. Il en va de même pour le dossier images. En effet, il est contenu dans upload, il est donc aussi public. Cette technique est donc à proscrire si l’accès aux images demande une identification !

Conclusion
Dans ce laboratoire, vous avez vu comment envoyer des images depuis React. Vous avez également vu comment les récupérer et les stocker en NodeJS. De plus, vous êtes capables de rendre ces images facilement accessibles.
Cependant, vous noterez que l’exemple est loin d’être parfait. Premièrement, la taille des images est bien trop grande pour de simples avatars. Il conviendrait donc d’adapter la taille des images en fonction de leur utilité. Deuxièmement, il n’y a aucun mécanisme permettant de supprimer les images. Un tel système serait donc condamné à remplir ses disques durs jusqu’à saturation. Enfin, comme dit plus haut, le système autorise un nombre illimité d’images par envoi. Cela peut constituer une brèche de sécurité majeure qui permet en une requête de remplir entièrement le serveur. Dans l’exemple présenté, la situation est encore pire. En effet, les images sont d’abord stockées en RAM et il est plus facile de saturer l’espace de la RAM que celui d’un disque dur (le premier étant souvent largement plus petit que le second).
Il est donc de votre responsabilité de gérer ces différents cas. Par exemple, si l’utilisateur uploade un nouvel avatar, vous pouvez supprimer l’ancien. Il s’agit d’un mécanisme simple mais qui permettra de vous protéger au niveau des avatars. N’oubliez pas de consulter la documentation de multer pour les autres points. En effet, vous pourriez trouver des options qui vous faciliteront ces tâches.
