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

Ein Bild in RichTextEditor, nachdem es an den Route Handler des Next.js-Beispiels hochgeladen wurde, darüber die vollständige Symbolleiste

In einem Next.js-Projekt mit App Router brauchen die Bild-Uploads von RichTextEditor zwei Route Handler: einen, der die Datei entgegennimmt, und einen, der sie wieder ausliefert. Sie können Uploads nicht einfach nach public/ schreiben, wie es eine statische Website tun würde. Diese Seite zeigt beide Handler, was sich in der Node.js-Runtime ändert und warum die Dateien stattdessen nach uploads/ gehen.

Die Browserseite

Der Editor übergibt ein hineingezogenes, eingefügtes oder ausgewähltes Bild an window.rte_file_upload_handler. Bilder, die aus dem Galerie-Dialog hochgeladen werden, gehen an window.richTextBoxUploadFile, das zusätzlich den geöffneten Ordner erhält. Die gemeinsame Datei rte-files.js definiert beide. Sie sendet die Datei an /rte/upload und gibt die zurückgelieferte URL an den Editor weiter. Die Seite lädt sie zusammen mit den Editor-Skripten (die Client Component finden Sie in der Anleitung zur Galerie) und setzt die Editor-Optionen aus einer Server Component:

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,
};

Der Route Handler für den Upload

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);
  }
}

Einige Dinge darin sind spezifisch für Next.js:

  • Die Node.js-Runtime. Der Handler schreibt mit node:fs, darf also nicht in der Edge-Runtime laufen. nodejs ist ohnehin der Standardwert. Die Datei setzt ihn trotzdem, damit die Anforderung direkt beim Code steht. Eine Ausnahme laut Next.js-Dokumentation: Wenn Cache Components aktiviert sind, entfernen Sie den Export runtime, auch mit dem Wert nodejs.
  • formData() liefert ein Web-File. Es gibt weder multer noch eine temporäre Datei. file.name ist der Name, den der Browser gesendet hat, und file.arrayBuffer() hält die Bytes im Speicher. Der Name wird nur für seine Dateiendung und einen bereinigten Basisnamen verwendet. Er wird nie zu einem Pfad.
  • readForm() statt request.formData(). Ein einfaches request.formData() in einem Route Handler hat in unserem Test eine 50-MB-Datei angenommen. readForm() bricht beim Limit des Beispiels ab. Details stehen in Größenlimits für Bild-Uploads in Next.js.
  • checkOrigin() lehnt POST-Requests von anderen Websites ab, weil Route Handler nicht die Origin-Prüfung erhalten, die Server Actions bekommen. Siehe sichere Bild-Uploads in Next.js.

Die Prüfungen und das Schreiben stecken in lib/gallery.ts, das nichts von Next.js weiß:

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);
}

Der gespeicherte Name ist der bereinigte Basisname plus acht zufällige Hex-Zeichen. Das Flag "wx" lässt writeFile fehlschlagen, statt zu überschreiben. Die Antwort lautet {"url":"/uploads/blog/photo-2cb4607d.jpg"}, und der Editor fügt diese URL ein.

Warum nicht public/

Next.js liefert public/ als statische Dateien aus, deshalb liegt es nahe, Uploads dorthin zu schreiben. Nach next build funktioniert das aber nicht. Wir haben eine Datei nach public/ geschrieben, während next start lief, und sie angefordert: 404. Eine Datei, die schon beim Build vorhanden war: 200. Deshalb schreibt das Beispiel nach uploads/ neben dem Projekt und liefert den Ordner über einen Catch-all-Route-Handler aus:

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;
}

In Next.js 16 ist params ein Promise und wird daher mit await abgewartet. Bei [...path] ist es ein Array bereits dekodierter Segmente: Ein Request für /uploads/My%20Photos/a.jpg kommt als ["My Photos", "a.jpg"] an. Die Route liefert nur Bildnamen aus und nur Dateien, deren realer Pfad innerhalb von uploads/ liegt. Sie sendet sie mit X-Content-Type-Options: nosniff.

Dafür brauchen Sie einen Server mit einer Festplatte, die behält, was Sie schreiben: next start auf Ihrem eigenen Rechner oder Ihrer VM. Ein Serverless-Host ohne persistente Festplatte braucht statt uploads/ einen Objektspeicher.

Testen

Laden Sie das Beispielprojekt zu dieser Anleitung herunter. Kopieren Sie den Ordner richtexteditor aus dem Editor-Download nach public/richtexteditor oder setzen Sie RTE_DIR auf diesen Ordner. Führen Sie dann npm install, npm run build und npm start aus und anschließend in einem Ordner mit einem Foto:

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

Öffnen Sie http://localhost:5102 und ziehen Sie ein Bild in den Editor: Es steht im Text und liegt in uploads/. node shared/contract-test.mjs http://localhost:5102 führt alle 35 Prüfungen aus. Sie waren sowohl mit npm run dev als auch mit npm start erfolgreich.

Dieselbe Anleitung für andere Plattformen

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

Weiterlesen