Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.7 KiB
TypeScript
154 lines
6.7 KiB
TypeScript
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)
|