项目里"加个上传功能"听起来是半小时的事,但真做成能用的,坑基本都在细节里:文件重名覆盖、上传目录被塞满、用户传个 .exe 进来、文件名带 ../ 直接写到你不想写的地方。
下面从最小版本开始,一层层加。
环境
npm init -y
npm i express multer
最小可用版
// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
const upload = multer({ dest: 'uploads/' }); // 文件落在 ./uploads
app.post('/upload', upload.single('file'), (req, res) => {
// req.file 是上传的文件信息,req.body 是其他文本字段
if (!req.file) return res.status(400).json({ error: '没有收到文件' });
res.json({ filename: req.file.filename, size: req.file.size });
});
app.listen(3000, () => console.log('http://localhost:3000'));
测一下:
curl -F "file=@./test.png" http://localhost:3000/upload
这版能用,但落地的文件名是随机哈希、没有扩展名,而且谁都能传任何东西。接着改。
生产版:限制 + 重命名 + 类型白名单
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const app = express();
const UPLOAD_DIR = path.join(__dirname, 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// 只允许这几类
const ALLOWED = {
'image/jpeg': '.jpg',
'image/png' : '.png',
'image/gif' : '.gif',
'application/pdf': '.pdf',
};
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
// 不用用户给的原名:防重名、防中文乱码、防目录穿越
const ext = ALLOWED[file.mimetype] || path.extname(file.originalname).toLowerCase();
const name = crypto.randomBytes(16).toString('hex') + ext;
cb(null, name);
},
});
const upload = multer({
storage,
limits: {
fileSize: 10 * 1024 * 1024, // 单个文件 10MB
files: 5, // 一次最多 5 个
},
fileFilter: (req, file, cb) => {
if (ALLOWED[file.mimetype]) return cb(null, true);
cb(new Error(`不支持的文件类型: ${file.mimetype}`));
},
});
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: '没有收到文件' });
res.json({
filename: req.file.filename,
original: req.file.originalname,
size: req.file.size,
});
});
// 统一错误处理(multer 的限制错误会走到这)
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
const msg = {
LIMIT_FILE_SIZE: '文件超过 10MB',
LIMIT_FILE_COUNT: '文件数量超限',
LIMIT_UNEXPECTED_FILE: '字段名不匹配',
}[err.code] || err.message;
return res.status(400).json({ error: msg });
}
res.status(400).json({ error: err.message });
});
app.listen(3000, () => console.log('http://localhost:3000'));
几个关键点
为什么不用 file.originalname 直接存盘
三个原因:重名会互相覆盖;中文名在某些系统上乱码;更麻烦的是 originalname 完全由客户端控制,可能是 ../../etc/passwd 这种。multer 的 diskStorage 里我们只用 path.extname() 取扩展名、主体用随机哈希,目录穿越自然就没了。
mimetype 也不可全信
它是客户端给的 Content-Type,可以伪造。真要严格,得在写盘后读文件头魔数判断真实类型:
// 粗略判断:读前几个字节
function sniffExt(buf) {
if (buf[0] === 0xff && buf[1] === 0xd8) return '.jpg';
if (buf[0] === 0x89 && buf[1] === 0x50) return '.png';
if (buf[0] === 0x25 && buf[1] === 0x50) return '.pdf';
return null;
}
limits 是必要的
不限大小,一个 2GB 的文件就能把磁盘写满。除 fileSize 外,files 限制数量,fields 限制非文件字段数。
多文件与多字段
// 同一字段多个文件
app.post('/multi', upload.array('photos', 5), (req, res) => {
res.json(req.files.map(f => f.filename));
});
// 不同字段不同文件(比如头像 + 身份证)
const cpUpload = upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'idcard', maxCount: 1 },
]);
app.post('/profile', cpUpload, (req, res) => {
res.json({ avatar: req.files.avatar?.[0]?.filename, idcard: req.files.idcard?.[0]?.filename });
});
前端:带进度条
multer 时代前端一般用 XMLHttpRequest(fetch 拿不到上传进度):
<input type="file" id="f" />
<button onclick="upload()">上传</button>
<progress id="p" value="0" max="100"></progress>
<pre id="out"></pre>
<script>
async function upload() {
const file = document.getElementById('f').files[0];
if (!file) return alert('先选文件');
const form = new FormData();
form.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
document.getElementById('p').value = (e.loaded / e.total) * 100;
}
};
xhr.onload = () => {
document.getElementById('out').textContent = xhr.responseText;
};
xhr.onerror = () => alert('上传失败');
xhr.send(form);
}
</script>
静态目录别直接暴露
如果上传目录需要能被访问:
app.use('/files', express.static(UPLOAD_DIR, {
dotfiles: 'deny',
index: false,
setHeaders: (res) => {
// 防止浏览器把上传的 html 当页面执行(XSS)
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
X-Content-Type-Options: nosniff 和 CSP 这两行别省——否则别人上传一个含脚本的 .html 或伪装图片,在你的域名下就能执行,是个标准的存储型 XSS。
还要补什么
这份 demo 覆盖到"能用",上生产前通常还得加:
- 鉴权:上传接口必须登录后才能调,否则变成免费图床
- 限流:同 IP 频率限制,不然被刷
- 病毒扫描:对外服务建议接 ClamAV
- 对象存储:量大之后别存本地磁盘,换 S3/OSS,multer 换成
multer-s3 - 清理:失败的临时文件定期清,不然
uploads/会堆满半成品