Files
OJ2/apps/api/src/index.ts
T
2026-10-26 13:52:24 +08:00

154 lines
6.7 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 { scanPendingChallenges } from './services/challenge'
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}`)
// 迁移刚灌进来、或者上次没补完的挑战:拿历史上做对的代码补一遍谁已经做到了。
// 晚一点再跑,别和刚起来时涌进来的请求抢那两个核
setTimeout(() => void scanPendingChallenges(), 20_000)