Files
competition-website/app/utils/sendFile.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

110 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const path = require('path');
const fs = require('fs');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
/**
* 统一文件发送出口(X-Accel-Redirect 根治方案 + 完整性哨兵)。
*
* 所有「把磁盘文件交给调用方」的通道都必须经此函数,杜绝同源漏网。
*
* 2026-08-12 同源扩大化测试审计「缺口9」修复:materials.js(备赛资料) / exam.js(Word 模板)
* / admin.js(batchDownload ZIP 导出) 此前各自直写 fs.createReadStream,缺 X-File-Size 哨兵头,
* 导致前端 assertRealFile 的大小完整性校验收不到哨兵而失效(truncate / nginx fallback 替换
* 无法被前端侦测)。重构进本函数后,未来任意通道的逻辑升级都会自动同步。
*
* @param {object} ctx Egg 上下文
* @param {object} app Egg app(读 config.xAccelRedirect / baseDir)
* @param {object} opts
* - relativePath {string} 相对 app/public 的路径(如 works/benke/x.docx);X-Accel 用此拼内部路径
* - absolutePath {string} 绝对路径(如临时 zip);提供则忽略 relativePath,且强制 Node 流
* - fileName {string} 原始文件名(Content-Disposition)
* - contentType {string} MIME 类型
* - useXAccel {boolean} 是否允许 X-Accel 卸载(默认跟随 cfg.enabled;临时文件务必 false)
*/
async function sendFile(ctx, app, opts) {
const { absolutePath, relativePath, fileName, contentType, useXAccel } = opts;
const cfg = (app.config && app.config.xAccelRedirect) || { enabled: false, internalPrefix: '/internal-downloads/' };
// 路径解析
let absPath;
if (absolutePath) {
absPath = absolutePath;
} else {
// 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..)
if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) {
ctx.status = 400;
ctx.body = ctx.helper.error(400, '非法文件路径');
return;
}
absPath = path.join(app.baseDir, 'app/public', relativePath);
}
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
// 发头之前先确认磁盘上真有这个文件,缺失一律回干净 404(业务错误信封),
// 绝不把「下载」交给一个不存在的路径去碰运气。必须在 acquireFileIo 之前,避免 404 还配额。
let fileStat;
try {
fileStat = fs.statSync(absPath);
} catch (e) {
ctx.logger.error(`[sendFile] 文件缺失,拒绝下载 abs=${absPath}:${e && e.message}`);
ctx.status = 200; // 业务错误统一 200 + 信封,前端 safeDownload / fetchBlobGuarded 会识别并弹错
ctx.body = ctx.helper.error(404, `文件不存在(期望路径: ${relativePath || absolutePath})`);
return;
}
if (!fileStat.isFile()) {
ctx.body = ctx.helper.error(400, '目标不是一个有效文件');
return;
}
// 🔴 文件 IO 并发护栏(与上传通道共享同一全局配额):把「同时进行的文件流」限制在上限,
// 防止单 worker 在极端并发下被海量文件流压垮。配额必须在「打开/持有流」之前获取。
let released = false;
const rel = () => { if (!released) { released = true; releaseFileIo(); } };
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,下载通道暂满,请稍后重试');
return;
}
// X-Accel 决策:absolutePath(临时文件)强制 Node 流,避免「发头即 unlink」竞态损坏;
// 即使调用方误传 useXAccel:true + absolutePath,也因无 relativePath 拼内部路径而降级为 Node 流。
const xAccel = (useXAccel === undefined ? cfg.enabled : useXAccel) && !!relativePath;
if (xAccel) {
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
ctx.set('Content-Type', contentType);
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。
// nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。
ctx.set('X-File-Size', String(fileStat.size));
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
// 切勿再包 encodeURIComponent 造成双重编码。
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
ctx.set('X-Accel-Redirect', internalPath);
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
ctx.status = 200;
ctx.body = null;
rel(); // X-Accel 路径无 Node 文件流,立即释放配额
} else {
// 开发回退 / 临时文件:Node 直接流式(与改造前行为一致)。配额须随流「生命周期」释放,
// 不能放在方法返回时的 finally —— 流的读取发生在控制器返回之后。
ctx.attachment(fileName);
ctx.set('Content-Type', contentType);
// 与 X-Accel 分支保持一致,前端可统一按 X-File-Size 校验完整性
ctx.set('X-File-Size', String(fileStat.size));
const stream = fs.createReadStream(absPath);
stream.on('end', rel);
stream.on('close', rel);
stream.on('error', rel);
ctx.body = stream;
}
}
module.exports = { sendFile };