三条迁移,一次部署(0008/0009 含 DROP COLUMN,需要 OJ2_ALLOW_DESTRUCTIVE=1): - 0008 删 IP 相关:比赛 IP 白名单(前端本来就没有输入框,detail.vue 无条件置空)、 submission.ip(前端从未显示过)、以及一次都没被调用过的 IP 限流桶。 judge_server.ip 是运维数据,保留。 - 0009 删九个只有 Django 时代写过、OJ2 一次都没读过的列:user 的 auth_token / open_api / open_api_appkey / session_keys,user_profile 的 blog / github / school / major / language。open_api 后台连开关都没有,那段「已经开着就不重置 appkey」的逻辑从上线起没进过 if。判据是「全仓零读取」而不是「看着没用」—— raw_password 同样刺眼却是在用的,别一起清掉。 - 0010 给 17 条外键补上删除动作,不再是 Django 留下的一律 NO ACTION。父行消失后 必然无意义、且不构成学生留痕的走 CASCADE(中间表、题单/教程/成就的组成部分、 user_profile 与 user_stat);需要人看见的继续拦着——submission.problem_id、 以及 user 的绝大多数外键,删用户撞外键会被 handler 翻译成「请改为禁用账号」, 这是有意的:全 CASCADE 会静默抹掉成就与进度,而 submission.user_id 压根没有 外键,结果是一半删一半留。六处手工级联随之删掉。 角色字符串收进 packages/contract/src/roles.ts:原先 ADMIN_ROLES / TEACHER_ROLES 在两个文件各抄一份、学生口径在四个文件各写一遍、前端 USER_TYPE 是第三份副本。 AuthUser.adminType 与 drizzle 的列都收窄成联合类型,二十多处 `=== "Super Admin"` 从此受编译器管着($type 是纯 TS 层的,generate 确认不产生任何 SQL 变更)。 顺带删掉 db/relations.ts —— drizzle-kit pull 的产物,全仓零引用。 一处行为变化:后台用户列表传非法的 ?type= 回 400,不再静默返回空列表;界面上的 下拉只有合法值,打不到这条。 验证:tsc / vue-tsc / vite build / check:routes 全过;三条迁移在 dev 库执行, 并逐条建 fixture 走 HTTP 接口验过删除连坐与拦截(题单五张子表连坐、user_badge 二级连坐、删有提交的题目仍 409、删有表情的用户仍 409)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
import { ADMIN_ROLES, TEACHER_ROLES } from "@oj2/contract"
|
||
import type { Context, MiddlewareHandler } from "hono"
|
||
|
||
import { failure } from "../http"
|
||
import { getSessionUser, resolveSession, type AuthUser } from "./session"
|
||
|
||
export interface AppEnv {
|
||
Variables: {
|
||
user: AuthUser | null
|
||
}
|
||
}
|
||
|
||
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||
c.set("user", await getSessionUser(c))
|
||
await next()
|
||
}
|
||
|
||
/**
|
||
* 拿不到用户时该报哪个错。
|
||
*
|
||
* 「账号被禁用」必须和「没登录」分开报:前端拦截器见到 `login-required` 会弹登录框,
|
||
* 于是一个上课上到一半被禁用的学生会陷入「弹登录框 → 登进去 → 又被弹」的死循环,
|
||
* 而且完全看不出发生了什么。旧后端报的是「账号已禁用」,这里对齐。
|
||
*
|
||
* 用 403 而不是 401:凭证是有效的,是这个账号不让用了,和 login 接口对禁用账号
|
||
* 的回法(403 `account-disabled`)也一致。
|
||
*/
|
||
function denied(c: Context, reason: "anonymous" | "disabled") {
|
||
return reason === "disabled"
|
||
? failure(c, 403, "account-disabled", "账号已被禁用,请联系老师")
|
||
: failure(c, 401, "login-required", "请先登录")
|
||
}
|
||
|
||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||
const session = await resolveSession(c)
|
||
if (!session.user) return denied(c, session.reason)
|
||
c.set("user", session.user)
|
||
await next()
|
||
}
|
||
|
||
/**
|
||
* 后台接口的角色守卫,对应旧后端 `account/decorators.py` 的四个装饰器。
|
||
*
|
||
* 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`,
|
||
* 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api.ts`
|
||
* 的拦截器就是按这两个 code 分别弹登录框和弹提示的。禁用账号走第三个码,见 denied()。
|
||
*/
|
||
function requireRole(
|
||
allowed: (user: AuthUser) => boolean,
|
||
): MiddlewareHandler<AppEnv> {
|
||
return async (c, next) => {
|
||
const session = await resolveSession(c)
|
||
if (!session.user) return denied(c, session.reason)
|
||
if (!allowed(session.user)) return failure(c, 403, "permission-denied", "权限不足")
|
||
c.set("user", session.user)
|
||
await next()
|
||
}
|
||
}
|
||
|
||
/** 旧 `@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",
|
||
)
|