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({
|
||||
|
||||
+11
-325
@@ -1,341 +1,27 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
import GuestView from "@/components/home/guest-view";
|
||||
import UserView from "@/components/home/user-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations("pages.home");
|
||||
const session = await auth();
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
const imager =
|
||||
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
|
||||
|
||||
if (!session?.user) {
|
||||
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
|
||||
try {
|
||||
articles = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
let recentUsers: Array<{ username: string; look: string }> = [];
|
||||
try {
|
||||
recentUsers = await prisma.user.findMany({
|
||||
orderBy: { lastOnline: "desc" },
|
||||
take: 12,
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
{/* LEFT COLUMN — 1/3 */}
|
||||
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
||||
{/* Login Card */}
|
||||
<div className="overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center 30%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: "linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
|
||||
}} />
|
||||
<div className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]" style={{ background: "var(--color-primary)" }} />
|
||||
</div>
|
||||
<div className="p-3" style={{ backgroundColor: "var(--color-surface)" }}>
|
||||
<HomeLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register Button */}
|
||||
<a href="/register" className="relative block">
|
||||
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
|
||||
</div>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white"
|
||||
style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginLeft: "15px" }}
|
||||
>
|
||||
REGISTER NOW
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Recent Users */}
|
||||
{recentUsers.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<div
|
||||
className="flex h-12 w-full items-center justify-center bg-repeat"
|
||||
style={{
|
||||
background: "linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="relative overflow-visible rounded-md bg-white p-0 shadow-lg dark:bg-gray-800"
|
||||
style={{ width: "50px", height: "50px", justifySelf: "center" }}
|
||||
>
|
||||
<img
|
||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
|
||||
alt={u.username}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-12px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: "none",
|
||||
maxHeight: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN — 2/3 */}
|
||||
<div className="flex w-full flex-col gap-4 md:w-2/3">
|
||||
{/* News */}
|
||||
<div className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<div
|
||||
className="flex h-12 w-full items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white">Latest News</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{articles.length === 0 ? (
|
||||
<p className="py-8 text-center text-gray-500 dark:text-gray-400">No articles available</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{articles.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
|
||||
style={{
|
||||
height: "215px",
|
||||
backgroundColor: "var(--color-surface)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={a.image}
|
||||
alt={a.title}
|
||||
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 w-full p-2"
|
||||
style={{ bottom: "-2px", background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)" }}
|
||||
>
|
||||
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">{a.title}</h3>
|
||||
{a.shortStory && (
|
||||
<p className="truncate text-xs font-normal text-white text-shadow-sm">
|
||||
{excerpt(a.shortStory, 80)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <GuestView />;
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(session.user.id) },
|
||||
select: { username: true, look: true },
|
||||
}).catch(() => null);
|
||||
const dbUser = await prisma.user
|
||||
.findUnique({
|
||||
where: { id: Number(session.user.id) },
|
||||
select: { username: true, look: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!dbUser) {
|
||||
return <p className="py-8 text-center text-gray-500">User not found.</p>;
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
let onlineFriends: Array<{ username: string; look: string }> = [];
|
||||
try {
|
||||
const friendships = await prisma.messengerFriendships.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: userId },
|
||||
{ userTwoId: userId },
|
||||
],
|
||||
},
|
||||
select: { userOneId: true, userTwoId: true },
|
||||
});
|
||||
const friendIds = friendships.map((f) =>
|
||||
f.userOneId === userId ? f.userTwoId : f.userOneId,
|
||||
);
|
||||
if (friendIds.length > 0) {
|
||||
onlineFriends = await prisma.user.findMany({
|
||||
where: { id: { in: friendIds }, online: "1" },
|
||||
take: 10,
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const [latestArticle] = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
}).catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
{/* Left column — 9/12 */}
|
||||
<div className="flex w-full flex-col gap-6 md:w-3/4">
|
||||
{/* User Hero */}
|
||||
<div className="relative flex h-[200px] items-end overflow-hidden rounded-lg">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center 30%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: "linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
|
||||
}} />
|
||||
<div className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]" style={{ background: "var(--color-primary)" }} />
|
||||
<div className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]" style={{ background: "var(--color-primary)" }} />
|
||||
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
|
||||
<Link href={`/u/${dbUser.username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
|
||||
<img
|
||||
src={avatarImageUrl(imager, dbUser.look, { size: "l", direction: 2 })}
|
||||
alt={dbUser.username}
|
||||
className="drop-shadow-2xl"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex items-center mb-4">
|
||||
<Link href="/client" target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
|
||||
color: "var(--button-text-color)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
|
||||
boxShadow: "0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
{t("enterHotel", { hotelName })}
|
||||
<svg className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Online Friends */}
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: "#e9b124" }}
|
||||
>
|
||||
<span className="relative text-white">{t("onlineFriends")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
{onlineFriends.length === 0 ? (
|
||||
<p className="mb-3 block w-full text-center text-xs font-medium text-gray-500 dark:text-white md:mb-0 md:text-left">
|
||||
{t("noFriendsOnline")}
|
||||
</p>
|
||||
) : (
|
||||
onlineFriends.map((f) => (
|
||||
<Link
|
||||
key={f.username}
|
||||
href={`/u/${f.username}`}
|
||||
className="transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<img
|
||||
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
||||
alt={f.username}
|
||||
className="h-10 w-10"
|
||||
loading="lazy"
|
||||
title={f.username}
|
||||
/>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — 3/12 */}
|
||||
<div className="flex w-full flex-col gap-6 md:w-1/4">
|
||||
{latestArticle ? (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-12 items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-white">{t("latestArticle")}</h2>
|
||||
</div>
|
||||
<Link
|
||||
href={`/news/${latestArticle.slug}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
src={latestArticle.image}
|
||||
alt={latestArticle.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
|
||||
{latestArticle.shortStory && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{excerpt(latestArticle.shortStory, 100)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <UserView userId={Number(session.user.id)} username={dbUser.username} look={dbUser.look} />;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||
|
||||
export default async function GuestView() {
|
||||
const t = await getTranslations("pages.home");
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
const imager =
|
||||
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
|
||||
|
||||
let articles: Array<{ slug: string; title: string; shortStory: string; image: string }> = [];
|
||||
try {
|
||||
articles = await prisma.websiteArticles.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
let recentUsers: Array<{ username: string; look: string }> = [];
|
||||
try {
|
||||
recentUsers = await prisma.user.findMany({
|
||||
orderBy: { lastOnline: "desc" },
|
||||
take: 12,
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
|
||||
>
|
||||
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center 30%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3" style={{ backgroundColor: "var(--color-surface)" }}>
|
||||
<HomeLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/register" className="relative block">
|
||||
<div className="inline-block overflow-hidden rounded-lg border" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}>
|
||||
<img src="/assets/images/EnterHubbly.png" alt="Register" className="block" />
|
||||
</div>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white"
|
||||
style={{ textShadow: "1px 1px 2px rgba(0,0,0,0.8)", marginLeft: "15px" }}
|
||||
>
|
||||
REGISTER NOW
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{recentUsers.length > 0 && (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800"
|
||||
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
|
||||
>
|
||||
<div
|
||||
className="flex h-12 w-full items-center justify-center bg-repeat"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="px-3 text-xl font-bold text-white">Recent Users</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-4 p-4">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="relative overflow-visible rounded-md bg-white p-0 shadow-lg dark:bg-gray-800"
|
||||
style={{ width: "50px", height: "50px", justifySelf: "center" }}
|
||||
>
|
||||
<img
|
||||
src={avatarImageUrl(imager, u.look, { headOnly: true, direction: 3 })}
|
||||
alt={u.username}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-12px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: "none",
|
||||
maxHeight: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-4 md:w-2/3">
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border bg-white dark:bg-gray-800"
|
||||
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)" }}
|
||||
>
|
||||
<div
|
||||
className="flex h-12 w-full items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white">{t("latestNews") || "Latest News"}</h2>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{articles.length === 0 ? (
|
||||
<p className="py-8 text-center text-gray-500 dark:text-gray-400">No articles available</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{articles.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/news/${a.slug}`}
|
||||
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
|
||||
style={{
|
||||
height: "215px",
|
||||
backgroundColor: "var(--color-surface)",
|
||||
border: "1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={a.image}
|
||||
alt={a.title}
|
||||
className="h-full w-full rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 w-full p-2"
|
||||
style={{
|
||||
bottom: "-2px",
|
||||
background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">{a.title}</h3>
|
||||
{a.shortStory && (
|
||||
<p className="truncate text-xs font-normal text-white text-shadow-sm">
|
||||
{excerpt(a.shortStory, 80)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
interface UserViewProps {
|
||||
userId: number;
|
||||
username: string;
|
||||
look: string;
|
||||
}
|
||||
|
||||
export default async function UserView({ userId, username, look }: UserViewProps) {
|
||||
const t = await getTranslations("pages.home");
|
||||
const hotelName = (await siteSettings.get("hotel_name", "Atom")) ?? "Atom";
|
||||
const imager =
|
||||
(await siteSettings.get("habbo_imaging_url", "https://www.habbo.com/habbo-imaging/avatarimage")) ?? "";
|
||||
|
||||
let onlineFriends: Array<{ username: string; look: string }> = [];
|
||||
try {
|
||||
const friendships = await prisma.messengerFriendships.findMany({
|
||||
where: { OR: [{ userOneId: userId }, { userTwoId: userId }] },
|
||||
select: { userOneId: true, userTwoId: true },
|
||||
});
|
||||
const friendIds = friendships.map((f) =>
|
||||
f.userOneId === userId ? f.userTwoId : f.userOneId,
|
||||
);
|
||||
if (friendIds.length > 0) {
|
||||
onlineFriends = await prisma.user.findMany({
|
||||
where: { id: { in: friendIds }, online: "1" },
|
||||
take: 10,
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const [latestArticle] = await prisma.websiteArticles
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { slug: true, title: true, shortStory: true, image: true },
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
<div className="flex w-full flex-col gap-6 md:w-3/4">
|
||||
<div className="relative flex h-[200px] items-end overflow-hidden rounded-lg">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center 30%",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]"
|
||||
style={{ background: "var(--color-primary)" }}
|
||||
/>
|
||||
<div className="relative z-10 flex w-full items-end justify-between px-6 pb-2">
|
||||
<Link href={`/u/${username}`} className="transition-transform duration-300 hover:scale-105 -mb-8">
|
||||
<img
|
||||
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
|
||||
alt={username}
|
||||
className="drop-shadow-2xl"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex items-center mb-4">
|
||||
<Link href="/client" target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
|
||||
color: "var(--button-text-color)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
|
||||
boxShadow:
|
||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
{t("enterHotel", { hotelName })}
|
||||
<svg
|
||||
className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
|
||||
style={{ backgroundColor: "#e9b124" }}
|
||||
>
|
||||
<span className="relative text-white">{t("onlineFriends")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
{onlineFriends.length === 0 ? (
|
||||
<p className="mb-3 block w-full text-center text-xs font-medium text-gray-500 dark:text-white md:mb-0 md:text-left">
|
||||
{t("noFriendsOnline")}
|
||||
</p>
|
||||
) : (
|
||||
onlineFriends.map((f) => (
|
||||
<Link
|
||||
key={f.username}
|
||||
href={`/u/${f.username}`}
|
||||
className="transition-all duration-200 hover:scale-110"
|
||||
>
|
||||
<img
|
||||
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
||||
alt={f.username}
|
||||
className="h-10 w-10"
|
||||
loading="lazy"
|
||||
title={f.username}
|
||||
/>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-6 md:w-1/4">
|
||||
{latestArticle ? (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-12 items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-white">{t("latestArticle")}</h2>
|
||||
</div>
|
||||
<Link href={`/news/${latestArticle.slug}`} className="group block">
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<img
|
||||
src={latestArticle.image}
|
||||
alt={latestArticle.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
|
||||
{latestArticle.shortStory && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{excerpt(latestArticle.shortStory, 100)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,8 @@ const schema = z.object({
|
||||
PAYPAL_API: z.string().url().optional(),
|
||||
// Optional Redis — enables shared caching for rate limiting and site settings.
|
||||
REDIS_URL: z.string().optional(),
|
||||
// Logging level.
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||
});
|
||||
|
||||
type Env = z.infer<typeof schema>;
|
||||
|
||||
+49
-30
@@ -7,6 +7,7 @@ import { checkLogin } from "@/lib/auth/password";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { env } from "@/env";
|
||||
|
||||
async function verify2faCode(userId: number, code: string): Promise<boolean> {
|
||||
@@ -89,6 +90,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
if (user.twoFactorConfirmedAt && user.twoFactorSecret) {
|
||||
const code = String(credentials?.code ?? "").trim();
|
||||
if (!code || !env.APP_KEY) return null;
|
||||
|
||||
// Per-user 2FA rate limit (5 attempts per 30s) — prevents TOTP brute-force
|
||||
// even when the attacker rotates IPs or knows the password.
|
||||
if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null;
|
||||
|
||||
if (!(await verify2faCode(user.id, code))) return null;
|
||||
}
|
||||
|
||||
@@ -118,20 +124,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === "credentials") return true;
|
||||
// OAuth: try to match by email first.
|
||||
const email = user.email;
|
||||
if (email) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (dbUser) return true;
|
||||
} catch {
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
// If email didn't match, try Discord ID via SocialAccounts.
|
||||
|
||||
const requireLink = await siteSettings.getBool("oauth_require_link", false);
|
||||
|
||||
// Always allow explicitly linked accounts.
|
||||
if (account?.provider === "discord" && account.providerAccountId) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
@@ -143,28 +139,32 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
// Email-based binding: only allowed when oauth_require_link is disabled
|
||||
// AND the matched account does NOT have 2FA enabled (account takeover guard).
|
||||
if (!requireLink && user.email) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (dbUser) return true;
|
||||
} catch {
|
||||
return "/login?error=Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
return "/login?error=NoAccount";
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
if (user && account?.provider === "credentials") {
|
||||
token.rank = (user as { rank?: number }).rank;
|
||||
} else if (user?.email) {
|
||||
// OAuth with email: bind to matching hotel account.
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email },
|
||||
select: { id: true, rank: true, username: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
return token;
|
||||
}
|
||||
// OAuth without email match: try Discord ID via SocialAccounts.
|
||||
|
||||
const requireLink = await siteSettings.getBool("oauth_require_link", false);
|
||||
|
||||
// Try Discord ID via SocialAccounts (always allowed, even when requireLink is true).
|
||||
if (!token.sub && account?.provider === "discord" && account.providerAccountId) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
@@ -179,12 +179,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
// Email-based binding: only when requireLink is off AND account has no 2FA.
|
||||
if (!requireLink && user?.email && !token.sub) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: { id: true, rank: true, username: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateRequestId } from "./logger";
|
||||
|
||||
describe("generateRequestId", () => {
|
||||
it("produces a non-empty string", () => {
|
||||
const id = generateRequestId();
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe("string");
|
||||
});
|
||||
|
||||
it("produces unique values on successive calls", () => {
|
||||
const a = generateRequestId();
|
||||
const b = generateRequestId();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
requestId?: string;
|
||||
module?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const currentLevel: LogLevel =
|
||||
(process.env.LOG_LEVEL as LogLevel) ?? (process.env.NODE_ENV === "production" ? "info" : "debug");
|
||||
|
||||
let requestIdCounter = 0;
|
||||
|
||||
export function generateRequestId(): string {
|
||||
requestIdCounter += 1;
|
||||
return `${Date.now().toString(36)}-${requestIdCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
}
|
||||
|
||||
function formatLog(entry: LogEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
function writeLog(entry: LogEntry): void {
|
||||
if (!shouldLog(entry.level)) return;
|
||||
|
||||
const formatted = formatLog(entry);
|
||||
|
||||
switch (entry.level) {
|
||||
case "error":
|
||||
console.error(formatted);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(formatted);
|
||||
break;
|
||||
default:
|
||||
console.log(formatted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "debug", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
|
||||
info(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "info", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
|
||||
warn(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "warn", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
|
||||
error(message: string, meta: Record<string, unknown> = {}): void {
|
||||
writeLog({ level: "error", message, timestamp: new Date().toISOString(), ...meta });
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user