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:
1 parent
5c638cd6bc
commit
c5db7f5156
17 files changed
+1175
-449
No files matched your search
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user