fix(阶段3): 提交可见性守卫补上匿名,详情脱敏,提交接口加限流

F3:`isRegularUser(user)` 对匿名用户(user 为 null)返回 false,
`submission_list_show_all` 关闭时守卫整体短路 —— 匿名能看到全部提交,
权限反而大于登录学生。实测开关关闭时匿名 total=23、登录学生 total=0。
而关闭这个开关的典型场景正是考试。改为 `!isAdminRole(user)`(非管理员即受限),
并删掉 isRegularUser(全仓只有这一处调用,留着就是下一个坑)。

F4:提交详情把 info(含每个测试点的 test_case 编号与 output_md5)与 ip
按「是不是自己的提交」下发。旧后端 submission/views/oj.py 把关的是角色:
is_admin_role() 决定用 SubmissionModelSerializer 还是
SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))。改为仅管理员可见。

F6:旧后端 SubmissionAPI.throttling 的 TokenBucket 没搬过来。
按旧参数重建(options/options.py:120 的 user 桶:capacity 20、fill_rate 0.03、
default_capacity 10,实际值仍以数据库 throttling 配置项为准),
落在 redis 上并改用 Lua 脚本做成原子操作 —— 旧实现自己注明「不是线程安全的」,
而限流要挡的正是并发突发。挂点位置与旧后端一致:比赛权限校验之后、取题目之前。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 01:59:19 -06:00
parent b4b61af6b0
commit 8237909d00
3 changed files with 131 additions and 4 deletions

View File

@@ -28,7 +28,8 @@ import {
} from "../services/contest"
import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options"
import { isAdminRole, isRegularUser, queryInteger, todayStart } from "./helpers"
import { consumeToken } from "../services/throttling"
import { isAdminRole, queryInteger, todayStart } from "./helpers"
export const submissionRoutes = new Hono<AppEnv>()
@@ -69,6 +70,13 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
contestId = contest.id
}
// 限流,位置与旧后端 submission/views/oj.py 的 SubmissionAPI.post 一致:
// 比赛权限校验之后、取题目之前,按用户 id 消耗一个令牌。判题沙箱是有限资源。
const throttle = await consumeToken("user", String(c.get("user")!.id))
if (!throttle.allowed) {
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
const [problem] = await db
.select({
id: schema.problem.id,
@@ -184,7 +192,11 @@ async function submissionDetail(id: string, user: AuthUser) {
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, id)).limit(1)
if (!row || !canViewSubmission(user, row.submission, row.problem, row.contest)) return null
const full = isAdminRole(user) || row.submission.userId === user.id
// info含每个测试点的 test_case 编号与 output_md5与 ip 只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer(exclude=("info", "contest", "ip")) 之间二选一,
// 把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user)
return submissionDetailSchema.parse({
id: row.submission.id,
createTime: row.submission.createTime,
@@ -208,7 +220,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const user = c.get("user")
if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) {
// 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户user 为 null会短路匿名反而能看到全部提交权限大于登录学生。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, submissionListSchema.parse({ results: [], total: 0 }))
}
const filters = [isNull(schema.submission.contestId)]