Files
EpicNext-Cms/src/lib/services/site-settings.ts
T
openhands e5ec3c1f06
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s
perf: optimize CMS queries, caching, and asset delivery
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
2026-08-14 11:20:37 +02:00

141 lines
3.9 KiB
TypeScript

import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = {
hotel_name: FALLBACK_HOTEL_NAME,
habbo_imaging_url: "/imaging",
logo_url: "",
nitro_client_url: "",
habbo_gamedata_hotel: "it",
};
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: Map<string, string>; expiresAt: number } | null = null;
private async loadFromDb(): Promise<Map<string, string>> {
try {
const rows = await db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting);
return new Map(rows.map((r) => [r.key, r.value]));
} catch {
logger.warn("Failed to load site settings from database, using defaults");
return new Map(Object.entries(DEFAULTS));
}
}
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>;
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");
}
}
// 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, expiresAt: Date.now() + MEMORY_TTL_MS };
if (redis) {
try {
const obj = Object.fromEntries(map.entries());
await redis.setex(
REDIS_CACHE_KEY,
Math.ceil(CACHE_TTL_MS / 1000),
JSON.stringify(obj),
);
} catch {
logger.warn("Failed to write site settings to Redis cache");
}
}
return map;
}
async getAll(): Promise<Map<string, string>> {
return await this.load();
}
async get(
key: string,
fallback: string | null = null,
): Promise<string | null> {
const map = await this.load();
if (map.has(key)) return map.get(key) as string;
// eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS`
if (key in DEFAULTS) return DEFAULTS[key] as string;
return fallback;
}
async getMany(keys: string[]): Promise<Record<string, string | null>> {
const map = await this.load();
const result: Record<string, string | null> = {};
for (const key of keys) {
result[key] =
map.get(key) ?? (key in DEFAULTS ? (DEFAULTS[key] as string) : null);
}
return result;
}
async getBool(key: string, fallback = false): Promise<boolean> {
const v = await this.get(key, null);
if (v === null) return fallback;
const s = v.toLowerCase();
return s === "1" || s === "true";
}
async update(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await this.reload();
}
async reload(): Promise<void> {
this.cache = null;
if (redis) {
try {
await redis.del(REDIS_CACHE_KEY);
} catch {
logger.warn("Failed to invalidate Redis cache for site settings");
}
}
}
}
export const siteSettings = new SiteSettings();