Enviar imagens de um editor de texto rico em Node.js

Uma imagem inserida no RichTextEditor depois de ser enviada ao servidor de exemplo em Express, com a barra de ferramentas completa acima dela

Quando alguém arrasta, cola ou escolhe uma imagem no RichTextEditor, o editor entrega o arquivo a uma função de upload e aguarda uma URL. Esta página traz as duas metades para Node.js: o arquivo do navegador que envia a imagem e uma rota do Express 5 que a verifica com o multer, a armazena com um novo nome e responde com a URL que o editor insere.

O lado do navegador

O editor não envia nada sozinho. Ele chama window.rte_file_upload_handler para arrastar e soltar, colar e a caixa de diálogo Insert Image, e window.richTextBoxUploadFile para uploads feitos pela caixa de diálogo da galeria, que também informa a pasta que está aberta. O rte-files.js define as duas. Ele envia o arquivo como multipart/form-data para /rte/upload e chama o callback com a URL ou com o texto de erro do servidor. Carregue-o depois dos scripts do editor:

HTML · public/index.html
<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>
HTML · public/index.html
<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 a caixa de diálogo da galeria recuse um arquivo grande demais antes de ele ser enviado. Mesmo assim, o servidor precisa verificar o tamanho por conta própria.

A rota

O multer é o parser de multipart que a maioria dos apps Express já usa. O exemplo usa o armazenamento em memória dele, então o arquivo chega como req.file.buffer e os primeiros bytes podem ser verificados antes de qualquer coisa ser gravada em disco. O armazenamento em memória só é seguro com limites: os padrões do próprio multer para fileSize, files e parts são Infinity.

JavaScript · server.mjs
// 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
});

Uma opção aqui passa despercebida com facilidade. O defParamCharset do multer tem como padrão 'latin1', mas os navegadores enviam nomes de arquivo em UTF-8. Com o padrão, um arquivo chamado café.png chegou como café.png no nosso teste. Com "utf8", ele chega intacto, e o exemplo o armazena como cafe-1a2b3c4d.png.

As rotas em si são curtas. Primeiro, um pequeno middleware escolhe a raiz de armazenamento; depois, o multer faz o parsing do formulário e o handler repassa os bytes:

JavaScript · server.mjs
// 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 });
});

Salvar o arquivo

saveImage(), em gallery.mjs, não tem nada de Express. Ela verifica a extensão e os primeiros bytes, garante que a pasta exista dentro da raiz de armazenamento e grava o arquivo com um nome gerado:

JavaScript · gallery.mjs
// 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);
}

O nome armazenado é o nome original limpo mais oito caracteres hexadecimais aleatórios, então dois uploads de photo.jpg nunca sobrescrevem um ao outro. A flag "wx" faz o writeFile falhar se o nome já existir mesmo assim. A extensão é convertida para minúsculas: photo.JPG é armazenado como photo-….jpg.

A resposta é {"url":"/uploads/blog/photo-ce99c014.jpg"}. O editor insere essa URL como o src da imagem, e o express.static serve o arquivo a partir de uploads/. Quais arquivos ele serve, e por que somente imagens, é explicado em upload seguro de imagens em Node.js.

Quando algo dá errado

Os route handlers não têm try/catch. No Express 5, uma promise rejeitada de um handler async vai para o middleware de erro, que transforma em JSON os erros do próprio exemplo e os erros do multer:

JavaScript · server.mjs
// 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." });
});

Esse é um comportamento do Express 5. Executamos o mesmo tipo de handler async no Express 4.22: o erro virou uma rejeição não tratada e, no Node 24, o processo inteiro foi encerrado. No Express 4, você precisa de um wrapper ou de um try/catch em cada rota assíncrona.

O rte-files.js devolve ao editor o texto de error do JSON como segundo argumento do callback. A caixa de diálogo da galeria não o exibe: ela mostra a própria mensagem, "Upload failed for holiday.jpg." (o upload de holiday.jpg falhou), seja qual for a resposta do servidor.

Teste

Baixe o projeto de exemplo deste guia, coloque a pasta richtexteditor do download do editor ao lado de server.mjs e execute npm install e npm start. O servidor escuta em 127.0.0.1:5101, então só a sua própria máquina consegue acessá-lo. Depois, a partir de uma pasta que contenha uma foto:

Shell
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."}

A segunda resposta é um HTTP 400. Abra http://localhost:5101, solte uma imagem no editor e ela aparece em uploads/ e no texto. Para verificar todas as regras de uma vez, execute node shared/contract-test.mjs http://localhost:5101. A execução termina com ALL 35 CHECKS PASSED.

O mesmo guia para outras plataformas

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

Leia a seguir