perf: optimize CMS queries, caching, and asset delivery
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s

Database:
- Add missing indexes (users.credits, users_currency(type,amount),
  users_settings.respects_received, camera_web.timestamp,
  messenger_offline.user_id) via migrations 0020/0021
- Use partial .select() everywhere instead of SELECT * (tickets, users,
  rooms, audit logs, catalog tree, polls, radio, password reset)
- Add queryPrepared/queryPreparedOne (server-side prepared statements)
  and switch the login check to a prepared statement; drop dead
  cache options from the pool config
- Raise total_users/total_rooms COUNT(*) cache TTL to 5m

Caching:
- Consolidate the three cache helpers (cached, redisCache, cachedQuery)
  into a single memory-first implementation backed by Redis
- invalidateKey now clears the in-process cache as well as Redis
- Cache homepage sections, news list, and leaderboard tabs; share one
  news_list cache key between homepage and news archive
- siteSettings: in-process cache with TTL so repeated getters no longer
  pay a Redis round-trip per call
- Share a 10s poll cache across all radio SSE connections
- Normalize timestamps after cache reads (Redis JSON round-trip)

Assets:
- Enable AVIF/WebP via images.formats and remove unoptimized from news
  covers and the homepage hero (149KB jpg) with proper sizes/priority
- Support ?format=webp|avif|png in the /imaging proxy via sharp

Other:
- Fix pnpm supply-chain minimumReleaseAge failures by excluding the
  freshly-published packages (next 16.3.1, hookform resolvers 5.8.0,
  resend 6.20.0)
- Remove unused before/after fields from housekeeping AuditEntry
This commit is contained in:
openhands committed 2026-08-14 11:20:37 +02:00
1 parent dc9e5a567c
commit e5ec3c1f06
29 files changed
+532 -322

No files matched your search

@@ -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`);
@@ -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`);
+5
View File
@@ -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 [
+30 -55
View File
@@ -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: "[email protected]",
mailVerified: "0",
},
]);
queryPreparedOne.mockResolvedValue({
password: "hash",
twoFactorConfirmedAt: null,
mail: "[email protected]",
mailVerified: "0",
});
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
expect(await precheckLogin("user", "pass")).toBe("unverified");
});
+12 -13
View File
@@ -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";
}
+4 -1
View File
@@ -90,7 +90,10 @@ export async function resetPassword(formData: FormData): Promise<void> {
if (!error) {
try {
const [row] = await db
.select()
.select({
token: PasswordReset.token,
createdAt: PasswordReset.createdAt,
})
.from(PasswordReset)
.where(eq(PasswordReset.email, email))
.limit(1);
+12 -2
View File
@@ -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));
+17 -4
View File
@@ -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);
+38 -71
View File
@@ -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<Row[]> {
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<Row[]> {
async function loadCurrencyRows(type: number): Promise<Row[]> {
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<Row[]> {
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 [];
}
+16 -33
View File
@@ -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();
+1
View File
@@ -228,6 +228,7 @@ export default async function ArticlePage({
<img
src={article.image}
alt=""
decoding="async"
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
/>
) : null}
+3 -14
View File
@@ -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 (
<main style={{ display: "grid", gap: "1.5rem" }}>
@@ -58,8 +47,8 @@ export default async function NewsPage() {
src={a.image}
alt=""
fill
sizes="(max-width: 1024px) 50vw, 300px"
style={{ objectFit: "cover" }}
unoptimized
/>
</div>
) : (
+35 -30
View File
@@ -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() {
<div
className="relative overflow-hidden rounded-2xl border"
style={{
background: `url(/assets/images/next_header.jpg) center/cover no-repeat`,
backgroundPosition: "center 20%",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
}}
>
<Image
src="/assets/images/next_header.jpg"
alt=""
fill
priority
sizes="100vw"
style={{ objectFit: "cover", objectPosition: "center 20%" }}
/>
<div
className="absolute inset-0"
style={{
@@ -556,8 +561,8 @@ export default async function Home() {
src={a.image}
alt={a.title}
fill
sizes="(max-width: 768px) 100vw, 400px"
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
unoptimized
/>
<div className="absolute top-2.5 right-2.5 w-8 h-8 rounded-full flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all duration-300 opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0">
<span className="text-white text-sm font-bold">
-2
View File
@@ -81,8 +81,6 @@ interface AuditEntry {
action: string;
target: string;
targetId: number | null;
before: unknown;
after: unknown;
createdAt: string;
}
+12 -1
View File
@@ -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);
+22 -2
View File
@@ -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)),
+58 -5
View File
@@ -27,7 +27,20 @@ type PageRow = {
async function getBcTreeFlat(): Promise<TreeNode[]> {
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<TreeNode[]> {
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<TreeNode[]> {
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))
+6 -1
View File
@@ -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);
+39 -3
View File
@@ -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": "*",
+18 -8
View File
@@ -5,6 +5,11 @@ import { redis } from "@/lib/redis";
type CacheEntry<T> = { data: T; expiresAt: number };
const memory = new Map<string, CacheEntry<unknown>>();
/** 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<T>(
): Promise<T> {
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") {
+17 -37
View File
@@ -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<T>(
cacheKey: string,
queryFn: () => Promise<T>,
ttl: number = DEFAULT_CACHE_TTL,
): Promise<T> {
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<number> {
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;
}
+36 -9
View File
@@ -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<typeof fullSchema>;
db?: ReturnType<typeof createDb>;
};
function createDb(): MySql2Database<typeof fullSchema> {
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<typeof fullSchema> {
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<typeof fullSchema>;
export type Db = ReturnType<typeof createDb>;
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<T extends Record<string, unknown>>(
sql: string,
params?: PreparedValue[],
): Promise<T[]> {
const client = db.$client as MySqlPool;
const [rows] = (await client.execute(sql, params)) as unknown as [
T[],
unknown,
];
return rows ?? [];
}
export async function queryPreparedOne<T extends Record<string, unknown>>(
sql: string,
params?: PreparedValue[],
): Promise<T | null> {
const rows = await queryPrepared<T>(sql, params);
return rows[0] ?? null;
}
export * from "@/db/schema";
+4 -23
View File
@@ -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<T>(
key: string,
ttlSeconds: number,
fetch: () => Promise<T>,
): Promise<T> {
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);
}
/**
+9 -1
View File
@@ -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))
+11 -1
View File
@@ -60,7 +60,17 @@ export async function getCatalogItemCounts(
*/
export async function getTreeFlat(): Promise<TreeNode[]> {
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();
+57
View File
@@ -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<NewsListItem[]> {
try {
const rows = await cached<NewsListRow[]>(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 [];
}
}
+17 -2
View File
@@ -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<string, { data: unknown; expiresAt: number }>();
async function cachedFetchJson(url: string, ms = 4000): Promise<unknown> {
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<unknown> {
if (!isSafeUrl(url)) return null;
const controller = new AbortController();
@@ -88,13 +103,13 @@ function parseNowPlaying(d: unknown): NowPlaying | null {
export async function fetchNowPlaying(): Promise<NowPlaying | null> {
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<number | null> {
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 =
+24 -4
View File
@@ -15,9 +15,20 @@ const DEFAULTS: Record<string, string> = {
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<string, string> | null = null;
private cache: { map: Map<string, string>; expiresAt: number } | null = null;
private async loadFromDb(): Promise<Map<string, string>> {
try {
@@ -32,22 +43,31 @@ class SiteSettings {
}
private async load(): Promise<Map<string, string>> {
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<string, string>;
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 {