Files
competition-website/app/controller/materials.js
WorkBuddy a303cf2d53 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*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

120 lines
4.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;