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*、构建/缓存产物。
This commit is contained in:
119
app/controller/materials.js
Normal file
119
app/controller/materials.js
Normal file
@@ -0,0 +1,119 @@
|
||||
'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;
|
||||
Reference in New Issue
Block a user