Node.js でのユーザーごとの画像フォルダー

Express のサンプルの uploads フォルダー。共有の blog、products、team フォルダーと、alice と bob を含む users フォルダーがあり、それぞれに専用の画像がある

各ユーザーがそれぞれ自分のコンテンツを書く場合、ギャラリーには全員の画像ではなく、そのユーザー自身の画像だけを表示すべきです。Express のサンプルでは、これはリクエストごとにストレージルートを決める rootFor(req) という 1 つの関数の変更で実現できます。このページでは、その関数、multer との実行順序、そしてユーザーごとのフォルダーでは保護できないものを紹介します。

ルートを決める 1 つの関数

ギャラリーが行う処理(一覧の表示、フォルダーの作成、アップロード)は、すべて rootFor(req) が返すフォルダーの中で行われます。既定では共有の uploads/ です。PER_USER=1 を指定すると uploads/users/<id>/ になります。

JavaScript · server.mjs
// The storage root for this request. Everything the gallery lists, creates or uploads
// stays inside it. Keep it inside UPLOADS_DIR, so the /uploads URLs still work.
// PER_USER=1 gives every user uploads/users/<id>/.
const PER_USER = process.env.PER_USER === "1";

function rootFor(req) {
  if (!PER_USER) return UPLOADS_DIR;
  const user = demoUser(req); // with real logins: req.user.id from your session middleware
  if (!user) throw new GalleryError(401, "Sign in to use the gallery.");
  return path.join(UPLOADS_DIR, "users", user);
}

// Stand-in for a login, for trying PER_USER locally: /demo-login?user=alice sets a cookie.
// Anyone can set any cookie, so this is NOT authentication.
function demoUser(req) {
  const match = /(?:^|;\s*)demo_user=([a-z0-9]{1,32})(?:;|$)/.exec(req.headers.cookie ?? "");
  return match ? match[1] : null;
}

ほかには何も変わりません。Alice の drafts フォルダーはディスク上では uploads/users/alice/drafts で、ダイアログでは drafts と表示され、アップロードのレスポンスは /uploads/users/alice/drafts/forest-5e6f80c8.jpg になります。この URL が機能するのは、ユーザーごとのルートが UPLOADS_DIR の中にあり、サンプルがすべての URL を UPLOADS_DIR 以下のパスから作っているためです。その外にあるフォルダーを返すと、URL が正しくなりません。

multer の後ではなく前に

ルートは、multer より前にある /rte のミドルウェアで決めます。

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

この順序が重要です。rootFor() がアップロードハンドラーの中で実行されると、multer はサインインしているかどうかにかかわらず、誰からでも最大 4,000,000 バイトを先に読み込み、その後でようやくリクエストが拒否されます。ミドルウェアにしておけば、本文を解析する前に GalleryError(401) がスローされ、Express がそのエラーをエラーミドルウェアに渡します。独自のログイン用ミドルウェア(セッション、passport、JWT のチェック)も、同じようにこの前に置いてください。

ユーザー ID の取得元

ID は必ずログイン情報から取得し、ブラウザーが送信するものから取得してはいけません。サンプルの demoUser() は /demo-login?user=alice が設定する Cookie を読み取るので、ログインの仕組みがなくても 2 人のユーザーを試せます。このルートは PER_USER=1 のときだけ存在し、a-z と 0-9 しか受け付けません。Cookie は誰でも自由に設定できるため、これは認証ではなく代用品です。この呼び出しは req.user.id、またはお使いのセッションミドルウェアが提供するものに置き換えてください。その ID が単純なトークンでない場合(メールアドレスなど)は、先に数値の ID に対応付けてください。ID はフォルダー名になるからです。

ユーザーがいない場合、/rte へのリクエストにはすべて 401 {"error":"Sign in to use the gallery."} が返ります。その場合、ダイアログには「The image gallery is unavailable right now (http-401).」と表示されます。

共有フォルダーとユーザーごとのフォルダーを同じディスクに置く

ユーザーごとのルートは uploads/users/ にあります。同じフォルダーを共有モードでも使うと、共有ギャラリーに users が表示され、誰でも全員のフォルダーを閲覧できてしまいます。そのため、共有ルートでは users を予約済みとして扱います。

JavaScript · gallery.mjs
// Per-user roots live in uploads/users/<id>/. The shared gallery must not show or touch them.
function isUsersFolder(root, folder) {
  return path.resolve(root) === UPLOADS_DIR && String(folder ?? "").split("/")[0].toLowerCase() === "users";
}

共有モードでは、ルートの一覧に users は含まれず、?folder=users/alice は 404、その中へのアップロードも 404、users という名前のフォルダーの作成は 400 になります。比較では大文字と小文字を区別しないため、Windows の Users にも対応します。ページ上部の画像は、両方を同じディスクに置いた状態です。共有の blog、products、team フォルダーと、users/alice、users/bob があります。

これで保護できないもの

画像は依然として /uploads/ 以下の静的ファイルです。当社のテストでは、Cookie をまったく付けずに Alice の画像を開いたところ、200 が返りました。名前のランダムな部分によって URL は推測しにくくなりますが、秘密にはなりません。その画像を表示するすべての文書の HTML に URL が含まれているからです。画像を非公開にする必要がある場合は、express.static のマウントを、ファイルを送信する前にユーザーをチェックするルートに置き換えてください。

テストする

このガイドのサンプルプロジェクトを、PER_USER=1 npm start でユーザーごとのモードで起動します。PowerShell では、先に $env:PER_USER=1 を実行してから npm start を実行します。curl の -b で代用の Cookie を送信します。

Shell
curl "http://localhost:5101/rte/gallery?folder="
{"error":"Sign in to use the gallery."}

curl -b demo_user=bob -F "file=@photo.jpg" http://localhost:5101/rte/upload
{"url":"/uploads/users/bob/photo-cbf27791.jpg"}

curl -b demo_user=bob "http://localhost:5101/rte/gallery?folder=../alice"
{"error":"Invalid folder."}

ブラウザーで http://localhost:5101/demo-login?user=alice を開き、Insert Gallery をクリックします。ダイアログには Alice のフォルダーと画像だけが表示されます。/demo-login?user=bob を開くと、Bob のものだけが表示されます。

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

PHP · ASP.NET Core · MVC 5 · Web Forms

次に読む