fix: production hardening — migration script, security fixes, structured logging, API docs, component splitting

- Create apply-migrations.ts and jobs-worker.ts scripts (package.json references)
- Convert badge leaderboard from $queryRawUnsafe to $queryRaw with Prisma.sql templates
- Fix OAuth email binding: add oauth_require_link site setting, skip 2FA-protected accounts
- Add per-user 2FA rate limiting (5/30s) to prevent TOTP brute-force
- Add structured JSON logger with levels (debug/info/warn/error)
- Split 341-line HomePage into GuestView + UserView components
- Add OpenAPI v3.1 spec at /api/openapi.json
- Add LOG_LEVEL env var, regenerate Prisma client
- Add mysql2 dependency for migration scripts
- All 58 tests pass, typecheck clean
This commit is contained in:
openhands committed 2026-07-08 13:06:02 +02:00
1 parent 5c638cd6bc
commit c5db7f5156
17 files changed
+1175 -449

No files matched your search

+106 -94
View File
@@ -1,3 +1,4 @@
import { Prisma } from "@/generated/prisma/client";
import { apiJson } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { auth } from "@/lib/auth";
@@ -52,13 +53,15 @@ function rankEntries(entries: BadgeLeaderboardEntry[]): BadgeLeaderboardEntry[]
}
async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderboardBoard> {
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; cnt: bigint }>>(
`SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
FROM users_badges ub
JOIN users u ON u.id = ub.user_id
GROUP BY ub.user_id
ORDER BY cnt DESC
LIMIT 20`
const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; cnt: bigint }>>(
Prisma.sql`
SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
FROM users_badges ub
JOIN users u ON u.id = ub.user_id
GROUP BY ub.user_id
ORDER BY cnt DESC
LIMIT 20
`,
);
const entries = rankEntries(
@@ -68,39 +71,41 @@ async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderb
figure: r.look,
score: Number(r.cnt),
rank: 0,
}))
})),
);
const totalPlayers = entries.length > 0
? Number(
(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`
)
)[0]?.cnt ?? 0
)
: 0;
const totalPlayers =
entries.length > 0
? Number(
(
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges`,
)
)[0]?.cnt ?? 0,
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) {
const viewerRow = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ?`,
userId
const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`,
);
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore > 0) {
const viewerRank = entries.length > 0
? Number(
(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
WHERE t.total > ?`,
viewerScore
)
)[0]?.cnt ?? entries.length + 1
)
: 1;
const viewerRank =
entries.length > 0
? Number(
(
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`
SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t
WHERE t.total > ${viewerScore}
`,
)
)[0]?.cnt ?? entries.length + 1,
)
: 1;
const viewerUser = await prisma.user.findUnique({
where: { id: userId },
select: { username: true, look: true },
@@ -121,12 +126,14 @@ async function loadTotalBadgesBoard(userId: number | null): Promise<BadgeLeaderb
}
async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderboardBoard> {
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; score: number }>>(
`SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score
FROM users_settings us
JOIN users u ON u.id = us.user_id
ORDER BY us.achievement_score DESC
LIMIT 20`
const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; score: number }>>(
Prisma.sql`
SELECT us.user_id AS userId, u.username, u.look, us.achievement_score AS score
FROM users_settings us
JOIN users u ON u.id = us.user_id
ORDER BY us.achievement_score DESC
LIMIT 20
`,
);
const entries = rankEntries(
@@ -136,18 +143,19 @@ async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderb
figure: r.look,
score: r.score,
rank: 0,
}))
})),
);
const totalPlayers = entries.length > 0
? Number(
(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`
)
)[0]?.cnt ?? 0
)
: 0;
const totalPlayers =
entries.length > 0
? Number(
(
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`SELECT COUNT(*) AS cnt FROM users_settings WHERE achievement_score > 0`,
)
)[0]?.cnt ?? 0,
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) {
@@ -163,11 +171,12 @@ async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderb
if (viewerSettings && viewerSettings.achievementScore > 0) {
const viewerRank = Number(
(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ?`,
viewerSettings.achievementScore
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`
SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore}
`,
)
)[0]?.cnt ?? entries.length + 1
)[0]?.cnt ?? entries.length + 1,
);
viewerEntry = {
userId,
@@ -186,20 +195,22 @@ async function loadAchievementBoard(userId: number | null): Promise<BadgeLeaderb
async function loadRarityBoard(
rarity: BadgeRarityKey,
badgeCodes: string[],
userId: number | null
userId: number | null,
): Promise<BadgeLeaderboardBoard> {
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
const placeholders = badgeCodes.map(() => "?").join(",");
const rows = await prisma.$queryRawUnsafe<Array<{ userId: number; username: string; look: string; cnt: bigint }>>(
`SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
FROM users_badges ub
JOIN users u ON u.id = ub.user_id
WHERE ub.badge_code IN (${placeholders})
GROUP BY ub.user_id
ORDER BY cnt DESC
LIMIT 20`,
...badgeCodes
const codes = Prisma.join(badgeCodes);
const rows = await prisma.$queryRaw<Array<{ userId: bigint; username: string; look: string; cnt: bigint }>>(
Prisma.sql`
SELECT ub.user_id AS userId, u.username, u.look, COUNT(*) AS cnt
FROM users_badges ub
JOIN users u ON u.id = ub.user_id
WHERE ub.badge_code IN (${codes})
GROUP BY ub.user_id
ORDER BY cnt DESC
LIMIT 20
`,
);
const entries = rankEntries(
@@ -209,26 +220,29 @@ async function loadRarityBoard(
figure: r.look,
score: Number(r.cnt),
rank: 0,
}))
})),
);
const totalPlayers = rows.length > 0
? Number(
(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${placeholders})`,
...badgeCodes
)
)[0]?.cnt ?? 0
)
: 0;
const totalPlayers =
rows.length > 0
? Number(
(
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`
SELECT COUNT(DISTINCT user_id) AS cnt FROM users_badges WHERE badge_code IN (${codes})
`,
)
)[0]?.cnt ?? 0,
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | undefined;
if (userId) {
const viewerRow = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ? AND badge_code IN (${placeholders})`,
userId,
...badgeCodes
const viewerRow = await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`
SELECT COUNT(*) AS cnt FROM users_badges
WHERE user_id = ${userId} AND badge_code IN (${codes})
`,
);
const viewerScore = Number(viewerRow[0]?.cnt ?? 0);
if (viewerScore > 0) {
@@ -239,14 +253,14 @@ async function loadRarityBoard(
if (viewerUser) {
const viewerRank = Number(
(
await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${placeholders}) GROUP BY user_id) t
WHERE t.total > ?`,
...badgeCodes,
viewerScore
await prisma.$queryRaw<Array<{ cnt: bigint }>>(
Prisma.sql`
SELECT COUNT(*) + 1 AS cnt
FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t
WHERE t.total > ${viewerScore}
`,
)
)[0]?.cnt ?? entries.length + 1
)[0]?.cnt ?? entries.length + 1,
);
viewerEntry = {
userId,
@@ -270,13 +284,12 @@ export async function GET(req: Request) {
userId = session?.user?.id ? Number(session.user.id) : null;
}
// Gather badge ownership stats
const badgeStatsRaw = await prisma.$queryRawUnsafe<
Array<{ badgeCode: string; ownerCount: bigint }>
>(
`SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
FROM users_badges
GROUP BY badge_code`
const badgeStatsRaw = await prisma.$queryRaw<Array<{ badgeCode: string; ownerCount: bigint }>>(
Prisma.sql`
SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount
FROM users_badges
GROUP BY badge_code
`,
);
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
@@ -285,7 +298,6 @@ export async function GET(req: Request) {
rarity: assignRarity(Number(r.ownerCount)),
}));
// Group badge codes by rarity
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
for (const stat of badgeStats) {
const list = badgesByRarity.get(stat.rarity) ?? [];
@@ -302,7 +314,7 @@ export async function GET(req: Request) {
]);
const rarity = Object.fromEntries(
rarityKeys.map((rk, i) => [rk, rarityBoards[i]])
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
) as Record<BadgeRarityKey, BadgeLeaderboardBoard>;
return apiJson({