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:
@@ -6,7 +6,7 @@ export function success<T>(c: Context, data: T, status = 200) {
|
||||
|
||||
export function failure(
|
||||
c: Context,
|
||||
status: 400 | 401 | 403 | 404 | 409 | 500 | 502,
|
||||
status: 400 | 401 | 403 | 404 | 409 | 429 | 500 | 502,
|
||||
code: string,
|
||||
message: string,
|
||||
) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
113
apps/api/src/services/throttling.ts
Normal file
113
apps/api/src/services/throttling.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { redis } from "../redis"
|
||||
import { getOptions } from "./options"
|
||||
|
||||
/**
|
||||
* 令牌桶限流,搬运自旧后端 `utils/throttling.py` 的 TokenBucket。
|
||||
*
|
||||
* 参数与旧后端 `options/options.py:120` 的默认值逐字对齐:
|
||||
* user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 }
|
||||
* ip: { capacity: 100, fill_rate: 0.1, default_capacity: 50 }
|
||||
* 和旧后端一样,实际值以数据库 `throttling` 配置项为准,缺失时用上面的默认值。
|
||||
*
|
||||
* 旧实现在注释里写明「对于单个 key 的操作不是线程安全的」;这里改用 Lua 脚本做成原子操作,
|
||||
* 算法和参数不变 —— 限流要挡的正是并发突发,读改写有竞态的话等于没挡。
|
||||
*/
|
||||
|
||||
export type BucketConfig = {
|
||||
capacity: number
|
||||
fill_rate: number
|
||||
default_capacity: number
|
||||
}
|
||||
|
||||
export const throttlingDefaults: Record<"ip" | "user", BucketConfig> = {
|
||||
ip: { capacity: 100, fill_rate: 0.1, default_capacity: 50 },
|
||||
user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 },
|
||||
}
|
||||
|
||||
// KEYS[1] = bucket key
|
||||
// ARGV = capacity, fill_rate, default_capacity, now(seconds), num, ttl(seconds)
|
||||
const CONSUME_SCRIPT = `
|
||||
local key = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local fill_rate = tonumber(ARGV[2])
|
||||
local default_capacity = tonumber(ARGV[3])
|
||||
local now = tonumber(ARGV[4])
|
||||
local num = tonumber(ARGV[5])
|
||||
local ttl = tonumber(ARGV[6])
|
||||
|
||||
local last_capacity = tonumber(redis.call('HGET', key, 'last_capacity'))
|
||||
local last_timestamp = tonumber(redis.call('HGET', key, 'last_timestamp'))
|
||||
if last_capacity == nil or last_timestamp == nil then
|
||||
last_capacity = default_capacity
|
||||
last_timestamp = now
|
||||
end
|
||||
|
||||
local current = last_capacity + fill_rate * (now - last_timestamp)
|
||||
if current > capacity then current = capacity end
|
||||
|
||||
local allowed = 0
|
||||
local wait = 0
|
||||
if current >= num then
|
||||
current = current - num
|
||||
allowed = 1
|
||||
else
|
||||
wait = (num - current) / fill_rate
|
||||
end
|
||||
|
||||
redis.call('HSET', key, 'last_capacity', tostring(current), 'last_timestamp', tostring(now))
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
return { allowed, tostring(wait) }
|
||||
`
|
||||
|
||||
function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
|
||||
const raw = value as Record<string, unknown>
|
||||
const pick = (key: keyof BucketConfig) =>
|
||||
typeof raw[key] === "number" && Number.isFinite(raw[key]) && (raw[key] as number) > 0
|
||||
? (raw[key] as number)
|
||||
: fallback[key]
|
||||
return {
|
||||
capacity: pick("capacity"),
|
||||
fill_rate: pick("fill_rate"),
|
||||
default_capacity: pick("default_capacity"),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBucketConfig(scope: "ip" | "user"): Promise<BucketConfig> {
|
||||
const fallback = throttlingDefaults[scope]
|
||||
try {
|
||||
const values = await getOptions(["throttling"])
|
||||
const throttling = values.throttling
|
||||
if (!throttling || typeof throttling !== "object" || Array.isArray(throttling)) return fallback
|
||||
return parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsumeResult = { allowed: true } | { allowed: false; wait: number }
|
||||
|
||||
export async function consumeToken(
|
||||
scope: "ip" | "user",
|
||||
identity: string,
|
||||
num = 1,
|
||||
): Promise<ConsumeResult> {
|
||||
const bucket = await getBucketConfig(scope)
|
||||
// 桶全满需要 capacity / fill_rate 秒;留出余量后过期,避免残留 key 无限堆积。
|
||||
// 每次调用都会刷新 TTL,因此只有长时间无提交才会过期,届时桶早已回满,
|
||||
// 重新按 default_capacity 初始化只会更严,不会放水。
|
||||
const ttl = Math.ceil(bucket.capacity / bucket.fill_rate) + 60
|
||||
const result = (await redis.eval(
|
||||
CONSUME_SCRIPT,
|
||||
1,
|
||||
`throttling:${scope}:${identity}`,
|
||||
String(bucket.capacity),
|
||||
String(bucket.fill_rate),
|
||||
String(bucket.default_capacity),
|
||||
String(Date.now() / 1000),
|
||||
String(num),
|
||||
String(ttl),
|
||||
)) as [number, string]
|
||||
if (Number(result[0]) === 1) return { allowed: true }
|
||||
return { allowed: false, wait: Number(result[1]) || 0 }
|
||||
}
|
||||
Reference in New Issue
Block a user