查比赛功能时实跑出来的四个问题,都在这一条里修掉: **倒计时两倍速**(store/contest.ts)。init() 里 setInterval 之前不清旧表,而 detail.vue 在「未开始 → 进行中」那一刻会再 init 一次(为了捞开赛后才拿得到的题), 于是两个 interval 一起给 now 加 1000。学生赛前挂着页面就会中招:一场 60 分钟的 比赛,真过了 30 分钟页面就显示「已结束」、倒计时归零,而服务端还在正常收提交。 ojnext 里就有,是原样搬过来的。 **排名页「开启自动刷新」开着但不刷新**(contest/pages/rank.vue)。useIntervalFn 传的是 immediate: false,而 watch(autoRefresh) 只在开关变化时才 resume —— 开关初值就是 true、进页面不产生变化,表从没启动过,得手动关一次再开。改成 watchEffect,由「开关 + 比赛进行中」共同驱动,顺带不再在赛后空转轮询。同样来自 ojnext。 **比赛题的 myStatus 恒为 null**(routes/contest.ts)。判题其实把状态记进了 user_profile 的 acm_problems_status.contest_problems,只是这两条路由硬编码下发 空值,于是题目页的「状态」列永远是「未做」,赛后也不恢复。旧后端在赛后/管理员 视角是给的,这是回归。不按赛中赛后分档:这是学生自己的判题结果,不泄露别人任何 信息(旧后端赛中不给,只是因为它整条路换了个 serializer)。 **比赛一隐藏,审核页的「查看代码」必 404**(services/contest.ts)。acm-helper 故意不卡 visible(赛后核查恰恰发生在比赛收起来之后),它调的比赛提交列表却卡着, 两边对不上。findVisibleContest 换成 findAccessibleContest:公开的谁都取得到, 隐藏的只有比赛管理员取得到,学生看隐藏比赛照旧 404。 实跑验证(dev 全栈,判题走临时 worker 绕开本机 token 不一致): - 浏览器跨过开赛时刻挂着不动 —— 墙钟 20.0 秒,倒计时正好减 20 秒(原来会减 40)。 - 排名页停在「无数据」,另一账号提交一发 AC,5 秒内表格自己长出 `1 student2 1/3 0:00:42`,没刷新页面。 - 学生 AC 后列表和详情都回 myStatus: 0,没做过的另一个学生仍是 null,匿名照旧 401。 - 比赛隐藏 + 已结束:出题人 detail / problems / rank / submissions / acm-helper 全 200,学生这 5 条全 404,提交也 404,隐藏比赛不进公开列表。 - 排名记账口径未受影响:10 次提交(8 编译失败 + 2 AC)落库 submission_number=9、 accepted_number=1、total_time=231=ac_time、is_first_ac=true。 tsc / vue-tsc / check:routes(175 条无遮蔽)均干净,测试数据已清库。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xu912Rv5JUUuy6MqMcQW2
117 lines
5.4 KiB
TypeScript
117 lines
5.4 KiB
TypeScript
import { createHash } from "node:crypto"
|
||
|
||
import { eq } from "drizzle-orm"
|
||
import type { Context, MiddlewareHandler } from "hono"
|
||
|
||
import type { AppEnv } from "../auth/middleware"
|
||
import type { AuthUser } from "../auth/session"
|
||
import { getContestPassword } from "../auth/session"
|
||
import { db, schema } from "../db"
|
||
import { failure } from "../http"
|
||
|
||
export type ContestRow = typeof schema.contest.$inferSelect
|
||
|
||
/**
|
||
* 走过 requireContestAccess 的路由,可以从 c.var.contest 直接拿到已鉴权的比赛。
|
||
*
|
||
* 类型上是可选的(同一个 router 里还有不涉及比赛的路由),所以 handler 里要写 `!`。
|
||
* 万一漏挂中间件,这里会在运行时抛错变成 500 —— 吵闹但安全,
|
||
* 而漏调 canAccessContest 是静默放行,两者不可同日而语。
|
||
*/
|
||
export interface ContestEnv extends AppEnv {
|
||
Variables: AppEnv["Variables"] & { contest?: ContestRow }
|
||
}
|
||
|
||
export function contestStatus(contest: ContestRow) {
|
||
const now = Date.now()
|
||
if (Date.parse(contest.startTime) > now) return "1" as const
|
||
if (Date.parse(contest.endTime) < now) return "-1" as const
|
||
return "0" as const
|
||
}
|
||
|
||
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
|
||
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
|
||
}
|
||
|
||
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
|
||
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
|
||
}
|
||
|
||
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
|
||
if (!candidate || !expected) return false
|
||
if (candidate === expected) return true
|
||
const parts = candidate.split("#")
|
||
if (parts.length !== 2) return false
|
||
const [signature, expiresAt] = parts
|
||
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
|
||
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
|
||
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
||
}
|
||
|
||
/**
|
||
* 取一场「这个人看得见」的比赛:公开(visible)的谁都取得到,隐藏的只有比赛管理员
|
||
* (出题人本人 / 超管)取得到,对其余人一律当作不存在。
|
||
*
|
||
* 原来这里一律卡 visible,于是老师赛后把比赛收起来之后,核查页的「查看代码」必然 404:
|
||
* 那个页面自己**故意不卡** visible(赛后核查恰恰发生在比赛收起来之后,见
|
||
* admin/contest.ts 的说明),它调的比赛提交列表却卡着,两边对不上。
|
||
*
|
||
* 放宽的只有出题人自己的视角,学生看隐藏比赛照旧是 404。
|
||
*/
|
||
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) {
|
||
const [contest] = await db.select().from(schema.contest)
|
||
.where(eq(schema.contest.id, id)).limit(1)
|
||
if (!contest) return null
|
||
return contest.visible || isContestAdmin(user, contest) ? contest : null
|
||
}
|
||
|
||
// 泛型而不是写死 Context<AppEnv>:requireContestAccess 传进来的是 Context<ContestEnv>,
|
||
// 它比 AppEnv 多一个变量,而 Hono 的 Context 在 Variables 上是逆变的,写死会类型不兼容。
|
||
export async function canAccessContest<E extends AppEnv>(
|
||
c: Context<E>,
|
||
contest: ContestRow,
|
||
checkType: "details" | "problems" | "ranks" | "submissions",
|
||
) {
|
||
const user = c.get("user")
|
||
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
|
||
if (isContestAdmin(user, contest)) return { ok: true as const }
|
||
if (contest.password) {
|
||
const stored = await getContestPassword(c, contest.id)
|
||
if (!checkContestPassword(stored, contest.password)) {
|
||
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
|
||
}
|
||
}
|
||
if (contestStatus(contest) === "1" && checkType !== "details") {
|
||
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
|
||
}
|
||
return { ok: true as const }
|
||
}
|
||
|
||
/**
|
||
* 比赛内容路由的守卫中间件。旧后端用 `@check_contest_permission` 装饰器,漏挂一眼看得出来;
|
||
* 手工在 handler 里调 `canAccessContest` 则漏调一次就是静默放行,而且这类路由挂的是
|
||
* `optionalAuth`(本身不拦人),从路由注册那一行完全看不出它受保护。这个中间件把
|
||
* 「取比赛 → 404 → 鉴权 → 401/403」四步收进注册行里,恢复旧后端那种显眼程度。
|
||
*
|
||
* 通过后比赛对象放进 `c.var.contest`,handler 直接取,不必再查一次库。
|
||
*
|
||
* 注意:`POST /submissions` 用不了它 —— 那里的比赛 id 来自请求体而非路径参数,
|
||
* 中间件跑的时候还没解析 body。那一处仍是手工调用,见 submission.ts 内的说明。
|
||
*/
|
||
export function requireContestAccess(
|
||
checkType: "details" | "problems" | "ranks" | "submissions",
|
||
paramName = "id",
|
||
): MiddlewareHandler<ContestEnv> {
|
||
return async (c, next) => {
|
||
const id = Number(c.req.param(paramName))
|
||
const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
|
||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||
const access = await canAccessContest(c, contest, checkType)
|
||
if (!access.ok) {
|
||
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||
}
|
||
c.set("contest", contest)
|
||
await next()
|
||
}
|
||
}
|