From b4b61af6b031cd1c612cd55a91d953dc0090c1b2 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 7 Aug 2026 01:59:05 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=98=B6=E6=AE=B53):=20=E5=8C=BF=E5=90=8D?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=AF=BB=E7=94=A8=E6=88=B7=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=EF=BC=8C=E7=9C=9F=E5=90=8D=E6=94=B9=E4=B8=BA=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=B8=8D=E4=B8=8B=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1:GET /profiles/:username 只挂了 optionalAuth、handler 内无登录判断, 匿名可读 email、adminType、className、lastLogin。用户名又能经 /rankings/users 公开枚举,等于可以无 cookie 批量收集全校学生的邮箱与最后登录时间。 handler 开头补上未登录即返回空,对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行 `if not user.is_authenticated: return self.success()`。 F2:旧后端把「是否下发真名」做成 UsernameSerializer(need_real_name=False) 的默认关闭开关,全仓 11 处调用只有比赛榜单一处显式打开;新后端没搬这一层, 真名随用户对象无条件下发,13 个下发点里 8 个匿名可达。 这里补回同一层:helpers.ts 新增 sampleUser(),realName 默认不下发, 需要的地方显式传 { includeRealName: true }。12 个下发点改为走这个函数, 只有比赛榜单一处打开(对齐 contest/serializers.py:84 的 is_contest_admin)。 没有逐处删字段 —— 那样下次新增端点还会重犯。 Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/account.ts | 8 ++++++-- apps/api/src/routes/content.ts | 10 +++++----- apps/api/src/routes/contest.ts | 12 +++++++----- apps/api/src/routes/helpers.ts | 31 +++++++++++++++++++++++++++---- apps/api/src/routes/problem.ts | 10 +++------- apps/api/src/routes/problemset.ts | 8 ++++---- 6 files changed, 52 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 7d12b76..532ff50 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -35,7 +35,7 @@ import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" import { getBooleanOption } from "../services/options" import { getUserProfileById } from "../services/profile" -import { objectValue, queryInteger } from "./helpers" +import { objectValue, queryInteger, sampleUser } from "./helpers" export const accountRoutes = new Hono() @@ -99,6 +99,10 @@ accountRoutes.post("/users", async (c) => { }) accountRoutes.get("/profiles/:username", optionalAuth, async (c) => { + // 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行: + // `if not user.is_authenticated: return self.success()` —— 匿名一律返回空, + // 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。 + if (!c.get("user")) return success(c, null) const [target] = await db.select({ id: schema.user.id }).from(schema.user) .where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1) if (!target) return failure(c, 404, "user-not-found", "User does not exist") @@ -165,7 +169,7 @@ accountRoutes.get("/rankings/users", async (c) => { .limit(top > 0 ? Math.min(top, 250) : limit).offset(top > 0 ? 0 : offset) const results = rows.map(({ profile, user }) => rankProfileSchema.parse({ id: profile.id, - user: { id: user.id, username: user.username, realName: profile.realName }, + user: sampleUser(user, profile.realName), acceptedNumber: profile.acceptedNumber, submissionNumber: profile.submissionNumber, mood: profile.mood, diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts index 121c592..00c17b8 100644 --- a/apps/api/src/routes/content.ts +++ b/apps/api/src/routes/content.ts @@ -19,7 +19,7 @@ import { requireAuth, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" -import { isSuperAdmin, objectValue, queryInteger } from "./helpers" +import { isSuperAdmin, objectValue, queryInteger, sampleUser } from "./helpers" export const contentRoutes = new Hono() @@ -40,7 +40,7 @@ contentRoutes.get("/announcements", async (c) => { title: announcement.title, tag: announcement.tag, top: announcement.top, - createdBy: { id: user.id, username: user.username, realName }, + createdBy: sampleUser(user, realName), createTime: announcement.createTime, lastUpdateTime: announcement.lastUpdateTime, })), @@ -61,7 +61,7 @@ contentRoutes.get("/announcements/:id", async (c) => { tag: row.announcement.tag, content: row.announcement.content, top: row.announcement.top, - createdBy: { id: row.user.id, username: row.user.username, realName: row.realName }, + createdBy: sampleUser(row.user, row.realName), createTime: row.announcement.createTime, lastUpdateTime: row.announcement.lastUpdateTime, })) @@ -82,7 +82,7 @@ contentRoutes.get("/messages", requireAuth, async (c) => { return success(c, messageListSchema.parse({ results: rows.map(({ message, sender, realName, submission }) => messageSchema.parse({ id: message.id, - sender: { id: sender.id, username: sender.username, realName }, + sender: sampleUser(sender, realName), createTime: message.createTime, message: message.message, submission: submissionDetailSchema.parse({ @@ -193,7 +193,7 @@ contentRoutes.get("/tutorials/:id", async (c) => { isPublic: row.tutorial.isPublic, order: row.tutorial.order, type: row.tutorial.type, - createdBy: { id: row.user.id, username: row.user.username, realName: row.realName }, + createdBy: sampleUser(row.user, row.realName), createdAt: row.tutorial.createdAt, updatedAt: row.tutorial.updatedAt, })) diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index d5ddd3c..9840e60 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -23,7 +23,7 @@ import { findVisibleContest, isContestAdmin, } from "../services/contest" -import { objectValue, publicTemplates, queryInteger, stringArray } from "./helpers" +import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers" export const contestRoutes = new Hono() @@ -31,7 +31,7 @@ async function creator(id: number) { const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName }) .from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(eq(schema.user.id, id)).limit(1) - return row ?? { id, username: "", realName: null } + return sampleUser(row ?? { id, username: "" }, row?.realName) } async function serializeContest(contest: typeof schema.contest.$inferSelect, includeNow = false) { @@ -128,7 +128,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, async (c) => { submissionNumber: allowed ? problem.submissionNumber : 0, acceptedNumber: allowed ? problem.acceptedNumber : 0, difficulty: allowed ? problem.difficulty : "", - createdBy: { id: user.id, username: user.username, realName }, + createdBy: sampleUser(user, realName), tags: tags.get(problem.id) ?? [], contestId: contest.id, allowFlowchart: problem.allowFlowchart, @@ -174,7 +174,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) = shareSubmission: row.problem.shareSubmission, contestId: contest.id, tags: tags.get(row.problem.id) ?? [], - createdBy: { id: row.user.id, username: row.user.username, realName: row.realName }, + createdBy: sampleUser(row.user, row.realName), myStatus: null, myFailedCount: 0, allowFlowchart: row.problem.allowFlowchart, @@ -206,7 +206,9 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, async (c) => { return success(c, contestRankSchema.parse({ results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({ id: rank.id, - user: { id: user.id, username: user.username, realName: admin ? realName : null }, + // 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84 + // `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)` + user: sampleUser(user, realName, { includeRealName: admin }), submissionNumber: rank.submissionNumber, acceptedNumber: rank.acceptedNumber, totalTime: rank.totalTime, diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index fc8c29a..cfb9631 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -1,5 +1,29 @@ +import { sampleUserSchema, type SampleUser } from "@oj2/contract" + import type { AuthUser } from "../auth/session" +/** + * 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。 + * + * 旧后端把「是否下发真名」做成 `need_real_name` 开关,**默认关闭**,全仓 11 处调用里只有 + * 比赛榜单一处显式打开。这里保持同一约定:`realName` 默认不下发,需要的地方显式传 + * `{ includeRealName: true }`。 + * + * 所有下发用户对象的地方都必须走这个函数,不要再手写 `{ id, username, realName }` —— + * 手写的话下次新增端点必然重犯「学生真名无条件下发」。 + */ +export function sampleUser( + source: { id: number; username: string }, + realName: string | null | undefined, + options: { includeRealName?: boolean } = {}, +): SampleUser { + return sampleUserSchema.parse({ + id: source.id, + username: source.username, + realName: options.includeRealName === true ? (realName ?? null) : null, + }) +} + export function objectValue(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -24,10 +48,9 @@ export function queryInteger( return parsed } -export function isRegularUser(user: AuthUser | null | undefined) { - return user?.adminType === "Regular User" -} - +// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 —— +// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。 +// 需要「非管理员即受限」时一律用 !isAdminRole(user)。 export function isAdminRole(user: AuthUser | null | undefined) { return Boolean(user && user.adminType !== "Regular User") } diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 3abd3fe..4fbabf5 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -28,7 +28,7 @@ import { optionalAuth, type AppEnv } from "../auth/middleware" import { db, schema } from "../db" import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" -import { objectValue as toObject, queryInteger } from "./helpers" +import { objectValue as toObject, queryInteger, sampleUser } from "./helpers" export const problemRoutes = new Hono() @@ -83,7 +83,7 @@ function listItem( submissionNumber: row.problem.submissionNumber, acceptedNumber: row.problem.acceptedNumber, difficulty: row.problem.difficulty, - createdBy: { id: row.user.id, username: row.user.username, realName: row.realName }, + createdBy: sampleUser(row.user, row.realName), tags: tags.get(row.problem.id) ?? [], contestId: row.problem.contestId, allowFlowchart: row.problem.allowFlowchart, @@ -327,11 +327,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { shareSubmission: row.problem.shareSubmission, contestId: row.problem.contestId, tags: tagRows.map((tag) => tag.name), - createdBy: { - id: row.creatorId, - username: row.creatorUsername, - realName: null, - }, + createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null), myStatus, myFailedCount, allowFlowchart: row.problem.allowFlowchart, diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 30800c5..86da9fd 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -33,7 +33,7 @@ import { publishAchievementNotification } from "../events" import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" import { updateAchievementsForProblemSet } from "../services/achievements" -import { isTeacherOrAbove, objectValue, queryInteger } from "./helpers" +import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers" export const problemsetRoutes = new Hono() @@ -59,7 +59,7 @@ async function problemSetCreator(id: number) { const [row] = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName }) .from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(eq(schema.user.id, id)).limit(1) - return row ?? { id, username: "", realName: null } + return sampleUser(row ?? { id, username: "" }, row?.realName) } function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) { @@ -166,7 +166,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { submissionNumber: problem.submissionNumber, acceptedNumber: problem.acceptedNumber, difficulty: problem.difficulty, - createdBy: { id: user.id, username: user.username, realName }, + createdBy: sampleUser(user, realName), tags: tags.get(problem.id) ?? [], contestId: problem.contestId, allowFlowchart: problem.allowFlowchart, @@ -395,7 +395,7 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireAuth, async (c) = const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({ id: progress.id, problemsetId: progress.problemsetId, - user: { id: progressUser.id, username: progressUser.username, realName }, + user: sampleUser(progressUser, realName), joinTime: progress.joinTime, completeTime: progress.completeTime, isCompleted: progress.isCompleted,