feat(server,web,shared,desktop): 上传超限正确报错 + 两个登录入口都限流 + 作业按天深链,文档与验证脚本同步
服务端加固: - express.json 上限 2mb → 12mb:表格导入把文件以 base64 放进 JSON(约 4/3 倍), 8MB 的声明上限原先到 1.5MB 就被拦掉;超限现在由 error 中间件映射成 413 IMPORT_TOO_LARGE(中文提示"请拆分后分批导入"),不再落进 500 兜底分支抛英文原文; - loginRateLimiter 同时挂 /auth/login 与 /auth/class-login(班级密码默认 123456, 班级登录曾是唯一没有暴力破解防护的入口),两个路径共享同一计数器; - CORS_ORIGIN=* 时启动告警(不拦截启动,避免挡住教室机 file:// 的合法部署); - 删除已无用的 DEFAULT_STUDENT_PASSWORD;班级 termWeeks 上限 40 → 30, 与 MAX_TERM_WEEK / weekNumberSchema 对齐(原先对外承诺了系统兑现不了的学期长度)。 Web 端: - 作业页 ?date= 深链在同路由再推 query 时也生效(watch route.query.date); - 表格导入对话框适配 8MB 上限与 413 提示。 冒烟与文档: - 新增 scripts/lib/smoke-class.mjs:验证用临时班级,避免探针通知弹到用户自己的客户端与灵动岛; - README 与 AGENTS.md 同步本轮全部行为变更(含 schedule source、学生账号清理、凭据门禁)。
This commit is contained in:
1 parent
3880eee08e
commit
20fcca0388
28 files changed
+1649
-915
No files matched your search
@@ -145,8 +145,15 @@ export function createApp(): Express {
|
||||
app.use(securityHeaders());
|
||||
app.use(compression());
|
||||
app.use(cors({ origin: env.corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
/**
|
||||
* 请求体上限:表格导入是把文件以 **base64** 放进 JSON 的,base64 约为原文件的 4/3,
|
||||
* 再算上 JSON 包装,8MB 的表格文件约需 11MB 请求体。
|
||||
* 这里必须留够余量,否则「导入模板写的 8MB 上限」永远到不了(文件到 1.5MB 就被 body-parser 拦掉),
|
||||
* 而且用户看到的是一句 500「服务器内部错误:request entity too large」——
|
||||
* 与 `table-import.service.ts` 的 MAX_IMPORT_BYTES(8MB) 校验完全对不上。
|
||||
*/
|
||||
app.use(express.json({ limit: '12mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
app.use(requestLogger());
|
||||
|
||||
// 探针(不参与限流)
|
||||
@@ -173,7 +180,10 @@ export function createApp(): Express {
|
||||
|
||||
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
||||
app.use(API_PREFIX, apiRateLimiter());
|
||||
app.use(`${API_PREFIX}/auth/login`, loginRateLimiter());
|
||||
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
|
||||
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
|
||||
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
|
||||
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
|
||||
|
||||
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
||||
for (const module of apiModules) {
|
||||
|
||||
@@ -27,7 +27,6 @@ const envSchema = z.object({
|
||||
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||||
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
|
||||
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||||
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
||||
@@ -109,6 +108,22 @@ if (isProduction) {
|
||||
'请修改 packages/server/.env 后重试。',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 默认值告警(**不拦截启动**)。
|
||||
*
|
||||
* `CORS_ORIGIN=*` 与 `credentials: true` 同时开启时,cors 中间件会反射请求方 Origin 并带上
|
||||
* `Access-Control-Allow-Credentials: true`。本项目的鉴权走 `Authorization` 头(不是 cookie),
|
||||
* 第三方页面拿不到用户的 token,因此实际风险有限;但"允许任意站点带凭据访问"是没有必要的暴露面,
|
||||
* 生产部署应填成实际域名。这里只告警:真要卡死会挡住"教室机 file:// 页面直连后端"这类合法部署
|
||||
* (那类请求的 Origin 是 `file://`,无法提前枚举),代价不值得。
|
||||
*/
|
||||
if (raw.CORS_ORIGIN.trim() === '*') {
|
||||
console.warn(
|
||||
'⚠ CORS_ORIGIN=* 且已开启 credentials:生产环境建议改成具体来源(逗号分隔),' +
|
||||
'例如 CORS_ORIGIN=https://your-domain.example(教室机 file:// 客户端不受影响)。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
|
||||
@@ -142,7 +157,6 @@ export const env = {
|
||||
.filter(Boolean),
|
||||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||||
/** 新增学生时未指定密码时的初始密码 */
|
||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||||
/** 新建班级时的默认班级密码(班级账号登录) */
|
||||
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
||||
/** 新建/重置教师账号时的初始密码 */
|
||||
|
||||
@@ -31,5 +31,24 @@ export const booleanFlagSchema = z
|
||||
/** 关键字查询 */
|
||||
export const keywordSchema = z.string().trim().max(64).optional();
|
||||
|
||||
/**
|
||||
* 附件链接。
|
||||
*
|
||||
* 只允许 http(s) 绝对地址或站内相对路径:这一层是为**渲染端**兜底 ——
|
||||
* 该字段最终会绑到前端的 `<a href>` / `el-link :href` 上,
|
||||
* 若放行 `javascript:` / `data:text/html` 就会变成一条存储型 XSS 的注入点。
|
||||
* 空串与 null 都表示"没有附件"。
|
||||
*/
|
||||
export const attachmentUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.refine(
|
||||
(value) =>
|
||||
value === '' || value.startsWith('/') || /^https?:\/\//i.test(value),
|
||||
{ message: '附件链接需为 http(s):// 地址或站内相对路径' },
|
||||
)
|
||||
.nullish();
|
||||
|
||||
/** 周次:1..30 */
|
||||
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
|
||||
@@ -40,6 +40,24 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体超限(body-parser 抛的 `entity.too.large`)。
|
||||
*
|
||||
* 必须单独处理:它带 `status: 413` 但不是 ApiError,落进下面的兜底分支会变成
|
||||
* 「500 服务器内部错误:request entity too large」——既是错的状态码,也把英文原文抛给用户,
|
||||
* 而用户真正需要知道的是"文件太大了、请拆分"(表格导入的 8MB 上限见 table-import.service)。
|
||||
*/
|
||||
if ((error as { type?: string }).type === 'entity.too.large') {
|
||||
logger.warn(`请求体超过上限:${req.method} ${req.originalUrl}`);
|
||||
sendError(
|
||||
res,
|
||||
413,
|
||||
'上传内容过大:请把文件拆分后分批导入(表格文件上限 8MB)',
|
||||
'IMPORT_TOO_LARGE',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
|
||||
const prismaCode = (error as { code?: string }).code;
|
||||
if (prismaCode === 'P2002') {
|
||||
|
||||
@@ -17,8 +17,9 @@ router.get('/summary', async (req, res) => {
|
||||
/**
|
||||
* GET /api/dashboard/term?classId= - 学期信息(当前教学周 + 本班教学周数)
|
||||
*
|
||||
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,班主任可在班级设置里调,默认 20),
|
||||
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,管理员在班级设置里调,默认 20),
|
||||
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
|
||||
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
|
||||
*/
|
||||
router.get('/term', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
assertClassAccess,
|
||||
classScopeIdFilter,
|
||||
classScopeWhere,
|
||||
isStudent,
|
||||
@@ -20,12 +21,14 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
||||
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
|
||||
*/
|
||||
/**
|
||||
* 本班教学周数(班主任可调,默认 20)。
|
||||
* 本班教学周数(默认 20)。
|
||||
* - 学生 / 班级会话:固定为自己所在班级;
|
||||
* - 教师 / 管理员:优先用显式 classId(越权查不到时回退默认值);
|
||||
* - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径,
|
||||
* 否则任意登录用户都能拿 classId 探测别班的教学周数);
|
||||
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
|
||||
*/
|
||||
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
|
||||
if (!targetClassId) return MAX_TERM_WEEK;
|
||||
const record = await prisma.class.findUnique({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { isDayKey } from '@classhelper/shared';
|
||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
||||
import { attachmentUrlSchema, booleanFlagSchema } from '../../lib/schemas.js';
|
||||
|
||||
/** YYYY-MM-DD,且必须是真实存在的日期 */
|
||||
const dayKeySchema = z
|
||||
@@ -32,7 +32,7 @@ export const createHomeworkSchema = z.object({
|
||||
courseId: z.string().min(1).nullish(),
|
||||
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
||||
content: z.string().min(1, '请输入作业内容').max(5000),
|
||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
||||
attachmentUrl: attachmentUrlSchema,
|
||||
/** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */
|
||||
assignDate: dayKeySchema.nullish(),
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export const updateHomeworkSchema = z.object({
|
||||
assignDate: dayKeySchema.optional(),
|
||||
title: z.string().trim().min(1).max(120).optional(),
|
||||
content: z.string().min(1).max(5000).optional(),
|
||||
attachmentUrl: z.string().trim().max(500).nullish(),
|
||||
attachmentUrl: attachmentUrlSchema,
|
||||
});
|
||||
|
||||
export const updateHomeworkStatusSchema = z.object({
|
||||
|
||||
@@ -268,12 +268,16 @@ export async function updateHomeworkStatus(
|
||||
return dto;
|
||||
}
|
||||
|
||||
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 userId 并默默写到
|
||||
// 操作者自己的账号上(教师时 user.sub 就是教师 id),那条记录既不在学生名单里,
|
||||
// 又会被教师视角的 completedCount 计入,导致"完成人数 > 班级人数"。
|
||||
const targetUserId = input.userId ?? user.sub;
|
||||
if (input.userId) {
|
||||
const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } });
|
||||
if (!target || target.classId !== homework.classId) {
|
||||
throw ApiError.badRequest('目标学生不属于该作业所在班级');
|
||||
}
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { role: true, classId: true },
|
||||
});
|
||||
if (!target || target.role !== 'STUDENT' || target.classId !== homework.classId) {
|
||||
throw ApiError.badRequest('请指定该作业所在班级的学生(userId),未指定时只能操作自己的账号');
|
||||
}
|
||||
|
||||
const status = await prisma.homeworkStatus.upsert({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
IMPORT_KINDS,
|
||||
@@ -140,9 +141,10 @@ router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (r
|
||||
});
|
||||
|
||||
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
||||
router.delete('/time-layout/:id', async (req, res) => {
|
||||
await deleteTimeLayout(getAuthUser(req), req.params.id ?? '');
|
||||
sendOk(res, { id: req.params.id }, '时间配置已删除');
|
||||
router.delete('/time-layout/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await deleteTimeLayout(getAuthUser(req), id);
|
||||
sendOk(res, { id }, '时间配置已删除');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
|
||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
|
||||
@@ -46,9 +46,9 @@ export const IMPORT_FIELDS: Record<
|
||||
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
|
||||
],
|
||||
students: [
|
||||
// 学生只有名单没有账号(不能登录、无密码),模板不再有"初始密码"列
|
||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
|
||||
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
||||
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
|
||||
],
|
||||
teachers: [
|
||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
|
||||
@@ -371,7 +371,6 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
const rowNumber = index + 2;
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const password = cellValue(row, parsed.columns, input.mapping, 'password');
|
||||
|
||||
if (!username || !name) {
|
||||
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
|
||||
@@ -395,14 +394,14 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
|
||||
updated += 1;
|
||||
} else {
|
||||
const initialPassword = password || env.defaultStudentPassword;
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
name,
|
||||
role: 'STUDENT',
|
||||
classId: classId,
|
||||
passwordHash: await bcrypt.hash(initialPassword, 10),
|
||||
// 学生不设密码(空串占位,登录 403 判定在密码校验之前),见"学生只有名单没有账号"
|
||||
passwordHash: '',
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
@@ -478,7 +477,7 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
||||
username,
|
||||
name,
|
||||
role: parseTeacherRole(roleCell) ?? 'TEACHER',
|
||||
passwordHash: await bcrypt.hash(initialPassword, 10),
|
||||
passwordHash: await hashPassword(initialPassword),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
|
||||
Reference in new issue
Block a user