fix(阶段3): 匿名不可读用户档案,真名改为默认不下发
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 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ import { failure, success } from "../http"
|
|||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { getUserProfileById } from "../services/profile"
|
import { getUserProfileById } from "../services/profile"
|
||||||
import { objectValue, queryInteger } from "./helpers"
|
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const accountRoutes = new Hono<AppEnv>()
|
export const accountRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -99,6 +99,10 @@ accountRoutes.post("/users", async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
accountRoutes.get("/profiles/:username", optionalAuth, 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)
|
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)
|
.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")
|
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)
|
.limit(top > 0 ? Math.min(top, 250) : limit).offset(top > 0 ? 0 : offset)
|
||||||
const results = rows.map(({ profile, user }) => rankProfileSchema.parse({
|
const results = rows.map(({ profile, user }) => rankProfileSchema.parse({
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
user: { id: user.id, username: user.username, realName: profile.realName },
|
user: sampleUser(user, profile.realName),
|
||||||
acceptedNumber: profile.acceptedNumber,
|
acceptedNumber: profile.acceptedNumber,
|
||||||
submissionNumber: profile.submissionNumber,
|
submissionNumber: profile.submissionNumber,
|
||||||
mood: profile.mood,
|
mood: profile.mood,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { requireAuth, type AppEnv } from "../auth/middleware"
|
|||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { isSuperAdmin, objectValue, queryInteger } from "./helpers"
|
import { isSuperAdmin, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const contentRoutes = new Hono<AppEnv>()
|
export const contentRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ contentRoutes.get("/announcements", async (c) => {
|
|||||||
title: announcement.title,
|
title: announcement.title,
|
||||||
tag: announcement.tag,
|
tag: announcement.tag,
|
||||||
top: announcement.top,
|
top: announcement.top,
|
||||||
createdBy: { id: user.id, username: user.username, realName },
|
createdBy: sampleUser(user, realName),
|
||||||
createTime: announcement.createTime,
|
createTime: announcement.createTime,
|
||||||
lastUpdateTime: announcement.lastUpdateTime,
|
lastUpdateTime: announcement.lastUpdateTime,
|
||||||
})),
|
})),
|
||||||
@@ -61,7 +61,7 @@ contentRoutes.get("/announcements/:id", async (c) => {
|
|||||||
tag: row.announcement.tag,
|
tag: row.announcement.tag,
|
||||||
content: row.announcement.content,
|
content: row.announcement.content,
|
||||||
top: row.announcement.top,
|
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,
|
createTime: row.announcement.createTime,
|
||||||
lastUpdateTime: row.announcement.lastUpdateTime,
|
lastUpdateTime: row.announcement.lastUpdateTime,
|
||||||
}))
|
}))
|
||||||
@@ -82,7 +82,7 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
|||||||
return success(c, messageListSchema.parse({
|
return success(c, messageListSchema.parse({
|
||||||
results: rows.map(({ message, sender, realName, submission }) => messageSchema.parse({
|
results: rows.map(({ message, sender, realName, submission }) => messageSchema.parse({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
sender: { id: sender.id, username: sender.username, realName },
|
sender: sampleUser(sender, realName),
|
||||||
createTime: message.createTime,
|
createTime: message.createTime,
|
||||||
message: message.message,
|
message: message.message,
|
||||||
submission: submissionDetailSchema.parse({
|
submission: submissionDetailSchema.parse({
|
||||||
@@ -193,7 +193,7 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
|||||||
isPublic: row.tutorial.isPublic,
|
isPublic: row.tutorial.isPublic,
|
||||||
order: row.tutorial.order,
|
order: row.tutorial.order,
|
||||||
type: row.tutorial.type,
|
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,
|
createdAt: row.tutorial.createdAt,
|
||||||
updatedAt: row.tutorial.updatedAt,
|
updatedAt: row.tutorial.updatedAt,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
findVisibleContest,
|
findVisibleContest,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
import { objectValue, publicTemplates, queryInteger, stringArray } from "./helpers"
|
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers"
|
||||||
|
|
||||||
export const contestRoutes = new Hono<AppEnv>()
|
export const contestRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -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 })
|
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))
|
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(eq(schema.user.id, id)).limit(1)
|
.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) {
|
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,
|
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||||
difficulty: allowed ? problem.difficulty : "",
|
difficulty: allowed ? problem.difficulty : "",
|
||||||
createdBy: { id: user.id, username: user.username, realName },
|
createdBy: sampleUser(user, realName),
|
||||||
tags: tags.get(problem.id) ?? [],
|
tags: tags.get(problem.id) ?? [],
|
||||||
contestId: contest.id,
|
contestId: contest.id,
|
||||||
allowFlowchart: problem.allowFlowchart,
|
allowFlowchart: problem.allowFlowchart,
|
||||||
@@ -174,7 +174,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, async (c) =
|
|||||||
shareSubmission: row.problem.shareSubmission,
|
shareSubmission: row.problem.shareSubmission,
|
||||||
contestId: contest.id,
|
contestId: contest.id,
|
||||||
tags: tags.get(row.problem.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,
|
myStatus: null,
|
||||||
myFailedCount: 0,
|
myFailedCount: 0,
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
allowFlowchart: row.problem.allowFlowchart,
|
||||||
@@ -206,7 +206,9 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, async (c) => {
|
|||||||
return success(c, contestRankSchema.parse({
|
return success(c, contestRankSchema.parse({
|
||||||
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
|
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
|
||||||
id: rank.id,
|
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,
|
submissionNumber: rank.submissionNumber,
|
||||||
acceptedNumber: rank.acceptedNumber,
|
acceptedNumber: rank.acceptedNumber,
|
||||||
totalTime: rank.totalTime,
|
totalTime: rank.totalTime,
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
|
import { sampleUserSchema, type SampleUser } from "@oj2/contract"
|
||||||
|
|
||||||
import type { AuthUser } from "../auth/session"
|
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<string, unknown> {
|
export function objectValue(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
@@ -24,10 +48,9 @@ export function queryInteger(
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRegularUser(user: AuthUser | null | undefined) {
|
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
|
||||||
return user?.adminType === "Regular User"
|
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
||||||
}
|
// 需要「非管理员即受限」时一律用 !isAdminRole(user)。
|
||||||
|
|
||||||
export function isAdminRole(user: AuthUser | null | undefined) {
|
export function isAdminRole(user: AuthUser | null | undefined) {
|
||||||
return Boolean(user && user.adminType !== "Regular User")
|
return Boolean(user && user.adminType !== "Regular User")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { optionalAuth, type AppEnv } from "../auth/middleware"
|
|||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
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<AppEnv>()
|
export const problemRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ function listItem(
|
|||||||
submissionNumber: row.problem.submissionNumber,
|
submissionNumber: row.problem.submissionNumber,
|
||||||
acceptedNumber: row.problem.acceptedNumber,
|
acceptedNumber: row.problem.acceptedNumber,
|
||||||
difficulty: row.problem.difficulty,
|
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) ?? [],
|
tags: tags.get(row.problem.id) ?? [],
|
||||||
contestId: row.problem.contestId,
|
contestId: row.problem.contestId,
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
allowFlowchart: row.problem.allowFlowchart,
|
||||||
@@ -327,11 +327,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
|||||||
shareSubmission: row.problem.shareSubmission,
|
shareSubmission: row.problem.shareSubmission,
|
||||||
contestId: row.problem.contestId,
|
contestId: row.problem.contestId,
|
||||||
tags: tagRows.map((tag) => tag.name),
|
tags: tagRows.map((tag) => tag.name),
|
||||||
createdBy: {
|
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
|
||||||
id: row.creatorId,
|
|
||||||
username: row.creatorUsername,
|
|
||||||
realName: null,
|
|
||||||
},
|
|
||||||
myStatus,
|
myStatus,
|
||||||
myFailedCount,
|
myFailedCount,
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
allowFlowchart: row.problem.allowFlowchart,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { publishAchievementNotification } from "../events"
|
|||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { updateAchievementsForProblemSet } from "../services/achievements"
|
import { updateAchievementsForProblemSet } from "../services/achievements"
|
||||||
import { isTeacherOrAbove, objectValue, queryInteger } from "./helpers"
|
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const problemsetRoutes = new Hono<AppEnv>()
|
export const problemsetRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -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 })
|
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))
|
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(eq(schema.user.id, id)).limit(1)
|
.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) {
|
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,
|
submissionNumber: problem.submissionNumber,
|
||||||
acceptedNumber: problem.acceptedNumber,
|
acceptedNumber: problem.acceptedNumber,
|
||||||
difficulty: problem.difficulty,
|
difficulty: problem.difficulty,
|
||||||
createdBy: { id: user.id, username: user.username, realName },
|
createdBy: sampleUser(user, realName),
|
||||||
tags: tags.get(problem.id) ?? [],
|
tags: tags.get(problem.id) ?? [],
|
||||||
contestId: problem.contestId,
|
contestId: problem.contestId,
|
||||||
allowFlowchart: problem.allowFlowchart,
|
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({
|
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({
|
||||||
id: progress.id,
|
id: progress.id,
|
||||||
problemsetId: progress.problemsetId,
|
problemsetId: progress.problemsetId,
|
||||||
user: { id: progressUser.id, username: progressUser.username, realName },
|
user: sampleUser(progressUser, realName),
|
||||||
joinTime: progress.joinTime,
|
joinTime: progress.joinTime,
|
||||||
completeTime: progress.completeTime,
|
completeTime: progress.completeTime,
|
||||||
isCompleted: progress.isCompleted,
|
isCompleted: progress.isCompleted,
|
||||||
|
|||||||
Reference in New Issue
Block a user