diff --git a/drizzle/migrations/0020_performance_indexes.sql b/drizzle/migrations/0020_performance_indexes.sql new file mode 100644 index 00000000..a8431eac --- /dev/null +++ b/drizzle/migrations/0020_performance_indexes.sql @@ -0,0 +1,12 @@ +-- 0020_performance_indexes.sql +-- Adds indexes for the hot CMS read paths (shared DB with the Arcturus +-- emulator — additive only, no schema changes to emulator-owned columns). +-- +-- users.credits → credits leaderboard (ORDER BY credits DESC LIMIT 20) +-- users_currency(type, amount) → duckets/diamonds leaderboard (WHERE type=? ORDER BY amount DESC LIMIT 20) +-- users_settings.respects_received → respects leaderboard (ORDER BY respects_received DESC LIMIT 20) +-- camera_web.timestamp → homepage recent photos (ORDER BY timestamp DESC LIMIT 4) +CREATE INDEX IF NOT EXISTS `idx_users_credits` ON `users` (`credits`); +CREATE INDEX IF NOT EXISTS `idx_users_currency_type_amount` ON `users_currency` (`type`, `amount`); +CREATE INDEX IF NOT EXISTS `idx_users_settings_respects_received` ON `users_settings` (`respects_received`); +CREATE INDEX IF NOT EXISTS `idx_camera_web_timestamp` ON `camera_web` (`timestamp`); diff --git a/drizzle/migrations/0021_add_messenger_offline_user_id_index.sql b/drizzle/migrations/0021_add_messenger_offline_user_id_index.sql new file mode 100644 index 00000000..3084c3d8 --- /dev/null +++ b/drizzle/migrations/0021_add_messenger_offline_user_id_index.sql @@ -0,0 +1,4 @@ +-- 0021_add_messenger_offline_user_id_index.sql +-- The /me dashboard counts unread offline messages with +-- `WHERE user_id = ?`; messenger_offline previously had no index there. +CREATE INDEX IF NOT EXISTS `idx_messenger_offline_user_id` ON `messenger_offline` (`user_id`); diff --git a/next.config.ts b/next.config.ts index 87873c4f..e5cf1c78 100644 --- a/next.config.ts +++ b/next.config.ts @@ -65,6 +65,11 @@ const nextConfig: NextConfig = { hideLogsAfterAbort: true, // <-- Dit verbergt de prerender bail-out logberichten! }, + // Optimize images served through next/image with sharp → AVIF/WebP. + images: { + formats: ["image/avif", "image/webp"], + }, + // Add caching headers for static assets async headers() { return [ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd3c6108..3c18a282 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,21 +19,34 @@ minimumReleaseAgeExclude: - "@biomejs/cli-linux-x64@2.5.7" - "@biomejs/cli-win32-arm64@2.5.7" - "@biomejs/cli-win32-x64@2.5.7" + - "@hookform/resolvers@5.8.0" - "@next/bundle-analyzer@16.3.0" + - "@next/bundle-analyzer@16.3.1" - "@next/env@16.3.0" + - "@next/env@16.3.1" - "@next/swc-darwin-arm64@16.3.0" + - "@next/swc-darwin-arm64@16.3.1" - "@next/swc-darwin-x64@16.3.0" + - "@next/swc-darwin-x64@16.3.1" - "@next/swc-linux-arm64-gnu@16.3.0" + - "@next/swc-linux-arm64-gnu@16.3.1" - "@next/swc-linux-arm64-musl@16.3.0" + - "@next/swc-linux-arm64-musl@16.3.1" - "@next/swc-linux-x64-gnu@16.3.0" + - "@next/swc-linux-x64-gnu@16.3.1" - "@next/swc-linux-x64-musl@16.3.0" + - "@next/swc-linux-x64-musl@16.3.1" - "@next/swc-win32-arm64-msvc@16.3.0" + - "@next/swc-win32-arm64-msvc@16.3.1" - "@next/swc-win32-x64-msvc@16.3.0" + - "@next/swc-win32-x64-msvc@16.3.1" - "icu-minify@4.13.5" - "lenis@1.3.26" - "next-intl-swc-plugin-extractor@4.13.5" - "next-intl@4.13.5" - "next@16.3.0" + - "next@16.3.1" + - "resend@6.20.0" - "tsx@4.23.8" - "use-intl@4.13.5" diff --git a/src/actions/auth-precheck.test.ts b/src/actions/auth-precheck.test.ts index 8b0167bd..6c6df35c 100644 --- a/src/actions/auth-precheck.test.ts +++ b/src/actions/auth-precheck.test.ts @@ -6,31 +6,14 @@ import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; import { precheckLogin } from "./auth-precheck"; -const { selectLimit } = vi.hoisted(() => { - const selectLimit = vi.fn().mockResolvedValue([]); - return { selectLimit }; +const { queryPreparedOne } = vi.hoisted(() => { + const queryPreparedOne = vi.fn().mockResolvedValue(null); + return { queryPreparedOne }; }); vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } })); vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() })); -vi.mock("@/lib/db", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: selectLimit, - })), - })), - })), - }, - User: { - password: "password", - twoFactorConfirmedAt: "twoFactorConfirmedAt", - mail: "mail", - mailVerified: "mailVerified", - username: "username", - }, -})); +vi.mock("@/lib/db", () => ({ queryPreparedOne })); vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() })); vi.mock("@/lib/services/captcha", () => ({ captchaConfig: vi.fn(), @@ -46,31 +29,27 @@ beforeEach(() => { vi.mocked(rateLimit).mockResolvedValue({ ok: true }); vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never); vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never); - selectLimit.mockResolvedValue([]); + queryPreparedOne.mockResolvedValue(null); }); describe("precheckLogin", () => { it("returns ok for valid login without 2FA", async () => { - selectLimit.mockResolvedValue([ - { - password: "hash", - twoFactorConfirmedAt: null, - mail: null, - mailVerified: "0", - }, - ]); + queryPreparedOne.mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: null, + mail: null, + mailVerified: "0", + }); expect(await precheckLogin("user", "pass")).toBe("ok"); }); it("returns twofactor when 2FA is set up", async () => { - selectLimit.mockResolvedValue([ - { - password: "hash", - twoFactorConfirmedAt: new Date(), - mail: null, - mailVerified: "0", - }, - ]); + queryPreparedOne.mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: new Date(), + mail: null, + mailVerified: "0", + }); expect(await precheckLogin("user", "pass")).toBe("twofactor"); }); @@ -83,33 +62,29 @@ describe("precheckLogin", () => { provider: "hcaptcha", } as never); vi.mocked(verifyCaptcha).mockResolvedValue(false); - selectLimit.mockResolvedValue([ - { - password: "hash", - twoFactorConfirmedAt: null, - mail: null, - mailVerified: "0", - }, - ]); + queryPreparedOne.mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: null, + mail: null, + mailVerified: "0", + }); expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha"); }); it("returns invalid when user not found (dummy hash check)", async () => { - selectLimit.mockResolvedValue([]); + queryPreparedOne.mockResolvedValue(null); const result = await precheckLogin("nonexistent", "pass"); expect(result).toBe("invalid"); expect(checkLogin).toHaveBeenCalled(); }); it("returns unverified when email verification required", async () => { - selectLimit.mockResolvedValue([ - { - password: "hash", - twoFactorConfirmedAt: null, - mail: "user@example.com", - mailVerified: "0", - }, - ]); + queryPreparedOne.mockResolvedValue({ + password: "hash", + twoFactorConfirmedAt: null, + mail: "user@example.com", + mailVerified: "0", + }); vi.mocked(siteSettings.getBool).mockResolvedValue(true); expect(await precheckLogin("user", "pass")).toBe("unverified"); }); diff --git a/src/actions/auth-precheck.ts b/src/actions/auth-precheck.ts index 7b09a8e2..b5eee29a 100644 --- a/src/actions/auth-precheck.ts +++ b/src/actions/auth-precheck.ts @@ -1,9 +1,8 @@ "use server"; -import { eq } from "drizzle-orm"; import { env } from "@/env"; import { checkLogin } from "@/lib/auth/password"; -import { db, User } from "@/lib/db"; +import { queryPreparedOne } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; @@ -46,17 +45,17 @@ export async function precheckLogin( mailVerified: string; } | null; try { - const [row] = await db - .select({ - password: User.password, - twoFactorConfirmedAt: User.twoFactorConfirmedAt, - mail: User.mail, - mailVerified: User.mailVerified, - }) - .from(User) - .where(eq(User.username, u)) - .limit(1); - user = row ?? null; + user = await queryPreparedOne<{ + password: string; + twoFactorConfirmedAt: Date | null; + mail: string | null; + mailVerified: string; + }>( + `SELECT password, two_factor_confirmed_at AS twoFactorConfirmedAt, + mail, mail_verified AS mailVerified + FROM users WHERE username = ? LIMIT 1`, + [u], + ); } catch { return "invalid"; } diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index dba9cb18..fdd5f03b 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -90,7 +90,10 @@ export async function resetPassword(formData: FormData): Promise { if (!error) { try { const [row] = await db - .select() + .select({ + token: PasswordReset.token, + createdAt: PasswordReset.createdAt, + }) .from(PasswordReset) .where(eq(PasswordReset.email, email)) .limit(1); diff --git a/src/actions/polls.ts b/src/actions/polls.ts index e936b5ca..63ea030c 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -166,7 +166,12 @@ export const voteOnPoll = authAction( } const [poll] = await db - .select() + .select({ + id: WebsitePoll.id, + status: WebsitePoll.status, + startsAt: WebsitePoll.startsAt, + endsAt: WebsitePoll.endsAt, + }) .from(WebsitePoll) .where(eq(WebsitePoll.id, ctx.data.pollId)) .limit(1); @@ -184,7 +189,12 @@ export const voteOnPoll = authAction( } const questions = await db - .select() + .select({ + id: WebsitePollQuestion.id, + pollId: WebsitePollQuestion.pollId, + type: WebsitePollQuestion.type, + options: WebsitePollQuestion.options, + }) .from(WebsitePollQuestion) .where(eq(WebsitePollQuestion.pollId, poll.id)); diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts index cf81e6ea..1dd13134 100644 --- a/src/actions/tickets.ts +++ b/src/actions/tickets.ts @@ -60,7 +60,10 @@ export const adminReplyTicket = adminAction( }, async (ctx) => { const [ticket] = await db - .select() + .select({ + id: WebsiteTicket.id, + assigneeId: WebsiteTicket.assigneeId, + }) .from(WebsiteTicket) .where(eq(WebsiteTicket.id, ctx.data.ticketId)) .limit(1); @@ -106,7 +109,11 @@ export const updateTicketStatus = adminAction( }, async (ctx) => { const [ticket] = await db - .select() + .select({ + id: WebsiteTicket.id, + assigneeId: WebsiteTicket.assigneeId, + status: WebsiteTicket.status, + }) .from(WebsiteTicket) .where(eq(WebsiteTicket.id, ctx.data.ticketId)) .limit(1); @@ -149,7 +156,10 @@ export const assignTicket = adminAction( }, async (ctx) => { const [ticket] = await db - .select() + .select({ + id: WebsiteTicket.id, + assigneeId: WebsiteTicket.assigneeId, + }) .from(WebsiteTicket) .where(eq(WebsiteTicket.id, ctx.data.ticketId)) .limit(1); @@ -185,7 +195,10 @@ export const updateTicketPriority = adminAction( }, async (ctx) => { const [ticket] = await db - .select() + .select({ + id: WebsiteTicket.id, + priority: WebsiteTicket.priority, + }) .from(WebsiteTicket) .where(eq(WebsiteTicket.id, ctx.data.ticketId)) .limit(1); diff --git a/src/app/(site)/leaderboard/page.tsx b/src/app/(site)/leaderboard/page.tsx index c25b9326..ad6cbc30 100644 --- a/src/app/(site)/leaderboard/page.tsx +++ b/src/app/(site)/leaderboard/page.tsx @@ -1,4 +1,4 @@ -import { desc, eq, inArray } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; @@ -7,6 +7,7 @@ import { type CurrencyKind, } from "@/components/shared/currency-icon"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { cached } from "@/lib/cache"; import { db, User, UsersCurrency, UsersSettings } from "@/lib/db"; export const metadata = { title: "Leaderboard" }; @@ -43,15 +44,17 @@ type Row = { username: string; look: string; value: number }; async function loadCreditsRows(): Promise { try { - const users = await db - .select({ - username: User.username, - look: User.look, - credits: User.credits, - }) - .from(User) - .orderBy(desc(User.credits)) - .limit(20); + const users = await cached("lb_credits", 60_000, () => + db + .select({ + username: User.username, + look: User.look, + credits: User.credits, + }) + .from(User) + .orderBy(desc(User.credits)) + .limit(20), + ); return users.map((u) => ({ username: u.username, look: u.look, @@ -64,35 +67,19 @@ async function loadCreditsRows(): Promise { async function loadCurrencyRows(type: number): Promise { try { - const top = await db - .select({ - userId: UsersCurrency.userId, - amount: UsersCurrency.amount, - }) - .from(UsersCurrency) - .where(eq(UsersCurrency.type, type)) - .orderBy(desc(UsersCurrency.amount)) - .limit(20); - if (top.length === 0) return []; - - const users = await db - .select({ id: User.id, username: User.username, look: User.look }) - .from(User) - .where( - inArray( - User.id, - top.map((t) => t.userId), - ), - ); - const byId = new Map(users.map((u) => [u.id, u])); - - return top - .map((t) => { - const u = byId.get(t.userId); - if (!u) return null; - return { username: u.username, look: u.look, value: t.amount }; - }) - .filter((r): r is Row => r !== null); + return await cached(`lb_currency_${type}`, 60_000, () => + db + .select({ + username: User.username, + look: User.look, + value: UsersCurrency.amount, + }) + .from(UsersCurrency) + .innerJoin(User, eq(UsersCurrency.userId, User.id)) + .where(eq(UsersCurrency.type, type)) + .orderBy(desc(UsersCurrency.amount)) + .limit(20), + ); } catch { return []; } @@ -103,38 +90,18 @@ async function loadSettingsRows( ): Promise { try { const column = UsersSettings[field]; - const top = await db - .select({ - userId: UsersSettings.userId, - value: column, - }) - .from(UsersSettings) - .orderBy(desc(column)) - .limit(20); - if (top.length === 0) return []; - - const users = await db - .select({ id: User.id, username: User.username, look: User.look }) - .from(User) - .where( - inArray( - User.id, - top.map((t) => t.userId), - ), - ); - const byId = new Map(users.map((u) => [u.id, u])); - - return top - .map((t) => { - const u = byId.get(t.userId); - if (!u) return null; - return { - username: u.username, - look: u.look, - value: Number(t.value ?? 0), - }; - }) - .filter((r): r is Row => r !== null); + return await cached(`lb_settings_${field}`, 60_000, () => + db + .select({ + username: User.username, + look: User.look, + value: column, + }) + .from(UsersSettings) + .innerJoin(User, eq(UsersSettings.userId, User.id)) + .orderBy(desc(column)) + .limit(20), + ); } catch { return []; } diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 3cb47987..a65eecb5 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -18,7 +18,6 @@ import { UsersBadges, UsersSettings, WebsiteLoginLogs, - WebsiteSetting, } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { resolveHotelName } from "@/lib/hotel-name"; @@ -85,9 +84,9 @@ export default async function MePage({ const [ userRows, hotelName, - neededRows, - amountRows, - currencyRows, + neededRaw, + rewardAmountRaw, + currencySettings, alertRaw, recentRooms, badges, @@ -112,29 +111,12 @@ export default async function MePage({ .where(eq(User.id, userId)) .limit(1), resolveHotelName(), - db - .select({ value: WebsiteSetting.value }) - .from(WebsiteSetting) - .where(eq(WebsiteSetting.key, "referrals_needed")) - .limit(1) - .catch(() => []), - db - .select({ value: WebsiteSetting.value }) - .from(WebsiteSetting) - .where(eq(WebsiteSetting.key, "referral_reward_amount")) - .limit(1) - .catch(() => []), - db - .select({ value: WebsiteSetting.value }) - .from(WebsiteSetting) - .where( - inArray(WebsiteSetting.key, [ - "referral_reward_currency_type", - "referral_reward_currency", - ]), - ) - .limit(1) - .catch(() => []), + siteSettings.get("referrals_needed", "5"), + siteSettings.get("referral_reward_amount", "30"), + siteSettings.getMany([ + "referral_reward_currency_type", + "referral_reward_currency", + ]), siteSettings.get("hotel_alert_banner", ""), db .select({ @@ -191,9 +173,6 @@ export default async function MePage({ ]); const user = userRows[0] ?? null; - const neededRaw = neededRows[0] ?? null; - const amountRaw = amountRows[0] ?? null; - const currencyRaw = currencyRows[0] ?? null; const lastWebLogin = lastWebLoginRows[0] ?? null; const userSettings = userSettingsRows[0] ?? null; const friendCount = Number(friendCountRows[0]?.value ?? 0); @@ -203,9 +182,13 @@ export default async function MePage({ if (!user) throw new Error("user-not-found"); - const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5; - const rewardAmount = Number.parseInt(amountRaw?.value ?? "30", 10) || 0; - const rewardCurrency = (currencyRaw?.value ?? "diamonds") + const needed = Number.parseInt(neededRaw ?? "5", 10) || 5; + const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0; + const rewardCurrency = ( + currencySettings.referral_reward_currency_type ?? + currencySettings.referral_reward_currency ?? + "diamonds" + ) .trim() .toLowerCase(); diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx index 1ed8a2c0..ae627d00 100644 --- a/src/app/(site)/news/[...slug]/page.tsx +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -228,6 +228,7 @@ export default async function ArticlePage({ ) : null} diff --git a/src/app/(site)/news/page.tsx b/src/app/(site)/news/page.tsx index 02392dc0..dacea66c 100644 --- a/src/app/(site)/news/page.tsx +++ b/src/app/(site)/news/page.tsx @@ -1,28 +1,17 @@ -import { desc } from "drizzle-orm"; import Image from "next/image"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState } from "@/components/public/ui"; -import { db, WebsiteArticles } from "@/lib/db"; import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; +import { getNewsList } from "@/lib/services/news-list"; export const metadata = { title: "News" }; export default async function NewsPage() { const t = await getTranslations("pages.news"); - let articles: (typeof WebsiteArticles.$inferSelect)[] = []; - try { - articles = await db - .select() - .from(WebsiteArticles) - .orderBy(desc(WebsiteArticles.createdAt)) - .limit(30); - } catch { - // DB unavailable — render the empty state rather than a 500. - articles = []; - } + const articles = await getNewsList(30); return (
@@ -58,8 +47,8 @@ export default async function NewsPage() { src={a.image} alt="" fill + sizes="(max-width: 1024px) 50vw, 300px" style={{ objectFit: "cover" }} - unoptimized /> ) : ( diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 7c346036..a8e83c7b 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -15,10 +15,11 @@ import { ThemeSwitcher } from "@/components/theme-switcher"; import { TypewriterText } from "@/components/typewriter-text"; import { auth } from "@/lib/auth"; import { cached } from "@/lib/cache"; -import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db"; +import { CameraWeb, db, Rooms, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; +import { getNewsList } from "@/lib/services/news-list"; import { siteSettings } from "@/lib/services/site-settings"; async function getHotelData() { @@ -43,42 +44,40 @@ async function getHotelData() { .where(eq(User.online, "1")) .then((rows) => rows[0]?.total ?? 0), ).catch(() => 0), - cached("total_users", 30_000, () => + cached("total_users", 300_000, () => db .select({ total: count() }) .from(User) .then((rows) => rows[0]?.total ?? 0), ).catch(() => 0), - cached("total_rooms", 30_000, () => + cached("total_rooms", 300_000, () => db .select({ total: count() }) .from(Rooms) .then((rows) => rows[0]?.total ?? 0), ).catch(() => 0), - db - .select() - .from(WebsiteArticles) - .orderBy(desc(WebsiteArticles.createdAt)) - .limit(5) - .catch(() => []), - db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.online, "1")) - .limit(30) - .catch(() => []), - db - .select() - .from(CameraWeb) - .orderBy(desc(CameraWeb.timestamp)) - .limit(4) - .catch(() => []), - db - .select({ username: User.username, look: User.look }) - .from(User) - .orderBy(desc(User.accountCreated)) - .limit(8) - .catch(() => []), + getNewsList(4), + cached("home_online_users", 15_000, () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.online, "1")) + .limit(30), + ).catch(() => []), + cached("home_recent_photos", 60_000, () => + db + .select({ id: CameraWeb.id, url: CameraWeb.url }) + .from(CameraWeb) + .orderBy(desc(CameraWeb.timestamp)) + .limit(4), + ).catch(() => []), + cached("home_latest_users", 60_000, () => + db + .select({ username: User.username, look: User.look }) + .from(User) + .orderBy(desc(User.accountCreated)) + .limit(8), + ).catch(() => []), ]); return { @@ -191,12 +190,18 @@ export default async function Home() {
+
diff --git a/src/app/admin/housekeeping/client.tsx b/src/app/admin/housekeeping/client.tsx index 8fa74a64..a0e5f114 100644 --- a/src/app/admin/housekeeping/client.tsx +++ b/src/app/admin/housekeeping/client.tsx @@ -81,8 +81,6 @@ interface AuditEntry { action: string; target: string; targetId: number | null; - before: unknown; - after: unknown; createdAt: string; } diff --git a/src/app/admin/rooms/_lib/load-room.ts b/src/app/admin/rooms/_lib/load-room.ts index 202d3a3a..5637a920 100644 --- a/src/app/admin/rooms/_lib/load-room.ts +++ b/src/app/admin/rooms/_lib/load-room.ts @@ -7,7 +7,18 @@ export async function loadRoom(id: string) { if (Number.isNaN(roomId)) notFound(); const [room] = await db - .select() + .select({ + id: Rooms.id, + ownerId: Rooms.ownerId, + ownerName: Rooms.ownerName, + name: Rooms.name, + description: Rooms.description, + state: Rooms.state, + users: Rooms.users, + usersMax: Rooms.usersMax, + category: Rooms.category, + score: Rooms.score, + }) .from(Rooms) .where(eq(Rooms.id, roomId)) .limit(1); diff --git a/src/app/admin/users/_lib/load-user.ts b/src/app/admin/users/_lib/load-user.ts index c51c6590..30021d6a 100644 --- a/src/app/admin/users/_lib/load-user.ts +++ b/src/app/admin/users/_lib/load-user.ts @@ -21,7 +21,24 @@ export async function loadUserById(id: string) { numericId > 0 && String(numericId) === id; const [user] = await db - .select() + .select({ + id: User.id, + username: User.username, + mail: User.mail, + gender: User.gender, + machineId: User.machineId, + twoFactorConfirmedAt: User.twoFactorConfirmedAt, + rank: User.rank, + online: User.online, + motto: User.motto, + look: User.look, + credits: User.credits, + pixels: User.pixels, + accountCreated: User.accountCreated, + lastLogin: User.lastLogin, + ipRegister: User.ipRegister, + ipCurrent: User.ipCurrent, + }) .from(User) .where(isNumeric ? eq(User.id, numericId) : eq(User.username, id)) .limit(1); @@ -40,7 +57,10 @@ export async function loadUserById(id: string) { ] = await Promise.all([ db.select().from(UsersCurrency).where(eq(UsersCurrency.userId, user.id)), db - .select() + .select({ + id: UsersBadges.id, + badgeCode: UsersBadges.badgeCode, + }) .from(UsersBadges) .where(eq(UsersBadges.userId, user.id)) .orderBy(asc(UsersBadges.slotId)), diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 44d3986e..f14ab994 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -27,7 +27,20 @@ type PageRow = { async function getBcTreeFlat(): Promise { const [allPages, itemCounts] = await Promise.all([ - db.select().from(CatalogPagesBc).orderBy(asc(CatalogPagesBc.orderNum)), + db + .select({ + id: CatalogPagesBc.id, + parentId: CatalogPagesBc.parentId, + caption: CatalogPagesBc.caption, + pageLayout: CatalogPagesBc.pageLayout, + iconColor: CatalogPagesBc.iconColor, + iconImage: CatalogPagesBc.iconImage, + orderNum: CatalogPagesBc.orderNum, + visible: CatalogPagesBc.visible, + enabled: CatalogPagesBc.enabled, + }) + .from(CatalogPagesBc) + .orderBy(asc(CatalogPagesBc.orderNum)), db .select({ pageId: CatalogItemsBc.pageId, @@ -108,7 +121,17 @@ async function getChildren( if (isBc) { const pages = await db - .select() + .select({ + id: CatalogPagesBc.id, + parentId: CatalogPagesBc.parentId, + caption: CatalogPagesBc.caption, + pageLayout: CatalogPagesBc.pageLayout, + iconColor: CatalogPagesBc.iconColor, + iconImage: CatalogPagesBc.iconImage, + orderNum: CatalogPagesBc.orderNum, + visible: CatalogPagesBc.visible, + enabled: CatalogPagesBc.enabled, + }) .from(CatalogPagesBc) .where(parentFilterBc) .orderBy(asc(CatalogPagesBc.orderNum)); @@ -163,7 +186,17 @@ async function getChildren( } const pages = await db - .select() + .select({ + id: CatalogPages.id, + parentId: CatalogPages.parentId, + caption: CatalogPages.caption, + pageLayout: CatalogPages.pageLayout, + iconColor: CatalogPages.iconColor, + iconImage: CatalogPages.iconImage, + orderNum: CatalogPages.orderNum, + visible: CatalogPages.visible, + enabled: CatalogPages.enabled, + }) .from(CatalogPages) .where(parentFilter) .orderBy(asc(CatalogPages.orderNum)); @@ -213,7 +246,17 @@ async function searchPages(q: string, isBc: boolean): Promise { const conditions = [like(CatalogPagesBc.caption, `%${needle}%`)]; if (idOk) conditions.push(eq(CatalogPagesBc.id, idExact)); const pages = await db - .select() + .select({ + id: CatalogPagesBc.id, + parentId: CatalogPagesBc.parentId, + caption: CatalogPagesBc.caption, + pageLayout: CatalogPagesBc.pageLayout, + iconColor: CatalogPagesBc.iconColor, + iconImage: CatalogPagesBc.iconImage, + orderNum: CatalogPagesBc.orderNum, + visible: CatalogPagesBc.visible, + enabled: CatalogPagesBc.enabled, + }) .from(CatalogPagesBc) .where(or(...conditions)) .orderBy(asc(CatalogPagesBc.orderNum)) @@ -240,7 +283,17 @@ async function searchPages(q: string, isBc: boolean): Promise { const conditions = [like(CatalogPages.caption, `%${needle}%`)]; if (idOk) conditions.push(eq(CatalogPages.id, idExact)); const pages = await db - .select() + .select({ + id: CatalogPages.id, + parentId: CatalogPages.parentId, + caption: CatalogPages.caption, + pageLayout: CatalogPages.pageLayout, + iconColor: CatalogPages.iconColor, + iconImage: CatalogPages.iconImage, + orderNum: CatalogPages.orderNum, + visible: CatalogPages.visible, + enabled: CatalogPages.enabled, + }) .from(CatalogPages) .where(or(...conditions)) .orderBy(asc(CatalogPages.orderNum)) diff --git a/src/app/api/radio/shouts/route.ts b/src/app/api/radio/shouts/route.ts index 3226096c..82351aa2 100644 --- a/src/app/api/radio/shouts/route.ts +++ b/src/app/api/radio/shouts/route.ts @@ -13,7 +13,12 @@ const MAX_MESSAGE_LENGTH = 255; export async function GET(_req: Request) { try { const shouts = await db - .select() + .select({ + id: RadioShouts.id, + userId: RadioShouts.userId, + message: RadioShouts.message, + createdAt: RadioShouts.createdAt, + }) .from(RadioShouts) .orderBy(desc(RadioShouts.createdAt)) .limit(50); diff --git a/src/app/imaging/route.ts b/src/app/imaging/route.ts index fa95d54f..c6cf7871 100644 --- a/src/app/imaging/route.ts +++ b/src/app/imaging/route.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from "next/server"; +import sharp from "sharp"; import { resolveImagerBase } from "@/lib/imager"; const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i; @@ -6,6 +7,19 @@ const FIGURE_MAX_LEN = 512; const FIGURE_MAX_PARTS = 24; const UPSTREAM_TIMEOUT_MS = 10_000; +type AvatarFormat = "png" | "webp" | "avif"; + +function resolveFormat(raw: string | null): AvatarFormat { + switch (raw?.toLowerCase()) { + case "webp": + return "webp"; + case "avif": + return "avif"; + default: + return "png"; + } +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); @@ -66,6 +80,8 @@ export async function GET(request: NextRequest) { const imgFormat = searchParams.get("img_format"); if (imgFormat) params.set("img_format", imgFormat); + const format = resolveFormat(searchParams.get("format")); + const upstream = resolveImagerBase(); const upstreamUrl = `${upstream}?${params.toString()}`; @@ -85,15 +101,35 @@ export async function GET(request: NextRequest) { ); } - const buffer = await res.arrayBuffer(); - const contentType = res.headers.get("content-type") || "image/png"; + const buffer = Buffer.from(await res.arrayBuffer()); - return new NextResponse(buffer, { + // Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth. + // Falls back to the original PNG if conversion fails. + let body: Uint8Array = new Uint8Array(buffer); + let contentType = res.headers.get("content-type") || "image/png"; + if (format !== "png") { + try { + if (format === "webp") { + body = new Uint8Array(await sharp(buffer).webp().toBuffer()); + contentType = "image/webp"; + } else { + body = new Uint8Array(await sharp(buffer).avif().toBuffer()); + contentType = "image/avif"; + } + } catch { + body = new Uint8Array(buffer); + contentType = res.headers.get("content-type") || "image/png"; + } + } + + return new NextResponse(body as unknown as BodyInit, { status: 200, headers: { "Content-Type": contentType, + "Content-Length": String(body.length), // s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC), // stale-while-revalidate keeps them fresh without blocking requests. + // The ?format= param is part of the URL, so variants cache separately. "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", "Access-Control-Allow-Origin": "*", diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 74df9a4d..844eccd6 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -5,6 +5,11 @@ import { redis } from "@/lib/redis"; type CacheEntry = { data: T; expiresAt: number }; const memory = new Map>(); +/** Drop a key from the in-process cache (used when an upstream value changes). */ +export function invalidateMemory(key: string): void { + memory.delete(key); +} + /** * Redis-first cached query with an in-memory fallback. * Use for read-heavy endpoints polled by the browser (online count, etc.). @@ -16,24 +21,29 @@ export async function cached( ): Promise { const ttlSec = Math.ceil(ttlMs / 1000); + // In-memory fast path — served first so repeated reads within a TTL window + // don't each pay a Redis round-trip (Redis is still the shared fallback). + // `existing &&` short-circuits so Date.now() is never evaluated during + // prerender when the map is empty (keeps `next build` prerendering clean). + const existing = memory.get(key); + if (existing && existing.expiresAt > Date.now()) { + return existing.data as T; + } + // Redis path (shared across instances). if (redis && redis.status !== "end") { try { const cached = await redis.get(key); if (cached !== null && cached !== undefined) { - return JSON.parse(cached) as T; + const data = JSON.parse(cached) as T; + memory.set(key, { data, expiresAt: Date.now() + ttlMs }); + return data; } } catch { - /* fall through to DB / memory */ + /* fall through to fn */ } } - // In-memory fallback (single-instance fast path). - const existing = memory.get(key); - if (existing && existing.expiresAt > Date.now()) { - return existing.data as T; - } - const data = await fn(); if (redis && redis.status !== "end") { diff --git a/src/lib/cached-db.ts b/src/lib/cached-db.ts index fd86c93d..0b25d5f0 100644 --- a/src/lib/cached-db.ts +++ b/src/lib/cached-db.ts @@ -1,52 +1,32 @@ import "server-only"; -import { logger } from "@/lib/logger"; +import { cached, invalidateMemory } from "@/lib/cache"; import { redis } from "@/lib/redis"; const DEFAULT_CACHE_TTL = 60; -function isRedisAvailable(): boolean { - return !!redis && redis.status !== "end"; -} - +/** + * Redis-backed cached query with an in-process fast path (see `cached()`). + * Use for read-heavy lookups that are safe to serve slightly stale + * (login user, article bodies, …). Redis stays the shared source of truth. + */ export async function cachedQuery( cacheKey: string, queryFn: () => Promise, ttl: number = DEFAULT_CACHE_TTL, ): Promise { - if (!isRedisAvailable()) { - return queryFn(); - } - - try { - const cached = await redis?.get(cacheKey); - if (cached !== null && cached !== undefined) { - return JSON.parse(cached) as T; - } - } catch (err) { - logger.warn("[cache] read failed, falling back to DB", { - key: cacheKey, - error: String(err), - }); - } - - const result = await queryFn(); - - try { - await redis?.setex(cacheKey, ttl, JSON.stringify(result)); - } catch (err) { - logger.warn("[cache] write failed, continuing without cache", { - key: cacheKey, - error: String(err), - }); - } - - return result; + return cached(cacheKey, ttl * 1000, queryFn); } -/** Invalidate a single cache key immediately. */ +/** Invalidate a single cache key immediately (memory + Redis). */ export async function invalidateKey(key: string): Promise { - if (!isRedisAvailable()) return 0; - if (!redis) return 0; - return redis.del(key); + invalidateMemory(key); + if (redis && redis.status !== "end") { + try { + return await redis.del(key); + } catch { + return 0; + } + } + return 0; } diff --git a/src/lib/db.ts b/src/lib/db.ts index 7a4047a5..42e72f7d 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,4 +1,5 @@ -import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import { drizzle } from "drizzle-orm/mysql2"; +import type { Pool as MySqlPool } from "mysql2/promise"; import mysql from "mysql2/promise"; import * as relations from "@/db/relations"; import * as schema from "@/db/schema"; @@ -8,10 +9,10 @@ import { resolveConnectionLimit } from "@/lib/db-pool"; const fullSchema = { ...schema, ...relations }; const globalForDb = globalThis as unknown as { - db?: MySql2Database; + db?: ReturnType; }; -function createDb(): MySql2Database { +function createDb() { const url = new URL(env.DATABASE_URL); const isBuild = process.env.NEXT_PHASE === "phase-production-build" || @@ -35,11 +36,6 @@ function createDb(): MySql2Database { connectTimeout, idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, enableKeepAlive: true, - // Prepared-statement caching via mysql2's native support (Node 22+). - // Saves query-parse per request on hot paths. - ...("cache" in mysql.createPool && { - cache: { type: "prepared" }, - }), // Allow :placeholder syntax for raw SQL helpers. namedPlaceholders: true, // Reject multiple statements (SQL injection hardening). @@ -65,6 +61,37 @@ export const db = globalForDb.db ?? createDb(); if (env.NODE_ENV !== "production") globalForDb.db = db; -export type Db = MySql2Database; +export type Db = ReturnType; + +type PreparedValue = string | number | bigint | boolean | Date | null | Buffer; + +/** + * Server-side prepared statements (mysql2 pool.execute()). + * + * Drizzle's mysql2 driver runs `.select()` through the text protocol + * (pool.query()), so it never uses server-side prepared statements. For + * hot-path queries the SQL is parsed once on the server per connection + * instead of every execution. Column aliases let you pick exactly the + * columns you need (no SELECT *). + */ +export async function queryPrepared>( + sql: string, + params?: PreparedValue[], +): Promise { + const client = db.$client as MySqlPool; + const [rows] = (await client.execute(sql, params)) as unknown as [ + T[], + unknown, + ]; + return rows ?? []; +} + +export async function queryPreparedOne>( + sql: string, + params?: PreparedValue[], +): Promise { + const rows = await queryPrepared(sql, params); + return rows[0] ?? null; +} export * from "@/db/schema"; diff --git a/src/lib/redis-cache.ts b/src/lib/redis-cache.ts index a155fd0a..bc279695 100644 --- a/src/lib/redis-cache.ts +++ b/src/lib/redis-cache.ts @@ -1,36 +1,17 @@ import "server-only"; -import { redis } from "@/lib/redis"; +import { cached } from "@/lib/cache"; /** - * Cache the result of a fetch function in Redis. - * Falls back to the fresh fetch if Redis is unavailable. + * Cache the result of a fetch function, memory-first with a Redis fallback + * (see `cached()`). TTL is given in seconds. */ export async function redisCache( key: string, ttlSeconds: number, fetch: () => Promise, ): Promise { - if (!redis) return fetch(); - - try { - const cached = await redis.get(key); - if (cached !== null) { - return JSON.parse(cached) as T; - } - } catch { - // cache miss or error — fall through to fresh fetch - } - - const fresh = await fetch(); - - try { - await redis.setex(key, ttlSeconds, JSON.stringify(fresh)); - } catch { - // ignore write errors - } - - return fresh; + return cached(key, ttlSeconds * 1000, fetch); } /** diff --git a/src/lib/services/audit.ts b/src/lib/services/audit.ts index 363b6ee2..36e819a9 100644 --- a/src/lib/services/audit.ts +++ b/src/lib/services/audit.ts @@ -87,7 +87,15 @@ export async function getAuditLogs(options: GetLogsOptions = {}) { const [rows, totalResult] = await Promise.all([ db - .select() + .select({ + id: AdminAuditLog.id, + userId: AdminAuditLog.userId, + action: AdminAuditLog.action, + target: AdminAuditLog.target, + targetId: AdminAuditLog.targetId, + diff: AdminAuditLog.diff, + createdAt: AdminAuditLog.createdAt, + }) .from(AdminAuditLog) .where(where) .orderBy(desc(AdminAuditLog.id)) diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index 7781a8fc..c92175a2 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -60,7 +60,17 @@ export async function getCatalogItemCounts( */ export async function getTreeFlat(): Promise { const allPages = await db - .select() + .select({ + id: CatalogPages.id, + parentId: CatalogPages.parentId, + caption: CatalogPages.caption, + pageLayout: CatalogPages.pageLayout, + iconColor: CatalogPages.iconColor, + iconImage: CatalogPages.iconImage, + orderNum: CatalogPages.orderNum, + visible: CatalogPages.visible, + enabled: CatalogPages.enabled, + }) .from(CatalogPages) .orderBy(asc(CatalogPages.orderNum)); const itemCountMap = await getCatalogItemCounts(); diff --git a/src/lib/services/news-list.ts b/src/lib/services/news-list.ts new file mode 100644 index 00000000..141c29d8 --- /dev/null +++ b/src/lib/services/news-list.ts @@ -0,0 +1,57 @@ +import "server-only"; + +import { desc } from "drizzle-orm"; +import { cached } from "@/lib/cache"; +import { db, WebsiteArticles } from "@/lib/db"; + +export interface NewsListItem { + slug: string; + title: string; + shortStory: string; + image: string; + createdAt: Date | null; +} + +interface NewsListRow { + slug: string; + title: string; + shortStory: string; + image: string; + createdAt: Date | null; +} + +const TTL_MS = 300_000; +const CACHE_KEY = "news_list"; +const FETCH_LIMIT = 30; + +/** + * Shared, cached news list used by both the homepage and the news archive so + * a single "news_list" cache entry serves both routes. Returns at most + * `limit` rows with `createdAt` normalized to a Date (the cache round-trip + * serializes timestamps to ISO strings). + */ +export async function getNewsList(limit: number): Promise { + try { + const rows = await cached(CACHE_KEY, TTL_MS, () => + db + .select({ + slug: WebsiteArticles.slug, + title: WebsiteArticles.title, + shortStory: WebsiteArticles.shortStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + }) + .from(WebsiteArticles) + .orderBy(desc(WebsiteArticles.createdAt)) + .limit(FETCH_LIMIT), + ); + return rows + .map((a) => ({ + ...a, + createdAt: a.createdAt ? new Date(a.createdAt) : null, + })) + .slice(0, limit); + } catch { + return []; + } +} diff --git a/src/lib/services/radio.ts b/src/lib/services/radio.ts index cdd84694..19f71f97 100644 --- a/src/lib/services/radio.ts +++ b/src/lib/services/radio.ts @@ -27,6 +27,21 @@ export interface NowPlaying { artist: string | null; } +// Shared in-process poll cache: every connected SSE client (players, widgets) +// would otherwise hit the radio API once per 10s each. With one cache entry per +// endpoint, N connections cost 1 request per interval regardless of N. +const POLL_CACHE_TTL_MS = 10_000; +const pollCache = new Map(); + +async function cachedFetchJson(url: string, ms = 4000): Promise { + const now = Date.now(); + const hit = pollCache.get(url); + if (hit && hit.expiresAt > now) return hit.data; + const data = await fetchJson(url, ms); + pollCache.set(url, { data, expiresAt: now + POLL_CACHE_TTL_MS }); + return data; +} + async function fetchJson(url: string, ms = 4000): Promise { if (!isSafeUrl(url)) return null; const controller = new AbortController(); @@ -88,13 +103,13 @@ function parseNowPlaying(d: unknown): NowPlaying | null { export async function fetchNowPlaying(): Promise { const url = (await siteSettings.get("radio_now_playing_api_url", "")) ?? ""; if (!url) return null; - return parseNowPlaying(await fetchJson(url)); + return parseNowPlaying(await cachedFetchJson(url)); } export async function fetchListeners(): Promise { const url = (await siteSettings.get("radio_listeners_api_url", "")) ?? ""; if (!url) return null; - const d = await fetchJson(url); + const d = await cachedFetchJson(url); if (d == null) return null; const obj = d as { listeners?: unknown; current_listeners?: unknown }; const raw = diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index a44392c6..e0fe9242 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -15,9 +15,20 @@ const DEFAULTS: Record = { const CACHE_TTL_MS = 300_000; const REDIS_CACHE_KEY = "site_settings"; +// Short in-process window so repeated getters in one request (header, nav, +// footer all read hotel_name / logo) don't each pay a Redis round-trip. +// Redis stays the source of truth across instances. +const MEMORY_TTL_MS = 60_000; + +// During `next build`, pages are prerendered and `Date.now()` is treated as an +// unstable prerender value — always serve the in-process cache then (settings +// cannot change mid-build). Runtime keeps the normal TTL check. +const IS_PRERENDER = + process.env.NEXT_PHASE === "phase-production-build" || + process.env.NEXT_PHASE === "phase-production-compile"; class SiteSettings { - private cache: Map | null = null; + private cache: { map: Map; expiresAt: number } | null = null; private async loadFromDb(): Promise> { try { @@ -32,22 +43,31 @@ class SiteSettings { } private async load(): Promise> { + if (this.cache !== null) { + if (IS_PRERENDER || this.cache.expiresAt > Date.now()) { + return this.cache.map; + } + } + if (redis) { try { const cached = await redis.get(REDIS_CACHE_KEY); if (cached) { const parsed = JSON.parse(cached) as Record; - return new Map(Object.entries(parsed)); + const map = new Map(Object.entries(parsed)); + this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; + return map; } } catch { logger.warn("Redis cache read failed for site settings"); } } - if (this.cache !== null) return this.cache; + // Expired but usable fallback — keeps the site up if both Redis and DB fail. + if (this.cache !== null) return this.cache.map; const map = await this.loadFromDb(); - this.cache = map; + this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; if (redis) { try {