fix(阶段4): 两份独立安全评审的 Critical/Important 全部修掉

## 权限边界评审(后台 86 个 handler)

守卫本身一个没漏,问题全在对象级归属校验:

- C1 跨题单删奖章:先按 (id, problemsetId) 校验奖章归属再删,
  否则拿自己的题单 id + 别人的奖章 id 就能把别人的 user_badge 删掉
- C2 make-public 无归属校验:补 canEdit,越权者拿不到题面
- I1 两个分析端点被 `/problems/:id` 遮蔽 —— Hono 按注册顺序匹配,
  不是静态优先。挪到 `/problem-analytics/*`
- I2 from-public 只校验目标比赛归属:源题也必须是公开题库题
- I3 克隆比赛回传原比赛明文密码:克隆一律 password: null
- I4 upload-image 守卫比旧后端严,教师写题面会 403:收回 requireAdmin

两个互不可见的教师账号实跑复验,六条全部拦住。

## SQL 判题沙箱评审

- I-1 查询题只读被一句 `PRAGMA query_only=0` 关掉,实测 DML 拿到 AC。
  query_only 自己就是个 PRAGMA,旧实现靠 authorizer 把 SQLITE_PRAGMA
  一律拒了才没这个洞。现在 runStudent 逐语句拦 PRAGMA(用
  sqlite3_normalized_sql 判关键字,注释和大小写由 SQLite 抹平),
  并在每条语句前重放 query_only 和 max_page_count 兜底。
  顺带修掉 M-1 里 max_page_count 学生可自行调大的部分。

- I-2 单条语句进了 step() 就打断不了,只能等父进程 SIGKILL,
  而兜底时限是整个作业一口价 25s —— 1s 限的题要 26s 才判 TLE,
  判题池只有 2 个槽,几发死循环就能把所有人堵住。
  改成分阶段:子进程用 stderr 报 prepare/student/display,
  父进程边读边换表,一进学生 SQL 就把兜底收到「题目时限 + 2s」。
  实测 26s → 3.06s。

  归因也跟着修了:卡在受信脚本(出题人的初始化脚本、标准答案)
  现在报 SYSTEM_ERROR,不再当成学生超时甩 TLE。

engine.ts 头部那张防护对照表按实测重写 —— 原来那版把 query_only
写成等价于 authorizer 白名单,是不成立的。另记一笔:stock sql.js 的
wasm 没导出 progress_handler / interrupt / set_authorizer / limit,
想要得自己编,别再去翻了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 21:59:42 -06:00
parent e3faa689e7
commit 0f94e7ecbd
11 changed files with 859 additions and 67 deletions

View File

@@ -27,8 +27,16 @@ export type SqlJob =
}
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
/**
* 写阶段标记。必须用 writeSync父进程正是靠这个标记决定「多久之后 SIGKILL」
* 以及「超时算谁的」,走异步 stderr 的话标记可能还在缓冲里就被杀掉了。
*/
function markPhase(phase: string) {
process.stderr.write(`@phase:${phase}\n`)
const bytes = new TextEncoder().encode(`@phase:${phase}\n`)
let written = 0
while (written < bytes.length) {
written += writeSync(2, bytes, written, bytes.length - written)
}
}
/**
@@ -61,12 +69,13 @@ async function main() {
const display = await buildDisplay(job.initSql, job.refSql, job.mode)
finish({ ok: true, display })
}
markPhase("judge")
// 阶段由 runCase 内部回调标记prepare受信脚本→ student学生 SQL
const result = await runCase(job.initSql, job.refSql, job.studentSql, {
mode: job.mode,
orderSensitive: job.orderSensitive,
timeLimitMs: job.timeLimitMs,
memoryLimitMb: job.memoryLimitMb,
onPhase: markPhase,
})
finish({ ok: true, case: result })
} catch (error) {

View File

@@ -14,10 +14,22 @@
* | 旧防护 | 新做法 |
* |---|---|
* | authorizer 禁 ATTACH防读写服务器任意 SQLite 文件) | WASM 没有宿主文件系统绑定ATTACH **结构上**够不到宿主,只能碰随进程消失的虚拟 FS |
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1`SQLite 原生只读开关 |
* | progress_handler 墙钟超时 | 子进程外部 SIGKILLOS 级,比指令计数更硬)+ 语句间 deadline 检查 |
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -v`,触顶时 WASM 抛可捕获错误 |
* | max_page_count | 原样保留 |
* | authorizer 白名单让查询题只读 | `PRAGMA query_only=1` **加上逐语句拒绝学生的 PRAGMA**,见 runStudent |
* | progress_handler 墙钟超时 | 语句**之间**查 deadline + 子进程外部 SIGKILL 兜底,见下 |
* | setlimit(LIMIT_LENGTH) 防单值撑爆内存 | 子进程 `ulimit -d`,触顶时 WASM 抛可捕获错误 |
* | max_page_count | 保留,且每条学生语句前重放一遍(否则学生能自己调大) |
*
* 两处必须知道的削弱:
*
* 1. **只有 query_only 是不够的。** 它自己就是个 PRAGMA学生一句 `PRAGMA query_only=0`
* 就能关掉它 —— 旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒了,所以没这个洞。
* 这里靠 runStudent 的逐语句守卫补上:学生 SQL 里的 PRAGMA 一律拒绝。
* 2. **超时粒度是「一条语句」。** deadline 只在语句之间查,单条语句(递归 CTE、
* 大 CROSS JOIN一旦进了 step() 就没法从 JS 里打断。stock sql.js 的 wasm 没导出
* sqlite3_progress_handler / sqlite3_interrupt / sqlite3_set_authorizer / sqlite3_limit
* (已核对导出表,别再去找了),要用就得自己编 wasm。所以真正的硬上限是父进程的
* SIGKILL`./index.ts` 收到 `@phase:student` 标记后会把兜底时限收到「题目时限 + 2s」
* 跑飞的学生语句最多多占这么久,而不是整个作业预算。
*/
import initSqlJs, { type Database, type SqlJsStatic } from "sql.js"
@@ -98,36 +110,74 @@ interface ResultSet {
rows: string[]
}
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
const db = new SQL.Database()
/** 引擎侧的资源限制。学生的每条语句前都要重放一遍,见 runStudent */
function applyLimits(db: Database, memoryLimitMb: number) {
const limit = Math.max(Math.trunc(memoryLimitMb), 1)
db.run("PRAGMA page_size=4096")
// 4096B/页 × 256 页/MB超限报 "database or disk is full"
db.run(`PRAGMA max_page_count=${limit * 256}`)
}
function newDatabase(SQL: SqlJsStatic, memoryLimitMb: number) {
const db = new SQL.Database()
db.run("PRAGMA page_size=4096")
applyLimits(db, memoryLimitMb)
return db
}
/** sql.js 的 iterateStatements 没进 @types/sql.js这里补上类型 */
interface PreparedStatement {
step(): boolean
get(): unknown[]
getColumnNames(): string[]
getSQL(): string
getNormalizedSQL(): string
free(): void
}
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
return (db as unknown as {
iterateStatements(sql: string): Iterable<PreparedStatement>
}).iterateStatements(script)
}
/**
* 取语句的首关键字。优先用 sqlite3_normalized_sql —— 归一化由 SQLite 自己做,
* 注释、大小写、空白都已抹平(`/*x*/ pragma Query_Only = 0` → `PRAGMA query_only=?`
* 比在原文上自己做词法猜测可靠得多。
*/
function leadingKeyword(statement: PreparedStatement) {
let text = ""
try {
text = statement.getNormalizedSQL() ?? ""
} catch {
text = ""
}
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE退回到原文剥注释
if (!text) {
text = statement.getSQL().replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ")
}
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? ""
}
/**
* 逐条执行,返回最后一条产生结果集的语句的 (列数, 行);无结果集返回 null。
*
* 用 sql.js 的 iterateStatements底层是 sqlite3_prepare_v2 逐条推进),
* 比旧实现手写的分号切分更准 —— 字符串和注释里的分号天然不会误切。
*
* `guard` 在每条语句 step 之前调用,用来拦学生的 PRAGMA 并重放限制。
*/
function executeStatements(db: Database, script: string, deadline: number): ResultSet | null {
function executeStatements(
db: Database,
script: string,
deadline: number,
guard?: (statement: PreparedStatement) => void,
): ResultSet | null {
let last: ResultSet | null = null
for (const statement of (db as unknown as {
iterateStatements(sql: string): Iterable<{
step(): boolean
get(): unknown[]
getColumnNames(): string[]
free(): void
}>
}).iterateStatements(script)) {
if (Date.now() > deadline) {
statement.free()
throw new Error("interrupted")
}
for (const statement of iterate(db, script)) {
try {
if (Date.now() > deadline) throw new Error("interrupted")
guard?.(statement)
const names = statement.getColumnNames()
if (names.length > 0) {
const rows: string[] = []
@@ -195,11 +245,27 @@ function executeTrusted(db: Database, script: string, deadline: number, prefix:
}
/** 带防护执行学生 SQL异常映射为学生级 JudgeStatus */
function runStudent(db: Database, script: string, mode: string, deadline: number) {
function runStudent(
db: Database,
script: string,
mode: string,
deadline: number,
memoryLimitMb: number,
) {
// 查询题只读PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
if (mode === "query") db.run("PRAGMA query_only=1")
try {
const last = executeStatements(db, script, deadline)
const last = executeStatements(db, script, deadline, (statement) => {
// query_only 自己就是个 PRAGMA不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
if (leadingKeyword(statement) === "PRAGMA") {
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
}
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1")
})
if (mode === "query") return last
return dumpTables(db)
} catch (error) {
@@ -250,8 +316,21 @@ export interface RunCaseOptions {
orderSensitive: boolean
timeLimitMs: number
memoryLimitMb: number
/** 阶段回调,子进程据此写 stderr 标记,父进程据此收紧兜底 SIGKILL 时限 */
onPhase?: (phase: "prepare" | "student") => void
}
/**
* 受信脚本(初始化 + 标准答案)**合计**的墙钟预算。
* 父进程按同一口径算兜底时限,两边必须用这一个函数,别各写各的。
*/
export function trustedBudgetMs(timeLimitMs: number) {
return Math.max(timeLimitMs * 5, 10_000)
}
/** 题目页展示数据的墙钟预算 */
export const DISPLAY_BUDGET_MS = 10_000
export interface CaseResult {
test_case: string
result: JudgeStatusValue
@@ -276,14 +355,17 @@ export async function runCase(
options: RunCaseOptions,
): Promise<CaseResult> {
const SQL = await sqlEngine()
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞
const trustedLimitMs = Math.max(options.timeLimitMs * 5, 10_000)
options.onPhase?.("prepare")
// 受信脚本的运行上限放宽,避免出题数据较大时误报;仍防子进程永久阻塞。
// 三段受信执行(两次初始化 + 一次标准答案)共用同一个 deadline
// 这样"受信阶段总耗时"有确定上限,父进程才能算出匹配的兜底时限。
const trustedDeadline = Date.now() + trustedBudgetMs(options.timeLimitMs)
let expected: unknown
const refDb = newDatabase(SQL, options.memoryLimitMb)
try {
executeTrusted(refDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
const last = executeTrusted(refDb, refSql, Date.now() + trustedLimitMs, "标准答案执行失败")
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
if (options.mode === "query") {
expected = last
} else {
@@ -317,10 +399,17 @@ export async function runCase(
let actual: unknown
let elapsed = 0
try {
executeTrusted(studentDb, initSql, Date.now() + trustedLimitMs, "初始化脚本执行失败")
executeTrusted(studentDb, initSql, trustedDeadline, "初始化脚本执行失败")
options.onPhase?.("student")
const start = Date.now()
try {
actual = runStudent(studentDb, studentSql, options.mode, start + options.timeLimitMs)
actual = runStudent(
studentDb,
studentSql,
options.mode,
start + options.timeLimitMs,
options.memoryLimitMb,
)
} catch (error) {
elapsed = Date.now() - start
const failure = error as SqlCaseError
@@ -399,7 +488,7 @@ export async function buildDisplay(
) {
const SQL = await sqlEngine()
const db = newDatabase(SQL, memoryLimitMb)
const deadline = Date.now() + 10_000
const deadline = Date.now() + DISPLAY_BUDGET_MS
try {
executeTrusted(db, initSql, deadline, "初始化脚本执行失败")
const tables = dumpDisplayTables(db)
@@ -407,11 +496,7 @@ export async function buildDisplay(
if (mode === "query") {
let expected: unknown = null
try {
for (const statement of (db as unknown as {
iterateStatements(sql: string): Iterable<{
step(): boolean; get(): unknown[]; getColumnNames(): string[]; free(): void
}>
}).iterateStatements(refSql)) {
for (const statement of iterate(db, refSql)) {
try {
const names = statement.getColumnNames()
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
@@ -443,7 +528,7 @@ export async function buildDisplay(
}
const before = dumpTables(db)
executeTrusted(db, refSql, Date.now() + 10_000, "标准答案执行失败")
executeTrusted(db, refSql, deadline, "标准答案执行失败")
const after = dumpTables(db)
const changed = new Set<string>()
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {

View File

@@ -1,7 +1,7 @@
import { resolve } from "node:path"
import { JudgeStatus, type JudgeStatusValue } from "../status"
import type { CaseResult } from "./engine"
import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine"
import type { SqlJob } from "./child"
/**
@@ -17,12 +17,28 @@ import type { SqlJob } from "./child"
* 实测 -v 之下 Bun 退出时有概率 panicSIGILL—— 结果早已写出但进程异常终止,
* 父进程读到空串误判成超时,时好时坏。-d 限的是实际提交的内存Linux 4.7 起
* 也覆盖匿名 mmap512MB 下正常判题稳定、`hex(zeroblob(2e8))` 被拦。
*
* ## 兜底时限是分阶段的
*
* 单条 SQL 一旦进了 SQLite 的 step() 就打断不了(见 engine.ts 的说明),所以
* 跑飞的语句只能等这里的 SIGKILL。如果整个作业只给一个宽松的兜底时限
* 1 秒时限的题也要等十几秒才判超时 —— 判题池就那么几个槽,一个学生提几发
* 死循环就能把所有人堵住。
*
* 所以子进程用 stderr 报阶段(`@phase:`),父进程边读边换表:
* 受信阶段(出题人的初始化脚本、标准答案)给足预算,一进学生 SQL 就把兜底
* 收到「题目时限 + 2s」。跑飞的学生语句最多多占 2 秒。
*
* 阶段还决定超时算谁的:卡在 prepare/display 是出题配置问题SYSTEM_ERROR
* 卡在 student 才是学生超时TLE
*/
/** 子进程数据段上限KB。低于 512MB Bun 自己起不来 */
const CHILD_DATA_LIMIT_KB = 512 * 1024
/** 进程的兜底墙钟。比作业自报的时限宽裕,只负责杀掉真正跑飞的进程 */
const HARD_TIMEOUT_SLACK_MS = 15_000
/** 进程启动 + WASM 初始化 + JSON 收发的余量 */
const STARTUP_SLACK_MS = 3_000
/** 学生阶段的兜底余量:只用来覆盖单条语句无法打断这一段 */
const STUDENT_SLACK_MS = 2_000
export interface SqlJobFailure {
ok: false
@@ -32,7 +48,28 @@ export interface SqlJobFailure {
type SqlJobOutcome<T> = { ok: true; value: T } | SqlJobFailure
async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T>> {
interface JobBudget {
/** 受信阶段(初始化脚本 + 标准答案)合计预算 */
trustedMs: number
/** 学生 SQL 的预算display 作业没有学生阶段,传 null */
studentMs: number | null
}
const PHASE_FAILURE: Record<string, SqlJobFailure> = {
display: {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "生成展示数据超时或内存超限,请检查初始化脚本与标准答案",
},
prepare: {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "初始化脚本或标准答案超时/内存超限,请检查题目配置",
},
student: { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" },
}
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> {
const entry = resolve(import.meta.dir, "child.ts")
// 经 sh 起是为了用 ulimit —— Bun.spawn 没有直接设 rlimit 的接口
const child = Bun.spawn(
@@ -42,14 +79,32 @@ async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T
child.stdin.write(JSON.stringify(job))
await child.stdin.end()
const timer = setTimeout(() => child.kill("SIGKILL"), budgetMs + HARD_TIMEOUT_SLACK_MS)
let timer = setTimeout(() => child.kill("SIGKILL"), budget.trustedMs + STARTUP_SLACK_MS)
let phase = ""
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
const readStderr = (async () => {
const decoder = new TextDecoder()
const reader = (child.stderr as ReadableStream<Uint8Array>).getReader()
let text = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value, { stream: true })
const marks = text.match(/@phase:(\w+)/g)
const latest = marks?.[marks.length - 1]?.slice("@phase:".length)
if (latest && latest !== phase) {
phase = latest
if (phase === "student" && budget.studentMs !== null) {
clearTimeout(timer)
timer = setTimeout(() => child.kill("SIGKILL"), budget.studentMs + STUDENT_SLACK_MS)
}
}
}
})()
let stdout = ""
let stderr = ""
try {
;[stdout, stderr] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr])
await child.exited
} finally {
clearTimeout(timer)
@@ -57,12 +112,14 @@ async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T
if (!stdout.trim()) {
// 子进程没来得及写结果就没了 —— 要么被我们 SIGKILL要么被内核 OOM 掉。
// 用 stderr 里的阶段标记区分:卡在受信脚本是出题问题,卡在学生 SQL 是超时。
const phase = stderr.includes("@phase:") ? stderr.split("@phase:")[1]?.split("\n")[0] : null
if (phase === "display") {
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "生成展示数据超时或内存超限,请检查初始化脚本与标准答案" }
}
return { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" }
// 按最后一个阶段标记归因:卡在受信脚本是出题问题,卡在学生 SQL 是超时。
return (
PHASE_FAILURE[phase] ?? {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程异常退出",
}
)
}
try {
@@ -77,12 +134,15 @@ async function runJob<T>(job: SqlJob, budgetMs: number): Promise<SqlJobOutcome<T
}
export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
return runJob<CaseResult>(job, Math.max(job.timeLimitMs * 5, 10_000))
return runJob<CaseResult>(job, {
trustedMs: trustedBudgetMs(job.timeLimitMs),
studentMs: job.timeLimitMs,
})
}
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
return runJob<{ tables: unknown[]; expected: unknown }>(
{ kind: "display", initSql, refSql, mode },
10_000,
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
)
}

View File

@@ -14,7 +14,7 @@ import { resolve } from "node:path"
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { config } from "../../config"
import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events"
@@ -202,8 +202,12 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024
* Simditor 富文本编辑器的图片上传。响应形状是编辑器约定的
* `{success, msg, filePath}`,不是本项目的 `{data}` 信封 —— 但外面仍然包一层 data
* 由前端 api 层解包,这样它和其它接口共用同一个错误处理拦截器。
*
* 守卫用 requireAdmin 而不是 requireSuperAdmin题面和比赛描述的富文本编辑器都调它
* admin/problem/detail.vue、admin/contest/detail.vue收成超管专属会让教师和
* 学生管理员插图直接 403。旧后端这里是**零装饰器**(匿名可传),那太松,取中间档。
*/
adminConfRoutes.post("/upload-image", requireSuperAdmin, async (c) => {
adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
const form = await c.req.formData().catch(() => null)
const image = form?.get("image")
if (!(image instanceof File)) {

View File

@@ -189,7 +189,11 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
title: original.contest.title,
description: original.contest.description,
tag: original.contest.tag,
password: original.contest.password,
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
password: null,
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
visible: false,
allowedIpRanges: original.contest.allowedIpRanges,

View File

@@ -495,6 +495,12 @@ adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, a
const [problem] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
// 归属校验不能少:这个接口会把整道题(含 answers 标准答案)复制出来并回传,
// 没有它,任何有出题权的人拿别人比赛题的 id 就能把题面和答案整份拿走。
// 旧后端同样缺这个校验,但它只 `return self.success()` 不带数据,泄露面比这里小。
if (!(await canEdit(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (!problem.contestId || problem.isPublic) {
return failure(c, 409, "already-public", "Already be a public problem")
}
@@ -546,6 +552,14 @@ adminProblemRoutes.post("/contests/:contestId/problems/from-public", requireProb
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
if (problem.contestId !== null) {
return failure(c, 400, "not-a-public-problem", "只能从公开题库添加题目")
}
if (!problem.visible && !(await canEdit(user, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
if (contestStatus(contest) === "-1") return failure(c, 409, "contest-ended", "Contest has ended")
const [duplicate] = await db.select({ id: schema.problem.id }).from(schema.problem)

View File

@@ -366,14 +366,20 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badgeId))
return tx.delete(schema.problemsetBadge).where(and(
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT数据没了却报「不存在」。
const [badge] = await db.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(and(
eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id),
)).returning({ id: schema.problemsetBadge.id })
)).limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badge.id))
await tx.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
})
if (deleted.length === 0) return failure(c, 404, "badge-not-found", "奖章不存在")
return success(c, null)
})

View File

@@ -187,7 +187,11 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
adminTagRoutes.get("/problems/stuck", requireTeacher, async (c) => {
// 路径特意不放在 /problems 下Hono 按**注册顺序**匹配(不是静态优先),
// 而 problem.ts 的 `GET /problems/:id` 先注册,会把 `/problems/stuck` 整个吃掉 ——
// 这两个 handler 曾经从未执行过,生效的还是那边的 requireProblemPermission 而非这里的
// requireTeacher而且完全没有报错。换个前缀结构上就不可能再被遮蔽。
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
const rows = await db.select({
displayId: schema.problem.displayId,
@@ -212,7 +216,7 @@ adminTagRoutes.get("/problems/stuck", requireTeacher, async (c) => {
})))
})
adminTagRoutes.get("/problems/ac-trend", requireTeacher, async (c) => {
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
const currentYear = new Date().getFullYear()
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)