diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index d6fd66f..8f3d16e 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -22,3 +22,46 @@ export const requireAuth: MiddlewareHandler = async (c, next) => { c.set("user", user) await next() } + +/** + * 后台接口的角色守卫,对应旧后端 `account/decorators.py` 的四个装饰器。 + * + * 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`, + * 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api2.ts` + * 的拦截器就是按这两个 code 分别弹登录框和弹提示的。 + * + * 「账号已禁用」这一支不需要单独处理:`getSessionUser` 对禁用用户直接返回 null, + * 于是落到 401,比旧后端先认证再报 403 更早拦一步。 + */ +function requireRole( + allowed: (user: AuthUser) => boolean, +): MiddlewareHandler { + return async (c, next) => { + const user = await getSessionUser(c) + if (!user) return failure(c, 401, "login-required", "请先登录") + if (!allowed(user)) return failure(c, 403, "permission-denied", "权限不足") + c.set("user", user) + await next() + } +} + +const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"] +const TEACHER_ROLES = ["Teacher Admin", "Super Admin"] + +/** 旧 `@admin_role_required` */ +export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.adminType)) + +/** 旧 `@teacher_admin_required` */ +export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType)) + +/** 旧 `@super_admin_required` */ +export const requireSuperAdmin = requireRole((user) => user.adminType === "Super Admin") + +/** + * 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。 + * 注意它只管「能不能进这个接口」,「能改哪些题」(Own vs All)由各 handler 自己按 + * created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。 + */ +export const requireProblemPermission = requireRole( + (user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None", +) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 496c816..d129a45 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import { basename, resolve } from "node:path" import { getRequestSessionUser } 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" @@ -38,6 +39,7 @@ app.route("/api", flowchartRoutes) app.route("/api", problemRoutes) app.route("/api", submissionRoutes) app.route("/api", judgeServerRoutes) +app.route("/api/admin", adminRoutes) app.onError((error, c) => { console.error(error) diff --git a/apps/api/src/routes/admin/announcement.ts b/apps/api/src/routes/admin/announcement.ts new file mode 100644 index 0000000..a975991 --- /dev/null +++ b/apps/api/src/routes/admin/announcement.ts @@ -0,0 +1,114 @@ +import { + adminAnnouncementListSchema, + adminAnnouncementSchema, + createAnnouncementRequestSchema, + updateAnnouncementRequestSchema, +} from "@oj2/contract" +import { count, desc, eq } from "drizzle-orm" +import { Hono } from "hono" + +import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" +import { db, schema } from "../../db" +import { failure, success } from "../../http" +import { queryInteger, sampleUser } from "../helpers" + +export const adminAnnouncementRoutes = new Hono() + +function serialize(row: { + announcement: typeof schema.announcement.$inferSelect + user: typeof schema.user.$inferSelect + realName: string | null +}) { + return adminAnnouncementSchema.parse({ + id: row.announcement.id, + title: row.announcement.title, + tag: row.announcement.tag, + content: row.announcement.content, + visible: row.announcement.visible, + top: row.announcement.top, + createdBy: sampleUser(row.user, row.realName), + createTime: row.announcement.createTime, + lastUpdateTime: row.announcement.lastUpdateTime, + }) +} + +function selectOne(id: number) { + return db + .select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName }) + .from(schema.announcement) + .innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id)) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .where(eq(schema.announcement.id, id)) + .limit(1) +} + +adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => { + const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) + const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) + const [totalRows, rows] = await Promise.all([ + db.select({ value: count() }).from(schema.announcement), + db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName }) + .from(schema.announcement) + .innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id)) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .orderBy(desc(schema.announcement.createTime)) + .limit(limit) + .offset(offset), + ]) + return success(c, adminAnnouncementListSchema.parse({ + // 列表 schema omit 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍 + results: rows.map(serialize), + total: totalRows[0]?.value ?? 0, + })) +}) + +adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => { + const parsed = createAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const now = new Date().toISOString() + const [created] = await db.insert(schema.announcement).values({ + ...parsed.data, + createTime: now, + lastUpdateTime: now, + createdById: c.get("user")!.id, + }).returning({ id: schema.announcement.id }) + const [row] = await selectOne(created!.id) + return success(c, serialize(row!), 201) +}) + +adminAnnouncementRoutes.get("/announcements/:id", requireSuperAdmin, async (c) => { + const [row] = await selectOne(queryInteger(c.req.param("id"), 0, { min: 1 })) + if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist") + return success(c, serialize(row)) +}) + +adminAnnouncementRoutes.put("/announcements/:id", requireSuperAdmin, async (c) => { + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) { + return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") + } + const updated = await db.update(schema.announcement) + .set({ ...parsed.data, lastUpdateTime: new Date().toISOString() }) + .where(eq(schema.announcement.id, id)) + .returning({ id: schema.announcement.id }) + if (updated.length === 0) { + return failure(c, 404, "announcement-not-found", "Announcement does not exist") + } + const [row] = await selectOne(id) + return success(c, serialize(row!)) +}) + +adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => { + // 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404: + // 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。 + const deleted = await db.delete(schema.announcement) + .where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) + .returning({ id: schema.announcement.id }) + if (deleted.length === 0) { + return failure(c, 404, "announcement-not-found", "Announcement does not exist") + } + return success(c, null) +}) diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts new file mode 100644 index 0000000..42db1d1 --- /dev/null +++ b/apps/api/src/routes/admin/index.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono" + +import type { AppEnv } from "../../auth/middleware" +import { adminAnnouncementRoutes } from "./announcement" + +/** + * 后台路由总入口,挂在 `/api/admin` 下。 + * + * 每个子路由自己挂角色守卫(requireAdmin / requireTeacher / requireSuperAdmin / + * requireProblemPermission),**不在这里统一兜一层** —— 旧后端的权限就是逐个视图不同的, + * 在总入口兜一个最宽的会让「这个接口到底要什么角色」从注册行上看不出来, + * 正是阶段 3 的 Minor M2 踩过的坑。 + */ +export const adminRoutes = new Hono() + +adminRoutes.route("/", adminAnnouncementRoutes) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 9a32961..7db45d6 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -1,4 +1,6 @@ import http from "utils/http" +import api2 from "utils/api2" +import { legacyResponse } from "utils/legacy" import { toProblemListItem } from "admin/transforms" import type { AdminProblem, @@ -266,25 +268,29 @@ export function deleteJudgeServer(hostname: string) { } export function getAnnouncementList(offset = 0, limit = 10) { - return http.get("admin/announcement", { - params: { paging: true, offset, limit }, - }) + return legacyResponse( + api2.get("admin/announcements", { + params: { offset, limit }, + }), + ) } export function getAnnouncement(id: number) { - return http.get("admin/announcement", { params: { id } }) + return legacyResponse(api2.get(`admin/announcements/${id}`)) } export function deleteAnnouncement(id: number) { - return http.delete("admin/announcement", { params: { id } }) + return api2.delete(`admin/announcements/${id}`) } export function editAnnouncement(announcement: AnnouncementEdit) { - return http.put("admin/announcement", announcement) + const { id, ...body } = announcement + return legacyResponse(api2.put(`admin/announcements/${id}`, body)) } export function createAnnouncement(announcement: AnnouncementEdit) { - return http.post("admin/announcement", announcement) + const { id: _id, ...body } = announcement + return legacyResponse(api2.post("admin/announcements", body)) } export async function getTutorialList() { diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 6b64ebf..4da036e 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -6,6 +6,7 @@ import { type SubmissionStatistics, } from "@oj2/contract" import api2 from "utils/api2" +import { legacyResponse, toLegacy } from "utils/legacy" import type { ApiResponse } from "utils/http" import { filterResult } from "oj/transforms" import type { @@ -19,25 +20,6 @@ import type { WebsiteConfig, } from "utils/types" -function snakeKey(key: string) { - return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) -} - -function toLegacy(value: unknown): T { - if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T - if (!value || typeof value !== "object") return value as T - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]), - ) as T -} - -async function legacyResponse( - request: Promise>, -): Promise> { - const response = await request - return { error: response.error, data: toLegacy(response.data) } -} - function listProblem(value: any): Problem { return { id: value.id, diff --git a/apps/web/src/utils/legacy.ts b/apps/web/src/utils/legacy.ts new file mode 100644 index 0000000..a1e0c46 --- /dev/null +++ b/apps/web/src/utils/legacy.ts @@ -0,0 +1,27 @@ +import type { ApiResponse } from "./http" + +/** + * 新后端一律 camelCase,而现存组件读的都是旧 Django 的 snake_case。 + * 迁移期在 api 层做一次键名转换,组件不动 —— 否则每搬一个端点就要顺带改一堆 .vue, + * 改动面大到没法一个个验。 + * + * 迁移完成后这一层应当整体拆掉,届时组件改成 camelCase 是一次性的机械替换。 + */ +function snakeKey(key: string) { + return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) +} + +export function toLegacy(value: unknown): T { + if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T + if (!value || typeof value !== "object") return value as T + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]), + ) as T +} + +export async function legacyResponse( + request: Promise>, +): Promise> { + const response = await request + return { error: response.error, data: toLegacy(response.data) } +} diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts new file mode 100644 index 0000000..9401a4d --- /dev/null +++ b/packages/contract/src/admin.ts @@ -0,0 +1,38 @@ +import { z } from "zod" + +import { paginatedSchema, sampleUserSchema } from "./common" + +/** + * 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同 + * (后台要 `visible` 这类管理字段,oj 侧连键都不该出现), + * 混在一个 schema 里迟早会有人为了省事在 oj 侧复用后台那个。 + */ + +export const adminAnnouncementSchema = z.object({ + id: z.number().int(), + title: z.string(), + tag: z.string(), + content: z.string(), + visible: z.boolean(), + top: z.boolean(), + createdBy: sampleUserSchema, + createTime: z.string(), + lastUpdateTime: z.string(), +}) + +export const adminAnnouncementListSchema = paginatedSchema( + // 列表不带 content:公告正文是 8MB 上限的富文本,列表页只显示标题 + adminAnnouncementSchema.omit({ content: true }), +) + +export const createAnnouncementRequestSchema = z.object({ + title: z.string().trim().min(1).max(64), + tag: z.string().max(64), + content: z.string().max(1024 * 1024 * 8), + visible: z.boolean(), + top: z.boolean(), +}) + +export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema + +export type AdminAnnouncement = z.infer diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index db03aee..2d39db2 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,3 +1,4 @@ +export * from "./admin" export * from "./auth" export * from "./account" export * from "./achievement"