Node.js 实现文件下载服务:下载计数、断点续传与防盗链
一个看似简单的下载接口,要处理好下载计数、中文文件名、大文件断点续传和防盗链四件事,才算真正能用。
#从最朴素的写法开始
第一版通常长这样:
app.get('/download/:id', (req, res) => {
const file = db.get(req.params.id);
res.download(file.path);
});
能跑,但有四个问题会在真实使用中陆续暴露。
#问题一:中文文件名变成乱码
res.download() 设置 Content-Disposition 时,如果文件名含非 ASCII 字符,需要按 RFC 5987 编码:
const filename = '技术方案v2.pdf';
const encoded = encodeURIComponent(filename);
res.setHeader(
'Content-Disposition',
`attachment; filename="${filename.replace(/[^\x20-\x7e]/g, '_')}"; filename*=UTF-8''${encoded}`
);
filename 是给老浏览器看的 ASCII 降级版本,filename* 才是现代浏览器实际使用的。两个都要给。
#问题二:上传时的中文文件名就已经乱了
这是更隐蔽的一个坑。multipart/form-data 协议里,文件名默认按 latin1 编码传输,而 Node 收到后不会自动转换:
// multer 拿到的 file.originalname 是 latin1 字符串
const fixed = Buffer.from(file.originalname, 'latin1').toString('utf8');
不改这一步,数据库里存的就是乱码,后面怎么处理都没用。
同时建议把磁盘文件名和展示名解耦:
filename(req, file, cb) {
const original = Buffer.from(file.originalname, 'latin1').toString('utf8');
const ext = path.extname(original);
// 磁盘上用 时间戳+随机串,彻底避开中文、空格、特殊字符
cb(null, `${Date.now()}-${randomId(8)}${ext}`);
}
好处有三个:避开文件系统编码问题、避免同名覆盖、防止路径穿越(../../etc/passwd 这类文件名)。
#问题三:下载计数怎么算才准
不要在下载完成时计数,因为大文件下载中断时服务端很难可靠感知。也不要放在响应之后,因为响应一发出连接可能就断了。
正确位置是在校验通过、准备开始传输的那一刻:
router.get('/download/:id', (req, res, next) => {
const a = store.attachments.getById(req.params.id);
if (!a) return next();
const abs = path.join(UPLOAD_PATH, path.basename(a.filename));
if (!fs.existsSync(abs)) {
return res.status(404).render('error', { message: '文件已丢失' });
}
// 校验全部通过,此刻计数
store.attachments.addDownload(a.id);
store.attachments.logDownload(a.id, req.ip, req.get('user-agent'));
res.download(abs, a.original_name);
});
注意 path.basename(a.filename) —— 这行是防路径穿越的关键。即使数据库被污染成 ../../etc/passwd,basename 也会把它削成 passwd。
#问题四:大文件断点续传
res.download() 不支持 Range 请求。要支持断点续传和视频拖动,得手动实现:
router.get('/file/:id', (req, res) => {
const abs = resolvePath(req.params.id);
const stat = fs.statSync(abs);
const range = req.headers.range;
if (!range) {
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': 'application/octet-stream',
'Accept-Ranges': 'bytes',
});
return fs.createReadStream(abs).pipe(res);
}
// 解析 "bytes=0-1023"
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
if (start >= stat.size || end >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
return res.end();
}
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': 'application/octet-stream',
});
fs.createReadStream(abs, { start, end }).pipe(res);
});
关键点:
- 无
Range头时返回 200 并带上Accept-Ranges: bytes - 有
Range时返回 206 Partial Content - 范围非法返回 416
- 一定要用
createReadStream而不是readFileSync,否则大文件会把内存吃光
#顺带说防盗链
如果资料想限制来源,用 Referer 判断:
function antiLeech(req, res, next) {
const ref = req.get('referer');
// 允许直接访问(没有 referer)
if (!ref) return next();
try {
const host = new URL(ref).host;
if (host === req.get('host')) return next();
} catch (_) {}
return res.status(403).send('请从站点页面下载');
}
注意 Referer 可以被伪造,只能防君子不能防小人。 真要严格管控,需要引入带时效签名的下载链接:
const crypto = require('crypto');
function sign(id, expiresAt) {
const data = `${id}.${expiresAt}`;
return crypto.createHmac('sha256', SECRET).update(data).digest('hex').slice(0, 16);
}
#别忘了 Nginx 侧的配合
即使 Node 支持了断点续传,Nginx 也可能把它吃掉。确认配置里有:
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_force_ranges on; # 让 Nginx 自己处理 Range,效率更高
另外,上传目录一定要禁止脚本执行:
location ^~ /uploads/ {
location ~* \.(php|phtml|jsp|asp|aspx|cgi|pl)$ {
deny all;
}
}
哪怕 Node 不会执行 .php,多一层防护没有坏处。
#相关资源
以下资料可直接下载:
Nginx 反向代理站点配置模板
含 HTTPS 跳转、证书自动续期目录放行、WebSocket 升级、静态资源缓存与安全响应头的完整配置模板,改两个变量即可用。
#小结
| 问题 | 关键做法 |
|---|---|
| 中文文件名 | latin1 → utf8 转码 + RFC 5987 双写 filename |
| 计数准确 | 校验通过后、传输开始前计数 |
| 路径安全 | 始终 path.basename() |
| 大文件 | 手动实现 Range,用流不用 readFile |
| 防盗链 | Referer 做基础防护,重要资源用签名链接 |
下载接口看着简单,但每一条都是实际踩出来的。