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