胖叔网络科技 网络科技 · 技术笔记
后端技术

Node.js 实现文件下载服务:下载计数、断点续传与防盗链

一个看似简单的下载接口,要处理好下载计数、中文文件名、大文件断点续传和防盗链四件事,才算真正能用。

# Express# HTTP# Node.js# 文件下载

#从最朴素的写法开始

第一版通常长这样:

JavaScript
app.get('/download/:id', (req, res) => {
  const file = db.get(req.params.id);
  res.download(file.path);
});

能跑,但有四个问题会在真实使用中陆续暴露。

#问题一:中文文件名变成乱码

res.download() 设置 Content-Disposition 时,如果文件名含非 ASCII 字符,需要按 RFC 5987 编码:

JavaScript
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 收到后不会自动转换:

JavaScript
// multer 拿到的 file.originalname 是 latin1 字符串
const fixed = Buffer.from(file.originalname, 'latin1').toString('utf8');

不改这一步,数据库里存的就是乱码,后面怎么处理都没用。

同时建议把磁盘文件名和展示名解耦

JavaScript
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 这类文件名)。

#问题三:下载计数怎么算才准

不要在下载完成时计数,因为大文件下载中断时服务端很难可靠感知。也不要放在响应之后,因为响应一发出连接可能就断了。

正确位置是在校验通过、准备开始传输的那一刻

JavaScript
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/passwdbasename 也会把它削成 passwd

#问题四:大文件断点续传

res.download() 不支持 Range 请求。要支持断点续传和视频拖动,得手动实现:

JavaScript
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 判断:

JavaScript
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 可以被伪造,只能防君子不能防小人。 真要严格管控,需要引入带时效签名的下载链接:

JavaScript
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 也可能把它吃掉。确认配置里有:

Nginx
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_force_ranges on;   # 让 Nginx 自己处理 Range,效率更高

另外,上传目录一定要禁止脚本执行

Nginx
location ^~ /uploads/ {
    location ~* \.(php|phtml|jsp|asp|aspx|cgi|pl)$ {
        deny all;
    }
}

哪怕 Node 不会执行 .php,多一层防护没有坏处。

#相关资源

以下资料可直接下载:

CONF

Nginx 反向代理站点配置模板

含 HTTPS 跳转、证书自动续期目录放行、WebSocket 升级、静态资源缓存与安全响应头的完整配置模板,改两个变量即可用。

nginx-site-template.conf 2.2 KB 87 次下载

#小结

问题 关键做法
中文文件名 latin1 → utf8 转码 + RFC 5987 双写 filename
计数准确 校验通过后、传输开始前计数
路径安全 始终 path.basename()
大文件 手动实现 Range,用流不用 readFile
防盗链 Referer 做基础防护,重要资源用签名链接

下载接口看着简单,但每一条都是实际踩出来的。