Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62de3e3a8b | ||
|
|
9f024dc6ac | ||
|
|
05c2706eaf |
No files matched your search
@@ -18,6 +18,21 @@ CACHE_MEMORY_MAX_ENTRIES=2000
|
|||||||
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
||||||
IMAGING_CACHE_MAX_ENTRIES=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 ---
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3002
|
PORT=3002
|
||||||
@@ -78,6 +93,14 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
|||||||
# Override for tests/staging (production uses the public endpoint by default).
|
# Override for tests/staging (production uses the public endpoint by default).
|
||||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
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 ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError } from "./helpers";
|
||||||
|
|
||||||
|
// Unauthenticated requests to private routes must bounce to the login form —
|
||||||
|
// never render private content and never 500. Two guard paths exist: the edge
|
||||||
|
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
|
||||||
|
// member pages and the mod console redirect from their layouts with a bare
|
||||||
|
// `/login`.
|
||||||
|
|
||||||
|
// Routes where the proxy preserves the requested destination.
|
||||||
|
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
|
||||||
|
// Routes guarded by a page/layout `redirect("/login")` with no destination.
|
||||||
|
const LAYOUT_ROUTES = [
|
||||||
|
"/settings",
|
||||||
|
"/me",
|
||||||
|
"/messages",
|
||||||
|
"/notifications",
|
||||||
|
"/friends",
|
||||||
|
"/redeem",
|
||||||
|
"/mod",
|
||||||
|
"/client",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PROXY_ROUTES) {
|
||||||
|
test(`${route} redirects to login and keeps the return path`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
const from = new URL(page.url()).searchParams.get("from");
|
||||||
|
expect(from, "login lost the return destination").toBeTruthy();
|
||||||
|
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of LAYOUT_ROUTES) {
|
||||||
|
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared assertions for the read-only public e2e suite. These specs run against
|
||||||
|
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
|
||||||
|
* guard the "everything loads, nothing is broken" contract that no unit test
|
||||||
|
* can see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fails if Next rendered a runtime/server error boundary into the page. */
|
||||||
|
export async function expectNoApplicationError(page: Page) {
|
||||||
|
await expect(page.locator("body")).not.toContainText("Application error");
|
||||||
|
await expect(page.locator("body")).not.toContainText("Internal Server Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every <img> that finished loading must have decoded pixels. A failed request
|
||||||
|
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
|
||||||
|
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
|
||||||
|
* that are still pending (`complete === false`) are ignored.
|
||||||
|
*/
|
||||||
|
export async function expectNoBrokenImages(page: Page) {
|
||||||
|
const broken = await page.evaluate(() => {
|
||||||
|
return Array.from(document.images)
|
||||||
|
.filter((img) => {
|
||||||
|
const src = img.currentSrc || img.src;
|
||||||
|
if (!src || src.startsWith("data:")) return false;
|
||||||
|
return img.complete && img.naturalWidth === 0;
|
||||||
|
})
|
||||||
|
.map((img) => img.currentSrc || img.src);
|
||||||
|
});
|
||||||
|
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No horizontal scrollbar — the classic mobile layout regression. */
|
||||||
|
export async function expectNoHorizontalOverflow(page: Page) {
|
||||||
|
const overflowing = await page.evaluate(() => {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
return doc.scrollWidth > window.innerWidth + 1;
|
||||||
|
});
|
||||||
|
expect(overflowing, "page scrolls horizontally").toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
|
||||||
|
* chance to start before the broken-image check runs.
|
||||||
|
*/
|
||||||
|
export async function scrollThroughPage(page: Page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const step = window.innerHeight;
|
||||||
|
for (let y = 0; y < document.body.scrollHeight; y += step) {
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skips a spec when the CMS answered with a maintenance redirect. */
|
||||||
|
export function isMaintenanceRedirect(url: string) {
|
||||||
|
return /\/maintenance(?:$|[/?])/.test(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Latency + caching contract for the anonymous pages. The perf work relies on
|
||||||
|
// the edge/shared cache (`src/proxy.ts`): public content pages advertise a
|
||||||
|
// short `s-maxage`, while session-dependent pages (login, register, settings)
|
||||||
|
// must stay `private, no-store`. Budgets are deliberately loose so a cold
|
||||||
|
// cache or a slow CI hop does not flap the gate.
|
||||||
|
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
|
||||||
|
|
||||||
|
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
|
||||||
|
|
||||||
|
for (const route of WARM_ROUTES) {
|
||||||
|
test(`${route} responds within the warm budget`, async ({ request }) => {
|
||||||
|
await request.get(route); // warm the render + data caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(route);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(
|
||||||
|
elapsed,
|
||||||
|
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
|
||||||
|
).toBeLessThan(BUDGET_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("anonymous content pages advertise a shared cache", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/news", "/staff", "/shop"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session-dependent pages stay private and uncached", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/login", "/register"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
|
||||||
|
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
|
||||||
|
"s-maxage",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoApplicationError,
|
||||||
|
expectNoBrokenImages,
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
scrollThroughPage,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
// Read-only smoke coverage for the anonymous-facing pages: each must return a
|
||||||
|
// real document, render without an error boundary, and load every image. This
|
||||||
|
// is where a missing upload, a bad avatar figure or a broken theme asset shows
|
||||||
|
// up — none of which the unit suite can catch.
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
"/",
|
||||||
|
"/news",
|
||||||
|
"/staff",
|
||||||
|
"/shop",
|
||||||
|
"/search",
|
||||||
|
"/leaderboard",
|
||||||
|
"/help",
|
||||||
|
"/radio",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PUBLIC_ROUTES) {
|
||||||
|
test(`${route} renders, loads its images and does not overflow`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const response = await page.goto("/");
|
||||||
|
expect(response?.ok()).toBe(true);
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoApplicationError,
|
||||||
|
expectNoBrokenImages,
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
isMaintenanceRedirect,
|
||||||
|
scrollThroughPage,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
// The register form is the gate for every new player, so it gets a deeper
|
||||||
|
// read-only pass than the generic public-page spec. It also pins the captcha
|
||||||
|
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
|
||||||
|
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
|
||||||
|
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
|
||||||
|
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
|
||||||
|
|
||||||
|
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
|
||||||
|
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
|
||||||
|
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
|
||||||
|
|
||||||
|
test.describe("register", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders every field a new player has to fill in", async ({ page }) => {
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="mail"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]').first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('input[name="password_confirmation"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="terms"]')).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads its theme/avatar images and fits the viewport", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes exactly one captcha widget with a site key", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const widgets = page.locator(CAPTCHA_SELECTOR);
|
||||||
|
|
||||||
|
if (EXPECT === "none") {
|
||||||
|
await expect(widgets).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EXPECT === "turnstile") {
|
||||||
|
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await widgets.count();
|
||||||
|
expect(
|
||||||
|
count,
|
||||||
|
`expected at most one captcha provider, found ${count}`,
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
|
||||||
|
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
|
||||||
|
expect(siteKey?.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
|
||||||
|
const script = page.locator(
|
||||||
|
'script[src*="challenges.cloudflare.com/turnstile"]',
|
||||||
|
);
|
||||||
|
await expect(script).toHaveCount(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-14
@@ -52,8 +52,8 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.104.2",
|
"@tanstack/react-query": "5.104.3",
|
||||||
"@tanstack/react-virtual": "3.14.17",
|
"@tanstack/react-virtual": "3.14.19",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
@@ -65,32 +65,32 @@
|
|||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.2",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.55.0",
|
"lucide-react": "1.57.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "14.1.0",
|
"motion": "14.2.0",
|
||||||
"music-metadata": "12.1.1",
|
"music-metadata": "12.2.1",
|
||||||
"mysql2": "3.24.5",
|
"mysql2": "3.24.6",
|
||||||
"next": "16.4.0",
|
"next": "16.4.1",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.9",
|
"next-intl": "4.14.9",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.4.0",
|
"pino": "10.4.0",
|
||||||
"react": "19.3.0",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.1",
|
||||||
"react-hook-form": "7.90.0",
|
"react-hook-form": "7.90.0",
|
||||||
"resend": "6.32.1",
|
"resend": "6.33.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.7.0",
|
"tailwind-merge": "3.7.0",
|
||||||
"tinymce": "8.9.3",
|
"tinymce": "8.10.0",
|
||||||
"zod": "4.6.7"
|
"zod": "4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/parser": "8.0.7",
|
"@babel/parser": "8.0.7",
|
||||||
"@biomejs/biome": "2.5.15",
|
"@biomejs/biome": "2.5.15",
|
||||||
"@playwright/test": "1.64.0",
|
"@playwright/test": "1.64.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
@@ -102,9 +102,9 @@
|
|||||||
"esbuild": "0.28.2",
|
"esbuild": "0.28.2",
|
||||||
"msw": "^3.0.3",
|
"msw": "^3.0.3",
|
||||||
"pino-pretty": "13.2.0",
|
"pino-pretty": "13.2.0",
|
||||||
"postcss": "8.5.29",
|
"postcss": "8.5.30",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"testcontainers": "12.2.0",
|
"testcontainers": "12.2.1",
|
||||||
"tsx": "4.23.15",
|
"tsx": "4.23.15",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.3.4",
|
"vite": "8.3.4",
|
||||||
|
|||||||
Generated
+599
-620
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@ import {
|
|||||||
restoreDeletedCatalogItemsCommand,
|
restoreDeletedCatalogItemsCommand,
|
||||||
} from "@/features/catalog/server/item-deletes";
|
} from "@/features/catalog/server/item-deletes";
|
||||||
import {
|
import {
|
||||||
|
bulkUpdateOffersCommand,
|
||||||
createOfferCommand,
|
createOfferCommand,
|
||||||
moveBcOffersCommand,
|
moveBcOffersCommand,
|
||||||
moveOffersCommand,
|
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<string, unknown> }>;
|
||||||
|
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: {
|
export async function translateCatalogItems(input: {
|
||||||
/** `id` is items_base.id (not catalog_items.id) */
|
/** `id` is items_base.id (not catalog_items.id) */
|
||||||
items: Array<{ id: number; publicName: string; description?: string }>;
|
items: Array<{ id: number; publicName: string; description?: string }>;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -18,6 +18,14 @@ interface ItemsShopPreviewProps {
|
|||||||
onItemClick?: (item: CatalogItemData) => void;
|
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({
|
export function ItemsShopPreview({
|
||||||
items,
|
items,
|
||||||
furniRevisionMap,
|
furniRevisionMap,
|
||||||
@@ -38,15 +46,23 @@ export function ItemsShopPreview({
|
|||||||
|
|
||||||
const gridClass = getShopGridClass(layout);
|
const gridClass = getShopGridClass(layout);
|
||||||
const tileSize = getShopTileSize(layout);
|
const tileSize = getShopTileSize(layout);
|
||||||
|
const visibleItems =
|
||||||
|
items.length > PREVIEW_TILE_LIMIT
|
||||||
|
? items.slice(0, PREVIEW_TILE_LIMIT)
|
||||||
|
: items;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
||||||
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
|
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
|
||||||
<span className="font-medium">Shop preview · {layout}</span>
|
<span className="font-medium">Shop preview · {layout}</span>
|
||||||
<span>{items.length} items</span>
|
<span>
|
||||||
|
{items.length} items
|
||||||
|
{visibleItems.length !== items.length &&
|
||||||
|
` · showing first ${visibleItems.length}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("grid gap-2", gridClass)}>
|
<div className={cn("grid gap-2", gridClass)}>
|
||||||
{items.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
const firstId = parseFirstItemId(item.itemIds);
|
const firstId = parseFirstItemId(item.itemIds);
|
||||||
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
||||||
|
|
||||||
@@ -62,6 +78,12 @@ export function ItemsShopPreview({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{visibleItems.length !== items.length && (
|
||||||
|
<p className="mt-3 text-center text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Preview capped at {PREVIEW_TILE_LIMIT} items — use the table view for
|
||||||
|
the rest.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+34
@@ -103,6 +103,27 @@ const schema = z
|
|||||||
// Redis — strongly recommended in production (required for multi-instance).
|
// Redis — strongly recommended in production (required for multi-instance).
|
||||||
// Without it, rate limits / shared caches are in-process only.
|
// Without it, rate limits / shared caches are in-process only.
|
||||||
REDIS_URL: z.string().optional(),
|
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
|
// App-layer anti-DDoS gate (proxy rate limiter). On by default in
|
||||||
// production; set to "false" or "0" to disable without removing it.
|
// production; set to "false" or "0" to disable without removing it.
|
||||||
ANTI_DDOS_ENABLED: z
|
ANTI_DDOS_ENABLED: z
|
||||||
@@ -151,6 +172,19 @@ const schema = z
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value !== "false" && value !== "0"),
|
.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) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.NODE_ENV !== "production") return;
|
if (data.NODE_ENV !== "production") return;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* perf-ttls reads the CMS_* env vars at module load, so each case re-imports
|
||||||
|
* with a freshly stubbed process.env (vitest sets SKIP_ENV_VALIDATION=1, so
|
||||||
|
* @/env is the raw process.env object and vi.stubEnv is visible to it).
|
||||||
|
*/
|
||||||
|
async function loadTtls() {
|
||||||
|
return await import("@/lib/perf-ttls");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perf-ttls", () => {
|
||||||
|
it("uses the documented defaults when the env is unset", async () => {
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.HOME_PAYLOAD_TTL_MS).toBe(15_000);
|
||||||
|
expect(mod.NEWS_LIST_TTL_MS).toBe(300_000);
|
||||||
|
expect(mod.NEWS_ARCHIVE_TTL_MS).toBe(60_000);
|
||||||
|
expect(mod.PUBLIC_SEARCH_TTL_MS).toBe(30_000);
|
||||||
|
expect(mod.ONLINE_COUNT_TTL_MS).toBe(10_000);
|
||||||
|
expect(mod.COUNTERS_TTL_MS).toBe(300_000);
|
||||||
|
expect(mod.SITE_SETTINGS_TTL_MS).toBe(300_000);
|
||||||
|
expect(mod.SITE_SETTINGS_MEMORY_TTL_MS).toBe(60_000);
|
||||||
|
expect(mod.CACHE_WARMUP_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours env overrides (seconds → ms)", async () => {
|
||||||
|
vi.stubEnv("CMS_HOME_PAYLOAD_TTL_SEC", "99");
|
||||||
|
vi.stubEnv("CMS_ONLINE_COUNT_TTL_SEC", "3");
|
||||||
|
vi.stubEnv("CMS_WARMUP_ENABLED", "false");
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.HOME_PAYLOAD_TTL_MS).toBe(99_000);
|
||||||
|
expect(mod.ONLINE_COUNT_TTL_MS).toBe(3_000);
|
||||||
|
expect(mod.CACHE_WARMUP_ENABLED).toBe(false);
|
||||||
|
// Untouched keys keep their defaults.
|
||||||
|
expect(mod.NEWS_LIST_TTL_MS).toBe(300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid numbers and keeps the default", async () => {
|
||||||
|
vi.stubEnv("CMS_NEWS_ARCHIVE_TTL_SEC", "not-a-number");
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.NEWS_ARCHIVE_TTL_MS).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats zero and negative values as unset", async () => {
|
||||||
|
vi.stubEnv("CMS_SEARCH_TTL_SEC", "0");
|
||||||
|
const mod = await loadTtls();
|
||||||
|
expect(mod.PUBLIC_SEARCH_TTL_MS).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -4,6 +4,11 @@ import { asc, desc, eq, gte } from "drizzle-orm";
|
|||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { CameraWeb, db, User, WebsiteTeams } from "@/lib/db";
|
import { CameraWeb, db, User, WebsiteTeams } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
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 { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
||||||
import { cachedHomePayload } from "@/lib/services/home-payload";
|
import { cachedHomePayload } from "@/lib/services/home-payload";
|
||||||
import { getNewsList } from "@/lib/services/news-list";
|
import { getNewsList } from "@/lib/services/news-list";
|
||||||
@@ -30,8 +35,8 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
* stop the server from serving.
|
* stop the server from serving.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const COUNTER_TTL_MS = 300_000;
|
const COUNTER_TTL_MS = COUNTERS_TTL_MS;
|
||||||
const ONLINE_TTL_MS = 10_000;
|
const ONLINE_TTL_MS = ONLINE_COUNT_TTL_MS;
|
||||||
|
|
||||||
async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -45,6 +50,10 @@ async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function warmPublicCaches(): 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(*)
|
// 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
|
// 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
|
// nog aan het opstarten is. Dat is goed genoeg; kunstmatige sleeps zouden
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { captchaConfig, verifyCaptcha } from "./captcha";
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("verifyCaptcha", () => {
|
describe("verifyCaptcha", () => {
|
||||||
@@ -74,3 +75,63 @@ describe("captchaConfig", () => {
|
|||||||
expect(cfg.provider).toBe("none");
|
expect(cfg.provider).toBe("none");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("env fallback", () => {
|
||||||
|
it("enables Turnstile from env when the DB is unconfigured", async () => {
|
||||||
|
mockGet.mockResolvedValue("");
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
const cfg = await captchaConfig();
|
||||||
|
expect(cfg.provider).toBe("turnstile");
|
||||||
|
expect(cfg.siteKey).toBe("env-site-key");
|
||||||
|
expect(cfg.field).toBe("cf-turnstile-response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an explicit DB value — including none — win over env", async () => {
|
||||||
|
mockGet.mockImplementation(async (key: string) => {
|
||||||
|
if (key === "captcha_provider") return "none";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
const cfg = await captchaConfig();
|
||||||
|
expect(cfg.provider).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the DB site key over the env one", async () => {
|
||||||
|
mockGet.mockImplementation(async (key: string) => {
|
||||||
|
if (key === "captcha_provider") return "turnstile";
|
||||||
|
if (key === "turnstile_site_key") return "db-site-key";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
const cfg = await captchaConfig();
|
||||||
|
expect(cfg.siteKey).toBe("db-site-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies against the env secret when the DB secret is unset", async () => {
|
||||||
|
mockGet.mockResolvedValue("");
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
vi.stubEnv("TURNSTILE_SECRET_KEY", "env-secret");
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
expect(await verifyCaptcha("tok", "1.2.3.4")).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
expect((init.body as URLSearchParams).get("secret")).toBe("env-secret");
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the provider comes from env but no secret exists", async () => {
|
||||||
|
mockGet.mockResolvedValue("");
|
||||||
|
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
|
||||||
|
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
|
||||||
|
expect(await verifyCaptcha("tok")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-16
@@ -1,20 +1,28 @@
|
|||||||
|
import { env } from "@/env";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
* Server-side CAPTCHA verification. The provider is driven by website_settings
|
||||||
* provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
|
* (staff pick it in housekeeping) with an ENV FALLBACK so a deploy can enable
|
||||||
* and hCaptcha (three AtomCMS offers, mutually exclusive).
|
* 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:
|
* 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
|
* - provider configured but site key / secret missing, token absent, provider
|
||||||
* API error, or network failure → fail-closed (deny)
|
* API error, or network failure → fail-closed (deny)
|
||||||
*
|
*
|
||||||
* Settings keys:
|
* Settings keys (DB) / env fallbacks:
|
||||||
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
|
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none"
|
||||||
* turnstile_secret / turnstile_site_key
|
* CAPTCHA_PROVIDER (env, used when the DB key is unset)
|
||||||
* recaptcha_secret / recaptcha_site_key
|
* turnstile_site_key / TURNSTILE_SITE_KEY
|
||||||
* hcaptcha_secret / hcaptcha_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 {
|
export interface CaptchaConfig {
|
||||||
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
||||||
@@ -28,15 +36,32 @@ const TURNSTILE_URL =
|
|||||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||||
const HCAPTCHA_URL = "https://hcaptcha.com/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). */
|
/** Public config the register/login pages need to render the widget (no secrets). */
|
||||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
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 = (
|
const provider = (
|
||||||
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
await settingOrEnv("captcha_provider", "", env.CAPTCHA_PROVIDER)
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
if (provider === "turnstile") {
|
if (provider === "turnstile") {
|
||||||
return {
|
return {
|
||||||
provider: "turnstile",
|
provider: "turnstile",
|
||||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
siteKey: await settingOrEnv(
|
||||||
|
"turnstile_site_key",
|
||||||
|
"",
|
||||||
|
env.TURNSTILE_SITE_KEY,
|
||||||
|
),
|
||||||
field: "cf-turnstile-response",
|
field: "cf-turnstile-response",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,13 +92,12 @@ export async function verifyCaptcha(
|
|||||||
|
|
||||||
if (!cfg.siteKey) return false;
|
if (!cfg.siteKey) return false;
|
||||||
|
|
||||||
const secretKey: string =
|
const secret =
|
||||||
cfg.provider === "turnstile"
|
cfg.provider === "turnstile"
|
||||||
? "turnstile_secret"
|
? await settingOrEnv("turnstile_secret", "", env.TURNSTILE_SECRET_KEY)
|
||||||
: cfg.provider === "recaptcha"
|
: cfg.provider === "recaptcha"
|
||||||
? "recaptcha_secret"
|
? ((await siteSettings.get("recaptcha_secret", "")) ?? "")
|
||||||
: "hcaptcha_secret";
|
: ((await siteSettings.get("hcaptcha_secret", "")) ?? "");
|
||||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
|
||||||
if (!secret) return false;
|
if (!secret) return false;
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "server-only";
|
|||||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { HOME_PAYLOAD_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||||
import { cacheNews } from "@/lib/services/news-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. */
|
/** Cached read of {@link loadHomePayload} under the route's own key. */
|
||||||
export async function cachedHomePayload() {
|
export async function cachedHomePayload() {
|
||||||
return cacheNews(apiCacheKey("home"), 15_000, loadHomePayload);
|
return cacheNews(apiCacheKey("home"), HOME_PAYLOAD_TTL_MS, loadHomePayload);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
|
import { NEWS_ARCHIVE_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { cacheSafe } from "@/lib/redis-cache";
|
import { cacheSafe } from "@/lib/redis-cache";
|
||||||
import { cacheNews } from "@/lib/services/news-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
|
* entries immediately, so the window only applies when pub/sub cannot be
|
||||||
* delivered.
|
* delivered.
|
||||||
*/
|
*/
|
||||||
const ARCHIVE_TTL_MS = 60_000;
|
const ARCHIVE_TTL_MS = NEWS_ARCHIVE_TTL_MS;
|
||||||
|
|
||||||
async function queryNewsArchive(
|
async function queryNewsArchive(
|
||||||
options: { search?: string; page?: number; order?: string } = {},
|
options: { search?: string; page?: number; order?: string } = {},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { NEWS_LIST_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { cacheNews } from "@/lib/services/news-cache";
|
import { cacheNews } from "@/lib/services/news-cache";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ interface NewsListRow {
|
|||||||
createdAt: Date | null;
|
createdAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTL_MS = 300_000;
|
const TTL_MS = NEWS_LIST_TTL_MS;
|
||||||
const CACHE_KEY = "news_list";
|
const CACHE_KEY = "news_list";
|
||||||
const FETCH_LIMIT = 30;
|
const FETCH_LIMIT = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import "server-only";
|
|||||||
import { and, count, eq, or, sql } from "drizzle-orm";
|
import { and, count, eq, or, sql } from "drizzle-orm";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
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.
|
* 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
|
// 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
|
// 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
|
// omitted `staleMs` wrote an entry without a grace window, which made the key
|
||||||
// block on COUNT(*) at every TTL boundary.
|
// block on COUNT(*) at every TTL boundary. The TTL itself is env-tuned via
|
||||||
export const ONLINE_COUNT_TTL_MS = 10_000;
|
// perf-ttls (CMS_ONLINE_COUNT_TTL_SEC).
|
||||||
|
|
||||||
export async function cachedOnlineCount(): Promise<number> {
|
export async function cachedOnlineCount(): Promise<number> {
|
||||||
return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, {
|
return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, {
|
||||||
staleMs: 15_000,
|
staleMs: 15_000,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
|
import { PUBLIC_SEARCH_TTL_MS } from "@/lib/perf-ttls";
|
||||||
import { publicReadFailure } from "@/lib/services/public-read";
|
import { publicReadFailure } from "@/lib/services/public-read";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
|
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
|
* directory, and a user who registers or a room that opens should show up
|
||||||
* without waiting.
|
* without waiting.
|
||||||
*/
|
*/
|
||||||
const SEARCH_TTL_MS = 30_000;
|
const SEARCH_TTL_MS = PUBLIC_SEARCH_TTL_MS;
|
||||||
|
|
||||||
function pageNumber(value: number | undefined, total: number) {
|
function pageNumber(value: number | undefined, total: number) {
|
||||||
return Math.min(
|
return Math.min(
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import "server-only";
|
|||||||
|
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
SITE_SETTINGS_MEMORY_TTL_MS,
|
||||||
|
SITE_SETTINGS_TTL_MS,
|
||||||
|
} from "@/lib/perf-ttls";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
const DEFAULTS: Record<string, string> = {
|
const DEFAULTS: Record<string, string> = {
|
||||||
@@ -11,12 +15,12 @@ const DEFAULTS: Record<string, string> = {
|
|||||||
habbo_gamedata_hotel: "it",
|
habbo_gamedata_hotel: "it",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_TTL_MS = 300_000;
|
const CACHE_TTL_MS = SITE_SETTINGS_TTL_MS;
|
||||||
const REDIS_CACHE_KEY = "site_settings";
|
const REDIS_CACHE_KEY = "site_settings";
|
||||||
// Short in-process window so repeated getters in one request (header, nav,
|
// 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.
|
// footer all read logo and other settings) don't each pay a Redis round-trip.
|
||||||
// Redis stays the source of truth across instances.
|
// 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
|
// During `next build`, pages are prerendered and `Date.now()` is treated as an
|
||||||
// unstable prerender value — always serve the in-process cache then (settings
|
// unstable prerender value — always serve the in-process cache then (settings
|
||||||
|
|||||||
Reference in new issue
Block a user