perf(app): batch catalog updates, tune caches, add captcha env fallback

- catalog: apply bulk offer edits in a single transaction with one RCON
  refresh instead of N round-trips, virtualize the items table past 150
  rows and cap the shop preview at 200 tiles
- cache: lift the public cache TTLs and the boot warm-up into env-tunable
  constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED)
- auth: resolve the captcha provider, site key and secret from
  CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the
  admin settings are unset; an explicit DB value (including "none") still
  wins, so Turnstile can be enabled per-deploy without touching the DB.
This commit is contained in:
openhands committed 2026-10-15 16:00:00 +02:00
1 parent 05c2706eaf
commit 9f024dc6ac
15 files changed
+529 -83

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import "server-only";
import { env } from "@/env";
/**
* Env-tuned cache TTLs for the hot public/admin read paths. Every value falls
* back to the previous hard-coded default when the variable is unset (and under
* SKIP_ENV_VALIDATION, where env is the raw process.env), so existing installs
* behave identically until they opt into tuning.
*
* Keep these in seconds on the wire (env) and milliseconds in code, matching
* the REDIS_CACHE_TTL_DEFAULT convention.
*/
function seconds(
value: string | number | undefined,
fallbackSec: number,
): number {
const parsed = typeof value === "number" ? value : Number(value);
return (Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackSec) * 1000;
}
/** `/api/home` landing payload (articles + online count + hotel name). */
export const HOME_PAYLOAD_TTL_MS = seconds(env.CMS_HOME_PAYLOAD_TTL_SEC, 15);
/** Shared news preview list used by the homepage and widgets. */
export const NEWS_LIST_TTL_MS = seconds(env.CMS_NEWS_LIST_TTL_SEC, 300);
/** News archive / search landing — includes a COUNT(*) over a LIKE scan. */
export const NEWS_ARCHIVE_TTL_MS = seconds(env.CMS_NEWS_ARCHIVE_TTL_SEC, 60);
/** Public directory search sections (users/rooms/news/events). */
export const PUBLIC_SEARCH_TTL_MS = seconds(env.CMS_SEARCH_TTL_SEC, 30);
/** Online player counter — the hottest COUNT(*) on the site. */
export const ONLINE_COUNT_TTL_MS = seconds(env.CMS_ONLINE_COUNT_TTL_SEC, 10);
/** Public counter keys (totals) and the warm-up's stale window for them. */
export const COUNTERS_TTL_MS = seconds(env.CMS_COUNTERS_TTL_SEC, 300);
/** Site settings: Redis/shared TTL and the per-process memory mirror. */
export const SITE_SETTINGS_TTL_MS = seconds(env.CMS_SITE_SETTINGS_TTL_SEC, 300);
export const SITE_SETTINGS_MEMORY_TTL_MS = seconds(
env.CMS_SITE_SETTINGS_MEMORY_TTL_SEC,
60,
);
/** Boot-time cache warm-up toggle (instrumentation.ts). */
export const CACHE_WARMUP_ENABLED = (() => {
// Validated mode exposes the zod-transformed boolean; SKIP_ENV_VALIDATION
// (tooling/tests) hands the raw string through, so accept both spellings.
const raw = env.CMS_WARMUP_ENABLED as unknown;
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") return raw !== "false" && raw !== "0";
return true;
})();
+11 -2
View File
@@ -4,6 +4,11 @@ import { asc, desc, eq, gte } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { CameraWeb, db, User, WebsiteTeams } from "@/lib/db";
import { logger } from "@/lib/logger";
import {
CACHE_WARMUP_ENABLED,
COUNTERS_TTL_MS,
ONLINE_COUNT_TTL_MS,
} from "@/lib/perf-ttls";
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
import { cachedHomePayload } from "@/lib/services/home-payload";
import { getNewsList } from "@/lib/services/news-list";
@@ -30,8 +35,8 @@ import { siteSettings } from "@/lib/services/site-settings";
* stop the server from serving.
*/
const COUNTER_TTL_MS = 300_000;
const ONLINE_TTL_MS = 10_000;
const COUNTER_TTL_MS = COUNTERS_TTL_MS;
const ONLINE_TTL_MS = ONLINE_COUNT_TTL_MS;
async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
try {
@@ -45,6 +50,10 @@ async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
}
export async function warmPublicCaches(): Promise<void> {
// Boot warm-up can be turned off per deploy (CMS_WARMUP_ENABLED=false): on
// small hosts a burst of COUNT(*) queries right after boot competes with the
// emulator for the same database.
if (!CACHE_WARMUP_ENABLED) return;
// Elke `warm` wordt afgewacht voordat de volgende begint, dus de COUNT(*)
// queries gaan één voor één in plaats van als een burst op een database die
// nog aan het opstarten is. Dat is goed genoeg; kunstmatige sleeps zouden
+40 -16
View File
@@ -1,20 +1,28 @@
import { env } from "@/env";
import { siteSettings } from "@/lib/services/site-settings";
/**
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
* provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
* and hCaptcha (three AtomCMS offers, mutually exclusive).
* Server-side CAPTCHA verification. The provider is driven by website_settings
* (staff pick it in housekeeping) with an ENV FALLBACK so a deploy can enable
* Turnstile without touching the DB: unset DB keys fall back to
* CAPTCHA_PROVIDER / TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY. An explicit DB
* value — including "none" — always wins over env. Supports Cloudflare
* Turnstile, Google reCAPTCHA, and hCaptcha (mutually exclusive).
*
* Behaviour:
* - provider "none" (or unset) → fail-open (allow)
* - provider "none" (or unset everywhere) → fail-open (allow)
* - provider configured but site key / secret missing, token absent, provider
* API error, or network failure → fail-closed (deny)
*
* Settings keys:
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
* turnstile_secret / turnstile_site_key
* recaptcha_secret / recaptcha_site_key
* hcaptcha_secret / hcaptcha_site_key
* Settings keys (DB) / env fallbacks:
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none"
* CAPTCHA_PROVIDER (env, used when the DB key is unset)
* turnstile_site_key / TURNSTILE_SITE_KEY
* turnstile_secret / TURNSTILE_SECRET_KEY
* recaptcha_site_key (DB only)
* recaptcha_secret (DB only)
* hcaptcha_site_key (DB only)
* hcaptcha_secret (DB only)
*/
export interface CaptchaConfig {
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
@@ -28,15 +36,32 @@ const TURNSTILE_URL =
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
const HCAPTCHA_URL = "https://hcaptcha.com/siteverify";
/** DB first (staff can override live), env fallback for unconfigured deploys. */
async function settingOrEnv(
settingKey: string,
settingDefault: string,
envValue: string | undefined,
): Promise<string> {
const dbValue = (await siteSettings.get(settingKey, settingDefault)) ?? "";
if (dbValue.trim() !== "") return dbValue.trim();
return envValue?.trim() || "";
}
/** Public config the register/login pages need to render the widget (no secrets). */
export async function captchaConfig(): Promise<CaptchaConfig> {
// Empty DB fallback distinguishes "unset" from an explicit "none": only an
// explicit DB value short-circuits the env fallback.
const provider = (
(await siteSettings.get("captcha_provider", "none")) ?? "none"
await settingOrEnv("captcha_provider", "", env.CAPTCHA_PROVIDER)
).toLowerCase();
if (provider === "turnstile") {
return {
provider: "turnstile",
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
siteKey: await settingOrEnv(
"turnstile_site_key",
"",
env.TURNSTILE_SITE_KEY,
),
field: "cf-turnstile-response",
};
}
@@ -67,13 +92,12 @@ export async function verifyCaptcha(
if (!cfg.siteKey) return false;
const secretKey: string =
const secret =
cfg.provider === "turnstile"
? "turnstile_secret"
? await settingOrEnv("turnstile_secret", "", env.TURNSTILE_SECRET_KEY)
: cfg.provider === "recaptcha"
? "recaptcha_secret"
: "hcaptcha_secret";
const secret = (await siteSettings.get(secretKey, "")) ?? "";
? ((await siteSettings.get("recaptcha_secret", "")) ?? "")
: ((await siteSettings.get("hcaptcha_secret", "")) ?? "");
if (!secret) return false;
if (!token) return false;
+2 -1
View File
@@ -3,6 +3,7 @@ import "server-only";
import { and, count, desc, eq, or, sql } from "drizzle-orm";
import { db, User, WebsiteArticles } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { HOME_PAYLOAD_TTL_MS } from "@/lib/perf-ttls";
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
@@ -47,5 +48,5 @@ export async function loadHomePayload() {
/** Cached read of {@link loadHomePayload} under the route's own key. */
export async function cachedHomePayload() {
return cacheNews(apiCacheKey("home"), 15_000, loadHomePayload);
return cacheNews(apiCacheKey("home"), HOME_PAYLOAD_TTL_MS, loadHomePayload);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import "server-only";
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
import { db, WebsiteArticles } from "@/lib/db";
import { NEWS_ARCHIVE_TTL_MS } from "@/lib/perf-ttls";
import { cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
@@ -12,7 +13,7 @@ import { cacheNews } from "@/lib/services/news-cache";
* entries immediately, so the window only applies when pub/sub cannot be
* delivered.
*/
const ARCHIVE_TTL_MS = 60_000;
const ARCHIVE_TTL_MS = NEWS_ARCHIVE_TTL_MS;
async function queryNewsArchive(
options: { search?: string; page?: number; order?: string } = {},
+2 -1
View File
@@ -1,4 +1,5 @@
import { logger } from "@/lib/logger";
import { NEWS_LIST_TTL_MS } from "@/lib/perf-ttls";
import { cacheNews } from "@/lib/services/news-cache";
import "server-only";
@@ -21,7 +22,7 @@ interface NewsListRow {
createdAt: Date | null;
}
const TTL_MS = 300_000;
const TTL_MS = NEWS_LIST_TTL_MS;
const CACHE_KEY = "news_list";
const FETCH_LIMIT = 30;
+5 -3
View File
@@ -3,6 +3,9 @@ import "server-only";
import { and, count, eq, or, sql } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
import { ONLINE_COUNT_TTL_MS } from "@/lib/perf-ttls";
export { ONLINE_COUNT_TTL_MS };
/**
* Row counters for the public homepage.
@@ -80,9 +83,8 @@ export async function countOnline(): Promise<number> {
// One function for every caller (homepage, login, register, headers, the client
// page and the warm-up). Each call site used to inline this, and the ones that
// omitted `staleMs` wrote an entry without a grace window, which made the key
// block on COUNT(*) at every TTL boundary.
export const ONLINE_COUNT_TTL_MS = 10_000;
// block on COUNT(*) at every TTL boundary. The TTL itself is env-tuned via
// perf-ttls (CMS_ONLINE_COUNT_TTL_SEC).
export async function cachedOnlineCount(): Promise<number> {
return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, {
staleMs: 15_000,
+2 -1
View File
@@ -1,4 +1,5 @@
import { cached } from "@/lib/cache";
import { PUBLIC_SEARCH_TTL_MS } from "@/lib/perf-ttls";
import { publicReadFailure } from "@/lib/services/public-read";
import "server-only";
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
@@ -14,7 +15,7 @@ import { loadNewsArchive } from "@/lib/services/news-archive";
* directory, and a user who registers or a room that opens should show up
* without waiting.
*/
const SEARCH_TTL_MS = 30_000;
const SEARCH_TTL_MS = PUBLIC_SEARCH_TTL_MS;
function pageNumber(value: number | undefined, total: number) {
return Math.min(
+6 -2
View File
@@ -2,6 +2,10 @@ import "server-only";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import {
SITE_SETTINGS_MEMORY_TTL_MS,
SITE_SETTINGS_TTL_MS,
} from "@/lib/perf-ttls";
import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = {
@@ -11,12 +15,12 @@ const DEFAULTS: Record<string, string> = {
habbo_gamedata_hotel: "it",
};
const CACHE_TTL_MS = 300_000;
const CACHE_TTL_MS = SITE_SETTINGS_TTL_MS;
const REDIS_CACHE_KEY = "site_settings";
// Short in-process window so repeated getters in one request (header, nav,
// footer all read logo and other settings) don't each pay a Redis round-trip.
// Redis stays the source of truth across instances.
const MEMORY_TTL_MS = 60_000;
const MEMORY_TTL_MS = SITE_SETTINGS_MEMORY_TTL_MS;
// During `next build`, pages are prerendered and `Date.now()` is treated as an
// unstable prerender value — always serve the in-process cache then (settings