Téléverser des images depuis un éditeur de texte enrichi avec Next.js

Une image insérée dans RichTextEditor après son envoi au gestionnaire de route de l’exemple Next.js, avec la barre d’outils complète au-dessus

Dans un projet Next.js avec l’App Router, les envois d’images de RichTextEditor nécessitent deux gestionnaires de route : l’un qui reçoit le fichier, l’autre qui le sert ensuite. Vous ne pouvez pas simplement écrire les fichiers envoyés dans public/ comme le ferait un site statique. Cette page présente les deux gestionnaires, montre ce qui change avec le runtime Node.js, et explique pourquoi les fichiers vont plutôt dans uploads/.

Côté navigateur

L’éditeur transmet une image déposée, collée ou choisie à window.rte_file_upload_handler. Les images envoyées depuis la boîte de dialogue de la galerie vont à window.richTextBoxUploadFile, qui reçoit aussi le dossier ouvert. Le script partagé rte-files.js définit les deux. Il envoie le fichier vers /rte/upload et transmet l’URL renvoyée à l’éditeur. La page le charge avec les scripts de l’éditeur (voir le guide sur la galerie pour le composant client) et définit les options de l’éditeur depuis un composant serveur :

TypeScript · app/page.tsx
// The editor and rte-files.js are plain browser scripts, served from public/
// (copied there by scripts/copy-editor.mjs).
const scripts = [
  "/richtexteditor/rte.js",
  "/richtexteditor/plugins/all_plugins.js",
  "/rte-files.js",
];

const config = {
  toolbar: "full",
  galleryEndpoint: "/rte/gallery",
  maxUploadFileSize: 4000000,
};

Le gestionnaire de route d’envoi

TypeScript · app/rte/upload/route.ts
// POST /rte/upload  file, folder  -> { url }
import { GalleryError, checkOrigin, errorResponse, readForm, saveImage } from "@/lib/gallery";
import { rootFor } from "@/lib/root-for";

export const runtime = "nodejs"; // the default, written out: this route uses node:fs

export async function POST(request: Request) {
  try {
    checkOrigin(request);
    const form = await readForm(request);
    const file = form.get("file");
    if (!(file instanceof File)) throw new GalleryError(400, "No file was uploaded.");
    const bytes = Buffer.from(await file.arrayBuffer());
    const url = await saveImage(await rootFor(request), form.get("folder") ?? "", file.name, bytes);
    return Response.json({ url });
  } catch (error) {
    return errorResponse(error);
  }
}

Quelques points y sont propres à Next.js :

  • Le runtime Node.js. Le gestionnaire écrit avec node:fs : il ne doit donc pas s’exécuter dans le runtime Edge. nodejs est déjà la valeur par défaut. Le fichier la définit quand même, pour que l’exigence soit écrite là où se trouve le code. Une exception, d’après la documentation de Next.js : avec les Cache Components activés, supprimez l’export runtime, y compris nodejs.
  • formData() vous donne un File Web. Pas de multer ni de fichier temporaire. file.name est le nom envoyé par le navigateur, et file.arrayBuffer() contient les octets en mémoire. Le nom ne sert que pour son extension et pour un nom de base nettoyé. Il ne devient jamais un chemin.
  • readForm() au lieu de request.formData(). Un simple request.formData() dans un gestionnaire de route a accepté un fichier de 50 Mo lors de notre test. readForm() s’arrête à la limite de l’exemple. Limites de taille d’envoi d’images avec Next.js donne les détails.
  • checkOrigin() refuse les POST provenant d’autres sites, car les gestionnaires de route ne bénéficient pas de la vérification d’Origin dont disposent les Server Actions. Voir l’envoi d’images sécurisé avec Next.js.

Les vérifications et l’écriture se trouvent dans lib/gallery.ts, qui ne sait rien de Next.js :

TypeScript · lib/gallery.ts
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root: string, folder: unknown, originalName: string, bytes: Buffer) {
  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, TOO_BIG);
  }
  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 de base nettoyé, suivi de huit caractères hexadécimaux aléatoires. L’indicateur "wx" fait échouer writeFile plutôt que d’écraser un fichier. La réponse est {"url":"/uploads/blog/photo-2cb4607d.jpg"}, et l’éditeur insère cette URL.

Pourquoi pas public/

Next.js sert public/ en fichiers statiques : y écrire les fichiers envoyés semble donc la solution évidente. Cela ne fonctionne pas après next build. Nous avons écrit un fichier dans public/ pendant que next start tournait, puis nous l’avons demandé : 404. Un fichier présent au moment du build : 200. L’exemple écrit donc dans uploads/, à côté du projet, et le sert avec un gestionnaire de route catch-all :

TypeScript · app/uploads/[...path]/route.ts
// GET /uploads/<folder>/<file>  -> the stored image. Only image extensions are served,
// with nosniff so the browser never treats one as anything else.
import { readFile } from "node:fs/promises";
import { CONTENT_TYPES, errorResponse, extensionOf, resolveUpload } from "@/lib/gallery";

export const runtime = "nodejs";

export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
  try {
    const file = await resolveUpload((await params).path);
    return new Response(await readFile(file), {
      headers: {
        "Content-Type": CONTENT_TYPES[extensionOf(file)],
        "X-Content-Type-Options": "nosniff",
      },
    });
  } catch (error) {
    return errorResponse(error);
  }
}
TypeScript · lib/gallery.ts
// /uploads/<parts> -> the image file on disk, or a 404 GalleryError.
export async function resolveUpload(parts: string[]): Promise<string> {
  const name = parts.at(-1) ?? "";
  if (!isImageName(name) || /[\\/\0]/.test(name)) {
    throw new GalleryError(404, "Not found.");
  }
  const dir = await resolveFolder(UPLOADS_DIR, parts.slice(0, -1).join("/")).catch(() => {
    throw new GalleryError(404, "Not found.");
  });
  const file = path.join(dir, name);
  const info = await lstat(file).catch(() => null);
  if (!info || !info.isFile()) {
    throw new GalleryError(404, "Not found.");
  }
  return file;
}

Dans Next.js 16, params est une Promise : on l’attend donc avec await. Pour [...path], c’est un tableau de segments déjà décodés : une requête pour /uploads/My%20Photos/a.jpg arrive sous la forme ["My Photos", "a.jpg"]. La route ne sert que des noms d’images, et uniquement des fichiers dont le chemin réel se trouve dans uploads/. Elle les envoie avec X-Content-Type-Options: nosniff.

Cela nécessite un serveur avec un disque qui conserve ce que vous y écrivez : next start sur votre propre machine ou VM. Un hébergeur serverless sans disque persistant nécessite un stockage objet à la place d'uploads/.

Tester

Téléchargez le projet d’exemple de ce guide. Copiez le dossier richtexteditor issu du téléchargement de l’éditeur dans public/richtexteditor, ou faites pointer RTE_DIR vers lui. Exécutez ensuite npm install, npm run build et npm start, puis, depuis un dossier contenant une photo :

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

curl -I http://localhost:5102/uploads/blog/photo-2cb4607d.jpg
HTTP/1.1 200 OK
content-type: image/jpeg
x-content-type-options: nosniff

Ouvrez http://localhost:5102, déposez une image dans l’éditeur : elle figure dans le texte et dans uploads/. node shared/contract-test.mjs http://localhost:5102 exécute les 35 vérifications. Elles sont passées avec npm run dev comme avec npm start.

Le même guide pour d’autres plateformes

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

À lire ensuite