Build Phase 2 judge vertical slice

This commit is contained in:
2026-08-06 22:42:39 -06:00
parent e6329ecabb
commit ec274419c3
41 changed files with 2945 additions and 669 deletions

110
apps/api/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,110 @@
import {
loginRequestSchema,
sessionUserSchema,
userProfileSchema,
} from "@oj2/contract"
import { and, eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { createSession, destroySession } from "../auth/session"
import { verifyPassword } from "../auth/password"
import { db, schema } from "../db"
import { failure, success } from "../http"
export const authRoutes = new Hono<AppEnv>()
authRoutes.post("/auth/login", async (c) => {
const parsed = loginRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", "Username and password are required")
}
const [user] = await db
.select()
.from(schema.user)
.where(
sql`lower(${schema.user.username}) = lower(${parsed.data.username})`,
)
.limit(1)
if (!user) {
return failure(c, 401, "invalid-credentials", "Invalid username or password")
}
if (user.isDisabled) {
return failure(c, 403, "account-disabled", "Your account has been disabled")
}
const password = await verifyPassword(parsed.data.password, user.password)
if (!password.valid) {
return failure(c, 401, "invalid-credentials", "Invalid username or password")
}
const now = new Date().toISOString()
const update: { lastLogin: string; password?: string } = { lastLogin: now }
if (password.needsUpgrade) {
update.password = await Bun.password.hash(parsed.data.password, {
algorithm: "argon2id",
})
}
await db.update(schema.user).set(update).where(eq(schema.user.id, user.id))
await createSession(c, user.id)
return success(c, { ok: true })
})
authRoutes.delete("/auth/session", async (c) => {
await destroySession(c)
return success(c, null)
})
authRoutes.get("/me", optionalAuth, async (c) => {
const authUser = c.get("user")
if (!authUser) return success(c, null)
const [row] = await db
.select({
profile: schema.userProfile,
user: schema.user,
})
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.user.id, authUser.id),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!row) return failure(c, 404, "profile-not-found", "User profile does not exist")
const data = userProfileSchema.parse({
id: row.profile.id,
user: sessionUserSchema.parse({
id: row.user.id,
username: row.user.username,
email: row.user.email,
adminType: row.user.adminType,
problemPermission: row.user.problemPermission,
createTime: row.user.createTime,
lastLogin: row.user.lastLogin,
openApi: row.user.openApi,
isDisabled: row.user.isDisabled,
className: row.user.className,
}),
realName: row.profile.realName,
acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar,
blog: row.profile.blog,
mood: row.profile.mood,
github: row.profile.github,
school: row.profile.school,
major: row.profile.major,
language: row.profile.language,
acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber,
})
return success(c, data)
})

View File

@@ -0,0 +1,86 @@
import { createHash, timingSafeEqual } from "node:crypto"
import { eq } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { z } from "zod"
import { config } from "../config"
import { db, schema } from "../db"
import { failure } from "../http"
const heartbeatSchema = z.object({
hostname: z.string().min(1).max(128),
judger_version: z.string().min(1).max(32),
cpu_core: z.number().int().positive(),
memory: z.number().min(0).max(100),
cpu: z.number().min(0).max(100),
action: z.literal("heartbeat"),
service_url: z.string().min(1).max(256),
})
export const judgeServerRoutes = new Hono()
function tokenMatches(value: string | undefined) {
if (!value) return false
const expected = createHash("sha256")
.update(config.judgeServerToken)
.digest("hex")
const actualBuffer = Buffer.from(value)
const expectedBuffer = Buffer.from(expected)
return (
actualBuffer.length === expectedBuffer.length &&
timingSafeEqual(actualBuffer, expectedBuffer)
)
}
async function heartbeat(c: Context) {
if (!tokenMatches(c.req.header("X-Judge-Server-Token"))) {
return failure(c, 403, "invalid-judge-token", "Invalid token")
}
const parsed = heartbeatSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-heartbeat", "Invalid heartbeat payload")
}
const now = new Date().toISOString()
const [existing] = await db
.select({ id: schema.judgeServer.id })
.from(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, parsed.data.hostname))
.limit(1)
const common = {
ip:
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
c.req.header("x-real-ip") ||
null,
judgerVersion: parsed.data.judger_version,
cpuCore: parsed.data.cpu_core,
memoryUsage: parsed.data.memory,
cpuUsage: parsed.data.cpu,
lastHeartbeat: now,
serviceUrl: parsed.data.service_url,
}
if (existing) {
await db
.update(schema.judgeServer)
.set(common)
.where(eq(schema.judgeServer.id, existing.id))
} else {
await db.insert(schema.judgeServer).values({
...common,
hostname: parsed.data.hostname,
createTime: now,
taskNumber: 0,
isDisabled: false,
})
}
// JudgeServer 1.6.1 healthcheck still reads the legacy { error, data } envelope.
return c.json({ error: null, data: null })
}
judgeServerRoutes.post("/judge-server/heartbeat", heartbeat)
judgeServerRoutes.post("/judge-server/heartbeat/", heartbeat)

View File

@@ -1,10 +1,32 @@
import { problemSummarySchema } from "@oj2/contract"
import { desc } from "drizzle-orm"
import { problemDetailSchema, problemSummarySchema } from "@oj2/contract"
import { and, count, desc, eq, isNull, notInArray } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
export const problemRoutes = new Hono()
export const problemRoutes = new Hono<AppEnv>()
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
}
function publicTemplates(value: unknown) {
const templates: Record<string, string> = {}
for (const [language, raw] of Object.entries(objectValue(value))) {
if (typeof raw !== "string") continue
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
templates[language] = match?.[1] ?? ""
}
return templates
}
problemRoutes.get("/problems", async (c) => {
const rows = await db
@@ -17,9 +39,111 @@ problemRoutes.get("/problems", async (c) => {
acceptedNumber: schema.problem.acceptedNumber,
})
.from(schema.problem)
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
.orderBy(desc(schema.problem.id))
.limit(20)
const data = rows.map((row) => problemSummarySchema.parse(row))
return c.json({ data })
return success(c, data)
})
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
const [row] = await db
.select({
problem: schema.problem,
creatorId: schema.user.id,
creatorUsername: schema.user.username,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(
and(
eq(schema.problem.displayId, c.req.param("displayId")),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
const tagRows = await db
.select({ name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(eq(schema.problemTags.problemId, row.problem.id))
const user = c.get("user")
let myStatus: number | null = null
let myFailedCount = 0
if (user) {
const [profile] = await db
.select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
const statuses = objectValue(objectValue(profile?.status).problems)
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
if (typeof problemStatus === "number") myStatus = problemStatus
const [failed] = await db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, row.problem.id),
notInArray(schema.submission.result, [0, 10]),
),
)
myFailedCount = failed?.value ?? 0
}
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
const data = problemDetailSchema.parse({
id: row.problem.id,
_id: row.problem.displayId,
title: row.problem.title,
description: row.problem.description,
inputDescription: row.problem.inputDescription,
outputDescription: row.problem.outputDescription,
samples,
hint: row.problem.hint,
languages: stringArray(row.problem.languages),
template: publicTemplates(row.problem.template),
createTime: row.problem.createTime,
lastUpdateTime: row.problem.lastUpdateTime,
timeLimit: row.problem.timeLimit,
memoryLimit: row.problem.memoryLimit,
difficulty: row.problem.difficulty,
source: row.problem.source,
prompt: row.problem.prompt,
submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber,
statisticInfo: objectValue(row.problem.statisticInfo),
shareSubmission: row.problem.shareSubmission,
contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name),
createdBy: {
id: row.creatorId,
username: row.creatorUsername,
realName: null,
},
myStatus,
myFailedCount,
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart
? null
: objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
})
return success(c, data)
})

View File

@@ -0,0 +1,155 @@
import { randomBytes } from "node:crypto"
import {
createSubmissionRequestSchema,
createSubmissionResponseSchema,
submissionDetailSchema,
} from "@oj2/contract"
import { and, eq, isNull } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { judgeQueue } from "../queue"
export const submissionRoutes = new Hono<AppEnv>()
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
submissionRoutes.use("/submissions", requireAuth)
submissionRoutes.use("/submissions/*", requireAuth)
submissionRoutes.post("/submissions", async (c) => {
const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", "Invalid submission payload")
}
if (parsed.data.contestId) {
return failure(
c,
400,
"contest-not-supported",
"Contest submissions are not part of the Phase 2 slice",
)
}
const [problem] = await db
.select({
id: schema.problem.id,
languages: schema.problem.languages,
})
.from(schema.problem)
.where(
and(
eq(schema.problem.id, parsed.data.problemId),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!stringArray(problem.languages).includes(parsed.data.language)) {
return failure(
c,
400,
"language-not-allowed",
`${parsed.data.language} is not allowed in the problem`,
)
}
const user = c.get("user")!
const submissionId = randomBytes(16).toString("hex")
const createTime = new Date().toISOString()
const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim()
const ip = forwarded || c.req.header("x-real-ip") || null
await db.insert(schema.submission).values({
id: submissionId,
problemId: problem.id,
createTime,
userId: user.id,
username: user.username,
code: parsed.data.code,
result: JudgeStatus.PENDING,
info: {},
language: parsed.data.language,
shared: false,
statisticInfo: {},
ip,
contestId: null,
})
try {
await judgeQueue.add(
"judge",
{ submissionId, problemId: problem.id },
{ jobId: submissionId },
)
} catch (error) {
await db
.update(schema.submission)
.set({ result: JudgeStatus.SYSTEM_ERROR })
.where(eq(schema.submission.id, submissionId))
console.error("Failed to enqueue submission", error)
return failure(c, 502, "queue-unavailable", "Judge queue is unavailable")
}
return success(
c,
createSubmissionResponseSchema.parse({ submissionId }),
201,
)
})
submissionRoutes.get("/submissions/:id", async (c) => {
const user = c.get("user")!
const [row] = await db
.select()
.from(schema.submission)
.where(
and(
eq(schema.submission.id, c.req.param("id")),
eq(schema.submission.userId, user.id),
),
)
.limit(1)
if (!row) {
return failure(c, 404, "submission-not-found", "Submission does not exist")
}
const data = submissionDetailSchema.parse({
id: row.id,
createTime: row.createTime,
userId: row.userId,
username: row.username,
code: row.code,
result: row.result,
info: row.info,
language: row.language,
shared: row.shared,
statisticInfo: objectValue(row.statisticInfo),
ip: row.ip,
contestId: row.contestId,
problemId: row.problemId,
showLink: true,
canUnshare: true,
})
return success(c, data)
})