Aller au contenu
Me contacter

Ouvert à un CDI · Métropole lilloise

Tous les articles
7 min de lecture

Traiter les fichiers à l’upload en Node.js : sharp, métadonnées et doublons

Ce qui se passe entre l’envoi d’un fichier et son arrivée dans une médiathèque : contrôles, déclinaisons d’images avec sharp, métadonnées par type de fichier et détection des doublons, à partir de la DXP Goodbizz.

  • Node.js
  • Express
  • sharp
  • FFmpeg
  • Upload
  • MongoDB

Dans Goodbizz, une DXP multi-tenants développée chez Ikomobi, la médiathèque sert de Google Drive aux équipes du client. Photos de produits, vidéos, PDF et tableurs y sont partagés entre les sites, les applications et les blogs. J’ai repris ce module et je l’ai fait évoluer, du microservice Node.js qui reçoit les fichiers jusqu’aux statistiques qui retracent l’utilisation de la bibliothèque (consultations, historique des actions, espace disque) et repèrent les fichiers à traiter. Un fichier franchit plusieurs étapes avant d’apparaître dans la bibliothèque. Chacune répond à une question que se pose tôt ou tard toute application qui accepte des uploads, et l’article les suit dans l’ordre.

Que vérifier avant d’accepter un fichier ?

Le microservice médias fixe une taille maximale par type de fichier : 10 Mo pour une image, 350 Mo pour une vidéo, 50 Mo pour un fichier audio ou un document. Le type MIME et l’extension doivent ensuite figurer sur une liste blanche établie par famille (images, vidéos, audio, documents bureautiques).

Ces deux informations viennent pourtant du client. Le navigateur déduit le type MIME de l’extension, et rien n’empêche une requête forgée de déclarer image/png pour un exécutable renommé. L’OWASP recommande donc de confirmer le type réel par la signature du fichier, c’est-à-dire ses premiers octets (les « magic bytes »), ce que fait par exemple la librairie file-type.

import { fileTypeFromFile } from "file-type";

const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);

async function assertRealType(path) {
  const detected = await fileTypeFromFile(path); // reads the magic bytes
  if (!detected || !ALLOWED.has(detected.mime)) {
    throw new BadRequestError("Unsupported file type");
  }
}

Les formats texte, comme le CSV ou le SVG, n’ont pas de signature et se vérifient autrement. Un SVG peut d’ailleurs contenir du JavaScript ; mieux vaut le nettoyer ou le servir depuis un domaine distinct de l’application. Pour des fichiers partagés entre plusieurs équipes, l’OWASP conseille aussi un passage à l’antivirus, ClamAV par exemple.

Reste le moment du contrôle. Vérifier la taille après coup oblige à recevoir et à écrire le fichier entier. L’option limits.fileSize de Multer interrompt la réception dès que le seuil est dépassé ; comme elle s’applique à toute la requête et que le type n’est pas encore connu à ce stade, on y place la limite la plus haute, celle des vidéos, et le contrôle par type vient ensuite.

Comment nommer et ranger les fichiers sur le disque ?

Un nom d’origine comme « IMG_4032 (2).JPG » ou « Fiche produit FINALE v3.pdf » ne se garde pas tel quel dans un chemin. Le service le transforme en slug, puis ajoute un suffixe -1, -2… si ce nom existe déjà, si bien que deux fichiers ne s’écrasent pas et que l’URL reste lisible.

Le rangement se fait ensuite par année et par mois (originals/2026/03/…). Aucun dossier ne grossit indéfiniment, une sauvegarde ou un nettoyage peut se faire par période, et l’original reste séparé des versions dérivées, qui ont leurs propres dossiers.

Comment optimiser les images avec sharp ?

L’original n’est jamais retouché. sharp en tire plusieurs déclinaisons, choisies selon l’usage et les media queries du front (mobile, tablette, desktop) :

  • une version compressée, ramenée à 1920 px sur son plus grand côté si elle les dépasse, en JPEG compressé avec mozjpeg, réglé à 60 sur une échelle de qualité de 100 ;
  • la même en WebP ;
  • des largeurs de 200 et 800 px, en WebP et dans le format d’origine, pour les vignettes et les contenus.

Ce réglage à 60 peut surprendre. Avec l’encodeur mozjpeg, la différence avec un réglage à 80 reste en général difficile à percevoir sur une photo affichée à l’écran, alors que le poids baisse nettement. La valeur reste un réglage à valider sur des images réelles du client.

// Simplified example: one derived version
await sharp(originalPath)
  .rotate() // applies the EXIF orientation before resizing
  .resize({ width: 1920, height: 1920, fit: "inside", withoutEnlargement: true })
  .jpeg({ quality: 60, mozjpeg: true })
  .toFile(compressedPath);

L’appel à rotate() sans argument compte plus qu’il n’y paraît. Un téléphone enregistre souvent la photo dans le sens du capteur et indique la bonne orientation dans une balise EXIF. Or sharp retire par défaut les métadonnées des fichiers qu’il produit : sans rotate(), la déclinaison perdrait cette information et s’afficherait couchée.

Certains formats restent tels quels. Un SVG est déjà vectoriel, un GIF animé perdrait son animation avec un traitement par défaut, et les fichiers HEIC, TIFF ou BMP sont conservés sans être recompressés.

Quelles métadonnées extraire, et pourquoi les normaliser ?

Chaque type de fichier a son extracteur, appelé une fois à l’import et dont le résultat est enregistré avec le fichier :

  • les images passent par exifr, qui lit l’EXIF (appareil, objectif, date de prise de vue, exposition, coordonnées GPS) ;
  • les fichiers audio par music-metadata (durée, débit, artiste) ;
  • les vidéos par FFmpeg (durée, résolution, codecs) ;
  • les PDF par pdf-parse, qui en extrait le texte et le nombre de pages, de quoi rendre leur contenu exploitable, par exemple pour la recherche ;
  • les tableurs Excel par xlsx, qui en liste les feuilles.

L’EXIF pose un problème particulier, car la même information porte des noms différents selon le fabricant et la version de la norme. La sensibilité s’appelle ISO, ISOSpeedRatings ou PhotographicSensitivity, la date de prise de vue DateTimeOriginal, CreateDate ou DateTimeDigitized. Une table d’alias ramène ces variantes à une clé unique, et l’interface comme les filtres n’ont plus à connaître qu’un seul nom.

import exifr from "exifr";

const EXIF_FIELDS = [
  { key: "iso", aliases: ["ISO", "ISOSpeedRatings", "PhotographicSensitivity"] },
  { key: "takenAt", aliases: ["DateTimeOriginal", "CreateDate", "DateTimeDigitized"] },
  { key: "lens", aliases: ["LensModel", "LensInfo"] },
];

async function readExif(path) {
  const raw = await exifr.parse(path);
  if (!raw) return null;

  const exif = {};
  for (const { key, aliases } of EXIF_FIELDS) {
    const alias = aliases.find((name) => raw[name] !== undefined);
    if (alias) exif[key] = raw[alias];
  }
  return exif;
}

Ces métadonnées contiennent parfois des données personnelles. Une photo prise au téléphone indique souvent où elle a été prise, à quelques mètres près. Les déclinaisons générées par sharp n’en gardent rien, alors que l’original les conserve : c’est une bonne raison de servir les déclinaisons sur les sites publics et de réserver l’original au back-office.

Comment repérer les doublons, même recompressés ?

Comparer les fichiers deux à deux coûterait cher. Le service calcule plutôt une empreinte à l’import et l’enregistre en base ; les statistiques n’ont plus qu’à regrouper les fichiers qui partagent la même.

Pour la plupart des fichiers, cette empreinte est un SHA-256 du contenu, identique pour deux copies exactes. Une image, elle, circule souvent sous plusieurs formes, ré-enregistrée par un logiciel ou recompressée par une messagerie, et un SHA-256 n’y voit que des fichiers différents.

Pour les JPEG, PNG et WebP, le service utilise donc un hash perceptuel, blockhash. L’image est découpée en 8 × 8 blocs dont la luminosité est comparée à la médiane, ce qui produit une empreinte de 64 bits décrivant l’image vue de loin, indépendamment de ses octets.

import crypto from "node:crypto";
import { bmvbhash } from "blockhash-core";

async function fingerprint(path, buffer) {
  if (isRasterImage(path)) {
    const image = await decodeImage(buffer); // { width, height, data }
    return bmvbhash(image, 8); // 8x8 blocks: 64-bit perceptual hash
  }
  return crypto.createHash("sha256").update(buffer).digest("hex");
}

Le même principe s’étend aux dossiers. Deux dossiers sont comparés par l’ensemble des empreintes de leurs fichiers, et une arborescence copiée puis renommée ressort donc elle aussi.

Le regroupement par empreinte identique trouve les copies exactes et les recompressions les plus courantes. Pour aller plus loin, on mesure la distance de Hamming entre deux empreintes, autrement dit le nombre de bits qui diffèrent : en dessous de quelques bits sur 64, il s’agit très probablement de la même image, légèrement recadrée ou retouchée.

Que faire quand les fichiers deviennent lourds ?

Une photo se traite vite. Une vidéo de 350 Mo, c’est autre chose : l’analyser, et plus encore la transcoder, demande du temps et de la mémoire, et un traitement lancé dans la requête HTTP fait attendre l’utilisateur tout en occupant le serveur.

L’approche habituelle consiste à enregistrer le fichier, à répondre tout de suite avec un statut « en cours de traitement », et à confier le reste à une file de tâches (BullMQ sur Redis, par exemple) traitée par des workers séparés. L’interface se met à jour à la fin du traitement, par une notification ou par une socket, comme dans la messagerie temps réel décrite dans un autre article.

Autre réflexe, garder les fichiers en flux. Lire un fichier entier avec readFile, ou le garder dans un Buffer pour le transmettre d’un service à l’autre, charge toute la vidéo en mémoire. Un stream, redirigé vers le disque, vers le service suivant ou vers crypto.createHash qui accepte les données par morceaux, garde une consommation stable quelle que soit la taille du fichier.