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:
LoveChengke committed 2026-10-02 00:25:21 +08:00
1 parent 3880eee08e
commit 20fcca0388
28 files changed
+1649 -915

No files matched your search

+13 -3
View File
@@ -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) {
+16 -2
View File
@@ -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,
/** 新建/重置教师账号时的初始密码 */
+19
View File
@@ -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);
+18
View File
@@ -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;