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*、构建/缓存产物。
This commit is contained in:
62
app/utils/filename.js
Normal file
62
app/utils/filename.js
Normal file
@@ -0,0 +1,62 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* 上传文件名归一化。
|
||||
*
|
||||
* 背景(生产事故复盘 2026-08-10):
|
||||
* 备赛资料上传曾出现「列表读取 ENOENT」——磁盘上的文件名是乱码(GBK 字节被按
|
||||
* UTF-8 误解码)+ 前导空格,导致 admin.getMaterials 在 readdirSync().map(statSync)
|
||||
* 时 stat 不到该文件而整列抛错。根因是 uploadMaterial 直接拿 stream.filename 当
|
||||
* 磁盘名、未做编码/空白归一化。
|
||||
*
|
||||
* 本函数对传入的「客户端原始文件名」做最小但充分的清洗,保证落盘名:
|
||||
* - 是合法 UTF-8(NFC 归一化,消除组合字符/变体差异)
|
||||
* - 不含控制字符与路径分隔符(防目录穿越 + 防 stat 失败)
|
||||
* - 不含前导/多余空白(折叠为单空格并 trim —— 直接修复「前导空格 + 乱码」类 ENOENT)
|
||||
* - 长度受限(保留扩展名)
|
||||
* 若清洗后为空(极端情况),调用方应回退到时间戳名,避免写出空名文件。
|
||||
*
|
||||
* 注意:本函数不负责「还原」客户端用错误编码(如 GBK)发来的文件名——那是客户端
|
||||
* 规范问题;它的职责是「让落盘名干净、可往返、不会让读盘崩溃」。已落盘的旧乱码文件
|
||||
* 需运维侧手动 rm(见 diag_orphan + 人工清理)。
|
||||
*/
|
||||
|
||||
function sanitizeUploadFileName(rawName, fallbackExt = '') {
|
||||
if (!rawName || typeof rawName !== 'string') return ''
|
||||
|
||||
let name = rawName
|
||||
|
||||
// 1) 客户端可能预编码(如 %E5%89%8D...),先尝试解码
|
||||
if (name.includes('%') && /%[0-9A-Fa-f]{2}/.test(name)) {
|
||||
try { name = decodeURIComponent(name) } catch (e) { /* 解码失败保留原串 */ }
|
||||
}
|
||||
|
||||
// 2) Unicode 归一化(NFC),消除同一字符的不同编码形态
|
||||
try {
|
||||
if (typeof name.normalize === 'function') name = name.normalize('NFC')
|
||||
} catch (e) { /* 兜底保留 */ }
|
||||
|
||||
// 3) 去除控制字符(C0: 0x00-0x1F,DEL/0x7F)与路径分隔符/危险字符
|
||||
// 控制字符在正则里用 \x00-\x1f 与 \x7f 表示,避免源码里写裸控制字节
|
||||
name = name.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
name = name.replace(/[\/\\:*?"<>|]/g, '_')
|
||||
|
||||
// 4) 折叠所有空白(含全角/不间断空格等 \s 覆盖的字符)为单空格并 trim
|
||||
name = name.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// 5) 去除首尾点(避免 ".name" / "name." / ".." 类)
|
||||
name = name.replace(/^[. ]+/, '').replace(/[. ]+$/, '')
|
||||
|
||||
// 6) 长度限制(保留扩展名)
|
||||
const MAX = 120
|
||||
if (name.length > MAX) {
|
||||
const dot = name.lastIndexOf('.')
|
||||
const ext = dot > 0 ? name.slice(dot) : ''
|
||||
const safeExt = ext || fallbackExt || ''
|
||||
name = name.slice(0, MAX - safeExt.length) + safeExt
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
module.exports = { sanitizeUploadFileName }
|
||||
109
app/utils/sendFile.js
Normal file
109
app/utils/sendFile.js
Normal file
@@ -0,0 +1,109 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user