Files
OJ2/apps/api/src/index.ts
T
xuyueandClaude Opus 5.5 793848b06f
Deploy / deploy (push) Canceled after 0s
refactor(登录): 登录态从 Cookie 换成 access token + refresh token
- access token 是 15 分钟的 JWT,走 Authorization: Bearer;refresh token 7 天,每次换都轮换
- 退出登录、改密码、禁用账号仍然立刻生效:每个请求核对 Redis 会话和库里新加的 user.token_version
- 在服务器后台改密码 / 禁用,机房那边登着的也会一起下线(原来两边 Redis 分开,管不到)
- WebSocket 握手借子协议带 token;JWT 密钥存在本站 Redis,部署不用加配置
- 去掉 COOKIE_SECURE;上线后所有人要重新登录一次

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:52 +08:00

168 lines
6.5 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 { Hono } from "hono"
import { basename, resolve } from "node:path"
import { WS_AUTH_PROTOCOL } from "@oj2/contract"
import { authenticateWebSocket, loadJwtSecret } from "./auth/session"
import { config } from "./config"
import { adminRoutes } from "./routes/admin"
import { authRoutes } from "./routes/auth"
import { accountRoutes } from "./routes/account"
import { judgeServerRoutes } from "./routes/judge-server"
import { contestRoutes } from "./routes/contest"
import { contentRoutes } from "./routes/content"
import { classroomRoutes } from "./routes/classroom"
import { rankingRoutes } from "./routes/ranking"
import { problemsetRoutes } from "./routes/problemset"
import { achievementRoutes } from "./routes/achievement"
import { aiRoutes } from "./routes/ai"
import { flowchartRoutes } from "./routes/flowchart"
import { problemRoutes } from "./routes/problem"
import { problemStatsRoutes } from "./routes/problem-stats"
import { submissionRoutes } from "./routes/submission"
import { trialRunRoutes } from "./routes/trial-run"
import { siteRoutes } from "./routes/site"
import {
bridgeSubmissionEvents,
isAllowedWebSocketOrigin,
startSessionSweep,
submissionWebSocketHandler,
type SubmissionSocketData,
} from "./websocket"
const app = new Hono()
app.get("/health", (c) => c.json({ ok: true }))
app.route("/api", authRoutes)
app.route("/api", accountRoutes)
app.route("/api", siteRoutes)
app.route("/api", contestRoutes)
app.route("/api", contentRoutes)
app.route("/api", classroomRoutes)
app.route("/api", rankingRoutes)
app.route("/api", problemsetRoutes)
app.route("/api", achievementRoutes)
app.route("/api", aiRoutes)
app.route("/api", flowchartRoutes)
app.route("/api", problemRoutes)
app.route("/api", problemStatsRoutes)
app.route("/api", submissionRoutes)
app.route("/api", trialRunRoutes)
app.route("/api", judgeServerRoutes)
app.route("/api/admin", adminRoutes)
app.onError((error, c) => {
console.error(error)
return c.json({ error: { code: "internal-error", message: "Internal server error" } }, 500)
})
/** 头像取不到时的占位图,避免每个没设头像的学生都打一次 404 */
const DEFAULT_AVATAR_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>'
/**
* 伺服 /public 下的用户上传文件。
*
* 只取路径最后一段并要求它和原样一致 —— `..`、子目录、编码过的斜杠都会在这里被拒,
* 拼接进 resolve() 的永远只是一个纯文件名。
*
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
*/
async function serveUpload(pathname: string, prefix: string, directory: string) {
const decoded = decodeURIComponent(pathname)
const filename = basename(decoded)
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
return new Response("Not found", { status: 404 })
}
const file = Bun.file(resolve(directory, filename))
if (await file.exists()) {
// 文件名由后端生成且内容不变,可以放心长缓存
return new Response(file, {
headers: {
"cache-control": "public, max-age=86400",
// 线上 Caddy 也加了这个头,这里再加一道:content-type 按扩展名配,
// 不让浏览器自己嗅探成 HTML 去执行
"x-content-type-options": "nosniff",
},
})
}
return null
}
await loadJwtSecret()
const server = Bun.serve<SubmissionSocketData>({
port: config.port,
// Bun 默认 10 秒没有字节往来就掐连接,而且不报任何错。AI 的 SSE 两条路都会静默
// 超过 10 秒:streamWhole 在 produce 期间每 15 秒才发一次心跳,streamChat 等首
// token 也常过 10 秒 —— 学生看到的就是「AI 提示生成失败」。对齐反代链上最短的
// 那一环(NPM 的 proxy_read_timeout 60s),两边的心跳 / 空闲超时都在它之内。
idleTimeout: 60,
async fetch(request, bunServer) {
const url = new URL(request.url)
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory)
if (hit) return hit
if (basename(decodeURIComponent(url.pathname)) === "default.png") {
return new Response(DEFAULT_AVATAR_SVG, {
headers: {
"content-type": "image/svg+xml",
"cache-control": "public, max-age=3600",
},
})
}
return new Response("Not found", { status: 404 })
}
// 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx,
// 但没有任何路由伺服它,题面图片一律 404。
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
return (
(await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ??
new Response("Not found", { status: 404 })
)
}
if (
url.pathname === "/ws/submissions" ||
url.pathname === "/ws/config" ||
url.pathname === "/ws/collab"
) {
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
return new Response("Forbidden", { status: 403 })
}
const session = await authenticateWebSocket(request)
if (!session) return new Response("Unauthorized", { status: 401 })
const { user } = session
const kind =
url.pathname === "/ws/config"
? "config"
: url.pathname === "/ws/collab"
? "collab"
: "submissions"
if (
bunServer.upgrade(request, {
// 前端是借子协议带 token 来的,不回一个它传过的子协议 Chrome 就判握手失败
headers: { "Sec-WebSocket-Protocol": WS_AUTH_PROTOCOL },
data: {
userId: user.id,
kind,
sid: session.sid,
tokenVersion: session.tokenVersion,
username: user.username,
adminType: user.adminType,
},
})
) {
return undefined
}
return new Response("WebSocket upgrade failed", { status: 400 })
}
return app.fetch(request)
},
websocket: submissionWebSocketHandler(),
})
await bridgeSubmissionEvents(server)
startSessionSweep()
console.log(`OJ2 API listening on http://localhost:${server.port}`)