Files
OJ2/apps/api/src/services/contest.ts
T
xuyueandClaude Opus 5 ed56a209ea
Deploy / deploy (push) Has been cancelled
chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:34 -06:00

158 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}