Node.js でリッチテキストエディターから画像をアップロードする

Express のサンプルサーバーにアップロードされ、RichTextEditor に挿入された画像。上部にはすべてのボタンを表示したツールバーがある

RichTextEditor で画像をドロップ、貼り付け、または選択すると、エディターはそのファイルをアップロード関数に渡し、URL が返ってくるのを待ちます。このページでは、Node.js 向けにその両側を紹介します。画像を送信するブラウザー側のファイルと、multer で画像をチェックし、新しい名前で保存して、エディターが挿入する URL を返す Express 5 のルートです。

ブラウザー側

エディター自体は何もアップロードしません。ドラッグ&ドロップ、貼り付け、「Insert Image」ダイアログでは window.rte_file_upload_handler を呼び出し、ギャラリーダイアログからのアップロードでは window.richTextBoxUploadFile を呼び出します。後者には、開いているフォルダーも渡されます。rte-files.js はこの両方を定義しています。ファイルを multipart/form-data として /rte/upload に POST し、URL またはサーバーのエラーテキストを渡してコールバックを呼び出します。エディターのスクリプトの後に読み込みます。

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 を指定すると、ギャラリーダイアログは大きすぎるファイルを送信前に拒否できます。それでも、サーバー側でサイズを必ずチェックしてください。

ルート

multer は、多くの Express アプリですでに使われているマルチパートパーサーです。サンプルではメモリストレージを使っているため、ファイルは req.file.buffer として届き、ディスクに書き込む前に先頭バイトをチェックできます。ただし、メモリストレージが安全なのは制限を設けた場合だけです。multer 自体の fileSize、files、parts の既定値は 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
});

ここには見落としやすいオプションが 1 つあります。multer の defParamCharset の既定値は 'latin1' ですが、ブラウザーはファイル名を UTF-8 で送信します。既定値のままだと、当社のテストでは café.png というファイルが café.png として届きました。"utf8" を指定すると正しく届き、サンプルはこれを cafe-1a2b3c4d.png として保存します。

ルート自体は短いものです。まず小さなミドルウェアがストレージルートを決め、次に multer がフォームを解析し、ハンドラーがバイト列を受け渡します。

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

ファイルの保存

gallery.mjs の saveImage() には Express のコードが含まれていません。拡張子と先頭バイトをチェックし、ストレージルート内にフォルダーがあることを確かめてから、生成した名前でファイルを書き込みます。

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

保存名は、整えた元のファイル名にランダムな 16 進数 8 文字を加えたものです。そのため、photo.jpg を 2 回アップロードしても、互いに上書きされることはありません。それでも名前が重複した場合は、"wx" フラグによって writeFile が失敗します。拡張子は小文字に変換されるため、photo.JPG は photo-….jpg として保存されます。

レスポンスは {"url":"/uploads/blog/photo-ce99c014.jpg"} です。エディターはこの URL を画像の src として挿入し、express.static が uploads/ からファイルを配信します。どのファイルを配信するのか、なぜ画像だけなのかについては、Node.js での安全な画像アップロードで説明しています。

問題が起きたとき

ルートハンドラーには try/catch がありません。Express 5 では、async ハンドラーから返された promise が reject されると、エラーミドルウェアに渡されます。エラーミドルウェアは、サンプル独自のエラーと multer のエラーを JSON に変換します。

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

これは Express 5 の動作です。同じような async ハンドラーを Express 4.22 で実行したところ、エラーは未処理の reject となり、Node 24 ではプロセス全体が終了しました。Express 4 では、すべての async ルートにラッパーか try/catch が必要です。

rte-files.js は、JSON の error テキストをコールバックの第 2 引数としてエディターに返します。ただし、ギャラリーダイアログはこれを表示しません。サーバーが何を返しても、独自のメッセージ「Upload failed for holiday.jpg.」(holiday.jpg のアップロードに失敗しました)を表示します。

テストする

このガイドのサンプルプロジェクトをダウンロードし、エディターのダウンロードに含まれる richtexteditor フォルダーを server.mjs と同じ場所に置いて、npm install と npm start を実行します。サーバーは 127.0.0.1:5101 で待ち受けるため、アクセスできるのは自分のマシンだけです。次に、写真が入っているフォルダーで以下を実行します。

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

2 つ目のレスポンスは HTTP 400 です。http://localhost:5101 を開いてエディターに画像をドロップすると、画像が uploads/ と本文に表示されます。すべてのルールを一度にチェックするには、node shared/contract-test.mjs http://localhost:5101 を実行します。最後に ALL 35 CHECKS PASSED と表示されます。

ほかのプラットフォーム向けの同じガイド

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

次に読む