diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 9840e60..9b24edf 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -8,10 +8,10 @@ import { problemDetailSchema, problemListItemSchema, } from "@oj2/contract" -import { and, asc, count, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm" import { Hono } from "hono" -import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware" +import { optionalAuth, requireAuth } from "../auth/middleware" import { setContestPassword } from "../auth/session" import { db, schema } from "../db" import { failure, success } from "../http" @@ -22,10 +22,12 @@ import { contestStatus, findVisibleContest, isContestAdmin, + requireContestAccess, + type ContestEnv, } from "../services/contest" import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers" -export const contestRoutes = new Hono() +export const contestRoutes = new Hono() async function creator(id: number) { const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName }) @@ -110,11 +112,8 @@ async function contestProblemTags(problemIds: number[]) { return map } -contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => { - const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) - if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") - const access = await canAccessContest(c, contest, "problems") - if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) +contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => { + const contest = c.get("contest")! const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) .from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) @@ -138,11 +137,8 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => { }))) }) -contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) => { - const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) - if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") - const access = await canAccessContest(c, contest, "problems") - if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) +contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => { + const contest = c.get("contest")! const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName }) .from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) @@ -187,11 +183,8 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) = })) }) -contestRoutes.get("/contests/:id/rank", optionalAuth, async (c) => { - const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 })) - if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") - const access = await canAccessContest(c, contest, "ranks") - if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) +contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => { + const contest = c.get("contest")! const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false)) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 288c2ad..5242297 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -14,7 +14,7 @@ import { import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm" import { Hono } from "hono" -import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware" +import { optionalAuth, requireAuth } from "../auth/middleware" import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { failure, success } from "../http" @@ -26,6 +26,8 @@ import { findVisibleContest, ipAllowed, isContestAdmin, + requireContestAccess, + type ContestEnv, } from "../services/contest" import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" @@ -40,7 +42,7 @@ import { todayStart, } from "./helpers" -export const submissionRoutes = new Hono() +export const submissionRoutes = new Hono() function stringArray(value: unknown): string[] { return Array.isArray(value) @@ -68,6 +70,8 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => { } let contestId: number | null = null if (parsed.data.contestId) { + // 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体, + // 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。 const contest = await findVisibleContest(parsed.data.contestId) if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") const access = await canAccessContest(c, contest, "problems") @@ -462,11 +466,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { })) }) -submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, async (c) => { - const contest = await findVisibleContest(queryInteger(c.req.param("contestId"), 0, { min: 1 })) - if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") - const access = await canAccessContest(c, contest, "submissions") - if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) +submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => { + const contest = c.get("contest")! const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const filters = [eq(schema.submission.contestId, contest.id)] diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts index 47c9cc6..7595876 100644 --- a/apps/api/src/services/contest.ts +++ b/apps/api/src/services/contest.ts @@ -1,15 +1,27 @@ import { createHash } from "node:crypto" import { and, eq } from "drizzle-orm" -import type { Context } from "hono" +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 @@ -42,8 +54,10 @@ export async function findVisibleContest(id: number) { return contest ?? null } -export async function canAccessContest( - c: Context, +// 泛型而不是写死 Context:requireContestAccess 传进来的是 Context, +// 它比 AppEnv 多一个变量,而 Hono 的 Context 在 Variables 上是逆变的,写死会类型不兼容。 +export async function canAccessContest( + c: Context, contest: ContestRow, checkType: "details" | "problems" | "ranks" | "submissions", ) { @@ -62,6 +76,34 @@ export async function canAccessContest( 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 { + return async (c, next) => { + const id = Number(c.req.param(paramName)) + const contest = Number.isInteger(id) && id > 0 ? await findVisibleContest(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() + } +} + function ipv4Number(value: string) { const parts = value.split(".").map(Number) if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null