feat: jwt cache, redis health, help-ticket admin, and write rate limits
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m33s

Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:58:48 +02:00
1 parent 3bb96eb6f3
commit 968ca15c27
23 files changed
+1167 -28

No files matched your search

+4 -3
View File
@@ -74,9 +74,10 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET= PAYPAL_SECRET=
PAYPAL_API=https://api-m.sandbox.paypal.com PAYPAL_API=https://api-m.sandbox.paypal.com
# Redis — strongly recommended in production (required for multi-instance). # Redis — REQUIRED for production (shared rate limits, site-settings cache,
# Shared rate limiting + site-settings cache. Without it, limits are in-process # JWT session invalidation cache). Without REDIS_URL the app falls back to
# only and do not hold across restarts or multiple app instances. # in-process memory: limits reset on restart and do not work across instances.
# Deploy logs a loud warning when this is unset in production.
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379
# Logging level (debug | info | warn | error). Defaults to 'info' in production, # Logging level (debug | info | warn | error). Defaults to 'info' in production,
+5
View File
@@ -288,6 +288,11 @@ jobs:
# Production env stays on the live tree; stage only needs a symlink for build/migrate. # Production env stays on the live tree; stage only needs a symlink for build/migrate.
ln -sfn "${LIVE}/.env" "${STAGE}/.env" ln -sfn "${LIVE}/.env" "${STAGE}/.env"
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
fi
cd "${STAGE}" cd "${STAGE}"
rm -f tsconfig.tsbuildinfo .tsbuildinfo rm -f tsconfig.tsbuildinfo .tsbuildinfo
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
File diff suppressed because it is too large. Load diff
+12 -1
View File
@@ -4,13 +4,19 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
// The reaction set the UI offers. The action rejects anything outside this list // The reaction set the UI offers. The action rejects anything outside this list
// so the website_article_reactions.reaction VARCHAR(50) only ever holds known // so the website_article_reactions.reaction VARCHAR(50) only ever holds known
// values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx. // values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx.
const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]); const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
type ReactionOutcome = "updated" | "invalid" | "not_found" | "error"; type ReactionOutcome =
| "updated"
| "invalid"
| "not_found"
| "ratelimit"
| "error";
function reactionRedirect(slug: string, outcome: ReactionOutcome): never { function reactionRedirect(slug: string, outcome: ReactionOutcome): never {
const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news"; const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news";
@@ -50,6 +56,10 @@ export async function toggleReaction(formData: FormData): Promise<void> {
redirect("/login"); redirect("/login");
} }
await clientIp();
if (!(await rateLimit(`article-reaction:${userId}`, 30, 60_000)).ok) {
outcome = "ratelimit";
} else {
const reaction = String(formData.get("reaction") ?? "") const reaction = String(formData.get("reaction") ?? "")
.normalize("NFC") .normalize("NFC")
.trim() .trim()
@@ -105,6 +115,7 @@ export async function toggleReaction(formData: FormData): Promise<void> {
} }
} }
} }
}
} catch (e) { } catch (e) {
if (isNextRedirect(e)) throw e; if (isNextRedirect(e)) throw e;
outcome = "error"; outcome = "error";
+11 -2
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { clientIp } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency"; import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
@@ -40,6 +40,7 @@ export async function claimReferral(_formData: FormData): Promise<void> {
| "not_enough" | "not_enough"
| "no_referrals" | "no_referrals"
| "bad_config" | "bad_config"
| "ratelimit"
| "error" = "error"; | "error" = "error";
try { try {
@@ -53,6 +54,10 @@ export async function claimReferral(_formData: FormData): Promise<void> {
redirect("/login"); redirect("/login");
} }
await clientIp();
if (!(await rateLimit(`referral-claim:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
// Reward configuration (CMS-owned website_settings). AtomCMS defaults: // Reward configuration (CMS-owned website_settings). AtomCMS defaults:
// 5 referrals needed, 30 diamonds reward. // 5 referrals needed, 30 diamonds reward.
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([ const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
@@ -74,7 +79,10 @@ export async function claimReferral(_formData: FormData): Promise<void> {
.findFirst({ .findFirst({
where: { where: {
key: { key: {
in: ["referral_reward_currency_type", "referral_reward_currency"], in: [
"referral_reward_currency_type",
"referral_reward_currency",
],
}, },
}, },
select: { value: true }, select: { value: true },
@@ -145,6 +153,7 @@ export async function claimReferral(_formData: FormData): Promise<void> {
outcome = "claimed"; outcome = "claimed";
} }
}
} catch (err) { } catch (err) {
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the // redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
// navigation actually happens instead of being treated as a failure. // navigation actually happens instead of being treated as a failure.
+13 -1
View File
@@ -1,11 +1,13 @@
"use server"; "use server";
import { auth, signOut } from "@/lib/auth"; import { auth, signOut } from "@/lib/auth";
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
/** /**
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version, * Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
* then end the current browser session too. * revoke personal access tokens, then end the current browser session too.
*/ */
export async function signOutEverywhere(): Promise<void> { export async function signOutEverywhere(): Promise<void> {
const session = await auth(); const session = await auth();
@@ -20,6 +22,16 @@ export async function signOutEverywhere(): Promise<void> {
where: { id: userId }, where: { id: userId },
data: { websiteJwtVersion: { increment: 1 } }, data: { websiteJwtVersion: { increment: 1 } },
}); });
await invalidateJwtVersionCache(userId);
} catch {
/* still continue */
}
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
try {
await prisma.personalAccessTokens.deleteMany({
where: personalTokenScope(userId),
});
} catch { } catch {
/* still sign out locally */ /* still sign out locally */
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types";
export interface HelpTicketRow {
id: string;
title: string;
user: string;
userId: number | null;
replies: number;
open: boolean;
date: string;
updated: string;
}
export function HelpTicketsTable({
data,
}: {
data: PaginatedResult<HelpTicketRow>;
}) {
const t = useTranslations("pages.admin.helpTickets");
const columns: DataTableColumn<HelpTicketRow>[] = [
{
key: "title",
label: t("colTicket"),
sortable: true,
render: (_value, row) => (
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground font-mono">
#{row.id}
</span>
<Link
href={`/admin/help-tickets/${row.id}`}
className="font-medium truncate hover:underline"
>
{row.title}
</Link>
</div>
<div className="text-xs text-muted-foreground">
{t("meta", { user: row.user, count: row.replies })}
</div>
</div>
),
},
{
key: "open",
label: t("colStatus"),
sortable: true,
filterKey: "filter_status",
filterOptions: [
{ label: t("presetOpen"), value: "open" },
{ label: t("statusClosed"), value: "closed" },
],
render: (value) => {
const open = value === true || value === "true";
return (
<Badge
variant={open ? "default" : "secondary"}
className="text-[0.7rem]"
>
{open ? t("statusOpen") : t("statusClosed")}
</Badge>
);
},
},
{ key: "user", label: t("colUser"), sortable: true },
{ key: "date", label: t("colCreated"), sortable: true },
{ key: "updated", label: t("colUpdated"), sortable: true },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{ label: t("presetOpen"), params: { filter_status: "open" } },
{ label: t("statusClosed"), params: { filter_status: "closed" } },
{ label: t("presetAll"), params: { filter_status: "all" } },
]}
/>
);
}
File diff suppressed because it is too large. Load diff
+22 -5
View File
@@ -1,21 +1,36 @@
import { env } from "@/env"; import { env } from "@/env";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** /**
* Ops health probe: database reachability, emulator RCON reachability, SMTP * Ops health probe: database reachability, Redis (when configured), emulator
* (when configured), and runtime info. Returns HTTP 200 always (read the * RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
* `status`/`database` fields), so it's safe for uptime monitors that only care * (read the `status`/`database` fields), so it's safe for uptime monitors that
* about reachability. * only care about reachability.
*/ */
export async function GET() { export async function GET() {
const database = await prisma.$queryRaw`SELECT 1` const database = await prisma.$queryRaw`SELECT 1`
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
let redisOk: boolean | null = null;
if (env.REDIS_URL) {
if (!redis) {
redisOk = false;
} else {
try {
const pong = await redis.ping();
redisOk = pong === "PONG";
} catch {
redisOk = false;
}
}
}
const emulator = await rcon.send("ping", null).catch(() => false); const emulator = await rcon.send("ping", null).catch(() => false);
let smtp = null; let smtp = null;
@@ -35,9 +50,11 @@ export async function GET() {
.catch(() => false); .catch(() => false);
} }
const degraded = !database || redisOk === false;
return apiJson({ return apiJson({
status: database ? "ok" : "degraded", status: degraded ? "degraded" : "ok",
database, database,
redis: redisOk,
emulator, emulator,
smtp, smtp,
node: process.version, node: process.version,
+5
View File
@@ -7,6 +7,7 @@
import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth"; import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,6 +20,10 @@ export async function POST(
const uid = await bearerUserId(req); const uid = await bearerUserId(req);
if (!uid) return apiError("Unauthorized", 401); if (!uid) return apiError("Unauthorized", 401);
if (!(await rateLimit(`api-ticket-reply:${uid}`, 10, 60_000)).ok) {
return apiError("Too many requests", 429);
}
const { id } = await params; const { id } = await params;
const ticketId = positiveBigInt(id); const ticketId = positiveBigInt(id);
if (!ticketId) return apiError("Invalid ticket id", 422); if (!ticketId) return apiError("Invalid ticket id", 422);
+5
View File
@@ -7,6 +7,7 @@
import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth"; import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -40,6 +41,10 @@ export async function POST(req: Request) {
const uid = await bearerUserId(req); const uid = await bearerUserId(req);
if (!uid) return apiError("Unauthorized", 401); if (!uid) return apiError("Unauthorized", 401);
if (!(await rateLimit(`api-ticket:${uid}`, 5, 60_000)).ok) {
return apiError("Too many requests", 429);
}
const body = (await req.json().catch(() => ({}))) as { const body = (await req.json().catch(() => ({}))) as {
title?: unknown; title?: unknown;
content?: unknown; content?: unknown;
+7 -2
View File
@@ -97,13 +97,18 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
titleKey: "tickets", titleKey: "tickets",
subtitleKey: "ticketsSubtitle", subtitleKey: "ticketsSubtitle",
icon: Ticket, icon: Ticket,
prefixes: ["/admin/tickets"], prefixes: ["/admin/tickets", "/admin/help-tickets"],
tabs: [ tabs: [
{ {
href: "/admin/tickets", href: "/admin/tickets",
labelKey: "tickets", labelKey: "tickets",
match: ["/admin/tickets"], match: ["/admin/tickets"],
}, },
{
href: "/admin/help-tickets",
labelKey: "helpTickets",
match: ["/admin/help-tickets"],
},
{ href: "/admin/tickets/templates", labelKey: "templates" }, { href: "/admin/tickets/templates", labelKey: "templates" },
], ],
}, },
@@ -285,7 +290,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
href: "/admin/tickets", href: "/admin/tickets",
labelKey: "tickets", labelKey: "tickets",
icon: Ticket, icon: Ticket,
matchPrefixes: ["/admin/tickets"], matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
}, },
], ],
}, },
+18 -13
View File
@@ -4,6 +4,7 @@ import Discord from "next-auth/providers/discord";
import Google from "next-auth/providers/google"; import Google from "next-auth/providers/google";
import { env } from "@/env"; import { env } from "@/env";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
import { checkLogin } from "@/lib/auth/password"; import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp"; import { verifyTotp } from "@/lib/auth/totp";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -210,6 +211,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
(user as { jwtVersion?: number }).jwtVersion ?? (user as { jwtVersion?: number }).jwtVersion ??
token.jwtVersion ?? token.jwtVersion ??
0; 0;
token.jwtCheckedAt = Date.now();
} }
if (user && account?.provider === "credentials") { if (user && account?.provider === "credentials") {
@@ -217,6 +219,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return token; return token;
} }
// OAuth account linking only when establishing a session — not on
// every subsequent request (avoids siteSettings + DB on each hit).
if (user || account) {
const requireLink = await siteSettings.getBool( const requireLink = await siteSettings.getBool(
"oauth_require_link", "oauth_require_link",
false, false,
@@ -252,6 +257,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
token.rank = dbUser.rank; token.rank = dbUser.rank;
token.name = dbUser.username; token.name = dbUser.username;
token.jwtVersion = dbUser.websiteJwtVersion; token.jwtVersion = dbUser.websiteJwtVersion;
token.jwtCheckedAt = Date.now();
return token; return token;
} }
} }
@@ -277,34 +283,33 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
token.rank = dbUser.rank; token.rank = dbUser.rank;
token.name = dbUser.username; token.name = dbUser.username;
token.jwtVersion = dbUser.websiteJwtVersion; token.jwtVersion = dbUser.websiteJwtVersion;
token.jwtCheckedAt = Date.now();
} }
} catch { } catch {
// leave token as-is on lookup failure // leave token as-is on lookup failure
} }
} }
}
// Re-check jwt version at most once per minute (memory/Redis cached).
if (token.sub && !token.invalid) { if (token.sub && !token.invalid) {
const lastCheck =
typeof token.jwtCheckedAt === "number" ? token.jwtCheckedAt : 0;
if (Date.now() - lastCheck >= 60_000) {
try { try {
const dbUser = await prisma.user.findUnique({ const version = await getCachedJwtVersion(Number(token.sub));
where: { id: Number(token.sub) },
select: {
websiteJwtVersion: true,
rank: true,
username: true,
},
});
if ( if (
!dbUser || version === null ||
(token.jwtVersion ?? 0) !== dbUser.websiteJwtVersion (token.jwtVersion ?? 0) !== version
) { ) {
token.invalid = true; token.invalid = true;
delete token.sub; delete token.sub;
return token; return token;
} }
token.rank = dbUser.rank; token.jwtCheckedAt = Date.now();
token.name = dbUser.username;
} catch { } catch {
/* keep session on transient DB errors */ /* keep session on transient DB/cache errors */
}
} }
} }
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFindUnique = vi.hoisted(() => vi.fn());
const mockRedisGet = vi.hoisted(() => vi.fn());
const mockRedisSetex = vi.hoisted(() => vi.fn());
const mockRedisDel = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: {
user: { findUnique: mockFindUnique },
},
}));
vi.mock("@/lib/redis", () => ({
redis: {
get: mockRedisGet,
setex: mockRedisSetex,
del: mockRedisDel,
},
}));
describe("jwt-version-cache", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockRedisGet.mockResolvedValue(null);
mockRedisSetex.mockResolvedValue("OK");
mockRedisDel.mockResolvedValue(1);
});
it("returns DB version and caches it", async () => {
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 3 });
const { getCachedJwtVersion } = await import("./jwt-version-cache");
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
expect(mockFindUnique).toHaveBeenCalledTimes(1);
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
expect(mockFindUnique).toHaveBeenCalledTimes(1);
});
it("invalidates memory and redis entries", async () => {
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 1 });
const { getCachedJwtVersion, invalidateJwtVersionCache } = await import(
"./jwt-version-cache"
);
await getCachedJwtVersion(7);
await invalidateJwtVersionCache(7);
expect(mockRedisDel).toHaveBeenCalled();
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 2 });
await expect(getCachedJwtVersion(7)).resolves.toBe(2);
});
});
+73
View File
@@ -0,0 +1,73 @@
import "server-only";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
const MEMORY_TTL_MS = 60_000;
const REDIS_TTL_SEC = 60;
const memory = new Map<number, { version: number; expiresAt: number }>();
function redisKey(userId: number): string {
return `jwtver:${userId}`;
}
/**
* Cached `users.website_jwt_version` for Auth.js JWT validation.
* Avoids a DB round-trip on every authenticated request.
*/
export async function getCachedJwtVersion(
userId: number,
): Promise<number | null> {
if (!Number.isInteger(userId) || userId <= 0) return null;
const now = Date.now();
const hit = memory.get(userId);
if (hit && hit.expiresAt > now) return hit.version;
if (redis) {
try {
const raw = await redis.get(redisKey(userId));
if (raw !== null && raw !== undefined) {
const version = Number.parseInt(raw, 10);
if (Number.isFinite(version)) {
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
return version;
}
}
} catch {
/* fall through to DB */
}
}
try {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { websiteJwtVersion: true },
});
if (!row) return null;
const version = row.websiteJwtVersion;
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
if (redis) {
try {
await redis.setex(redisKey(userId), REDIS_TTL_SEC, String(version));
} catch {
/* non-critical */
}
}
return version;
} catch {
return null;
}
}
/** Call after bumping website_jwt_version so other instances drop sessions ASAP. */
export async function invalidateJwtVersionCache(userId: number): Promise<void> {
memory.delete(userId);
if (redis) {
try {
await redis.del(redisKey(userId));
} catch {
/* non-critical */
}
}
}
+1
View File
@@ -15,6 +15,7 @@ describe("production deploy workflow", () => {
expect(deployJob).toContain('ln -sfn "${LIVE}/.env"'); expect(deployJob).toContain('ln -sfn "${LIVE}/.env"');
expect(deployJob).toContain("-e storage"); expect(deployJob).toContain("-e storage");
expect(deployJob).toContain("DATABASE_POOL_SIZE="); expect(deployJob).toContain("DATABASE_POOL_SIZE=");
expect(deployJob).toContain("REDIS_URL is unset");
expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1"); expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1");
expect(deployJob).toContain("pnpm install --frozen-lockfile"); expect(deployJob).toContain("pnpm install --frozen-lockfile");
}); });
+1 -1
View File
@@ -16,7 +16,7 @@ function createRedis(): Redis | null {
) { ) {
globalForRedis.redisMissingWarned = true; globalForRedis.redisMissingWarned = true;
console.error( console.error(
"[redis] REDIS_URL is unset in production. Rate limits and shared caches fall back to in-process memory and will not work correctly across multiple instances.", "[redis] REDIS_URL is unset in production. Rate limits, site-settings cache, and JWT session invalidation fall back to in-process memory and will not work correctly across multiple instances or restarts. Set REDIS_URL in .env.",
); );
} }
return null; return null;
+37
View File
@@ -1001,6 +1001,7 @@
"writeableBoxes": "Writeable boxes", "writeableBoxes": "Writeable boxes",
"photos": "Photos", "photos": "Photos",
"tickets": "Tickets", "tickets": "Tickets",
"helpTickets": "Help center tickets",
"events": "Events", "events": "Events",
"polls": "Polls", "polls": "Polls",
"banners": "Banners", "banners": "Banners",
@@ -1523,6 +1524,41 @@
"colDate": "Date", "colDate": "Date",
"meta": "by {user} · {count} msg · {category}" "meta": "by {user} · {count} msg · {category}"
}, },
"helpTickets": {
"title": "Help center tickets",
"subtitle": "Player tickets from /help/tickets",
"noTickets": "No open help center tickets",
"searchPlaceholder": "Search title, user or ID…",
"presetOpen": "Open",
"presetAll": "All",
"statusOpen": "Open",
"statusClosed": "Closed",
"colTicket": "Ticket",
"colStatus": "Status",
"colUser": "User",
"colCreated": "Created",
"colUpdated": "Updated",
"meta": "by {user} · {count} replies",
"backToList": "Back to list",
"threadTitle": "Conversation",
"staffBadge": "Staff",
"replyPlaceholder": "Reply as staff…",
"replySubmit": "Send reply",
"replyHint": "Ctrl+Enter to send.",
"closeSubmit": "Close ticket",
"reopenSubmit": "Reopen ticket",
"closedHint": "This ticket is closed.",
"actionsTitle": "Actions",
"requesterTitle": "Requester",
"detailsTitle": "Details",
"rankLabel": "Rank {rank}",
"messageCountLabel": "Messages",
"success": {
"replied": "Reply sent.",
"closed": "Ticket closed.",
"reopened": "Ticket reopened."
}
},
"cfh": { "cfh": {
"title": "Call for Help", "title": "Call for Help",
"subtitle": "In-game CFH / support tickets", "subtitle": "In-game CFH / support tickets",
@@ -2393,6 +2429,7 @@
"eventTypes": "Event types", "eventTypes": "Event types",
"polls": "Polls", "polls": "Polls",
"tickets": "Tickets", "tickets": "Tickets",
"helpTickets": "Help center tickets",
"templates": "Templates", "templates": "Templates",
"directory": "Directory", "directory": "Directory",
"multiAccounts": "Multi-accounts", "multiAccounts": "Multi-accounts",
+37
View File
@@ -953,6 +953,7 @@
"writeableBoxes": "Box modificabili", "writeableBoxes": "Box modificabili",
"photos": "Foto", "photos": "Foto",
"tickets": "Ticket", "tickets": "Ticket",
"helpTickets": "Ticket centro assistenza",
"events": "Eventi", "events": "Eventi",
"polls": "Sondaggi", "polls": "Sondaggi",
"banners": "Banner", "banners": "Banner",
@@ -1479,6 +1480,41 @@
"colDate": "Data", "colDate": "Data",
"meta": "di {user} · {count} msg · {category}" "meta": "di {user} · {count} msg · {category}"
}, },
"helpTickets": {
"title": "Ticket centro assistenza",
"subtitle": "Ticket dei giocatori da /help/tickets",
"noTickets": "Nessun ticket aperto nel centro assistenza",
"searchPlaceholder": "Cerca titolo, utente o ID…",
"presetOpen": "Aperti",
"presetAll": "Tutti",
"statusOpen": "Aperti",
"statusClosed": "Chiusi",
"colTicket": "Ticket",
"colStatus": "Stato",
"colUser": "Utente",
"colCreated": "Creato",
"colUpdated": "Aggiornato",
"meta": "di {user} · {count} risposte",
"backToList": "Torna all'elenco",
"threadTitle": "Conversazione",
"staffBadge": "Staff",
"replyPlaceholder": "Rispondi come staff…",
"replySubmit": "Invia risposta",
"replyHint": "Ctrl+Invio per inviare.",
"closeSubmit": "Chiudi ticket",
"reopenSubmit": "Riapri ticket",
"closedHint": "Questo ticket è chiuso.",
"actionsTitle": "Azioni",
"requesterTitle": "Richiedente",
"detailsTitle": "Dettagli",
"rankLabel": "Rank {rank}",
"messageCountLabel": "Messaggi",
"success": {
"replied": "Risposta inviata.",
"closed": "Ticket chiuso.",
"reopened": "Ticket riaperto."
}
},
"cfh": { "cfh": {
"title": "Call for Help", "title": "Call for Help",
"subtitle": "Ticket CFH in-game", "subtitle": "Ticket CFH in-game",
@@ -2628,6 +2664,7 @@
"eventTypes": "Tipi evento", "eventTypes": "Tipi evento",
"polls": "Sondaggi", "polls": "Sondaggi",
"tickets": "Ticket", "tickets": "Ticket",
"helpTickets": "Ticket centro assistenza",
"templates": "Template", "templates": "Template",
"directory": "Directory", "directory": "Directory",
"multiAccounts": "Multi-account", "multiAccounts": "Multi-account",
+37
View File
@@ -998,6 +998,7 @@
"writeableBoxes": "Bewerkbare boxes", "writeableBoxes": "Bewerkbare boxes",
"photos": "Foto's", "photos": "Foto's",
"tickets": "Tickets", "tickets": "Tickets",
"helpTickets": "Helpcentrum-tickets",
"events": "Events", "events": "Events",
"polls": "Polls", "polls": "Polls",
"banners": "Banners", "banners": "Banners",
@@ -2729,6 +2730,7 @@
"eventTypes": "Evenementtypen", "eventTypes": "Evenementtypen",
"polls": "Polls", "polls": "Polls",
"tickets": "Tickets", "tickets": "Tickets",
"helpTickets": "Helpcentrum-tickets",
"templates": "Sjablonen", "templates": "Sjablonen",
"directory": "Adresboek", "directory": "Adresboek",
"multiAccounts": "Multi-accounts", "multiAccounts": "Multi-accounts",
@@ -2815,6 +2817,41 @@
"colDate": "Datum", "colDate": "Datum",
"meta": "door {user} · {count} berichten · {category}" "meta": "door {user} · {count} berichten · {category}"
}, },
"helpTickets": {
"title": "Helpcentrum-tickets",
"subtitle": "Spelertickets van /help/tickets",
"noTickets": "Geen open helpcentrum-tickets",
"searchPlaceholder": "Zoek op titel, gebruiker of ID…",
"presetOpen": "Open",
"presetAll": "Alle",
"statusOpen": "Open",
"statusClosed": "Gesloten",
"colTicket": "Ticket",
"colStatus": "Status",
"colUser": "Gebruiker",
"colCreated": "Aangemaakt",
"colUpdated": "Bijgewerkt",
"meta": "door {user} · {count} antwoorden",
"backToList": "Terug naar lijst",
"threadTitle": "Gesprek",
"staffBadge": "Staff",
"replyPlaceholder": "Antwoord als staff…",
"replySubmit": "Antwoord versturen",
"replyHint": "Ctrl+Enter om te versturen.",
"closeSubmit": "Ticket sluiten",
"reopenSubmit": "Ticket heropenen",
"closedHint": "Dit ticket is gesloten.",
"actionsTitle": "Acties",
"requesterTitle": "Aanvrager",
"detailsTitle": "Details",
"rankLabel": "Rank {rank}",
"messageCountLabel": "Berichten",
"success": {
"replied": "Antwoord verzonden.",
"closed": "Ticket gesloten.",
"reopened": "Ticket heropend."
}
},
"cfh": { "cfh": {
"title": "Call for Help", "title": "Call for Help",
"subtitle": "In-game CFH / ondersteuningstickets", "subtitle": "In-game CFH / ondersteuningstickets",
+1
View File
@@ -10,6 +10,7 @@ declare module "next-auth/jwt" {
interface JWT { interface JWT {
rank?: number; rank?: number;
jwtVersion?: number; jwtVersion?: number;
jwtCheckedAt?: number;
invalid?: boolean; invalid?: boolean;
} }
} }