From 9f024dc6ac6663365fe222455010f3cbfae0c0b7 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 15 Oct 2026 16:00:00 +0200 Subject: [PATCH] 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. --- .env.example | 23 +++ src/actions/catalog-items.ts | 86 +++++++++ .../catalog-items-table.tsx | 169 ++++++++++++------ .../admin/catalog/items-shop-preview.tsx | 26 ++- src/env.ts | 34 ++++ src/features/catalog/server/offer-commands.ts | 122 ++++++++++++- src/lib/perf-ttls.ts | 55 ++++++ src/lib/services/cache-warmup.ts | 13 +- src/lib/services/captcha.ts | 56 ++++-- src/lib/services/home-payload.ts | 3 +- src/lib/services/news-archive.ts | 3 +- src/lib/services/news-list.ts | 3 +- src/lib/services/public-counters.ts | 8 +- src/lib/services/public-search.ts | 3 +- src/lib/services/site-settings.ts | 8 +- 15 files changed, 529 insertions(+), 83 deletions(-) create mode 100644 src/lib/perf-ttls.ts diff --git a/.env.example b/.env.example index a57337e1..6269e0c1 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,21 @@ CACHE_MEMORY_MAX_ENTRIES=2000 # every 5 minutes brings an over-budget directory back to 90% of this (default 20000). IMAGING_CACHE_MAX_ENTRIES=20000 +# --- CACHE TTL TUNING (seconds; defaults apply when unset) --- +# Higher = less DB load but staler data. Publishing news rotates its cache +# revision immediately, so news TTLs only delay content that bypasses that path. +CMS_HOME_PAYLOAD_TTL_SEC=15 +CMS_NEWS_LIST_TTL_SEC=300 +CMS_NEWS_ARCHIVE_TTL_SEC=60 +CMS_SEARCH_TTL_SEC=30 +CMS_ONLINE_COUNT_TTL_SEC=10 +CMS_COUNTERS_TTL_SEC=300 +CMS_SITE_SETTINGS_TTL_SEC=300 +CMS_SITE_SETTINGS_MEMORY_TTL_SEC=60 +# Boot-time cache warm-up (primes hot keys after each deploy). Set false on +# tiny hosts where the boot COUNT(*) burst is undesirable. +CMS_WARMUP_ENABLED=true + # --- CORE RUNTIME & PERFORMANCE FLAGS --- NODE_ENV=production PORT=3002 @@ -78,6 +93,14 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true # Override for tests/staging (production uses the public endpoint by default). CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4 +# --- CAPTCHA (env fallback; admin settings win when set) --- +# Enable Turnstile on register/login/forgot/resend without touching the DB. +# "none" (default), "turnstile", "recaptcha" or "hcaptcha". +CAPTCHA_PROVIDER= +# Cloudflare Turnstile keys from https://dash.cloudflare.com/turnstile +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + # --- PATHS --- BADGE_UPLOAD_DIR=./public/assets/images/badges EMULATOR_JAR_PATH=./emulator/Arcturus.jar diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 306eb112..d5090651 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -10,6 +10,7 @@ import { restoreDeletedCatalogItemsCommand, } from "@/features/catalog/server/item-deletes"; import { + bulkUpdateOffersCommand, createOfferCommand, moveBcOffersCommand, moveOffersCommand, @@ -399,6 +400,91 @@ export async function updateCatalogItem({ }); } +/** + * "Save All" for the inline table editor. One transaction, one RCON refresh, + * one audit row — instead of a full per-item round-trip that took tens of + * seconds on pages with hundreds of edited rows. A failing row rolls back the + * whole batch, so the editor never ends up half-saved. + */ +export async function updateCatalogItemsBulk({ + updates, + catalog = "normal", +}: { + updates: Array<{ id: number; catalogFields: Record }>; + catalog?: "normal" | "bc"; +}) { + const staff = await requirePermission(PERMS.CATALOG_EDIT); + if (!Array.isArray(updates) || updates.length === 0) { + return { ok: false as const, error: "No items to update" }; + } + if (updates.length > 500) { + return { ok: false as const, error: "Max 500 items per bulk update" }; + } + // BC rows go through their own command per item (no price columns, and the + // BC command keeps its narrower field set); the loop still saves the + // per-item RCON refreshes inside one export lock below. + if (catalog === "bc") { + const { updateBcItem } = await import("@/actions/catalog-bc"); + return await withCatalogExport(async () => { + let savedCount = 0; + let lastError = ""; + for (const update of updates) { + const result = await updateBcItem({ + id: update.id, + ...(update.catalogFields as { + itemIds?: string; + catalogName?: string; + orderNumber?: number; + extradata?: string; + }), + }); + if (result.ok) savedCount++; + else lastError = result.error ?? "Unable to update item"; + } + if (savedCount > 0) { + await logStaffActivity({ + staffId: staff.id, + action: "bc_items_bulk_update", + description: `Updated ${savedCount} BC catalog item(s)`, + targetType: "catalog_item_bc", + }); + } + if (savedCount === 0 && lastError) { + return { ok: false as const, error: lastError }; + } + return { + ok: true as const, + data: { savedCount, failedCount: updates.length - savedCount }, + }; + }); + } + return await withCatalogExport(async () => { + try { + await bulkUpdateOffersCommand(updates, staff.id); + } catch (error) { + return { + ok: false as const, + error: + error instanceof Error + ? error.message + : "Unable to update catalog items", + }; + } + await sendCatalogUpdate(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_bulk_update", + description: `Updated ${updates.length} catalog item(s)`, + targetType: "catalog_item", + }); + revalidatePath("/admin/catalog"); + return { + ok: true as const, + data: { savedCount: updates.length, failedCount: 0 }, + }; + }); +} + export async function translateCatalogItems(input: { /** `id` is items_base.id (not catalog_items.id) */ items: Array<{ id: number; publicName: string; description?: string }>; diff --git a/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx b/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx index d75acfc2..8b3bcc5d 100644 --- a/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx +++ b/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx @@ -17,6 +17,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowRightLeft, Copy, @@ -34,7 +35,7 @@ import { X, } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { createCatalogItem, @@ -43,6 +44,7 @@ import { reorderCatalogItems, restoreDeletedCatalogItems, updateCatalogItem, + updateCatalogItemsBulk, } from "@/actions/catalog-items"; import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items"; import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview"; @@ -115,10 +117,15 @@ export type { BaseItemData, CatalogItemData }; function SortableRow({ id, className, + dataIndex, + measureRef, children, }: { id: number; className?: string; + /** Virtualizer index; forwarded as data-index so measureElement can map it. */ + dataIndex?: number; + measureRef?: (el: HTMLTableRowElement | null) => void; children: (props: { dragHandleProps: React.HTMLAttributes; }) => React.ReactNode; @@ -133,6 +140,13 @@ function SortableRow({ } = useSortable({ id, }); + const setRefs = useCallback( + (node: HTMLTableRowElement | null) => { + setNodeRef(node); + measureRef?.(node); + }, + [setNodeRef, measureRef], + ); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, @@ -144,7 +158,8 @@ function SortableRow({ } as unknown as React.HTMLAttributes; return ( } + ref={setRefs} + data-index={dataIndex} style={style} className={className} > @@ -280,6 +295,23 @@ export function CatalogItemsTable({ .sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); }, [filteredItems, itemOrder, searchQuery, filters]); + // ── Virtualization (table view) ───────────────────────────────── + // A page with hundreds of offers used to mount a full per row, each + // with several inputs and icons — the single biggest source of lag in the + // catalog editor. Above the threshold only the visible window (plus a small + // overscan) is mounted; spacer rows keep the scrollbar honest. + const VIRTUALIZE_THRESHOLD = 150; + const scrollRef = useRef(null); + const isVirtualized = orderedFilteredItems.length > VIRTUALIZE_THRESHOLD; + const virtualizer = useVirtualizer({ + count: orderedFilteredItems.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 52, + overscan: 15, + enabled: isVirtualized, + }); + const virtualRows = isVirtualized ? virtualizer.getVirtualItems() : []; + // ── Selection ──────────────────────────────────────────────────── const [selected, setSelected] = useState>(new Set()); @@ -504,55 +536,36 @@ export function CatalogItemsTable({ async function handleSaveAllModified() { if (modifiedItems.length === 0) return; - run( - async () => { - let savedCount = 0; - let failedCount = 0; + const updates = modifiedItems.flatMap((item) => { + const edit = inlineEdits[item.id]; + if (!edit) return []; + return [ + { + id: item.id, + catalogFields: { + catalogName: item.catalogName, + itemIds: item.itemIds, + costCredits: edit.costCredits, + costPoints: edit.costPoints, + pointsType: edit.pointsType, + amount: edit.amount, + limitedSells: item.limitedSells, + limitedStack: item.limitedStack, + orderNumber: item.orderNumber, + offerId: item.offerId, + haveOffer: item.haveOffer, + clubOnly: item.clubOnly, + extradata: item.extradata, + }, + }, + ]; + }); - for (const item of modifiedItems) { - const edit = inlineEdits[item.id]; - if (!edit) continue; - - const result = await updateCatalogItem({ - id: item.id, - catalog, - catalogFields: { - catalogName: item.catalogName, - itemIds: item.itemIds, - costCredits: edit.costCredits, - costPoints: edit.costPoints, - pointsType: edit.pointsType, - amount: edit.amount, - limitedSells: item.limitedSells, - limitedStack: item.limitedStack, - orderNumber: item.orderNumber, - offerId: item.offerId, - haveOffer: item.haveOffer, - clubOnly: item.clubOnly, - extradata: item.extradata, - }, - }); - - if (result.ok) savedCount++; - else failedCount++; - } - - if (savedCount > 0) { - onRefresh(); - } - if (failedCount > 0) { - return { - ok: false as const, - error: `Saved ${savedCount}, failed ${failedCount}.`, - }; - } - return { ok: true as const, data: { savedCount } }; - }, - { - successMessage: `Saved ${modifiedItems.length} item(s).`, - errorMessage: "Failed to save items.", - }, - ); + run(() => updateCatalogItemsBulk({ updates, catalog }), { + successMessage: `Saved ${updates.length} item(s).`, + errorMessage: "Failed to save items.", + onSuccess: () => onRefresh(), + }); } // ── Bulk delete selected ───────────────────────────────────────── @@ -1243,7 +1256,10 @@ export function CatalogItemsTable({ onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} > -
+
@@ -1278,7 +1294,29 @@ export function CatalogItemsTable({ strategy={verticalListSortingStrategy} > - {orderedFilteredItems.map((item) => { + {isVirtualized && virtualRows.length > 0 && ( + + + )} + {(isVirtualized + ? virtualRows.map((vr) => ({ + item: orderedFilteredItems[vr.index], + index: vr.index, + })) + : orderedFilteredItems.map((item, index) => ({ + item, + index, + })) + ).map(({ item, index }) => { + if (!item) return null; const pts = getPointsLabel(item.pointsType); const edit = inlineEdits[item.id]; const modified = isItemModified(item); @@ -1289,6 +1327,14 @@ export function CatalogItemsTable({ { + if (el) virtualizer.measureElement(el); + } + : undefined + } className={cn( modified && "bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]", @@ -1561,6 +1607,25 @@ export function CatalogItemsTable({ ); })} + {isVirtualized && + virtualRows.length > 0 && + (() => { + const last = virtualRows[virtualRows.length - 1]; + const total = virtualizer.getTotalSize(); + if (!last || last.end >= total) return null; + return ( + + + ); + })()}
+
+
diff --git a/src/components/admin/catalog/items-shop-preview.tsx b/src/components/admin/catalog/items-shop-preview.tsx index 1b100518..f6542b4b 100644 --- a/src/components/admin/catalog/items-shop-preview.tsx +++ b/src/components/admin/catalog/items-shop-preview.tsx @@ -18,6 +18,14 @@ interface ItemsShopPreviewProps { onItemClick?: (item: CatalogItemData) => void; } +/** + * The preview is a visual check, not an editor. Mounting a tile (image, badges, + * click handler) per offer made pages with hundreds of items crawl, so the grid + * shows a window and states how many are hidden. The table view is the place to + * work through the full list. + */ +const PREVIEW_TILE_LIMIT = 200; + export function ItemsShopPreview({ items, furniRevisionMap, @@ -38,15 +46,23 @@ export function ItemsShopPreview({ const gridClass = getShopGridClass(layout); const tileSize = getShopTileSize(layout); + const visibleItems = + items.length > PREVIEW_TILE_LIMIT + ? items.slice(0, PREVIEW_TILE_LIMIT) + : items; return (
Shop preview · {layout} - {items.length} items + + {items.length} items + {visibleItems.length !== items.length && + ` · showing first ${visibleItems.length}`} +
- {items.map((item) => { + {visibleItems.map((item) => { const firstId = parseFirstItemId(item.itemIds); const furni = firstId != null ? furniRevisionMap[firstId] : undefined; @@ -62,6 +78,12 @@ export function ItemsShopPreview({ ); })}
+ {visibleItems.length !== items.length && ( +

+ Preview capped at {PREVIEW_TILE_LIMIT} items — use the table view for + the rest. +

+ )}
); } diff --git a/src/env.ts b/src/env.ts index 546171d5..d4eeb8c5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -103,6 +103,27 @@ const schema = z // Redis — strongly recommended in production (required for multi-instance). // Without it, rate limits / shared caches are in-process only. REDIS_URL: z.string().optional(), + // ── Cache TTL tuning (seconds; see src/lib/perf-ttls.ts) ────────────── + // Defaults match the previous hard-coded values; set only to tune. + CMS_HOME_PAYLOAD_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_NEWS_LIST_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_NEWS_ARCHIVE_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_SEARCH_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_ONLINE_COUNT_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_COUNTERS_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_SITE_SETTINGS_TTL_SEC: z.coerce.number().int().positive().optional(), + CMS_SITE_SETTINGS_MEMORY_TTL_SEC: z.coerce + .number() + .int() + .positive() + .optional(), + // Boot-time cache warm-up (priming hot keys after each deploy). Set to + // "false"/"0" to skip — useful on hosts where the boot DB burst is + // undesirable (e.g. tiny containers right after a restore). + CMS_WARMUP_ENABLED: z + .string() + .optional() + .transform((value) => value !== "false" && value !== "0"), // App-layer anti-DDoS gate (proxy rate limiter). On by default in // production; set to "false" or "0" to disable without removing it. ANTI_DDOS_ENABLED: z @@ -151,6 +172,19 @@ const schema = z .string() .optional() .transform((value) => value !== "false" && value !== "0"), + // ── CAPTCHA (Turnstile) — env fallback for the DB-backed settings ───── + // The admin panel (housekeeping → settings) wins when it has values; these + // let a deploy enable Turnstile without touching the DB. An explicit DB + // value — including "none" — always overrides the env fallback. + CAPTCHA_PROVIDER: z.preprocess( + (value) => (value === "" ? undefined : value), + z.enum(["none", "turnstile", "recaptcha", "hcaptcha"]).optional(), + ), + // Public site key (rendered in the widget via server → client props). + TURNSTILE_SITE_KEY: z.string().optional(), + // Server-only secret used against Cloudflare's siteverify endpoint. + // Never NEXT_PUBLIC_: verification would become client-trusting. + TURNSTILE_SECRET_KEY: z.string().optional(), }) .superRefine((data, ctx) => { if (data.NODE_ENV !== "production") return; diff --git a/src/features/catalog/server/offer-commands.ts b/src/features/catalog/server/offer-commands.ts index 1077732a..09215f20 100644 --- a/src/features/catalog/server/offer-commands.ts +++ b/src/features/catalog/server/offer-commands.ts @@ -31,7 +31,13 @@ export type UpdateOfferInput = { catalogFields: Record; baseItem?: { id: number; fields: Record }; }; -type OfferRow = { id: number; pageId: number | string; itemIds: string }; +type OfferRow = { + id: number; + pageId: number | string; + itemIds: string; + limitedStack?: number | string; + limitedSells?: number | string; +}; async function lockedOffers( tx: Transaction, ids: number[], @@ -39,7 +45,7 @@ async function lockedOffers( ): Promise { const offers = rowsFrom( await tx.execute( - sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join( + sql`SELECT id, page_id AS pageId, item_ids AS itemIds, limited_stack AS limitedStack, limited_sells AS limitedSells FROM ${offerTable(kind)} WHERE id IN (${sql.join( [...ids].sort((a, b) => a - b), sql`, `, )}) ORDER BY id FOR UPDATE`, @@ -193,6 +199,118 @@ export async function updateOfferCommand( await recordHistory(tx, "prices", input.id, userId, before); }); } +/** + * Many offer patches in ONE transaction: the inline table editor's "Save All" + * used to issue a transaction + lock + RCON refresh + audit row per item, which + * took tens of seconds on a page with hundreds of edited rows. Lock ordering + * matches {@link updateOfferCommand}: pages → offers → furniture. A failing row + * aborts the whole batch (full rollback) rather than leaving a half-applied save. + */ +export async function bulkUpdateOffersCommand( + updates: UpdateOfferInput[], + userId?: number, +) { + if (!updates.length) throw Error("No valid fields to update"); + if (updates.length > 500) throw Error("Max 500 items per bulk update"); + const parsed = updates.map((input) => { + offerIdSchema.parse(input.id); + const fields = offerPatchSchema.parse(input.catalogFields); + const baseFields = input.baseItem + ? furniturePatchSchema.parse(input.baseItem.fields) + : {}; + if (!Object.values(fields).some((value) => value !== undefined)) + throw Error("No valid fields to update"); + return { input, fields, baseFields }; + }); + + const offerIds = distinctOfferIds(parsed.map((row) => row.input.id)); + const furnitureIds: number[] = []; + for (const row of parsed) { + if (row.fields.itemIds !== undefined) + furnitureIds.push(...parseFurnitureIds(row.fields.itemIds)); + if (row.input.baseItem) furnitureIds.push(row.input.baseItem.id); + } + + return db.transaction(async (tx) => { + for (const pageId of new Set( + parsed.map((row) => row.fields.pageId).filter((id) => id !== undefined), + )) + await lockPage(tx, pageId as number); + const offers = await lockedOffers(tx, offerIds); + const offerById = new Map(offers.map((offer) => [Number(offer.id), offer])); + if (furnitureIds.length) await lockFurniture(tx, furnitureIds); + + const beforeById = new Map< + number, + Awaited> + >(); + if (userId) + for (const id of offerIds) + beforeById.set(id, await historySnapshot(tx, "prices", id)); + + for (const row of parsed) { + const offer = offerById.get(row.input.id); + if (!offer) throw Error("Catalog item not found"); + if (row.input.baseItem) { + const currentIds = String(offer.itemIds).split(";").map(Number); + const nextIds = + row.fields.itemIds === undefined + ? currentIds + : parseFurnitureIds(row.fields.itemIds); + if ( + !currentIds.includes(row.input.baseItem.id) || + !nextIds.includes(row.input.baseItem.id) + ) + throw Error("Shared furniture must belong to the catalog offer"); + } + if ( + row.fields.orderNumber !== undefined || + row.fields.pageId !== undefined + ) { + const targetPageId = row.fields.pageId ?? offer.pageId; + if (row.fields.orderNumber !== undefined) + await assertOrderUnique( + tx, + targetPageId, + row.fields.orderNumber, + row.input.id, + ); + } + if ( + row.fields.limitedStack !== undefined || + row.fields.limitedSells !== undefined + ) { + const stack = row.fields.limitedStack ?? 0; + const sells = row.fields.limitedSells ?? 0; + const resolvedStack = + row.fields.limitedStack !== undefined + ? stack + : Number(offer.limitedStack ?? 0); + const resolvedSells = + row.fields.limitedSells !== undefined + ? sells + : Number(offer.limitedSells ?? 0); + assertLimitedStackOk(resolvedStack, resolvedSells); + } + const offerAssignments = assignments(CatalogItems, row.fields); + if (offerAssignments.length) + await tx.execute( + sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${row.input.id}`, + ); + const baseAssignments = assignments(ItemsBase, row.baseFields); + if (row.input.baseItem && baseAssignments.length) + await tx.execute( + sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${row.input.baseItem.id}`, + ); + } + if (userId) + for (const id of offerIds) { + const before = beforeById.get(id); + if (before) await recordHistory(tx, "prices", id, userId, before); + } + }); +} + export async function reorderOffersCommand( orders: Array<{ id: number; orderNumber: number }>, ) { diff --git a/src/lib/perf-ttls.ts b/src/lib/perf-ttls.ts new file mode 100644 index 00000000..44247808 --- /dev/null +++ b/src/lib/perf-ttls.ts @@ -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; +})(); diff --git a/src/lib/services/cache-warmup.ts b/src/lib/services/cache-warmup.ts index d88c6f9c..4ac77374 100644 --- a/src/lib/services/cache-warmup.ts +++ b/src/lib/services/cache-warmup.ts @@ -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(name: string, load: () => Promise): Promise { try { @@ -45,6 +50,10 @@ async function warm(name: string, load: () => Promise): Promise { } export async function warmPublicCaches(): Promise { + // 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 diff --git a/src/lib/services/captcha.ts b/src/lib/services/captcha.ts index 5798a532..d72bce3b 100644 --- a/src/lib/services/captcha.ts +++ b/src/lib/services/captcha.ts @@ -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 { + 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 { + // 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; diff --git a/src/lib/services/home-payload.ts b/src/lib/services/home-payload.ts index 3cff7e85..eb97638c 100644 --- a/src/lib/services/home-payload.ts +++ b/src/lib/services/home-payload.ts @@ -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); } diff --git a/src/lib/services/news-archive.ts b/src/lib/services/news-archive.ts index 709d7753..50efe2e4 100644 --- a/src/lib/services/news-archive.ts +++ b/src/lib/services/news-archive.ts @@ -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 } = {}, diff --git a/src/lib/services/news-list.ts b/src/lib/services/news-list.ts index 1dd0c2be..410ea7e4 100644 --- a/src/lib/services/news-list.ts +++ b/src/lib/services/news-list.ts @@ -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; diff --git a/src/lib/services/public-counters.ts b/src/lib/services/public-counters.ts index 9d179268..5cfbf3b7 100644 --- a/src/lib/services/public-counters.ts +++ b/src/lib/services/public-counters.ts @@ -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 { // 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 { return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, { staleMs: 15_000, diff --git a/src/lib/services/public-search.ts b/src/lib/services/public-search.ts index 795d8394..5c3bb465 100644 --- a/src/lib/services/public-search.ts +++ b/src/lib/services/public-search.ts @@ -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( diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 8ce01d18..10341b73 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -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 = { @@ -11,12 +15,12 @@ const DEFAULTS: Record = { 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