Files
web/server/src/routes/submission.ts
T
xuyueandClaude Opus 5.5 73788c4595 fix: 修复学生代码窃取 token、提示词链 XSS 和打分没有上限的问题
- 运行提交代码的 iframe(编辑器预览、随机评分弹窗、/submission/:id、全屏预览)
  加上 sandbox,不给 allow-same-origin,代码读不到 localStorage 里的 token
- 提示词和 AI 回复的 markdown 渲染时转义原始 HTML,链接只放行 http(s)/mailto;
  ChainModal 也改用这个渲染函数
- 打分限定 1-5 分(超出返回 422),不能给自己的提交打分

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:33:26 +08:00

571 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
import { Role, isStaff, requireSuper, requireUser } from "../auth"
import {
HttpError,
ValidationError,
body,
intParam,
notFound,
orNotFound,
pageQuery,
pageWindow,
qs,
query,
type Env,
uuidParam,
} from "../http"
import { deleteSubmissions } from "../services/cascade"
import { updateSubmissionScore } from "../services/score"
import { classifyMessage, inBackground } from "../services/classifier"
import { buildGradebook, gradebookCsvRows, toCsv } from "../services/gradebook"
import {
buildPromptRounds,
createConversation,
getActiveConversation,
getPrecedingUserMessage,
insertMessage,
type MessageRow,
} from "../services/prompt"
import { pyRound } from "../services/pyround"
import showcase from "./showcase"
const router = new Hono<Env>()
router.route("/showcase", showcase)
// 当天(UTC 零点起)的评分次数,与原实现保持一致
function todayRatingCount(userId: number) {
const start = new Date()
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start.getTime() + 86_400_000)
const fmt = (d: Date) => d.toISOString().slice(0, 19).replace("T", " ") + ".000000"
return get<{ n: number }>(
`SELECT COUNT(*) AS n FROM submission_rating
WHERE user_id = $id AND created >= $start AND created <= $end`,
{ id: userId, start: fmt(start), end: fmt(end) },
)!.n
}
const SUBMISSION_SELECT = `SELECT s.id, s.user_id, u.username, s.task_id, t.display AS task_display,
t.title AS task_title, t.task_type, s.score, s.html, s.css, s.js, s.flag, s.zone,
s.view_count, s.created, s.modified,
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
const SUBMISSION_FROM = `FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id`
function submissionOut(s: any) {
return {
id: uuidOut(s.id),
userid: s.user_id,
username: s.username,
task_id: s.task_id,
task_display: s.task_display,
task_title: s.task_title,
task_type: s.task_type,
score: s.score,
my_score: s.my_score || 0,
html: s.html,
css: s.css,
js: s.js,
flag: s.flag,
zone: s.zone,
submit_count: s.submit_count || 0,
view_count: s.view_count,
created: iso(s.created),
modified: iso(s.modified),
}
}
const hasRatingsOrAwards = (submissionId: string) =>
!!get(
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
{ id: submissionId },
)
// ── 创建提交 ──────────────────────────────────────────────────────────────────
router.post("/", async (c) => {
const user = requireUser(c)
const p = await body(
c,
z.object({
task_id: z.number().int(),
html: z.string().nullable().optional(),
css: z.string().nullable().optional(),
js: z.string().nullable().optional(),
prompt: z.string().nullable().optional(),
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
}),
)
const task = orNotFound(
get<{ id: number; task_type: string }>(`SELECT id, task_type FROM task_task WHERE id = $id`, {
id: p.task_id,
}),
)
if (task.task_type === "creation") throw new HttpError(400, "自由创作不需要提交")
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
const { submissionId, newUserMsgId } = tx(() => {
let manualAsstMsgId: number | null = null
let linkedMsg: MessageRow | null = null
let newUserMsgId: number | null = null
if (p.prompt) {
const convId =
getActiveConversation(user.id, task.id) ?? createConversation(user.id, task.id, false)
newUserMsgId = insertMessage({
conversation_id: convId,
role: "user",
content: p.prompt,
source: "manual",
})
manualAsstMsgId = insertMessage({
conversation_id: convId,
role: "assistant",
content: "",
code_html: code.html,
code_css: code.css,
code_js: code.js,
source: "manual",
})
} else if (p.message_id) {
linkedMsg = get<MessageRow>(
`SELECT m.* FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE m.id = $id AND m.role = 'assistant' AND c.user_id = $user_id AND c.task_id = $task_id`,
{ id: p.message_id, user_id: user.id, task_id: task.id },
)
if (!linkedMsg) {
console.warn(
`create_submission: message_id ${p.message_id} not found for user ${user.id} task ${task.id}`,
)
} else {
newUserMsgId = getPrecedingUserMessage(linkedMsg)?.id ?? null
}
}
// 同一条 AI 消息重复提交时更新原来的提交,而不是再建一条
if (linkedMsg?.submission_id) {
run(
`UPDATE submission_submission SET html = $html, css = $css, js = $js WHERE id = $id`,
{ ...code, id: linkedMsg.submission_id },
)
return { submissionId: linkedMsg.submission_id, newUserMsgId }
}
const id = newUuidHex()
const ts = now()
run(
`INSERT INTO submission_submission
(id, created, modified, score, html, css, js, task_id, user_id, flag, raw_score, zone, view_count)
VALUES ($id, $ts, $ts, 0, $html, $css, $js, $task_id, $user_id, NULL, 0, NULL, 0)`,
{ id, ts, ...code, task_id: task.id, user_id: user.id },
)
const msgToLink = manualAsstMsgId ?? linkedMsg?.id
if (msgToLink) {
run(`UPDATE prompt_message SET submission_id = $sid WHERE id = $mid`, {
sid: id,
mid: msgToLink,
})
}
// 该学生该任务之前进入作品展的提交标记为“有更新”
run(
`UPDATE submission_submissionaward SET is_stale = 1
WHERE is_stale = 0 AND submission_id != $id AND submission_id IN
(SELECT id FROM submission_submission WHERE user_id = $user_id AND task_id = $task_id)`,
{ id, user_id: user.id, task_id: task.id },
)
return { submissionId: id, newUserMsgId }
})
// 只对新增的提示词做层级分类
if (
newUserMsgId !== null &&
!get(`SELECT 1 FROM prompt_message WHERE id = $id AND prompt_level IS NOT NULL`, {
id: newUserMsgId,
})
) {
inBackground(classifyMessage(newUserMsgId))
}
return c.json({ id: uuidOut(submissionId) })
})
// ── 列表 ──────────────────────────────────────────────────────────────────────
const listQuery = pageQuery.extend({
task_id: qs.opt(qs.int),
task_type: qs.opt(z.enum(["tutorial", "challenge"])),
username: qs.opt(z.string()),
user_id: qs.opt(qs.int),
flag: qs.opt(z.enum(["red", "blue", "green", "yellow", "any"])),
zone: qs.opt(z.enum(["featured", "low", "pending"])),
score_min: qs.opt(qs.num),
score_max_exclusive: qs.opt(qs.num),
score_lt_threshold: qs.opt(qs.num),
ordering: qs.opt(z.string()),
grouped: qs.opt(qs.bool).default(true),
})
router.get("/", (c) => {
const user = requireUser(c)
const f = query(c, listQuery)
const where: string[] = []
const params: Record<string, unknown> = {}
if (f.task_id) {
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: f.task_id }))
where.push("s.task_id = $task_id")
params.task_id = f.task_id
} else if (f.task_type) {
where.push("t.task_type = $task_type")
params.task_type = f.task_type
}
if (f.username) {
where.push(`u.username LIKE $username ESCAPE '\\'`)
params.username = likeEscape(f.username)
}
if (f.user_id) {
where.push("s.user_id = $user_id")
params.user_id = f.user_id
}
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
else if (f.flag) {
where.push("+s.flag = $flag")
params.flag = f.flag
}
if (f.zone) {
where.push("+s.zone = $zone")
params.zone = f.zone
}
if (f.score_lt_threshold !== undefined) {
where.push("s.score < $score_lt")
params.score_lt = f.score_lt_threshold
} else {
if (f.score_min !== undefined) {
where.push("s.score >= $score_min")
params.score_min = f.score_min
}
if (f.score_max_exclusive !== undefined) {
where.push("s.score < $score_max")
params.score_max = f.score_max_exclusive
}
}
if (f.grouped) {
// 分组模式:每个 (user, task) 只保留最新一条
// 用 rowid 而不是 id,这样子查询能完全走 submission_submission_slim 覆盖索引
where.push(`s.rowid = (SELECT s3.rowid FROM submission_submission s3
WHERE s3.user_id = s.user_id AND s3.task_id = s.task_id ORDER BY s3.created DESC LIMIT 1)`)
}
const orderings: Record<string, string> = {
"-score": "s.score DESC",
score: "s.score ASC",
"-created": "s.created DESC",
}
const orderBy = orderings[f.ordering ?? ""] ?? "s.created DESC"
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
const { limit, offset } = pageWindow(f)
// 先只在索引上排序分页拿到这一页的 rowid,再读这几行的完整内容;
// 否则排序时要把每条提交的 html/css/js 大行都读一遍
const items = all(
`${SUBMISSION_SELECT},
(SELECT COUNT(*) FROM submission_submission s2
WHERE s2.user_id = s.user_id AND s2.task_id = s.task_id) AS submit_count
${SUBMISSION_FROM}
WHERE s.rowid IN (SELECT s.rowid ${SUBMISSION_FROM} ${whereSql}
ORDER BY ${orderBy} LIMIT $limit OFFSET $offset)
ORDER BY ${orderBy}`,
{ ...params, me: user.id, limit, offset },
)
const count = get<{ n: number }>(`SELECT COUNT(*) AS n ${SUBMISSION_FROM} ${whereSql}`, params)!.n
return c.json({ items: items.map(submissionOut), count })
})
router.get("/by-user-task", (c) => {
const me = requireUser(c)
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
const rows = all(
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
{ me: me.id, user_id: q.user_id, task_id: q.task_id },
)
return c.json(rows.map(submissionOut))
})
// 随机返回一个待打分的其他同学的提交(AI 生成期间的随手打分弹窗)
router.get("/random-for-rating", (c) => {
const user = requireUser(c)
const q = query(c, z.object({ exclude_id: qs.opt(z.string()) }))
let excludeId: string | null = null
if (q.exclude_id) {
excludeId = uuidHex(q.exclude_id)
if (!excludeId) throw new ValidationError([{ loc: ["query", "exclude_id"], msg: "需要 UUID" }])
}
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js
const base = `SELECT s.rowid AS rid FROM submission_submission s
WHERE s.user_id != $me
AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me)
${excludeId ? "AND s.id != $exclude" : ""}`
const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) }
const picked =
get<{ rid: number }>(
`${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5
ORDER BY RANDOM() LIMIT 1`,
params,
) ?? get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
if (!picked) return c.json(null)
const pick = get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
t.task_type, s.html, s.css, s.js
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
{ rid: picked.rid },
)!
return c.json({
submission_id: uuidOut(pick.id),
username: pick.username,
task_title: pick.task_title,
task_display: pick.task_display,
task_type: pick.task_type,
html: pick.html,
css: pick.css,
js: pick.js,
})
})
router.delete("/flags", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
return c.json({ cleared: res.changes })
})
// ── 统计 ──────────────────────────────────────────────────────────────────────
router.get("/stats/:task_id", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const taskId = intParam(c, "task_id")
const { classname } = query(c, z.object({ classname: qs.opt(z.string()) }))
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }))
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role = 'normal' AND classname != '' ORDER BY classname`,
).map((r) => r.classname)
// 学生范围:普通用户,可按班级过滤
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user
WHERE role = 'normal' ${classname ? "AND classname = $classname" : ""}
ORDER BY classname, username`,
classname ? { classname } : {},
)
const studentIds = new Set(students.map((s) => s.id))
const subs = all<{ id: string; user_id: number; created: string; score: number; flag: string | null }>(
`SELECT id, user_id, created, score, flag FROM submission_submission WHERE task_id = $id`,
{ id: taskId },
)
const studentSubs = subs.filter((s) => studentIds.has(s.user_id))
const submittedIds = new Set(studentSubs.map((s) => s.user_id))
const latest = new Map<number, (typeof subs)[number]>()
const subCount = new Map<number, number>()
for (const s of studentSubs) {
const cur = latest.get(s.user_id)
if (!cur || s.created > cur.created) latest.set(s.user_id, s)
subCount.set(s.user_id, (subCount.get(s.user_id) ?? 0) + 1)
}
// 平均分:每人最新一次提交中分数 > 0 的
const scored = [...latest.values()].filter((s) => s.score > 0)
const averageScore = scored.length
? pyRound(scored.reduce((a, s) => a + s.score, 0) / scored.length, 2)
: null
// 未评分:提交过但该任务下所有提交都没人打分
const ratedIds = new Set(
all<{ user_id: number }>(
`SELECT DISTINCT s.user_id FROM submission_rating r
JOIN submission_submission s ON s.id = r.submission_id WHERE s.task_id = $id`,
{ id: taskId },
).map((r) => r.user_id),
)
const tag = (s: { username: string; classname: string }) => ({
username: s.username,
classname: s.classname,
})
const dist = { count_1: 0, count_2: 0, count_3: 0, count_4_plus: 0 }
for (const n of subCount.values()) {
if (n === 1) dist.count_1++
else if (n === 2) dist.count_2++
else if (n === 3) dist.count_3++
else dist.count_4_plus++
}
// 标记统计:该任务所有提交(不按学生过滤)
const flagStats = { red: 0, blue: 0, green: 0, yellow: 0 }
for (const s of subs) {
if (s.flag && s.flag in flagStats) flagStats[s.flag as keyof typeof flagStats]++
}
const topViewed = classname
? all(
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
WHERE s.task_id = $id AND u.role = 'normal' AND u.classname = $classname
ORDER BY s.view_count DESC LIMIT 5`,
{ id: taskId, classname },
)
: all(
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
WHERE s.task_id = $id AND u.role = 'normal'
ORDER BY s.view_count DESC LIMIT 5`,
{ id: taskId },
)
const unrated = students.filter((s) => submittedIds.has(s.id) && !ratedIds.has(s.id))
return c.json({
submitted_count: submittedIds.size,
unsubmitted_count: students.length - submittedIds.size,
average_score: averageScore,
unrated_count: unrated.length,
unsubmitted_users: students.filter((s) => !submittedIds.has(s.id)).map(tag),
unrated_users: unrated.map(tag),
submission_count_distribution: dist,
flag_stats: flagStats,
classes,
top_viewed: topViewed.map((s) => ({
username: s.username,
classname: s.classname,
view_count: s.view_count,
submission_id: uuidOut(s.id),
})),
})
})
// ── 成绩册 ────────────────────────────────────────────────────────────────────
const gradebookQuery = z.object({
classname: z.string().default(""),
task_type: qs.opt(z.enum(["tutorial", "challenge"])),
username: qs.opt(z.string()),
include_all_tasks: qs.opt(qs.bool).default(false),
})
router.get("/gradebook", (c) => {
requireSuper(c)
return c.json(buildGradebook(query(c, gradebookQuery)))
})
router.get("/gradebook/export", (c) => {
requireSuper(c)
const gb = buildGradebook(query(c, gradebookQuery))
const filename = encodeURIComponent(`gradebook-${gb.classname}.csv`)
return c.body("" + toCsv(gradebookCsvRows(gb)), 200, {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename*=UTF-8''${filename}`,
})
})
// ── 单个提交 ──────────────────────────────────────────────────────────────────
router.delete("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const sub = orNotFound(
get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }),
)
if (sub.user_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能删除自己的提交")
}
if (user.role !== Role.SUPER && hasRatingsOrAwards(id)) {
throw new HttpError(400, "该提交已被评分或获奖,无法删除")
}
tx(() => {
// 关联的助手消息会随提交一起删除,它前面那条用户消息也一起删掉
const asst = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
const userMsg = asst ? getPrecedingUserMessage(asst) : null
deleteSubmissions("id = $id", { id })
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
})
return c.json({ message: "删除成功" })
})
router.get("/:id/prompt-chain", (c) => {
requireUser(c)
const id = uuidParam(c, "id")
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
return c.json(buildPromptRounds(msg))
})
router.get("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
return c.json(submissionOut(s))
})
// 增加浏览次数(仅在全屏预览时调用)
router.post("/:id/view", (c) => {
requireUser(c)
const id = uuidParam(c, "id")
const res = run(`UPDATE submission_submission SET view_count = view_count + 1 WHERE id = $id`, { id })
if (!res.changes) notFound("提交不存在")
return c.json({ ok: true })
})
router.put("/:id/score", async (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
if (score <= 0) throw new HttpError(400, "分数不能为零")
const sub = orNotFound(get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }))
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
user_id: user.id,
id,
})
if (exists) return c.json({ message: "你已经给这个提交打过分了" })
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) {
throw new HttpError(400, "普通用户每天最多只能评分30次。")
}
tx(() => {
run(
`INSERT INTO submission_rating (score, created, submission_id, user_id)
VALUES ($score, $ts, $id, $user_id)`,
{ score, ts: now(), id, user_id: user.id },
)
updateSubmissionScore(id)
})
return c.json({ message: "打分成功" })
})
router.put("/:id/flag", async (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const id = uuidParam(c, "id")
const { flag } = await body(
c,
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
)
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
return c.json({ flag })
})
export default router