Téléverser des images depuis un éditeur de texte enrichi en Node.js

Une image insérée dans RichTextEditor après son envoi vers le serveur de l’exemple Express, avec la barre d’outils complète au-dessus

Quand quelqu’un dépose, colle ou choisit une image dans RichTextEditor, l’éditeur transmet le fichier à une fonction d’envoi et attend une URL. Cette page vous donne les deux moitiés pour Node.js : le fichier côté navigateur qui envoie l’image, et une route Express 5 qui la vérifie avec multer, la stocke sous un nouveau nom et répond avec l’URL qu’insère l’éditeur.

Côté navigateur

L’éditeur n’envoie rien par lui-même. Il appelle window.rte_file_upload_handler pour le glisser-déposer, le collage et la boîte de dialogue Insert Image, et window.richTextBoxUploadFile pour les envois depuis la boîte de dialogue de la galerie, qui transmet aussi le dossier ouvert. rte-files.js définit les deux. Il envoie le fichier en multipart/form-data vers /rte/upload et rappelle le callback avec l’URL, ou avec le texte d’erreur du serveur. Chargez-le après les scripts de l’éditeur :

HTML · public/index.html
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>
HTML · public/index.html
<div id="editor"><p>Hello from Express.</p></div>
<script>
  var editor = new RichTextEditor("#editor", {
    toolbar: "full",
    galleryEndpoint: "/rte/gallery",
    maxUploadFileSize: 4000000
  });
</script>

maxUploadFileSize permet à la boîte de dialogue de la galerie de refuser un fichier trop volumineux avant son envoi. Le serveur doit tout de même vérifier la taille lui-même.

La route

multer est l’analyseur multipart que la plupart des applications Express utilisent déjà. L’exemple utilise son stockage en mémoire : le fichier arrive donc sous la forme req.file.buffer, et ses premiers octets peuvent être vérifiés avant que quoi que ce soit ne soit écrit sur le disque. Le stockage en mémoire n’est sûr qu’avec des limites : les valeurs par défaut de multer pour fileSize, files et parts sont Infinity.

JavaScript · server.mjs
// multer is the multipart parser most Express apps already use. Memory storage keeps the
// file in req.file.buffer, so its bytes can be checked before anything touches the disk;
// the limits keep that buffer small. multer allows exactly fileSize bytes and fails with
// LIMIT_FILE_SIZE above that (turned into a 413 in the error handler below).
const parseForm = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: MAX_BYTES, files: 1, fields: 10, fieldSize: 1024, parts: 12 },
  defParamCharset: "utf8", // file names are sent as UTF-8; multer's default is latin1
});

Une option est ici facile à manquer. Le defParamCharset de multer vaut par défaut 'latin1', mais les navigateurs envoient les noms de fichiers en UTF-8. Avec la valeur par défaut, un fichier nommé café.png est arrivé sous le nom café.png lors de notre test. Avec "utf8", il arrive intact, et l’exemple le stocke sous cafe-1a2b3c4d.png.

Les routes elles-mêmes sont courtes. Un petit middleware choisit d’abord la racine de stockage, puis multer analyse le formulaire et le gestionnaire transmet les octets :

JavaScript · server.mjs
// Every /rte request gets its storage root first, before multer reads the body.
app.use("/rte", (req, res, next) => {
  req.storageRoot = rootFor(req);
  next();
});

// GET /rte/gallery?folder=products  -> listing
app.get("/rte/gallery", async (req, res) => {
  res.json(await listFolder(req.storageRoot, req.query.folder ?? ""));
});

// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
app.post("/rte/gallery", parseForm.none(), async (req, res) => {
  const { action, folder, name } = req.body ?? {};
  if (action !== "create-folder") throw new GalleryError(400, "Unknown action.");
  res.json(await createFolder(req.storageRoot, folder ?? "", name));
});

// POST /rte/upload  file, folder  -> { url }
app.post("/rte/upload", parseForm.single("file"), async (req, res) => {
  if (!req.file) throw new GalleryError(400, "No file was uploaded.");
  const url = await saveImage(req.storageRoot, req.body.folder ?? "", req.file.originalname, req.file.buffer);
  res.json({ url });
});

Enregistrer le fichier

saveImage(), dans gallery.mjs, ne contient rien de propre à Express. La fonction vérifie l’extension et les premiers octets, s’assure que le dossier existe dans la racine de stockage et écrit le fichier sous un nom généré :

JavaScript · gallery.mjs
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root, folder, originalName, bytes) {
  const ext = extensionOf(originalName);
  const matches = Object.hasOwn(SIGNATURES, ext) && SIGNATURES[ext];
  if (!matches) {
    throw new GalleryError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
  }
  if (bytes.length > MAX_BYTES) {
    throw new GalleryError(413, "The image is larger than 4,000,000 bytes.");
  }
  if (!matches(bytes)) {
    throw new GalleryError(400, "The file content is not a valid " + ext.slice(1).toUpperCase() + " image.");
  }

  if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
  const dir = await resolveFolder(root, folder);
  const storedName = baseNameOf(originalName) + "-" + randomBytes(4).toString("hex") + ext;
  const file = path.join(dir, storedName);
  await writeFile(file, bytes, { flag: "wx" }); // "wx" fails instead of overwriting
  return publicUrl(file);
}

Le nom stocké est le nom d’origine nettoyé, suivi de huit caractères hexadécimaux aléatoires : deux envois de photo.jpg ne s’écrasent donc jamais l’un l’autre. L’indicateur "wx" fait échouer writeFile si le nom existe malgré tout. L’extension est mise en minuscules : photo.JPG est stocké sous photo-….jpg.

La réponse est {"url":"/uploads/blog/photo-ce99c014.jpg"}. L’éditeur insère cette URL comme src de l’image, et express.static sert le fichier depuis uploads/. Les fichiers qu’il sert, et pourquoi uniquement des images, sont traités dans l’envoi d’images sécurisé en Node.js.

Quand quelque chose échoue

Les gestionnaires de route n’ont pas de try/catch. Dans Express 5, une promesse rejetée par un gestionnaire async est transmise au middleware d’erreurs, qui transforme en JSON les erreurs propres à l’exemple et celles de multer :

JavaScript · server.mjs
// Express 5 sends errors thrown in async handlers here, so the routes need no try/catch.
app.use((err, req, res, next) => {
  if (err instanceof GalleryError) {
    return res.status(err.status).json({ error: err.message });
  }
  if (err instanceof multer.MulterError) {
    const tooBig = err.code === "LIMIT_FILE_SIZE";
    return res
      .status(tooBig ? 413 : 400)
      .json({ error: tooBig ? "The image is larger than 4,000,000 bytes." : "Invalid upload: " + err.message });
  }
  console.error(err);
  res.status(500).json({ error: "Server error." });
});

C’est un comportement d’Express 5. Nous avons exécuté le même type de gestionnaire async avec Express 4.22 : l’erreur a donné un rejet non géré, et sous Node 24 tout le processus s’est arrêté. Avec Express 4, il faut un wrapper ou un try/catch dans chaque route asynchrone.

rte-files.js renvoie le texte error du JSON à l’éditeur comme second argument du callback. La boîte de dialogue de la galerie ne l’affiche pas : elle montre son propre message, « Upload failed for holiday.jpg. », quelle que soit la réponse du serveur.

Tester

Téléchargez le projet d’exemple de ce guide, placez le dossier richtexteditor issu du téléchargement de l’éditeur à côté de server.mjs, puis exécutez npm install et npm start. Le serveur écoute sur 127.0.0.1:5101 : seule votre propre machine peut donc l’atteindre. Ensuite, depuis un dossier contenant une photo :

Shell
curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5101/rte/upload
{"url":"/uploads/blog/photo-ce99c014.jpg"}

printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5101/rte/upload
{"error":"The file content is not a valid JPG image."}

La seconde réponse est un HTTP 400. Ouvrez http://localhost:5101, déposez une image dans l’éditeur : elle apparaît dans uploads/ et dans le texte. Pour vérifier toutes les règles d’un coup, exécutez node shared/contract-test.mjs http://localhost:5101. La commande se termine par ALL 35 CHECKS PASSED.

Le même guide pour d’autres plateformes

Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms

À lire ensuite