Subir imágenes desde un editor de texto enriquecido en Node.js

Cuando alguien suelta, pega o elige una imagen en RichTextEditor, el editor entrega el archivo a una función de carga y espera una URL. Esta página te da las dos mitades para Node.js: el archivo del navegador que envía la imagen y una ruta de Express 5 que la comprueba con multer, la guarda con un nombre nuevo y responde con la URL que inserta el editor.
La parte del navegador
El editor no sube nada por sí solo. Llama a window.rte_file_upload_handler para arrastrar y soltar, pegar y el cuadro de diálogo Insert Image, y a window.richTextBoxUploadFile para las cargas desde el cuadro de diálogo de la galería, que además pasa la carpeta abierta. rte-files.js define ambas. Envía el archivo como multipart/form-data a /rte/upload y llama al callback con la URL o con el texto de error del servidor. Cárgalo después de los scripts del editor:
<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><div id="editor"><p>Hello from Express.</p></div>
<script>
var editor = new RichTextEditor("#editor", {
toolbar: "full",
galleryEndpoint: "/rte/gallery",
maxUploadFileSize: 4000000
});
</script>maxUploadFileSize permite que el cuadro de diálogo de la galería rechace un archivo demasiado grande antes de enviarlo. El servidor tiene que comprobar el tamaño de todos modos.
La ruta
multer es el analizador multipart que ya usan la mayoría de las aplicaciones Express. El ejemplo usa su almacenamiento en memoria, así que el archivo llega como req.file.buffer y sus primeros bytes se pueden comprobar antes de escribir nada en el disco. El almacenamiento en memoria solo es seguro con límites: los valores predeterminados de multer para fileSize, files y parts son Infinity.
// 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
});Aquí hay una opción que es fácil pasar por alto. defParamCharset de multer vale 'latin1' de forma predeterminada, pero los navegadores envían los nombres de archivo en UTF-8. Con el valor predeterminado, un archivo llamado café.png llegó como café.png en nuestra prueba. Con "utf8" llega intacto, y el ejemplo lo guarda como cafe-1a2b3c4d.png.
Las rutas en sí son cortas. Primero, un pequeño middleware elige la raíz de almacenamiento; luego multer analiza el formulario y el handler pasa los bytes:
// 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 });
});Guardar el archivo
saveImage(), en gallery.mjs, no contiene nada de Express. Comprueba la extensión y los primeros bytes, se asegura de que la carpeta existe dentro de la raíz de almacenamiento y escribe el archivo con un nombre generado:
// 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);
}El nombre guardado es el nombre original saneado más ocho caracteres hexadecimales aleatorios, así que dos cargas de photo.jpg nunca se sobrescriben. El indicador "wx" hace que writeFile falle si el nombre existe de todos modos. La extensión se pasa a minúsculas: photo.JPG se guarda como photo-….jpg.
La respuesta es {"url":"/uploads/blog/photo-ce99c014.jpg"}. El editor inserta esa URL como src de la imagen, y express.static sirve el archivo desde uploads/. Qué archivos sirve, y por qué solo imágenes, se explica en cargas de imágenes seguras en Node.js.
Cuando algo sale mal
Los route handlers no tienen try/catch. En Express 5, una promesa rechazada en un handler async llega al middleware de errores, que convierte en JSON los errores propios del ejemplo y los de multer:
// 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." });
});Este es un comportamiento de Express 5. Ejecutamos el mismo tipo de handler async en Express 4.22: el error fue un rechazo no gestionado y, en Node 24, todo el proceso terminó. En Express 4 necesitas un wrapper o un try/catch en cada ruta asíncrona.
rte-files.js devuelve al editor el texto error del JSON como segundo argumento del callback. El cuadro de diálogo de la galería no lo muestra: muestra su propio mensaje, "Upload failed for holiday.jpg.", responda lo que responda el servidor.
Pruébalo
Descarga el proyecto de ejemplo de esta guía, coloca la carpeta richtexteditor de la descarga del editor junto a server.mjs y ejecuta npm install y npm start. El servidor escucha en 127.0.0.1:5101, así que solo tu propia máquina puede acceder a él. Después, desde una carpeta que contenga una foto:
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 segunda respuesta es un HTTP 400. Abre http://localhost:5101, suelta una imagen en el editor y aparecerá en uploads/ y en el texto. Para comprobar todas las reglas a la vez, ejecuta node shared/contract-test.mjs http://localhost:5101. Termina con ALL 35 CHECKS PASSED.
La misma guía para otras plataformas
Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms