Node.js 中每个用户的图片文件夹

Express 示例的 uploads 文件夹,包含共享的 blog、products 和 team 文件夹,以及一个 users 文件夹,其中 alice 和 bob 各有自己的图片

当每个人各自撰写内容时,图片库应该向每个人显示他们自己的图片,而不是所有人的图片。在 Express 示例中,这只需要修改一个函数 rootFor(req),它为每个请求选择存储根目录。本页介绍这个函数、它相对于 multer 的运行顺序,以及每个用户的文件夹不能保护什么。

由一个函数选择根目录

图片库所做的一切(列表、创建文件夹、上传)都发生在 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 之前,而不是之后

根目录在 /rte 上的一个中间件中选择,位于 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 });
});

顺序很重要。如果 rootFor() 在上传处理程序内部运行,multer 就会先从任何人(无论是否登录)那里读取最多 4,000,000 字节,然后才拒绝请求。作为中间件,它会在解析请求体之前抛出 GalleryError(401),由 Express 把错误交给错误处理中间件。请以同样的方式把您自己的登录中间件(会话、passport、JWT 检查)放在它之前。

用户 id 从哪里来

id 必须来自您的登录功能,绝不能来自浏览器发送的内容。示例的 demoUser() 读取由 /demo-login?user=alice 设置的 Cookie,这样无需登录系统就能试用两个用户。这个路由只在 PER_USER=1 时存在,并且只接受 a-z 和 0-9。任何人都可以设置任意 Cookie,所以它只是一个替身,而不是身份验证。请把这个调用替换为 req.user.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 中。如果图片必须保持私有,请把 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

延伸阅读