Bilder aus einem Rich-Text-Editor in Node.js hochladen

Wenn jemand in RichTextEditor ein Bild hineinzieht, einfügt oder auswählt, übergibt der Editor die Datei an eine Upload-Funktion und wartet auf eine URL. Diese Seite liefert Ihnen beide Hälften für Node.js: die Browserdatei, die das Bild sendet, und eine Express-5-Route, die es mit multer prüft, unter einem neuen Namen speichert und mit der URL antwortet, die der Editor einfügt.
Die Browserseite
Der Editor lädt nichts von sich aus hoch. Für Drag & Drop, Einfügen und den Dialog „Insert Image“ ruft er window.rte_file_upload_handler auf, für Uploads aus dem Galerie-Dialog window.richTextBoxUploadFile, das zusätzlich den geöffneten Ordner übergibt. rte-files.js definiert beide. Es sendet die Datei als multipart/form-data an /rte/upload und ruft den Callback mit der URL oder mit dem Fehlertext des Servers auf. Laden Sie es nach den Editor-Skripten:
<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>Mit maxUploadFileSize kann der Galerie-Dialog eine zu große Datei ablehnen, bevor sie gesendet wird. Der Server muss die Größe trotzdem selbst prüfen.
Die Route
multer ist der Multipart-Parser, den die meisten Express-Apps bereits verwenden. Das Beispiel nutzt seinen Memory Storage, sodass die Datei als req.file.buffer ankommt und ihre ersten Bytes geprüft werden können, bevor etwas auf die Festplatte geschrieben wird. Memory Storage ist nur mit Limits sicher: multers eigene Standardwerte für fileSize, files und parts sind 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
});Eine Option hier wird leicht übersehen. multers defParamCharset ist standardmäßig 'latin1', Browser senden Dateinamen aber als UTF-8. Mit dem Standardwert kam eine Datei namens café.png in unserem Test als café.png an. Mit "utf8" kommt sie unverändert an, und das Beispiel speichert sie als cafe-1a2b3c4d.png.
Die Routen selbst sind kurz. Eine kleine Middleware wählt zuerst den Stammordner für die Speicherung, dann parst multer das Formular, und der Handler reicht die Bytes weiter:
// 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 });
});Die Datei speichern
saveImage() in gallery.mjs enthält kein Express. Die Funktion prüft die Dateiendung und die ersten Bytes, stellt sicher, dass der Ordner innerhalb des Stammordners existiert, und schreibt die Datei unter einem generierten Namen:
// 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);
}Der gespeicherte Name ist der bereinigte Originalname plus acht zufällige Hex-Zeichen, sodass sich zwei Uploads von photo.jpg nie gegenseitig überschreiben. Das Flag "wx" lässt writeFile fehlschlagen, falls der Name trotzdem existiert. Die Dateiendung wird kleingeschrieben: photo.JPG wird als photo-….jpg gespeichert.
Die Antwort lautet {"url":"/uploads/blog/photo-ce99c014.jpg"}. Der Editor fügt diese URL als src des Bildes ein, und express.static liefert die Datei aus uploads/ aus. Welche Dateien ausgeliefert werden und warum nur Bilder, behandelt sichere Bild-Uploads in Node.js.
Wenn etwas schiefgeht
Die Route-Handler haben kein try/catch. In Express 5 landet ein abgelehntes Promise aus einem async-Handler in der Fehler-Middleware, die die eigenen Fehler des Beispiels und die Fehler von multer in JSON umwandelt:
// 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." });
});Dieses Verhalten gibt es erst in Express 5. Wir haben dieselbe Art von async-Handler unter Express 4.22 ausgeführt: Der Fehler war eine Unhandled Rejection, und unter Node 24 wurde der gesamte Prozess beendet. Unter Express 4 brauchen Sie in jeder asynchronen Route einen Wrapper oder ein try/catch.
rte-files.js gibt den JSON-Text aus error als zweites Callback-Argument an den Editor zurück. Der Galerie-Dialog zeigt ihn nicht an: Er zeigt immer seine eigene Meldung „Upload failed for holiday.jpg.“, ganz gleich, was der Server geantwortet hat.
Testen
Laden Sie das Beispielprojekt zu dieser Anleitung herunter, legen Sie den Ordner richtexteditor aus dem Editor-Download neben server.mjs und führen Sie npm install und npm start aus. Der Server lauscht auf 127.0.0.1:5101, sodass nur Ihr eigener Rechner ihn erreichen kann. Führen Sie dann in einem Ordner, der ein Foto enthält, Folgendes aus:
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."}Die zweite Antwort ist ein HTTP 400. Öffnen Sie http://localhost:5101 und ziehen Sie ein Bild in den Editor: Es erscheint in uploads/ und im Text. Um alle Regeln auf einmal zu prüfen, führen Sie node shared/contract-test.mjs http://localhost:5101 aus. Am Ende steht ALL 35 CHECKS PASSED.
Dieselbe Anleitung für andere Plattformen
Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms