Build Phase 2 judge vertical slice
This commit is contained in:
110
apps/api/src/routes/auth.ts
Normal file
110
apps/api/src/routes/auth.ts
Normal 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)
|
||||
})
|
||||
86
apps/api/src/routes/judge-server.ts
Normal file
86
apps/api/src/routes/judge-server.ts
Normal 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)
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
155
apps/api/src/routes/submission.ts
Normal file
155
apps/api/src/routes/submission.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user