Files

120 lines
4.4 KiB
JavaScript
Raw Permalink Normal View History

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