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;
|