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
useFormStatus()
useFormStatus est un hook qui permet de récupérer l'état du formulaire parent. Voici sa signature:
const { pending, data, method, action } = useFormStatus();
pendingpermet de savoir si le formulaire est en cours de soumission ou nondatareprésente les données envoyéesmethodpermet de connaitre la méthode HTTP utilisé pour l'envoi du formulaire.actionreprésente l'action passée au formulaire
C'est un hook qui est très pratique si vous avez un composant qui dépend d'un formulaire. L'exemple le plus courant est le bouton d'envoi qui va se griser pendant que le formulaire est transmis au serveur.
Le hook ne fonctionne que si le composant est dans un formulaire !
Partie 1 : l'envoi d'images (React)
Pour l'envoi des images, nous allons utiliser l'objet FormData.
Nous avions évoqué son nom durant le laboratoire précédent.
Un FormData est un objet utilisé lors de l'envoie d'un formulaire et qui est extrêmement pratique pour l'envoie de fichiers.
La structure d'un FormData est assez simple.
Il s'agit d'une structure clé-valeurs classique.
La seule particularité est la possibilité d'avoir plusieurs valeurs pour une même clé.
C'est intéressant pour les inputs avec l'attribut mulitple qui permet d'envoyer plusieurs fichiers en une fois.
Dans l'exercice que vous aurez, vous devrez utiliser un input qui prendra un seul fichier (l'avatar) un deuxième qui prendra plusieurs fichiers (pour une gallerie par exemple).
Il vous est vivement conseillé d'avoir la documentation du FormData à disposition pour le prochain exercice.
Quand utiliser un FormData au lieu d'envoyer en JSON ?
Le FormData est conseillé pour l'envoie de fichiers (images, vidéos, documents, etc).
Pour tout le reste, utilisez le format JSON qui conviendra mieux à votre API REST.
Créez les fichiers suivants:
export default function Formulaire(){
async function sendForm (formData) {
// TODO
}
const [state, dispatchAction] = useActionState(
// TODO
);
return(
<form action={dispatchAction} method={"post"}>
<label>Prenom:</label>
<input
name={"prenom"}
defaultValue={/*TODO*/}
type={'text'}
required
/>
<br/>
<label>Nom:</label>
<input
name={"nom"}
defaultValue={/*TODO*/}
type={'text'}
required
/>
<br/>
<label>Avatar:</label>
<input
name={"avatar"}
type={'file'}
accept={'image/*'}
required
/>
<br/>
<label>Images:</label>
<input
name={"images"}
type={'file'}
accept={'image/*'}
required
multiple
/>
<br/>
<FormButtons/>
</form>
);
}
import { useFormStatus } from "react-dom";
export function FormButtons({}) {
const /* TODO */ = useFormStatus()
return (
<>
<button type={"submit"} disabled={/*TODO*/}>{/*TODO*/}</button>
<button disabled={/*TODO*/}>Annuler</button>
</>
)
}
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};
J'utilise Axios pour faire ma requête vers l'API. Vous pouvez très bien utiliser fetch à la place.
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.
Dans cet exercice, je vous demande:
- Le bouton pour expédier le formulaire doit:
- Afficher le texte "Envoyer" et, si un envoie est un cours, afficher le texte "Envoi en cours..."
- Se désactiver si le formulaire est en train d'être envoyé
- Le bouton annuler devra être désactivé si aucun envoi n'est en cours et s'activer si un envoi est en cours.
- Compléter le composant
Formulairepour utiliser le hook demandé- Si vous avez une erreur durant l'envoi, vous devez laisser le nom et le prenom de l'utilisateur dans le formulaire
Assurez-vous juste que le comportement du formulaire soit correct et que l'envoi du formulaire se passe bien. Nous nous occuperons de la réception dans l'exercice suivant.
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 !

AbortControler
L'AbortControler permet de gérer l'annulation.
Il est utilisé pour les requêtes réalisées via fetch ou Axios.
Il existe d'autres cas d'utilisation comme pour les listeners, mais nous resterons sur les requêtes HTTP dans ce laboratoire.
Vous vous en doutez, il est utilisé pour les envois qui prennent du temps, notamment si vous avez des fichiers.
En effet, imaginez que vous êtes sur un formulaire et que vous envoyez une vidéo de plusieurs GB.
Vous vous rendez compte que ce n'est pas le bon fichier et vous souhaitez annuler.
Vous seriez peut-être tenté de cliquer sur un lien pour changer la page.
Ceci serait une erreur, car React procédera au démontage de votre composant mais ne pourra pas annuler la requête.
En effet, une requête est une ressource du navigateur et non de React (à l'instar d'un timer).
L'AbortControler peut se lier à une requête via un AbortSignal.
En utilisant la méthode abort() de l'AbortControler, le message est envoyé à la requête et le navigateur pourra procéder à son annulation.
Pour arriver à ce résultat, il faut lier la requête à un AbortSignal lors de la création de la requête.
let controller;
const url = "video.mp4";
const downloadBtn = document.querySelector(".download");
const abortBtn = document.querySelector(".abort");
downloadBtn.addEventListener("click", fetchVideo);
abortBtn.addEventListener("click", () => {
if (controller) {
controller.abort();
console.log("Download aborted");
}
});
async function fetchVideo() {
controller = new AbortController();
const signal = controller.signal;
try {
const response = await fetch(url, { signal });
console.log("Download complete", response);
// process response further
} catch (err) {
console.error(`Download error: ${err.message}`);
}
}
Partie 4: ajout du AbortControler
Dans cette partie, je vous demande de:
- L'
AbortControlersera créé dans le composantFormulaireen le stockant dans uneref - Modifier le fichier faisant la requête pour inclure le signal
- Le bouton "Annulation" devra procéder à l'annulation de la requête
- Si votre composant est demonté, vous devrez procéder à l'annulation également
Pour vous aider, il est conseillé de procéder à une petite manipulation au niveau du navigateur. En effet, vous pouvez brider la bande passante (vous travaillez en local, les transfers sont presque instantanés). Dans Firefox, ouvrez la console > Réseau > "Aucun limitation de la bande passante". Il vous suffira de cliquer sur la limitation désirée (je vous conseille de prendre des vitesses faibles pour avoir le temps d'annuler la requête).

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.
