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

En un proyecto de Next.js con App Router, las cargas de imágenes de RichTextEditor necesitan dos route handlers: uno que recibe el archivo y otro que lo devuelve. No puedes simplemente escribir las cargas en public/ como haría un sitio estático. Esta página te da ambos handlers, muestra qué cambia en el runtime de Node.js y explica por qué los archivos van a uploads/.
La parte del navegador
El editor entrega una imagen soltada, pegada o elegida a window.rte_file_upload_handler. Las imágenes subidas desde el cuadro de diálogo de la galería van a window.richTextBoxUploadFile, que además recibe la carpeta abierta. El rte-files.js compartido define ambas. Envía el archivo a /rte/upload y pasa al editor la URL devuelta. La página lo carga junto con los scripts del editor (consulta la guía de la galería para el componente de cliente) y define las opciones del editor desde un componente de servidor:
// 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,
};El route handler de carga
// 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);
}
}Algunas cosas de este código son propias de Next.js:
- El runtime de Node.js. El handler escribe con
node:fs, así que no debe ejecutarse en el runtime Edge.nodejsya es el valor predeterminado. El archivo lo establece de todos modos, para que el requisito quede escrito junto al código. Una excepción según la documentación de Next.js: con Cache Components activado, elimina el exportruntime, también si valenodejs. formData()te da unFileweb. No hay multer ni archivo temporal.file.namees el nombre que envió el navegador, yfile.arrayBuffer()contiene los bytes en memoria. El nombre solo se usa para obtener la extensión y un nombre base saneado. Nunca se convierte en una ruta.readForm()en lugar derequest.formData(). Unrequest.formData()simple en un route handler aceptó un archivo de 50 MB en nuestra prueba.readForm()se detiene en el límite del ejemplo. Límites de tamaño de carga de imágenes en Next.js tiene los detalles.checkOrigin()rechaza los POST de otros sitios, porque los route handlers no tienen la comprobación de Origin que sí tienen las Server Actions. Consulta cargas de imágenes seguras en Next.js.
Las comprobaciones y la escritura están en lib/gallery.ts, que no sabe nada de Next.js:
// 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);
}El nombre guardado es el nombre base saneado más ocho caracteres hexadecimales aleatorios. El indicador "wx" hace que writeFile falle en lugar de sobrescribir. La respuesta es {"url":"/uploads/blog/photo-2cb4607d.jpg"}, y el editor inserta esa URL.
Por qué no public/
Next.js sirve public/ como archivos estáticos, así que escribir ahí las cargas parece lo obvio. No funciona después de next build. Escribimos un archivo en public/ con next start en ejecución y lo solicitamos: 404. Un archivo que ya estaba ahí en el momento de la compilación: 200. Por eso el ejemplo escribe en uploads/, junto al proyecto, y lo sirve con un route handler catch-all:
// 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);
}
}// /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;
}En Next.js 16, params es una Promise, así que se espera con await. Para [...path] es un array de segmentos ya decodificados: una solicitud de /uploads/My%20Photos/a.jpg llega como ["My Photos", "a.jpg"]. La ruta solo sirve nombres de imagen y solo archivos cuya ruta real está dentro de uploads/. Los envía con X-Content-Type-Options: nosniff.
Esto necesita un servidor con un disco que conserve lo que escribes: next start en tu propia máquina o VM. Un host serverless sin disco persistente necesita almacenamiento de objetos en lugar de uploads/.
Pruébalo
Descarga el proyecto de ejemplo de esta guía. Copia la carpeta richtexteditor de la descarga del editor a public/richtexteditor, o define RTE_DIR con su ruta. Después ejecuta npm install, npm run build y npm start y, desde una carpeta que contenga una foto:
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: nosniffAbre http://localhost:5102, suelta una imagen en el editor y estará en el texto y en uploads/. node shared/contract-test.mjs http://localhost:5102 ejecuta las 35 comprobaciones. Pasaron tanto con npm run dev como con npm start.
La misma guía para otras plataformas
JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms