Node.js でリッチテキストエディターに画像ギャラリーを追加する

RichTextEditor の「Insert Gallery」ダイアログには、すでにサーバーにある画像を表示できます。そのため、同じ画像をもう一度アップロードしなくても、既存の画像から選べます。ダイアログに必要なのは、フォルダーの一覧を JSON で返す URL 1 つだけです。このページでは、node:fs だけを使ってそのエンドポイントを Express 5 で作り、ダイアログと接続します。
ダイアログの接続先をサーバーにする
エディターのオプションを 1 つ設定すると、ギャラリーは組み込みのサンプル画像ではなく、サーバーの画像を表示するようになります。
<div id="editor"><p>Hello from Express.</p></div>
<script>
var editor = new RichTextEditor("#editor", {
toolbar: "full",
galleryEndpoint: "/rte/gallery",
maxUploadFileSize: 4000000
});
</script>galleryEndpoint を設定すると、ダイアログは開いたときに GET /rte/gallery?folder= を送信し、フォルダーが開かれると ?folder=blog を付けて再度送信します。ダイアログの「Upload」ボタンを使うには、アップロードのガイドで紹介している rte-files.js が必要です。これがないと、ダイアログは閲覧しかできません。
エンドポイントが返すもの
一覧には、現在のフォルダー、その親フォルダー、サブフォルダー、画像が含まれます。各画像には、エディターが挿入できる URL、表示名、所属するフォルダー、バイト単位のサイズがあります。以下はサンプルが実際に返したレスポンスです。

ルートでは parentFolder が null になります。ダイアログはこれを見て「Up」ボタンを無効にします。フォルダーは blog/2026 のように / で区切った仮想パスで、ディスク上のパスではありません。
ルートと一覧の作成
Express のルートは 1 行です。Express 5 は、async ハンドラーで発生したエラーをエラーミドルウェアに渡します。
// 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 の listFolder() は、readdir(dir, { withFileTypes: true }) でフォルダーを読み取り、JSON を組み立てます。
// The JSON the gallery dialog reads (see CONTRACT.md).
export async function listFolder(root, folder = "") {
if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
const dir = await resolveFolder(root, folder);
const entries = await readdir(dir, { withFileTypes: true });
const join = (name) => (folder ? folder + "/" + name : name);
const folders = entries
.filter((entry) => entry.isDirectory() && isValidName(entry.name) && !isUsersFolder(root, join(entry.name)))
.map((entry) => ({ folder: join(entry.name), name: entry.name }));
const images = [];
for (const entry of entries) {
if (!entry.isFile() || !isImageName(entry.name)) continue;
const file = path.join(dir, entry.name);
const { size } = await lstat(file);
images.push({ url: publicUrl(file), name: entry.name, folder, size });
}
return {
currentFolder: folder,
currentFolderDisplay: "/" + folder,
parentFolder: folder === "" ? null : folder.split("/").slice(0, -1).join("/"),
folders: folders.sort(byName),
images: images.sort(byName),
};
}
// Plain lower-case comparison, so every server sample sorts the same way.
function byName(a, b) {
const x = a.name.toLowerCase();
const y = b.name.toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
}この中には、重要な判断がいくつかあります。
- 一覧に含めるのは画像の拡張子を持つファイルだけで、ドットで始まるものは含めません。
- 一覧に含めるのは有効な名前のフォルダーだけです。そのため、ダイアログに表示されるフォルダーはすべて開くことができます。
- 並べ替えには
localeCompareではなく、小文字にしての単純な比較を使っています。これにより、どのマシンでも、サンプルを書いたどの言語でも、同じ順序になります。 - URL は
uploads/以下のパスから作り、各部分をencodeURIComponentに通します。My Photosというフォルダーなら/uploads/My%20Photos/...になります。
これらの処理の前に、resolveFolder() が ?folder= をディスク上のパスに変換し、uploads/ の外に出るものはすべて拒否します。このチェックについては、Node.js のギャラリーフォルダーで詳しく説明しています。
クエリ文字列は配列になることがある
Express は ?folder=a&folder=b を ["a", "b"] として解析します。これは Express 4 でも Express 5 でも同じです。Express 5 で変わったのはネストした構文です。当社のテストでは、?a[b]=1 は Express 4.22 では { a: { b: "1" } } に、Express 5.3 では { "a[b]": "1" } になりました。Express 5 が既定で simple クエリパーサーを使うためです。いずれにしても、req.query.folder が常に文字列とは限りません。resolveFolder() は文字列以外を 400 で拒否するため、配列が path.resolve に渡ることはありません。
ダイアログに表示されるもの、表示されないもの
ページ上部の画像は、このサーバーに接続したダイアログです。ルートに 3 つのフォルダーと 4 つの画像があり、そのうち 1 つが選択されています。知っておくべき制限が 2 つあります。
- リリース版のギャラリーのヒントには、受け付ける形式として「JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF」と表示されます。一方、このサーバーが受け付けるのは .jpg、.jpeg、.png、.gif、.webp だけです。ダイアログで .bmp ファイルを選ぶと送信はされますが、400 で拒否され、ダイアログには「Upload failed for <name>.」と表示されます。
- サムネイルは
/uploads/から読み込まれる元の画像そのものです。大きな写真が多いフォルダーでは、そのすべてが読み込まれます。サンプルでは縮小版を作成していません。
テストする
このガイドのサンプルプロジェクトを npm install と npm start で起動し、フォルダーに画像をアップロードしてから、一覧を要求します。
curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5101/rte/upload
{"url":"/uploads/blog/photo-ce99c014.jpg"}
curl "http://localhost:5101/rte/gallery?folder=blog"
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[],"images":[...]}
curl "http://localhost:5101/rte/gallery?folder=../"
{"error":"Invalid folder."}最後のレスポンスは 400 です。次に http://localhost:5101 を開き、ツールバーの Insert Gallery をクリックして blog フォルダーを開きます。アップロードした画像が一覧に表示されます。
ほかのプラットフォーム向けの同じガイド
Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms