运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。 已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
120 lines
4.4 KiB
JavaScript
120 lines
4.4 KiB
JavaScript
'use strict';
|
||
|
||
const Controller = require('egg').Controller;
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
// 🔴 同源 collapse(缺口9 修复):文件发送统一走 sendFile util
|
||
const { sendFile } = require('../utils/sendFile');
|
||
|
||
/**
|
||
* 资料下载控制器 - MaterialsController
|
||
*
|
||
* 负责比赛相关资料的查询和文件下载。
|
||
* 从 app/public/materials/{group}/ 目录读取管理员上传的备赛资料。
|
||
*
|
||
* 主要接口:
|
||
* - GET /api/materials?group={group} : 获取资料列表
|
||
* - GET /api/materials/:group/:fileName/download : 流式下载指定文件
|
||
*/
|
||
class MaterialsController extends Controller {
|
||
/**
|
||
* 获取资料列表
|
||
*
|
||
* 读取 app/public/materials/{group}/ 目录下的所有文件,
|
||
* 返回文件名、大小、上传时间等信息。
|
||
*/
|
||
async index() {
|
||
const { ctx, app } = this;
|
||
const { group } = ctx.query;
|
||
|
||
// 如果未指定组别,使用当前用户的组别
|
||
const userGroup = group || (ctx.state.currentUser && ctx.state.currentUser.group);
|
||
|
||
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
|
||
const targetGroup = validGroups.includes(userGroup) ? userGroup : null;
|
||
|
||
if (!targetGroup) {
|
||
ctx.body = ctx.helper.success([], '获取资料列表成功');
|
||
return;
|
||
}
|
||
|
||
const materialsDir = path.join(app.baseDir, 'app/public', 'materials', targetGroup);
|
||
|
||
// 防御性读取:目录不存在或读取过程抛出异常(如生产环境目录权限/EACCES、
|
||
// 坏软链 ENOENT 等)都优雅返回空列表,绝不向上抛出——
|
||
// 否则异常会被 auth 中间件的 try/catch 兜底,被误报为 401「认证失败」,
|
||
// 导致前端误以为登录过期而将用户踢回登录页。
|
||
try {
|
||
if (!fs.existsSync(materialsDir)) {
|
||
ctx.body = ctx.helper.success([], '获取资料列表成功');
|
||
return;
|
||
}
|
||
|
||
// 逐文件 stat:单个文件 stat 失败(坏软链/权限)只跳过并告警,
|
||
// 绝不让一个坏文件把整份列表拖空(否则「刚上传的文件也不显示」)。
|
||
const files = [];
|
||
for (const f of fs.readdirSync(materialsDir)) {
|
||
if (f.startsWith('.')) continue;
|
||
try {
|
||
const stat = fs.statSync(path.join(materialsDir, f));
|
||
const ext = path.extname(f).toLowerCase();
|
||
files.push({
|
||
id: encodeURIComponent(f),
|
||
fileName: f,
|
||
name: f,
|
||
size: stat.size,
|
||
type: ext.replace('.', ''),
|
||
uploadTime: stat.mtime.toISOString(),
|
||
});
|
||
} catch (e) {
|
||
app.logger.warn(
|
||
`[materials.index] 跳过无法 stat 的文件 group=${targetGroup} file=${f}:` + (e && e.message ? e.message : e)
|
||
);
|
||
}
|
||
}
|
||
files.sort((a, b) => new Date(b.uploadTime) - new Date(a.uploadTime));
|
||
|
||
ctx.body = ctx.helper.success(files, '获取资料列表成功');
|
||
} catch (err) {
|
||
// 记录真实错误到日志,方便运维定位(权限/软链/磁盘问题)
|
||
app.logger.error(
|
||
`[materials] 读取备赛资料目录失败 group=${targetGroup} path=${materialsDir}:` +
|
||
(err && err.stack ? err.stack : err)
|
||
);
|
||
// 不因磁盘异常影响整个页面,返回空列表(前端不再被误踢)
|
||
ctx.body = ctx.helper.success([], '获取资料列表成功');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 流式下载文件
|
||
*
|
||
* 通过 URL 参数 :group 和 :fileName 定位文件,
|
||
* 读取并通过流式响应发送给客户端。
|
||
*/
|
||
async download() {
|
||
const { ctx, app } = this;
|
||
|
||
const { group, fileName } = ctx.params;
|
||
const decodedFileName = decodeURIComponent(fileName);
|
||
|
||
// 防止路径穿越攻击
|
||
if (decodedFileName.includes('..') || decodedFileName.includes('/') || decodedFileName.includes('\\')) {
|
||
ctx.body = ctx.helper.error(400, '无效的文件名');
|
||
return;
|
||
}
|
||
|
||
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
|
||
if (!validGroups.includes(group)) {
|
||
ctx.body = ctx.helper.error(400, '无效的组别');
|
||
return;
|
||
}
|
||
|
||
// 🔴 同源 collapse(缺口9 修复):统一走 sendFile(磁盘校验 + X-File-Size 哨兵 + X-Accel 卸载)
|
||
const relativePath = `materials/${group}/${decodedFileName}`;
|
||
return sendFile(ctx, app, { relativePath, fileName: decodedFileName, contentType: 'application/octet-stream' });
|
||
}
|
||
}
|
||
|
||
module.exports = MaterialsController;
|