diff --git a/README.md b/README.md index c416e663..e3b58ea8 100644 --- a/README.md +++ b/README.md @@ -974,6 +974,13 @@ Every heavy command therefore runs inside its own cgroup with a hard `MemoryMax`, via `scripts/with-memory-cap.sh`. If the build outgrows its ceiling, only that cgroup is killed — the build fails, the site keeps serving. +The systemd backend also sets `MemoryHigh` (95% of the ceiling) so the kernel +reclaims and throttles **before** the hard limit, and it clamps the ceiling to +`MemAvailable - reserve` (`CMS_MEMORY_CAP_RESERVE`, default `4g`). That stops a +cap larger than the free memory from dragging the whole host into the global +OOM-killer. `MemorySwapMax=0` is set explicitly (this host has no swap). If +fewer than 1 GB would remain, the script refuses to start. + | Script | Ceiling | Covers | | --------------------- | ------- | --------------------------------------- | | `pnpm dev` | 8 GB | Dev server | @@ -1027,6 +1034,10 @@ where the container *is* the boundary (the Docker build, a CI runner), set On a host **without** systemd and without `CMS_MEMORY_CAP_BACKEND=none`, the script refuses to run rather than proceeding unbounded. +Tunables for the systemd backend: `CMS_MEMORY_CAP_RESERVE` (default `4g`) is +the headroom left for everything else on the machine; the effective ceiling is +`min(requested, MemAvailable - reserve)`. + > Do not "fix" a cap failure by lowering `--max-old-space-size` or by raising > `CMS_MEMORY_CAP_VIRTUAL` (the default is `40g` on purpose). A `ulimit -v` of > 10g makes V8 clamp its own heap to ~2.25 GB and webpack dies with diff --git a/scripts/with-memory-cap.sh b/scripts/with-memory-cap.sh index 67a58b50..30742a14 100755 --- a/scripts/with-memory-cap.sh +++ b/scripts/with-memory-cap.sh @@ -26,9 +26,21 @@ # systemd (cgroup MemoryMax) # Meet RSS over de héle procesboom. Dit is de echte garantie en wordt # overal gebruikt waar systemd beschikbaar is (de host waar deze -# CMS draait). De RSS-plafonds in package.json zijn hierop gekozen: -# `next build` piekte op 6,5GB, dus 10GB laat ruimte over terwijl er -# 6GB basislast naast blijft passen binnen de 23,5GB van deze machine. +# CMS draait). De plafonds in package.json zijn hierop gekozen: +# `next build` piekt op ~9,7GB in `memory.current` (inclusief de +# page cache die de build zelf opbouwt), dus 10GB laat net genoeg ruimte +# over terwijl er ~6-9GB basislast naast blijft passen op deze machine. +# +# Twee extra vangnetten, omdat de basislast inmiddels groter kan zijn +# (Docker, de emulator en de agent tikken samen ~9GB aan) en een vaste cap +# die hoger is dan het vrije geheugen de host alsnog kan laten OOM-killen: +# - MemoryHigh (95% van de cap) dwingt de kernel eerder te reclaimen en +# te throttlen, zodat een allocatiestorm de harde grens niet voorbij +# schiet voordat de cgroup-OOM-killer reageert; +# - het plafond wordt teruggeschroefd tot `MemAvailable - reserve`, met +# `CMS_MEMORY_CAP_RESERVE` (standaard 4g) als marge voor de rest van +# de machine. Is er minder dan 1GB over, dan weigert het script. +# MemorySwapMax=0 staat expliciet aan: deze host heeft geen swap. # # ulimit -v (per proces, virtuele adresruimte) # Alleen als expliciet gevraagd. Meet virtuele adresruimte, NIET RSS, en @@ -58,6 +70,7 @@ # Gebruik: bash scripts/with-memory-cap.sh 10g # Backend kiezen: CMS_MEMORY_CAP_BACKEND=systemd|ulimit|none|auto # ulimit-waarde kiezen: CMS_MEMORY_CAP_VIRTUAL=40g +# Marge bovenop de basislast: CMS_MEMORY_CAP_RESERVE=4g set -Eeuo pipefail usage() { @@ -122,9 +135,46 @@ probe_systemd() { return 1 } +# Beschikbaar geheugen in bytes: wat het systeem kan toewijzen zonder te +# swappen. Dit is inclusief opvraagbare page cache, dus de juiste maat om te +# bepalen hoeveel een nieuwe cgroup nog mag gebruiken zonder de host in de +# globale OOM-killer te duwen. +mem_available_bytes() { + local kb + kb="$(grep -m1 '^MemAvailable:' /proc/meminfo 2>/dev/null | tr -dc '0-9')" + [ -n "$kb" ] || kb=0 + echo $((kb * 1024)) +} + run_systemd() { - echo "[mem-cap] systemd cgroup MemoryMax=$((bytes / 1024 / 1024 / 1024))GB: $*" >&2 - exec "${systemd_cmd[@]}" -p "MemoryMax=$bytes" "$@" + local effective="$bytes" + # Houd marge voor de al draaiende basislast (MariaDB, Docker, de live + # release, de agent) plus kernel/page cache. Zonder die marge kan een cap + # die hoger is dan het werkelijk beschikbare geheugen alsnog de host laten + # OOM-killen voordat de cgroup-grens grijpt. + local reserve + reserve="$(to_bytes "${CMS_MEMORY_CAP_RESERVE:-4g}")" || exit 64 + local available + available="$(mem_available_bytes)" + if [ "$available" -gt 0 ]; then + local headroom=$((available > reserve ? available - reserve : 0)) + if [ "$headroom" -lt "$effective" ]; then + echo "[mem-cap] plafond verlaagd naar $((headroom / 1024 / 1024))MB: MemAvailable=$((available / 1024 / 1024))MB reserve=$((reserve / 1024 / 1024))MB" >&2 + effective="$headroom" + fi + fi + if [ "$effective" -lt $((1024 * 1024 * 1024)) ]; then + echo "[mem-cap] te weinig vrij geheugen (MemAvailable=$((available / 1024 / 1024))MB) om begrensd te starten; weiger." >&2 + echo "[mem-cap] maak geheugen vrij of verhoog CMS_MEMORY_CAP_RESERVE bewust niet." >&2 + return 1 + fi + # MemoryHigh laat de kernel proactief reclaimen en throttlen vóór de harde + # MemoryMax, zodat een snelle allocatiestorm niet voorbij de grens schiet en + # de (globale) OOM-killer de hele machine meeneemt. MemorySwapMax=0 is + # expliciet: deze host heeft geen swap. + local high=$((effective * 95 / 100)) + echo "[mem-cap] systemd cgroup MemoryHigh=$((high / 1024 / 1024))MB MemoryMax=$((effective / 1024 / 1024))MB MemorySwapMax=0: $*" >&2 + exec "${systemd_cmd[@]}" -p "MemoryHigh=$high" -p "MemoryMax=$effective" -p "MemorySwapMax=0" "$@" } run_ulimit() { diff --git a/src/actions/admin-alerts.ts b/src/actions/admin-alerts.ts index 74414740..904143c6 100644 --- a/src/actions/admin-alerts.ts +++ b/src/actions/admin-alerts.ts @@ -34,7 +34,7 @@ export async function sendHotelAlert(formData: FormData): Promise { /** Mark every unread ops alert as read. */ export async function markAllAlertsRead(): Promise { - await requirePermission(PERMS.NOTIFICATIONS_VIEW); + await requirePermission(PERMS.NOTIFICATIONS_EDIT); try { await db .update(AlertLogs) diff --git a/src/actions/admin-antiddos.ts b/src/actions/admin-antiddos.ts index dde61ba6..590dcd4a 100644 --- a/src/actions/admin-antiddos.ts +++ b/src/actions/admin-antiddos.ts @@ -140,7 +140,7 @@ async function persistSettings(config: AntiddosConfig): Promise { * override the proxy reads, so the change is live within the proxy cache TTL. */ export async function saveAntiddosSettings(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const config = configFromForm(formData); try { @@ -165,7 +165,7 @@ export async function saveAntiddosSettings(formData: FormData): Promise { * DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*. */ export async function resetAntiddosSettings(): Promise { - const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); try { if (redis) await redis.del(OVERRIDE_KEY); @@ -186,7 +186,7 @@ export async function resetAntiddosSettings(): Promise { /** Remove a single IP from the temporary DDoS block list. */ export async function unbanAntiddosIp(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const ip = str(formData.get("ip")).trim(); if (!ip) return; @@ -213,7 +213,7 @@ export async function unbanAntiddosIp(formData: FormData): Promise { /** Remove an automatic Cloudflare edge block for a tracked IP. */ export async function removeCloudflareRule(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const ip = str(formData.get("ip")).trim(); if (!ip) return; @@ -233,7 +233,7 @@ export async function removeCloudflareRule(formData: FormData): Promise { /** Test the configured Cloudflare API credentials against the zone. */ export async function verifyCloudflareConfiguration(): Promise { - const staff = await requirePermission(PERMS.SETTINGS_VIEW); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const status = await verifyCloudflareConnection(); await setLastCloudflareVerify(status); logger.info("Cloudflare API configuration verified", { diff --git a/src/actions/admin-badges.test.ts b/src/actions/admin-badges.test.ts index ecbab0fc..503065ce 100644 --- a/src/actions/admin-badges.test.ts +++ b/src/actions/admin-badges.test.ts @@ -8,12 +8,16 @@ const state = vi.hoisted(() => ({ fail: false, giveBadge: vi.fn(async () => undefined), requirePermission: vi.fn(), + tryGuardRank: vi.fn(), revalidatePath: vi.fn(), })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.requirePermission, })); +vi.mock("@/lib/admin/rank-guard", () => ({ + tryGuardRank: state.tryGuardRank, +})); vi.mock("@/lib/permissions", () => ({ PERMS: { CATALOG_EDIT: "admin.catalog.edit" }, })); @@ -55,6 +59,10 @@ beforeEach(() => { state.insertValues.mockResolvedValue([{}]); state.giveBadge.mockResolvedValue(undefined); state.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" }); + state.tryGuardRank.mockResolvedValue({ + ok: true, + target: { username: "u", rank: 1, mail: null }, + }); }); describe("giveBadge", () => { @@ -64,6 +72,7 @@ describe("giveBadge", () => { await giveBadge(form("3", "ACH_Test")); expect(state.requirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT); + expect(state.tryGuardRank).toHaveBeenCalledWith(3, 7); expect(state.giveBadge).toHaveBeenCalledWith(3, "ACH_Test"); expect(state.insertValues).toHaveBeenCalledWith({ userId: 3, @@ -97,19 +106,42 @@ describe("giveBadge", () => { }); }); - it("does not persist a duplicate badge", async () => { + it("does not persist a duplicate badge and skips revalidation", async () => { state.existingRows = [{ id: 10 }]; await giveBadge(form("4", "DUP")); expect(state.giveBadge).toHaveBeenCalledWith(4, "DUP"); expect(state.insertValues).not.toHaveBeenCalled(); - expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges"); + expect(state.revalidatePath).not.toHaveBeenCalled(); + }); + + it("skips a user at or above the actor rank", async () => { + state.tryGuardRank.mockResolvedValueOnce({ ok: false, reason: "rank" }); + + await giveBadge(form("4", "DUP")); + + expect(state.giveBadge).not.toHaveBeenCalled(); + expect(state.insertValues).not.toHaveBeenCalled(); + expect(state.revalidatePath).not.toHaveBeenCalled(); + }); + + it("skips an unknown user", async () => { + state.tryGuardRank.mockResolvedValueOnce({ + ok: false, + reason: "not_found", + }); + + await giveBadge(form("404", "DUP")); + + expect(state.giveBadge).not.toHaveBeenCalled(); + expect(state.insertValues).not.toHaveBeenCalled(); }); it("returns early for a non-positive user id", async () => { await giveBadge(form("0", "X")); + expect(state.tryGuardRank).not.toHaveBeenCalled(); expect(state.giveBadge).not.toHaveBeenCalled(); expect(state.insertValues).not.toHaveBeenCalled(); expect(state.revalidatePath).not.toHaveBeenCalled(); @@ -138,21 +170,36 @@ describe("giveBadge", () => { expect(state.giveBadge).toHaveBeenCalledWith(5, "a".repeat(32)); }); - it("swallows persistence failures after the rcon grant", async () => { - state.insertValues.mockRejectedValueOnce(new Error("db down")); + it("caps bulk user and code lists", async () => { + const users = Array.from({ length: 250 }, (_, i) => i + 1).join(","); + const codes = Array.from({ length: 250 }, (_, i) => `C${i}`).join(","); - await expect(giveBadge(form("6", "BEST"))).resolves.toBeUndefined(); + await giveBadge(form(users, codes)); - expect(state.giveBadge).toHaveBeenCalledWith(6, "BEST"); - expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges"); + // 100 users x 100 codes = 10k pairs, capped to MAX_PAIRS (500). + const calls = state.insertValues.mock.calls.length; + expect(calls).toBeLessThanOrEqual(500); }); - it("swallows select failures and still revalidates", async () => { + it("throws when persistence fails instead of swallowing it", async () => { + state.insertValues.mockRejectedValueOnce(new Error("db down")); + + await expect(giveBadge(form("6", "BEST"))).rejects.toThrow( + "Failed to persist", + ); + + expect(state.giveBadge).toHaveBeenCalledWith(6, "BEST"); + expect(state.revalidatePath).not.toHaveBeenCalled(); + }); + + it("throws when the select fails instead of reporting a false success", async () => { state.fail = true; - await expect(giveBadge(form("6", "BEST"))).resolves.toBeUndefined(); + await expect(giveBadge(form("6", "BEST"))).rejects.toThrow( + "Failed to persist", + ); - expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges"); + expect(state.revalidatePath).not.toHaveBeenCalled(); }); it("propagates a permission denial", async () => { diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts index ca984da6..916e710c 100644 --- a/src/actions/admin-badges.ts +++ b/src/actions/admin-badges.ts @@ -3,12 +3,18 @@ import { and, eq, max } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; +import { tryGuardRank } from "@/lib/admin/rank-guard"; import { db, UsersBadges } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { rcon } from "@/lib/services/rcon"; +// Bound the bulk surface so a paste bomb cannot turn into an unbounded N+1. +const MAX_USERS = 100; +const MAX_CODES = 100; +const MAX_PAIRS = 500; + export async function giveBadge(formData: FormData): Promise { - await requirePermission(PERMS.CATALOG_EDIT); + const staff = await requirePermission(PERMS.CATALOG_EDIT); // Support comma-separated userIds and/or codes for bulk operations const userIdInput = String(formData.get("userId") ?? "").trim(); @@ -18,30 +24,40 @@ export async function giveBadge(formData: FormData): Promise { .split(",") .map((s) => s.trim()) .filter(Boolean) + .slice(0, MAX_USERS) : []; const codes = codeInput ? codeInput .split(",") .map((s) => s.trim()) .filter(Boolean) + .slice(0, MAX_CODES) : []; if (userIds.length === 0 || codes.length === 0) { return; } - // Process each user/code combination - let granted = false; - for (const userId of userIds) { - for (const code of codes) { - if (!(Number(userId) > 0) || code.length === 0) continue; + let granted = 0; + let failures = 0; + for (const rawUserId of userIds) { + const userId = Number(rawUserId); + if (!(userId > 0)) continue; + // Never hand badges to a target at or above the actor's rank. + const guard = await tryGuardRank(userId, staff.rank); + if (!guard.ok) continue; + + for (const rawCode of codes) { + if (granted + failures >= MAX_PAIRS) break; // Truncate badge code to 32 characters. - const truncatedCode = code.slice(0, 32); + const truncatedCode = rawCode.slice(0, 32); + if (truncatedCode.length === 0) continue; // Fire the emulator command so the badge appears live for online users. + // Offline/unreachable emulator must not block the DB persistence. try { - await rcon.giveBadge(Number(userId), truncatedCode); + await rcon.giveBadge(userId, truncatedCode); } catch { // ignore rcon errors per pair } @@ -55,7 +71,7 @@ export async function giveBadge(formData: FormData): Promise { .from(UsersBadges) .where( and( - eq(UsersBadges.userId, Number(userId)), + eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, truncatedCode), ), ) @@ -64,23 +80,28 @@ export async function giveBadge(formData: FormData): Promise { const [agg] = await db .select({ maxSlot: max(UsersBadges.slotId) }) .from(UsersBadges) - .where(eq(UsersBadges.userId, Number(userId))); + .where(eq(UsersBadges.userId, userId)); const slotId = (agg?.maxSlot ?? 0) + 1; await db.insert(UsersBadges).values({ - userId: Number(userId), + userId, slotId, badgeCode: truncatedCode, }); + granted++; } } catch { - // Best-effort: the RCON grant already succeeded for online users. + // A persistence failure must be visible, not silently dropped. + failures++; } - - granted = true; } } - if (granted) { + if (failures > 0) { + throw new Error( + `Failed to persist ${failures} badge grant(s); the emulator may have succeeded for some users`, + ); + } + if (granted > 0) { revalidatePath("/admin/badges"); } } diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts index ce7fa537..7eada37b 100644 --- a/src/actions/admin-bans.test.ts +++ b/src/actions/admin-bans.test.ts @@ -22,7 +22,7 @@ vi.mock("@/lib/auth", () => ({ signOut: vi.fn(), })); -import { Ban, db, User } from "@/lib/db"; +import { Ban, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { createBan, liftBan } from "./admin-bans"; @@ -42,6 +42,20 @@ vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: mockRequirePermissionRateLimited, })); +const mockTryGuardRank = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/admin/rank-guard", () => ({ + tryGuardRank: mockTryGuardRank, +})); + +const mockRedirect = vi.hoisted(() => + vi.fn(() => { + throw new Error("NEXT_REDIRECT"); + }), +); +vi.mock("next/navigation", () => ({ + redirect: mockRedirect, +})); + // Hoisted mocks for internal db functions const hoistedInsertValues = vi.hoisted(() => vi.fn()); const hoistedDeleteWhere = vi.hoisted(() => vi.fn()); @@ -61,7 +75,6 @@ vi.mock("@/lib/db", () => { select: vi.fn(() => ({ from: hoistedSelectFrom })), }, Ban: { id: "Ban.id", userId: "Ban.userId" }, - User: { id: "User.id", username: "User.username" }, }; }); @@ -93,7 +106,11 @@ beforeEach(() => { mockRequirePermissionRateLimited.mockResolvedValue({ id: 100, rank: 7 }); hoistedInsertValues.mockResolvedValue([{ insertId: 1 }]); hoistedDeleteWhere.mockResolvedValue([{ affectedRows: 1 }]); - hoistedSelectLimit.mockResolvedValue([{ username: "testuser" }]); + hoistedSelectLimit.mockResolvedValue([{ userId: 5 }]); + mockTryGuardRank.mockResolvedValue({ + ok: true, + target: { username: "testuser", rank: 1, mail: null }, + }); mockHeaders.mockReturnValue(new Map()); }); @@ -110,6 +127,7 @@ describe("createBan", () => { expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith( PERMS.USERS_BAN, ); + expect(mockTryGuardRank).toHaveBeenCalledWith(1, 7); expect(db.insert).toHaveBeenCalledWith(Ban); expect(hoistedInsertValues).toHaveBeenCalledWith( expect.objectContaining({ @@ -156,6 +174,7 @@ describe("createBan", () => { await createBan(formData); + expect(mockTryGuardRank).not.toHaveBeenCalled(); expect(db.insert).not.toHaveBeenCalled(); expect(mockLogStaffActivity).not.toHaveBeenCalled(); }); @@ -169,27 +188,45 @@ describe("createBan", () => { await createBan(formData); + expect(mockTryGuardRank).not.toHaveBeenCalled(); expect(db.insert).not.toHaveBeenCalled(); expect(mockLogStaffActivity).not.toHaveBeenCalled(); }); - it("disconnects user via RCON if user found", async () => { + it("disconnects user via RCON using the guarded username", async () => { + mockTryGuardRank.mockResolvedValueOnce({ + ok: true, + target: { username: "anotheruser", rank: 1, mail: null }, + }); const formData = new FormData(); formData.append("userId", "3"); formData.append("type", "account"); formData.append("reason", "Test reason"); - hoistedSelectLimit.mockResolvedValueOnce([{ username: "anotheruser" }]); - await createBan(formData); + expect(mockTryGuardRank).toHaveBeenCalledWith(3, 7); expect(mockRconDisconnectUser).toHaveBeenCalledWith(3, "anotheruser"); - expect(mockEq).toHaveBeenCalledWith(User.id, 3); + }); + + it("refuses to ban a user with equal or higher rank", async () => { + mockTryGuardRank.mockResolvedValueOnce({ ok: false, reason: "rank" }); + const formData = new FormData(); + formData.append("userId", "3"); + formData.append("type", "account"); + formData.append("reason", "Test reason"); + + await expect(createBan(formData)).rejects.toThrow("NEXT_REDIRECT"); + + expect(mockRedirect).toHaveBeenCalledWith("/admin/bans?error=rank"); + expect(db.insert).not.toHaveBeenCalled(); + expect(mockLogStaffActivity).not.toHaveBeenCalled(); }); }); describe("liftBan", () => { it("lifts an existing ban and logs activity", async () => { + hoistedSelectLimit.mockResolvedValueOnce([{ userId: 5 }]); const formData = new FormData(); formData.append("id", "123"); @@ -198,6 +235,8 @@ describe("liftBan", () => { expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith( PERMS.USERS_BAN, ); + expect(mockEq).toHaveBeenCalledWith(Ban.id, 123); + expect(mockTryGuardRank).toHaveBeenCalledWith(5, 7); expect(db.delete).toHaveBeenCalledWith(Ban); expect(hoistedDeleteWhere).toHaveBeenCalledWith(mockEq(Ban.id, 123)); expect(mockLogStaffActivity).toHaveBeenCalledWith( @@ -209,12 +248,37 @@ describe("liftBan", () => { expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/bans"); }); + it("refuses to lift a ban placed on an equal or higher rank user", async () => { + hoistedSelectLimit.mockResolvedValueOnce([{ userId: 5 }]); + mockTryGuardRank.mockResolvedValueOnce({ ok: false, reason: "rank" }); + const formData = new FormData(); + formData.append("id", "123"); + + await expect(liftBan(formData)).rejects.toThrow("NEXT_REDIRECT"); + + expect(mockRedirect).toHaveBeenCalledWith("/admin/bans?error=rank"); + expect(hoistedDeleteWhere).not.toHaveBeenCalled(); + expect(mockLogStaffActivity).not.toHaveBeenCalled(); + }); + + it("tolerates a lift when the targeted user no longer exists", async () => { + hoistedSelectLimit.mockResolvedValueOnce([{ userId: 5 }]); + mockTryGuardRank.mockResolvedValueOnce({ ok: false, reason: "not_found" }); + const formData = new FormData(); + formData.append("id", "123"); + + await liftBan(formData); + + expect(db.delete).toHaveBeenCalledWith(Ban); + }); + it("does not delete if ban id is not positive", async () => { const formData = new FormData(); formData.append("id", "0"); await liftBan(formData); + expect(mockEq).not.toHaveBeenCalled(); expect(db.delete).not.toHaveBeenCalled(); expect(hoistedDeleteWhere).not.toHaveBeenCalled(); expect(mockLogStaffActivity).not.toHaveBeenCalled(); diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index a6ed9deb..b45c0809 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -2,8 +2,10 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; -import { Ban, db, User } from "@/lib/db"; +import { tryGuardRank } from "@/lib/admin/rank-guard"; +import { Ban, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -26,16 +28,15 @@ export async function createBan(formData: FormData): Promise { const type = String(formData.get("type")); if (!(userId > 0) || !BAN_TYPES.has(type)) return; + const guard = await tryGuardRank(userId, staff.rank); + if (!guard.ok && guard.reason === "rank") { + redirect("/admin/bans?error=rank"); + } + const now = Math.floor(Date.now() / 1000); // Emulator convention: banExpire 0 = permanent (not a far-future timestamp). const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0; - const [user] = await db - .select({ username: User.username }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - await db.insert(Ban).values({ userId, ip: "", @@ -48,7 +49,7 @@ export async function createBan(formData: FormData): Promise { cfhTopic: -1, }); - if (user) await rcon.disconnectUser(userId, user.username); + if (guard.ok) await rcon.disconnectUser(userId, guard.target.username); await logStaffActivity({ staffId: staff.id, action: "user_ban", @@ -63,6 +64,17 @@ export async function liftBan(formData: FormData): Promise { const staff = await requirePermissionRateLimited(PERMS.USERS_BAN); const id = Number(formData.get("id")); if (id > 0) { + const [ban] = await db + .select({ userId: Ban.userId }) + .from(Ban) + .where(eq(Ban.id, id)) + .limit(1); + if (ban) { + const guard = await tryGuardRank(ban.userId, staff.rank); + if (!guard.ok && guard.reason === "rank") { + redirect("/admin/bans?error=rank"); + } + } await db.delete(Ban).where(eq(Ban.id, id)); await logStaffActivity({ staffId: staff.id, diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts index 79317d97..a01380dc 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -70,7 +70,7 @@ export const liftBanFromHelpTicket = adminAction( .where(eq(WebsiteHelpCenterTickets.id, ticketId)); } - logAudit({ + await logAudit({ userId: ctx.session.user.id, action: "unban_via_help_ticket", target: "User", @@ -123,7 +123,7 @@ export const replyHelpCenterTicket = adminAction( .where(eq(WebsiteHelpCenterTickets.id, ticketId)); }); - logAudit({ + await logAudit({ userId: staffId, action: "help_center_ticket_reply", target: "WebsiteHelpCenterTickets", @@ -157,7 +157,7 @@ export const closeHelpCenterTicket = adminAction( .set({ open: false, updatedAt: now }) .where(eq(WebsiteHelpCenterTickets.id, ticketId)); - logAudit({ + await logAudit({ userId: Number(ctx.session.user.id), action: "help_center_ticket_close", target: "WebsiteHelpCenterTickets", @@ -193,7 +193,7 @@ export const reopenHelpCenterTicket = adminAction( .set({ open: true, updatedAt: now }) .where(eq(WebsiteHelpCenterTickets.id, ticketId)); - logAudit({ + await logAudit({ userId: Number(ctx.session.user.id), action: "help_center_ticket_reopen", target: "WebsiteHelpCenterTickets", diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index d319b47a..fd30235d 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; import { EDGE_CACHE_TAGS, purgeEdgeCache } from "@/lib/edge-cache"; import { formPositiveBigInt } from "@/lib/form-data"; +import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; export async function createCategory(formData: FormData): Promise { @@ -31,8 +32,9 @@ export async function createCategory(formData: FormData): Promise { badge, priority, }); - } catch { - // Unique name collision or DB error — ignore, page will re-render unchanged. + } catch (err) { + // Unique name collision or DB error — page re-renders unchanged. + logger.error("Failed to create rare-value category", { err }); } revalidatePath("/admin/rare-values"); void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); @@ -51,8 +53,8 @@ export async function deleteCategory(formData: FormData): Promise { await db .delete(WebsiteRareValueCategories) .where(eq(WebsiteRareValueCategories.id, id)); - } catch { - // Not found or DB error — ignore. + } catch (err) { + logger.error("Failed to delete rare value", { err }); } revalidatePath("/admin/rare-values"); void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); @@ -100,8 +102,8 @@ export async function createValue(formData: FormData): Promise { currencyType, furnitureIcon, }); - } catch { - // DB error — ignore. + } catch (err) { + logger.error("Failed to persist rare value", { err }); } revalidatePath("/admin/rare-values"); void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); @@ -114,8 +116,8 @@ export async function deleteValue(formData: FormData): Promise { try { await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id)); - } catch { - // Not found or DB error — ignore. + } catch (err) { + logger.error("Failed to delete rare value", { err }); } revalidatePath("/admin/rare-values"); void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); @@ -146,8 +148,8 @@ export async function updateCategory(formData: FormData): Promise { .update(WebsiteRareValueCategories) .set({ name, badge, priority }) .where(eq(WebsiteRareValueCategories.id, id)); - } catch { - // Unique name collision or DB error — ignore. + } catch (err) { + logger.error("Failed to update rare-value category", { err }); } revalidatePath("/admin/rare-values"); void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); @@ -201,8 +203,8 @@ export async function updateValue(formData: FormData): Promise { .update(WebsiteRareValues) .set(sets) .where(eq(WebsiteRareValues.id, id)); - } catch { - // DB error — ignore. + } catch (err) { + logger.error("Failed to persist rare value", { err }); } revalidatePath("/admin/rare-values"); void purgeEdgeCache([EDGE_CACHE_TAGS.public], "rare values edited"); diff --git a/src/actions/admin-theme.test.ts b/src/actions/admin-theme.test.ts index 75e963b2..262b98c6 100644 --- a/src/actions/admin-theme.test.ts +++ b/src/actions/admin-theme.test.ts @@ -141,13 +141,13 @@ describe("saveTheme", () => { expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?saved=1"); }); - it("fails soft when a write fails and still redirects", async () => { + it("redirects with an error when a write fails", async () => { fakeDbConfig.fail = (kind: string) => kind === "insert"; await saveTheme(form({ color_primary: "#123456" })); expect(mockReload).not.toHaveBeenCalled(); expect(mockLogStaffActivity).not.toHaveBeenCalled(); expect(mockRevalidatePath).not.toHaveBeenCalled(); - expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?saved=1"); + expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?error=1"); }); }); @@ -182,12 +182,12 @@ describe("applyPreset", () => { expect(mockLogStaffActivity).not.toHaveBeenCalled(); }); - it("fails soft on write errors", async () => { + it("redirects with an error on write errors", async () => { fakeDbConfig.fail = (kind: string) => kind === "insert"; await applyPreset(form({ preset: "Ocean" })); expect(mockReload).not.toHaveBeenCalled(); expect(mockLogStaffActivity).not.toHaveBeenCalled(); - expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?preset=Ocean"); + expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?error=1"); }); }); @@ -218,12 +218,12 @@ describe("saveCustomTheme", () => { expect(mockSnapshotCurrentTheme).not.toHaveBeenCalled(); }); - it("fails soft on persist errors", async () => { + it("redirects with an error on persist errors", async () => { mockUpsertCustomTheme.mockRejectedValueOnce(new Error("store down")); await saveCustomTheme(form({ name: "My Theme" })); expect(mockLogStaffActivity).not.toHaveBeenCalled(); expect(mockRevalidatePath).not.toHaveBeenCalled(); - expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?savedTheme=1"); + expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?error=1"); }); }); @@ -284,7 +284,7 @@ describe("applyCustomTheme", () => { ); }); - it("fails soft on write errors", async () => { + it("redirects with an error on write errors", async () => { mockGetCustomTheme.mockResolvedValue({ id: "t1", name: "Studio", @@ -296,7 +296,7 @@ describe("applyCustomTheme", () => { expect(mockReload).not.toHaveBeenCalled(); expect(mockLogStaffActivity).not.toHaveBeenCalled(); expect(mockRevalidatePath).not.toHaveBeenCalled(); - expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?theme=Studio"); + expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?error=1"); }); }); @@ -324,11 +324,11 @@ describe("renameCustomTheme", () => { expect(mockSnapshotCurrentTheme).not.toHaveBeenCalled(); }); - it("fails soft on upsert errors", async () => { + it("redirects with an error on upsert errors", async () => { mockUpsertCustomTheme.mockRejectedValueOnce(new Error("store down")); await renameCustomTheme(form({ id: "t1", name: "Renamed" })); expect(mockRevalidatePath).not.toHaveBeenCalled(); - expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?renamed=1"); + expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?error=1"); }); }); @@ -350,10 +350,10 @@ describe("deleteCustomTheme", () => { expect(mockDeleteCustomThemeStore).not.toHaveBeenCalled(); }); - it("fails soft on store errors", async () => { + it("redirects with an error on store errors", async () => { mockDeleteCustomThemeStore.mockRejectedValueOnce(new Error("store down")); await deleteCustomTheme(form({ id: "t1" })); expect(mockRevalidatePath).not.toHaveBeenCalled(); - expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?deletedTheme=1"); + expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?error=1"); }); }); diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 8dc3e1e7..4089f315 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -104,7 +104,7 @@ export async function saveTheme(formData: FormData): Promise { }); revalidatePath("/", "layout"); } catch { - // ignore — page re-renders current state + return redirect("/admin/theme?error=1"); } redirect("/admin/theme?saved=1"); } @@ -128,7 +128,7 @@ export async function applyPreset(formData: FormData): Promise { }); revalidatePath("/", "layout"); } catch { - // ignore + return redirect("/admin/theme?error=1"); } redirect(`/admin/theme?preset=${encodeURIComponent(name)}`); } @@ -149,7 +149,7 @@ export async function saveCustomTheme(formData: FormData): Promise { }); revalidatePath("/admin/theme"); } catch { - // ignore + return redirect("/admin/theme?error=1"); } redirect("/admin/theme?savedTheme=1"); } @@ -175,7 +175,7 @@ export async function applyCustomTheme(formData: FormData): Promise { }); revalidatePath("/", "layout"); } catch { - // ignore + return redirect("/admin/theme?error=1"); } redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`); } @@ -194,7 +194,7 @@ export async function renameCustomTheme(formData: FormData): Promise { await upsertCustomTheme(name, snapshot, id); revalidatePath("/admin/theme"); } catch { - // ignore + return redirect("/admin/theme?error=1"); } redirect("/admin/theme?renamed=1"); } @@ -209,7 +209,7 @@ export async function deleteCustomTheme(formData: FormData): Promise { await deleteCustomThemeStore(id); revalidatePath("/admin/theme"); } catch { - // ignore + return redirect("/admin/theme?error=1"); } redirect("/admin/theme?deletedTheme=1"); } diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts index 4f58e38f..7fff72c5 100644 --- a/src/actions/admin-writeable-boxes.ts +++ b/src/actions/admin-writeable-boxes.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteWriteableBoxes } from "@/lib/db"; +import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -72,8 +73,9 @@ export async function createBox(formData: FormData): Promise { targetType: "writeable_box", targetId: Number(result.insertId), }); - } catch { - // DB unavailable — swallow and re-render. + } catch (err) { + // DB unavailable — keep the page usable, but record the failure. + logger.error("Failed to create writeable box", { err }); return; } @@ -116,7 +118,8 @@ export async function updateBox(formData: FormData): Promise { targetType: "writeable_box", targetId: Number(id), }); - } catch { + } catch (err) { + logger.error("Failed to persist writeable box", { err }); return; } @@ -140,7 +143,8 @@ export async function deleteBox(formData: FormData): Promise { targetType: "writeable_box", targetId: Number(id), }); - } catch { + } catch (err) { + logger.error("Failed to persist writeable box", { err }); return; } @@ -168,7 +172,8 @@ export async function toggleBox(formData: FormData): Promise { targetType: "writeable_box", targetId: Number(id), }); - } catch { + } catch (err) { + logger.error("Failed to persist writeable box", { err }); return; } diff --git a/src/actions/users.ts b/src/actions/users.ts index c3b76875..9b1f42a2 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -4,6 +4,7 @@ import crypto from "node:crypto"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy"; +import { guardRank } from "@/lib/admin/rank-guard"; import { invalidateLoginCache } from "@/lib/auth"; import { hashPassword } from "@/lib/auth/password"; import { revokeUserCredentials } from "@/lib/auth/session-revocation"; @@ -329,32 +330,6 @@ export const removeBadge = adminAction( }, ); -// ── Rank guard helper ─────────────────────────────────────────────── - -async function guardRank(targetUserId: number, sessionRank: number) { - const [target] = await db - .select({ - username: User.username, - rank: User.rank, - mail: User.mail, - }) - .from(User) - .where(eq(User.id, targetUserId)) - .limit(1); - if (!target) throw new ActionError("User not found"); - // The owner is whoever holds the hotel's highest rank *today*. The old - // `sessionRank < 7` shortcut handed every rank-7 account owner powers on - // any hotel whose top rank is 8+, which makes it a plain escalation. - const highestRank = await getHighestRank(); - if ( - target.rank >= sessionRank && - !isDynamicSuperAdmin(sessionRank, highestRank) - ) { - throw new ActionError("Cannot modify user with equal or higher rank"); - } - return target; -} - // ── Reset Password ────────────────────────────────────────────────── const resetPasswordSchema = z.object({ diff --git a/src/app/admin/bans/page.tsx b/src/app/admin/bans/page.tsx index 5730f7bf..0023717c 100644 --- a/src/app/admin/bans/page.tsx +++ b/src/app/admin/bans/page.tsx @@ -11,12 +11,17 @@ import { Ban, db } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -export default async function AdminBans() { +export default async function AdminBans({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) { redirect("/admin"); } + const params = await searchParams; const t = await getTranslations("pages.admin.bans"); const now = unixNow(); let bans: (typeof Ban.$inferSelect)[] = []; @@ -37,6 +42,11 @@ export default async function AdminBans() { return (
+ {params.error === "rank" ? ( +

+ {t("rankError")} +

+ ) : null}
[{ total: 0 }]), - db - .select({ total: count() }) - .from(WebsiteHelpCenterTickets) - .where(eq(WebsiteHelpCenterTickets.open, true)) - .catch(() => [{ total: 0 }]), - db - .select({ total: count() }) - .from(WebsiteHelpCenterTickets) - .where(eq(WebsiteHelpCenterTickets.open, false)) - .catch(() => [{ total: 0 }]), + const [total, openCount, closedCount, queues] = await Promise.all([ + getCachedAdminCount( + WebsiteHelpCenterTickets, + "help_tickets_total", + where, + ).catch(() => 0), + getCachedAdminCount( + WebsiteHelpCenterTickets, + "help_tickets_open", + eq(WebsiteHelpCenterTickets.open, true), + { cacheFiltered: true }, + ).catch(() => 0), + getCachedAdminCount( + WebsiteHelpCenterTickets, + "help_tickets_closed", + eq(WebsiteHelpCenterTickets.open, false), + { cacheFiltered: true }, + ).catch(() => 0), fetchTicketQueueOpenCounts(), ]); - const total = Number(totalRows[0]?.total ?? 0); - const openCount = Number(openRows[0]?.total ?? 0); - const closedCount = Number(closedRows[0]?.total ?? 0); - const pagination = calcPagination(total, parsed.page, parsed.perPage); const tickets = await db diff --git a/src/app/admin/items/page.tsx b/src/app/admin/items/page.tsx index 017e0c44..1a60a128 100644 --- a/src/app/admin/items/page.tsx +++ b/src/app/admin/items/page.tsx @@ -1,10 +1,11 @@ -import { and, count, desc, eq, like, or } from "drizzle-orm"; +import { and, desc, eq, like, or } from "drizzle-orm"; import { Package } from "lucide-react"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { fetchAdminList } from "@/lib/admin-list"; import { db, ItemsBase } from "@/lib/db"; import { canAccess, PERMS } from "@/lib/permissions"; +import { getCachedAdminCount } from "@/lib/services/admin-stats"; import { type ItemsBaseRow, ItemsTable } from "./items-table"; export default async function ItemsBasePage({ @@ -34,7 +35,7 @@ export default async function ItemsBasePage({ conditions.push(eq(ItemsBase.type, rawParams.filter_type)); } const where = conditions.length > 0 ? and(...conditions) : undefined; - const [rows, totals] = await Promise.all([ + const [rows, total] = await Promise.all([ db .select({ id: ItemsBase.id, @@ -51,7 +52,7 @@ export default async function ItemsBasePage({ .orderBy(desc(ItemsBase.id)) .limit(perPage) .offset((page - 1) * perPage), - db.select({ total: count() }).from(ItemsBase).where(where), + getCachedAdminCount(ItemsBase, "items_base", where), ]); return { rows: rows.map((r) => ({ @@ -64,7 +65,7 @@ export default async function ItemsBasePage({ width: r.width, length: r.length, })), - total: totals[0]?.total ?? 0, + total, }; }, }, diff --git a/src/app/admin/moderation/page.tsx b/src/app/admin/moderation/page.tsx index fbe8260f..39747492 100644 --- a/src/app/admin/moderation/page.tsx +++ b/src/app/admin/moderation/page.tsx @@ -16,6 +16,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getMinStaffRank } from "@/lib/admin/min-staff-rank"; import { AdminAuditLog, Ban, db, SupportTickets, User } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { getCachedAdminCount } from "@/lib/services/admin-stats"; const CFH_STATES: Record = { 0: { @@ -39,25 +40,31 @@ export default async function ModerationDashboard() { const minStaffRank = await getMinStaffRank(); const [ - openCfhRows, - pickedCfhRows, - todayBansRows, + openCfh, + pickedCfh, + todayBans, modsOnlineRows, recentCfh, recentModActions, ] = await Promise.all([ - db - .select({ total: count() }) - .from(SupportTickets) - .where(eq(SupportTickets.state, 0)), - db - .select({ total: count() }) - .from(SupportTickets) - .where(eq(SupportTickets.state, 1)), - db - .select({ total: count() }) - .from(Ban) - .where(gte(Ban.timestamp, todayStart)), + getCachedAdminCount( + SupportTickets, + "moderation_open_cfh", + eq(SupportTickets.state, 0), + { cacheFiltered: true }, + ), + getCachedAdminCount( + SupportTickets, + "moderation_picked_cfh", + eq(SupportTickets.state, 1), + { cacheFiltered: true }, + ), + getCachedAdminCount( + Ban, + "moderation_today_bans", + gte(Ban.timestamp, todayStart), + { cacheFiltered: true }, + ), db .select({ total: count() }) .from(User) @@ -81,9 +88,6 @@ export default async function ModerationDashboard() { .limit(10), ]); - const openCfh = Number(openCfhRows[0]?.total ?? 0); - const pickedCfh = Number(pickedCfhRows[0]?.total ?? 0); - const todayBans = Number(todayBansRows[0]?.total ?? 0); const modsOnline = Number(modsOnlineRows[0]?.total ?? 0); // Fetch usernames for CFH tickets diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index e094025e..15ad9543 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -1,4 +1,4 @@ -import { count, desc, inArray } from "drizzle-orm"; +import { desc, inArray } from "drizzle-orm"; import { Camera } from "lucide-react"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; @@ -11,6 +11,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { CameraWeb, db, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { getCachedAdminCount } from "@/lib/services/admin-stats"; export default async function AdminPhotos({ searchParams, @@ -25,11 +26,9 @@ export default async function AdminPhotos({ const t = await getTranslations("pages.admin.photos"); const parsed = parseListParams(new URLSearchParams(await searchParams)); - const [totalRow] = await db - .select({ total: count() }) - .from(CameraWeb) - .catch(() => [{ total: 0 }]); - const total = Number(totalRow?.total ?? 0); + const total = await getCachedAdminCount(CameraWeb, "camera_web").catch( + () => 0, + ); const pagination = calcPagination(total, parsed.page, parsed.perPage || 24); let photos: (typeof CameraWeb.$inferSelect)[] = []; diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx index 4426fe47..05b085c7 100644 --- a/src/app/admin/theme/page.tsx +++ b/src/app/admin/theme/page.tsx @@ -323,6 +323,7 @@ export default async function AdminTheme({ theme?: string; renamed?: string; deletedTheme?: string; + error?: string; }>; }) { const { session, permissions } = await getAdminContext(); @@ -372,6 +373,14 @@ export default async function AdminTheme({ return (
+ {sp.error ? ( +
+ {t("saveError")} +
+ ) : null} {sp.saved ? (
{t("saved")} diff --git a/src/app/admin/users/_components/user-sanction-timeline.tsx b/src/app/admin/users/_components/user-sanction-timeline.tsx deleted file mode 100644 index cd99400d..00000000 --- a/src/app/admin/users/_components/user-sanction-timeline.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Link from "@/components/link"; -import { formatDate } from "@/lib/format-date"; -import type { SanctionEvent } from "../_lib/load-user-sanctions"; - -const KIND_CLASS: Record = { - ban: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]", - lift: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]", - mute: "bg-[var(--admin-accent)]/20 text-[var(--admin-accent)]", - unmute: "bg-[var(--admin-success-subtle)] text-[var(--admin-success)]", - cfh: "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]", - staff: "bg-[var(--admin-border)] text-[var(--admin-text-muted)]", - status: "bg-[var(--admin-accent)]/25 text-[var(--admin-accent)]", -}; - -export function UserSanctionTimeline({ - events, - muteUntil, -}: { - events: SanctionEvent[]; - muteUntil: number; -}) { - const now = Math.floor(Date.now() / 1000); - const muted = muteUntil > now; - - return ( -
-
-

Sanctions timeline

- {muted ? ( - - Muted until {formatDate(new Date(muteUntil * 1000))} - - ) : null} -
- - {events.length === 0 ? ( -

- No bans, mutes, CFH, or staff actions recorded for this user. -

- ) : ( -
    - {events.map((ev) => ( -
  1. - - {ev.kind} - -
    -
    - {ev.href ? ( - - {ev.label} - - ) : ( - {ev.label} - )} - - {formatDate(new Date(ev.at * 1000))} - - {ev.actor ? ( - - by {ev.actor} - - ) : null} -
    -

    - {ev.detail} -

    -
    -
  2. - ))} -
- )} -
- ); -} diff --git a/src/app/admin/users/_lib/load-user-sanctions.ts b/src/app/admin/users/_lib/load-user-sanctions.ts deleted file mode 100644 index 1d3f1a12..00000000 --- a/src/app/admin/users/_lib/load-user-sanctions.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { and, desc, eq, inArray, or } from "drizzle-orm"; -import { isBanActive, unixNow } from "@/lib/bans"; -import { - AdminAuditLog, - Ban, - db, - StaffActivities, - SupportTickets, - User, - UsersSettings, -} from "@/lib/db"; -import { formatDate } from "@/lib/format-date"; - -export type SanctionEvent = { - id: string; - at: number; - kind: "ban" | "lift" | "mute" | "unmute" | "cfh" | "staff" | "status"; - label: string; - detail: string; - actor?: string; - href?: string; -}; - -function parseAuditAt(createdAt: string): number { - const asNum = Number(createdAt); - if (Number.isFinite(asNum) && asNum > 1_000_000_000) return asNum; - const ms = Date.parse(createdAt); - return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0; -} - -const AUDIT_ACTIONS = [ - "ban", - "unban", - "user_mute", - "user_unmute", - "mod_mute", - "mod_unmute", - "user_ban", - "ban_lift", -] as const; - -/** Build a merged sanction timeline for an admin user show tab. */ -export async function loadUserSanctions( - userId: number, -): Promise<{ events: SanctionEvent[]; muteUntil: number }> { - const now = unixNow(); - const events: SanctionEvent[] = []; - - const [bans, audits, staffActs, cfh, settingsRows] = await Promise.all([ - db - .select({ - id: Ban.id, - banReason: Ban.banReason, - type: Ban.type, - timestamp: Ban.timestamp, - banExpire: Ban.banExpire, - userStaffId: Ban.userStaffId, - }) - .from(Ban) - .where(eq(Ban.userId, userId)) - .orderBy(desc(Ban.timestamp)) - .limit(40) - .catch(() => []), - db - .select({ - id: AdminAuditLog.id, - action: AdminAuditLog.action, - details: AdminAuditLog.details, - createdAt: AdminAuditLog.createdAt, - userId: AdminAuditLog.userId, - actorUsername: User.username, - }) - .from(AdminAuditLog) - .leftJoin(User, eq(AdminAuditLog.userId, User.id)) - .where( - and( - eq(AdminAuditLog.target, "User"), - eq(AdminAuditLog.targetId, userId), - inArray(AdminAuditLog.action, [...AUDIT_ACTIONS]), - ), - ) - .orderBy(desc(AdminAuditLog.id)) - .limit(40) - .catch(() => []), - db - .select({ - id: StaffActivities.id, - action: StaffActivities.action, - description: StaffActivities.description, - createdAt: StaffActivities.createdAt, - userId: StaffActivities.userId, - }) - .from(StaffActivities) - .where( - and( - eq(StaffActivities.targetType, "user"), - eq(StaffActivities.targetId, BigInt(userId)), - ), - ) - .orderBy(desc(StaffActivities.id)) - .limit(40) - .catch(() => []), - db - .select({ - id: SupportTickets.id, - issue: SupportTickets.issue, - state: SupportTickets.state, - timestamp: SupportTickets.timestamp, - senderId: SupportTickets.senderId, - reportedId: SupportTickets.reportedId, - modId: SupportTickets.modId, - }) - .from(SupportTickets) - .where( - or( - eq(SupportTickets.reportedId, userId), - eq(SupportTickets.senderId, userId), - ), - ) - .orderBy(desc(SupportTickets.id)) - .limit(20) - .catch(() => []), - db - .select({ muteEndTimestamp: UsersSettings.muteEndTimestamp }) - .from(UsersSettings) - .where(eq(UsersSettings.userId, userId)) - .limit(1) - .catch(() => []), - ]); - - const settings = settingsRows[0] ?? null; - const muteUntil = settings?.muteEndTimestamp ?? 0; - if (muteUntil > now) { - events.push({ - id: "mute-active", - at: now, - kind: "status", - label: "Mute active", - detail: `Until ${formatDate(new Date(muteUntil * 1000))}`, - }); - } - - const staffIds = [ - ...new Set([ - ...bans.map((b) => b.userStaffId).filter((id) => id > 0), - ...staffActs.map((a) => Number(a.userId)).filter((id) => id > 0), - ]), - ]; - const staffUsers = - staffIds.length > 0 - ? await db - .select({ id: User.id, username: User.username }) - .from(User) - .where(inArray(User.id, staffIds)) - .catch(() => []) - : []; - const staffName = new Map(staffUsers.map((u) => [u.id, u.username])); - - for (const ban of bans) { - const active = isBanActive(ban.banExpire, now); - events.push({ - id: `ban-${ban.id}`, - at: ban.timestamp, - kind: "ban", - label: active ? `Ban (${ban.type}) · active` : `Ban (${ban.type})`, - detail: ban.banReason || "—", - actor: staffName.get(ban.userStaffId), - }); - } - - for (const a of audits) { - const kind: SanctionEvent["kind"] = - a.action.includes("unmute") || a.action === "unban" - ? a.action.includes("mute") - ? "unmute" - : "lift" - : a.action.includes("mute") - ? "mute" - : a.action.includes("ban") - ? "ban" - : "staff"; - events.push({ - id: `audit-${a.id}`, - at: parseAuditAt(a.createdAt), - kind, - label: a.action, - detail: a.details?.slice(0, 200) || "—", - actor: a.actorUsername ?? undefined, - }); - } - - for (const s of staffActs) { - events.push({ - id: `staff-${String(s.id)}`, - at: Math.floor(s.createdAt.getTime() / 1000), - kind: "staff", - label: s.action, - detail: s.description.slice(0, 200), - actor: staffName.get(Number(s.userId)), - }); - } - - for (const c of cfh) { - const role = - c.reportedId === userId - ? "reported" - : c.senderId === userId - ? "reporter" - : "related"; - events.push({ - id: `cfh-${c.id}`, - at: c.timestamp, - kind: "cfh", - label: `CFH #${c.id} (${role}, state ${c.state})`, - detail: c.issue?.slice(0, 200) || "—", - href: `/admin/moderation/cfh/${c.id}`, - }); - } - - events.sort((a, b) => b.at - a.at || a.id.localeCompare(b.id)); - - // Deduplicate near-identical ban+audit pairs within 5s - const deduped: SanctionEvent[] = []; - for (const ev of events) { - const prev = deduped[deduped.length - 1]; - if ( - prev && - prev.kind === ev.kind && - Math.abs(prev.at - ev.at) <= 5 && - prev.label.split(" ")[0] === ev.label.split(" ")[0] - ) { - continue; - } - deduped.push(ev); - } - - return { events: deduped.slice(0, 80), muteUntil }; -} diff --git a/src/app/api/admin/import/clone/icon/route.ts b/src/app/api/admin/import/clone/icon/route.ts index fb1ee24a..541f3778 100644 --- a/src/app/api/admin/import/clone/icon/route.ts +++ b/src/app/api/admin/import/clone/icon/route.ts @@ -1,5 +1,6 @@ import { apiError } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; +import { assertSafeImportUrl } from "@/lib/import-url-safety"; import { PERMS } from "@/lib/permissions"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { getSource } from "@/lib/services/clone-sources"; @@ -38,10 +39,20 @@ export const GET = withAdmin( if (icon === undefined) { const source = await getSource(sourceId); if (!source) return apiError("Source not found", 404); + if (!source.nitroBaseUrl) + return apiError("Source has no nitro base URL", 400); try { - const res = await fetch(`${source.nitroBaseUrl}/${classname}.nitro`, { - signal: AbortSignal.timeout(15000), - }); + await assertSafeImportUrl(source.nitroBaseUrl, "nitroBaseUrl"); + } catch (err) { + return apiError((err as Error).message, 400); + } + try { + const res = await fetch( + `${source.nitroBaseUrl.replace(/\/+$/, "")}/${classname}.nitro`, + { + signal: AbortSignal.timeout(15000), + }, + ); if (!res.ok) { iconCache.set(cacheKey, null); return apiError("Bundle not found", 404); diff --git a/src/app/api/admin/import/clone/route.ts b/src/app/api/admin/import/clone/route.ts index c77e9ab8..1a62f1c8 100644 --- a/src/app/api/admin/import/clone/route.ts +++ b/src/app/api/admin/import/clone/route.ts @@ -1,5 +1,6 @@ import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; +import { assertSafeImportUrl } from "@/lib/import-url-safety"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { @@ -87,22 +88,25 @@ export const POST = withAdmin( } = body as Record; const enabledRaw = (body as Record).enabled; - const isHttps = (u: string) => { - try { - return new URL(u).protocol === "https:"; - } catch { - return false; - } - }; - if (!name?.trim()) return apiError("name is required", 400); if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400); const normalized = (u: string | undefined) => (u ?? "").trim().replace(/\/+$/, ""); - if (!isHttps(furnidataUrl)) - return apiError("furnidataUrl must be a valid https:// URL", 400); + for (const [label, rawUrl] of [ + ["furnidataUrl", furnidataUrl], + ["nitroBaseUrl", nitroBaseUrl], + ["iconBaseUrl", iconBaseUrl], + ["sourceSwfBaseUrl", sourceSwfBaseUrl], + ] as const) { + if (!rawUrl?.trim()) continue; + try { + await assertSafeImportUrl(rawUrl, label); + } catch (err) { + return apiError((err as Error).message, 400); + } + } const entry = await upsertSource({ id, diff --git a/src/components/admin/studio/studio-furni-icons.ts b/src/components/admin/studio/studio-furni-icons.ts index f377f0b9..a66b23c0 100644 --- a/src/components/admin/studio/studio-furni-icons.ts +++ b/src/components/admin/studio/studio-furni-icons.ts @@ -8,11 +8,6 @@ export function getFurniImageUrl(item: FurniItem) { return `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; } -export function getFurniIconUrl(classname: string, revision: number) { - const iconName = classname.replace(/\*/g, "_"); - return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`; -} - export function handleImgError( e: SyntheticEvent, item: FurniItem, diff --git a/src/features/catalog/server/tree-nodes.ts b/src/features/catalog/server/tree-nodes.ts index da8c5fde..fd068671 100644 --- a/src/features/catalog/server/tree-nodes.ts +++ b/src/features/catalog/server/tree-nodes.ts @@ -17,19 +17,6 @@ export function toInt(value: unknown, fallback = 0): number { return Number.isFinite(n) ? n : fallback; } -/** The nine columns every catalog tree read needs, in one shape. */ -const PAGE_COLUMNS = [ - "id", - "parentId", - "caption", - "pageLayout", - "iconColor", - "iconImage", - "orderNum", - "visible", - "enabled", -] as const; - type PageRow = { id: number | string; parentId: number | string; @@ -270,5 +257,3 @@ export async function readAncestors(kind: CatalogKind, id: number) { } return chain; } - -export const CATALOG_PAGE_COLUMNS = PAGE_COLUMNS; diff --git a/src/lib/admin-operations-contract.test.ts b/src/lib/admin-operations-contract.test.ts index 9ccf0a83..5b3bf793 100644 --- a/src/lib/admin-operations-contract.test.ts +++ b/src/lib/admin-operations-contract.test.ts @@ -73,6 +73,7 @@ const ACTION_GATES: Array<[string, string]> = [ ["admin-ads.ts", "PERMS.PAGES_EDIT"], ["admin-vouchers.ts", "PERMS.SHOP_EDIT"], ["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"], + ["admin-antiddos.ts", "PERMS.SETTINGS_EDIT"], ["commandocentrum.ts", "PERMS.RCON_EXECUTE"], ["translations.ts", "PERMS.SETTINGS_EDIT"], ["tickets.ts", "PERMS.TICKETS_EDIT"], diff --git a/src/lib/admin/is-staff.test.ts b/src/lib/admin/is-staff.test.ts deleted file mode 100644 index 2c75ac1d..00000000 --- a/src/lib/admin/is-staff.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isStaff } from "./is-staff"; - -describe("isStaff", () => { - it("is true at or above the min staff rank", () => { - expect(isStaff(7, 7)).toBe(true); - expect(isStaff(10, 7)).toBe(true); - }); - - it("is false below the min staff rank", () => { - expect(isStaff(6, 7)).toBe(false); - expect(isStaff(1, 7)).toBe(false); - }); -}); diff --git a/src/lib/admin/is-staff.ts b/src/lib/admin/is-staff.ts deleted file mode 100644 index c8b4f68c..00000000 --- a/src/lib/admin/is-staff.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** AtomCMS housekeeping gate: staff are users with rank >= min_staff_rank. */ -export function isStaff(rank: number, minStaffRank: number): boolean { - return rank >= minStaffRank; -} diff --git a/src/lib/admin/log-filters.test.ts b/src/lib/admin/log-filters.test.ts deleted file mode 100644 index d5126df2..00000000 --- a/src/lib/admin/log-filters.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildStaffActivityWhere } from "./log-filters"; - -describe("buildStaffActivityWhere", () => { - it("returns an empty where when no filters are set", () => { - expect(buildStaffActivityWhere({})).toEqual({}); - }); - - it("builds an OR search across action, description and IP", () => { - const where = buildStaffActivityWhere({ q: "ban " }); - expect(where.OR).toEqual([ - { action: { contains: "ban" } }, - { description: { contains: "ban" } }, - { ipAddress: { contains: "ban" } }, - ]); - expect(where.OR?.[0]).toEqual({ action: { contains: "ban" } }); - }); - - it("sets userId when staffId is provided", () => { - const where = buildStaffActivityWhere({ staffId: 42 }); - expect(where.userId).toBe(BigInt(42)); - }); - - it("uses a prefix match for authorization-only", () => { - expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({ - action: { startsWith: "permission." }, - }); - }); - - it("uses a contains match for a specific action", () => { - expect(buildStaffActivityWhere({ action: "ban" })).toEqual({ - action: { contains: "ban" }, - }); - }); - - it("ignores whitespace-only search terms", () => { - expect(buildStaffActivityWhere({ q: " " })).toEqual({}); - }); -}); diff --git a/src/lib/admin/log-filters.ts b/src/lib/admin/log-filters.ts deleted file mode 100644 index 71b391d4..00000000 --- a/src/lib/admin/log-filters.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface StaffActivityFilters { - q?: string; - staffId?: number | null; - action?: string | null; - authorizationOnly?: boolean; -} - -interface StaffActivityWhere { - OR?: Array>; - userId?: bigint; - action?: { startsWith: string } | { contains: string }; -} - -export function buildStaffActivityWhere( - filters: StaffActivityFilters, -): StaffActivityWhere { - const where: StaffActivityWhere = {}; - if (filters.q?.trim()) - where.OR = [ - { action: { contains: filters.q.trim() } }, - { description: { contains: filters.q.trim() } }, - { ipAddress: { contains: filters.q.trim() } }, - ]; - if (filters.staffId) where.userId = BigInt(filters.staffId); - if (filters.authorizationOnly) where.action = { startsWith: "permission." }; - else if (filters.action) where.action = { contains: filters.action }; - return where; -} diff --git a/src/lib/admin/notice.test.ts b/src/lib/admin/notice.test.ts deleted file mode 100644 index a4954be3..00000000 --- a/src/lib/admin/notice.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { adminMutationNotice } from "./notice"; - -describe("adminMutationNotice", () => { - it("returns a danger notice when there is an error", () => { - expect(adminMutationNotice({ error: "boom" })).toEqual({ - tone: "danger", - label: "Error", - message: "The operation could not be completed.", - }); - }); - it("returns a success notice when saved is 1", () => { - expect(adminMutationNotice({ saved: "1" })).toEqual({ - tone: "ok", - label: "Saved", - message: "Changes were saved successfully.", - }); - }); - it("returns null when nothing is set", () => { - expect(adminMutationNotice({})).toBeNull(); - }); - it("prioritises errors over saved flags", () => { - const notice = adminMutationNotice({ saved: "1", error: "x" }); - expect(notice?.tone).toBe("danger"); - }); -}); diff --git a/src/lib/admin/notice.ts b/src/lib/admin/notice.ts deleted file mode 100644 index 83504c4c..00000000 --- a/src/lib/admin/notice.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface AdminMutationNotice { - tone: "ok" | "danger"; - label: "Saved" | "Error"; - message: string; -} - -export function adminMutationNotice(params: { - saved?: string; - error?: string; -}): AdminMutationNotice | null { - if (params.error) { - return { - tone: "danger", - label: "Error", - message: "The operation could not be completed.", - }; - } - if (params.saved === "1") { - return { - tone: "ok", - label: "Saved", - message: "Changes were saved successfully.", - }; - } - return null; -} diff --git a/src/lib/admin/rank-guard.test.ts b/src/lib/admin/rank-guard.test.ts new file mode 100644 index 00000000..47c6dc10 --- /dev/null +++ b/src/lib/admin/rank-guard.test.ts @@ -0,0 +1,82 @@ +// @ts-nocheck +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + users: [] as Array<{ username: string; rank: number; mail: string | null }>, + highestRank: 8, +})); + +vi.mock("@/lib/db", async () => { + const schema = await import("@/db/schema"); + const { createFakeDb } = await import("@/test/fake-db"); + const fake = createFakeDb((_table, projection) => + "username" in projection ? state.users : [], + ); + return { ...schema, db: fake }; +}); + +vi.mock("@/lib/permissions", () => ({ + getHighestRank: vi.fn(async () => state.highestRank), +})); + +vi.mock("@/lib/safe-action-shared", () => ({ + ActionError: class ActionError extends Error {}, +})); + +import { getHighestRank } from "@/lib/permissions"; +import { guardRank, tryGuardRank } from "./rank-guard"; + +beforeEach(() => { + vi.clearAllMocks(); + state.users = [{ username: "victim", rank: 5, mail: "v@x.nl" }]; + state.highestRank = 8; +}); + +describe("tryGuardRank", () => { + it("allows an actor at a strictly higher rank", async () => { + const result = await tryGuardRank(1, 7); + expect(result).toEqual({ ok: true, target: state.users[0] }); + }); + + it("blocks an actor at the same rank", async () => { + state.users = [{ username: "victim", rank: 7, mail: null }]; + const result = await tryGuardRank(1, 7); + expect(result).toEqual({ ok: false, reason: "rank" }); + }); + + it("blocks an actor at a lower rank", async () => { + const result = await tryGuardRank(1, 3); + expect(result).toEqual({ ok: false, reason: "rank" }); + }); + + it("lets the dynamic super admin act on anyone", async () => { + state.users = [{ username: "victim", rank: 8, mail: "v@x.nl" }]; + state.highestRank = 8; + const result = await tryGuardRank(1, 8); + expect(result).toEqual({ + ok: true, + target: { username: "victim", rank: 8, mail: "v@x.nl" }, + }); + expect(await getHighestRank()).toBe(8); + }); + + it("reports a missing target as not_found", async () => { + state.users = []; + const result = await tryGuardRank(999, 7); + expect(result).toEqual({ ok: false, reason: "not_found" }); + }); +}); + +describe("guardRank", () => { + it("throws a readable ActionError for a target at equal or higher rank", async () => { + state.users = [{ username: "victim", rank: 7, mail: null }]; + await expect(guardRank(1, 7)).rejects.toThrow( + "Cannot modify user with equal or higher rank", + ); + }); + + it("throws a readable ActionError for a missing target", async () => { + state.users = []; + await expect(guardRank(999, 7)).rejects.toThrow("User not found"); + }); +}); diff --git a/src/lib/admin/rank-guard.ts b/src/lib/admin/rank-guard.ts new file mode 100644 index 00000000..3688632b --- /dev/null +++ b/src/lib/admin/rank-guard.ts @@ -0,0 +1,65 @@ +import { eq } from "drizzle-orm"; +import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy"; +import { db, User } from "@/lib/db"; +import { getHighestRank } from "@/lib/permissions"; +import { ActionError } from "@/lib/safe-action-shared"; + +export interface RankGuardTarget { + username: string; + rank: number; + mail: string | null; +} + +export type RankGuardResult = + | { ok: true; target: RankGuardTarget } + | { ok: false; reason: "not_found" | "rank" }; + +/** + * Refuse to act on a target whose rank is equal to or higher than the actor's, + * unless the actor is the dynamic super admin (the account holding the hotel's + * current highest rank). This is the single source of truth for the + * "cannot modify equal/higher rank" invariant; every admin action that mutates + * a specific user must go through it. + * + * Returns a discriminated result instead of throwing so callers can choose + * between "block the request" and "tolerate a deleted user" (e.g. lifting a + * ban for an account that no longer exists). + */ +export async function tryGuardRank( + targetUserId: number, + sessionRank: number, +): Promise { + const [target] = await db + .select({ + username: User.username, + rank: User.rank, + mail: User.mail, + }) + .from(User) + .where(eq(User.id, targetUserId)) + .limit(1); + if (!target) return { ok: false, reason: "not_found" }; + const highestRank = await getHighestRank(); + if ( + target.rank >= sessionRank && + !isDynamicSuperAdmin(sessionRank, highestRank) + ) { + return { ok: false, reason: "rank" }; + } + return { ok: true, target }; +} + +export async function guardRank( + targetUserId: number, + sessionRank: number, +): Promise { + const result = await tryGuardRank(targetUserId, sessionRank); + if (!result.ok) { + throw new ActionError( + result.reason === "not_found" + ? "User not found" + : "Cannot modify user with equal or higher rank", + ); + } + return result.target; +} diff --git a/src/lib/admin/recent-furni-resync.test.ts b/src/lib/admin/recent-furni-resync.test.ts deleted file mode 100644 index dd8405e0..00000000 --- a/src/lib/admin/recent-furni-resync.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - RECENT_FURNI_RESYNC_URL, - requestRecentFurniResync, -} from "./recent-furni-resync"; - -describe("requestRecentFurniResync", () => { - it("POSTs to the resync endpoint and maps the payload", async () => { - const fetcher = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - examined: 10, - resynced: 4, - failed: 1, - rconOk: true, - errors: [{ classname: "sofa", message: "boom" }], - }), - }); - const result = await requestRecentFurniResync(fetcher); - expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, { - method: "POST", - }); - expect(result).toEqual({ - examined: 10, - resynced: 4, - failed: 1, - rconOk: true, - errors: [{ classname: "sofa", message: "boom" }], - }); - }); - - it("throws with the server error message on failure", async () => { - const fetcher = vi.fn().mockResolvedValue({ - ok: false, - json: async () => ({ error: "no permission" }), - }); - await expect(requestRecentFurniResync(fetcher)).rejects.toThrow( - "no permission", - ); - }); - - it("falls back to a generic error message", async () => { - const fetcher = vi.fn().mockResolvedValue({ - ok: false, - json: async () => ({}), - }); - await expect(requestRecentFurniResync(fetcher)).rejects.toThrow( - "Furni resync failed", - ); - }); - - it("filters malformed error entries and coerces counts", async () => { - const fetcher = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - examined: "10", - resynced: undefined, - failed: null, - rconOk: "yes", - errors: [{ classname: "a", message: "b" }, { nope: 1 }, "x"], - }), - }); - const result = await requestRecentFurniResync(fetcher); - expect(result.examined).toBe(10); - expect(result.resynced).toBe(0); - expect(result.failed).toBe(0); - expect(result.rconOk).toBe(false); - expect(result.errors).toEqual([{ classname: "a", message: "b" }]); - }); -}); diff --git a/src/lib/admin/recent-furni-resync.ts b/src/lib/admin/recent-furni-resync.ts deleted file mode 100644 index f22bca97..00000000 --- a/src/lib/admin/recent-furni-resync.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const RECENT_FURNI_RESYNC_URL = "/api/admin/import/furni/resync?days=7"; - -export interface RecentFurniResyncError { - classname: string; - message: string; -} - -export interface RecentFurniResyncResult { - examined: number; - resynced: number; - failed: number; - rconOk: boolean; - errors: RecentFurniResyncError[]; -} - -type AdminFetcher = ( - input: RequestInfo | URL, - init?: RequestInit, -) => Promise; - -export async function requestRecentFurniResync( - fetcher: AdminFetcher, -): Promise { - const response = await fetcher(RECENT_FURNI_RESYNC_URL, { method: "POST" }); - const payload = (await response.json()) as Record; - if (!response.ok) { - throw new Error( - typeof payload.error === "string" ? payload.error : "Furni resync failed", - ); - } - - const errors = Array.isArray(payload.errors) - ? payload.errors.filter( - (value): value is RecentFurniResyncError => - typeof value === "object" && - value !== null && - typeof (value as RecentFurniResyncError).classname === "string" && - typeof (value as RecentFurniResyncError).message === "string", - ) - : []; - - return { - examined: Number(payload.examined) || 0, - resynced: Number(payload.resynced) || 0, - failed: Number(payload.failed) || 0, - rconOk: payload.rconOk === true, - errors, - }; -} diff --git a/src/lib/admin/ticket-inbox.ts b/src/lib/admin/ticket-inbox.ts index 2cac45b7..9211c8d3 100644 --- a/src/lib/admin/ticket-inbox.ts +++ b/src/lib/admin/ticket-inbox.ts @@ -1,20 +1,17 @@ import "server-only"; -import { - and, - count, - desc, - eq, - inArray, - like, - ne, - or, - type SQL, -} from "drizzle-orm"; +import { and, count, eq, like, ne, or, type SQL, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/mysql-core"; import { calcPagination } from "@/lib/admin-helpers"; -import { db, User, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db"; +import { + db, + queryRows, + User, + WebsiteHelpCenterTickets, + WebsiteTicket, +} from "@/lib/db"; import { formatDate } from "@/lib/format-date"; +import { logger } from "@/lib/logger"; export type TicketInboxKind = "cms" | "help"; export type TicketInboxTypeFilter = "all" | TicketInboxKind; @@ -43,6 +40,17 @@ export interface FetchTicketInboxOptions { base?: "admin" | "mod"; } +interface InboxRawRow { + kind: TicketInboxKind; + id: string; + title: string; + username: string | null; + user_id: number | null; + status: string; + is_open: number | boolean; + sort_at: Date | null; +} + function toSortMs(value: Date | string | null | undefined): number { if (!value) return 0; const ms = @@ -50,160 +58,160 @@ function toSortMs(value: Date | string | null | undefined): number { return Number.isFinite(ms) ? ms : 0; } -async function fetchCmsCandidates( - limit: number, +/** + * Builds a single UNION ALL over both queues, normalized to the same column + * shape so the database can apply ORDER BY + LIMIT/OFFSET. This keeps deep + * pages O(perPage) instead of materializing every row up to the offset (the + * previous app-side merge fetched `offset + perPage` rows from each source). + * + * Ordering matches the old merge: newest first, ties broken by descending + * `kind` then descending string `id` (equivalent to the old `${kind}-${id}` + * key comparison). + */ +function buildInboxUnion( + includeCms: boolean, + includeHelp: boolean, search: string, openOnly: boolean, - base: "admin" | "mod", -): Promise { - const Creator = alias(User, "inbox_ticket_creator"); - const conditions: SQL[] = []; +): SQL { + const branches: SQL[] = []; - if (openOnly) { - conditions.push(ne(WebsiteTicket.status, "closed")); + if (includeCms) { + const conditions: SQL[] = []; + if (openOnly) conditions.push(sql`t.status <> 'closed'`); + if (search) { + const pattern = `%${search}%`; + const parts: SQL[] = [ + sql`t.subject LIKE ${pattern}`, + sql`t.category LIKE ${pattern}`, + sql`EXISTS (SELECT 1 FROM users cu WHERE cu.id = t.creator_id AND cu.username LIKE ${pattern})`, + ]; + const asId = Number(search); + if (Number.isFinite(asId) && asId > 0) parts.push(sql`t.id = ${asId}`); + conditions.push(sql`(${sql.join(parts, sql` OR `)})`); + } + const where = + conditions.length > 0 + ? sql` WHERE ${sql.join(conditions, sql` AND `)}` + : sql``; + branches.push(sql` + SELECT 'cms' AS kind, CAST(t.id AS CHAR) AS id, t.subject AS title, + cu.username AS username, t.creator_id AS user_id, t.status AS status, + (t.status <> 'closed') AS is_open, + COALESCE(t.updated_at, t.created_at) AS sort_at + FROM website_tickets t + LEFT JOIN users cu ON cu.id = t.creator_id${where} + `); } - if (search) { - const asId = Number(search); - const searchOr = [ - like(WebsiteTicket.subject, `%${search}%`), - like(WebsiteTicket.category, `%${search}%`), - like(Creator.username, `%${search}%`), - ...(Number.isFinite(asId) && asId > 0 - ? [eq(WebsiteTicket.id, asId)] - : []), - ]; - const searchFilter = or(...searchOr); - if (searchFilter) conditions.push(searchFilter); + if (includeHelp) { + const conditions: SQL[] = []; + if (openOnly) conditions.push(sql`h.open = 1`); + if (search) { + const pattern = `%${search}%`; + const parts: SQL[] = [ + sql`h.title LIKE ${pattern}`, + sql`h.content LIKE ${pattern}`, + sql`EXISTS (SELECT 1 FROM users hu WHERE hu.id = h.user_id AND hu.username LIKE ${pattern})`, + ]; + if (/^\d+$/.test(search)) parts.push(sql`h.id = ${search}`); + conditions.push(sql`(${sql.join(parts, sql` OR `)})`); + } + const where = + conditions.length > 0 + ? sql` WHERE ${sql.join(conditions, sql` AND `)}` + : sql``; + branches.push(sql` + SELECT 'help' AS kind, CAST(h.id AS CHAR) AS id, h.title AS title, + hu.username AS username, h.user_id AS user_id, + IF(h.open = 1, 'open', 'closed') AS status, h.open AS is_open, + COALESCE(h.updated_at, h.created_at) AS sort_at + FROM website_help_center_tickets h + LEFT JOIN users hu ON hu.id = h.user_id${where} + `); } - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const rows = await db - .select({ - id: WebsiteTicket.id, - subject: WebsiteTicket.subject, - status: WebsiteTicket.status, - creatorId: WebsiteTicket.creatorId, - updatedAt: WebsiteTicket.updatedAt, - createdAt: WebsiteTicket.createdAt, - creatorUsername: Creator.username, - }) - .from(WebsiteTicket) - .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) - .where(where) - .orderBy(desc(WebsiteTicket.updatedAt), desc(WebsiteTicket.id)) - .limit(limit) - .catch(() => []); - - const prefix = base === "mod" ? "/mod/tickets" : "/admin/tickets"; - - return rows.map((row) => ({ - key: `cms-${row.id}`, - kind: "cms" as const, - id: String(row.id), - title: row.subject, - user: row.creatorUsername ?? "—", - userId: row.creatorId, - status: row.status, - open: row.status !== "closed", - sortAt: toSortMs(row.updatedAt ?? row.createdAt), - date: formatDate(row.updatedAt ?? row.createdAt, "date"), - href: `${prefix}/${row.id}`, - })); + return sql.join(branches, sql` UNION ALL `); } -async function fetchHelpCandidates( - limit: number, - search: string, - openOnly: boolean, - base: "admin" | "mod", -): Promise { - const conditions: SQL[] = []; +/** Merged read-model over CMS desk + help-center queues (single DB query). */ +export async function fetchUnifiedTicketInbox( + options: FetchTicketInboxOptions, +): Promise<{ + rows: TicketInboxRow[]; + total: number; + page: number; + perPage: number; + lastPage: number; + cmsTotal: number; + helpTotal: number; +}> { + const type = options.type ?? "all"; + const openOnly = options.openOnly !== false; + const search = options.search?.trim() ?? ""; + const base = options.base ?? "admin"; - if (openOnly) { - conditions.push(eq(WebsiteHelpCenterTickets.open, true)); + const includeCms = type === "all" || type === "cms"; + const includeHelp = type === "all" || type === "help"; + + const [cmsTotal, helpTotal] = await Promise.all([ + includeCms ? countCms(search, openOnly) : Promise.resolve(0), + includeHelp ? countHelp(search, openOnly) : Promise.resolve(0), + ]); + + const total = cmsTotal + helpTotal; + const pagination = calcPagination(total, options.page, options.perPage); + + const cmsPrefix = base === "mod" ? "/mod/tickets" : "/admin/tickets"; + const helpPrefix = + base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets"; + + let rows: TicketInboxRow[] = []; + if (includeCms || includeHelp) { + const union = buildInboxUnion(includeCms, includeHelp, search, openOnly); + try { + const raw = await queryRows(sql` + SELECT * FROM (${union}) AS merged + ORDER BY sort_at DESC, kind DESC, id DESC + LIMIT ${pagination.perPage} OFFSET ${pagination.offset} + `); + rows = raw.map((row) => { + const open = Boolean(row.is_open); + return { + key: `${row.kind}-${row.id}`, + kind: row.kind, + id: String(row.id), + title: row.title, + user: + row.username ?? + (row.kind === "help" && row.user_id != null + ? `#${row.user_id}` + : "—"), + userId: row.user_id, + status: row.kind === "cms" ? row.status : open ? "open" : "closed", + open, + sortAt: toSortMs(row.sort_at), + date: formatDate(row.sort_at, "date"), + href: + row.kind === "cms" + ? `${cmsPrefix}/${row.id}` + : `${helpPrefix}/${row.id}`, + }; + }); + } catch (error) { + logger.error("Failed to load unified ticket inbox", { error }); + } } - if (search) { - const asId = /^\d+$/.test(search) ? BigInt(search) : null; - const matchingUsers = await db - .select({ id: User.id }) - .from(User) - .where(like(User.username, `%${search}%`)) - .limit(50) - .catch(() => []); - - const searchOr = [ - like(WebsiteHelpCenterTickets.title, `%${search}%`), - like(WebsiteHelpCenterTickets.content, `%${search}%`), - ...(matchingUsers.length > 0 - ? [ - inArray( - WebsiteHelpCenterTickets.userId, - matchingUsers.map((u) => u.id), - ), - ] - : []), - ...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []), - ]; - const searchFilter = or(...searchOr); - if (searchFilter) conditions.push(searchFilter); - } - - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const rows = await db - .select({ - id: WebsiteHelpCenterTickets.id, - title: WebsiteHelpCenterTickets.title, - open: WebsiteHelpCenterTickets.open, - userId: WebsiteHelpCenterTickets.userId, - updatedAt: WebsiteHelpCenterTickets.updatedAt, - createdAt: WebsiteHelpCenterTickets.createdAt, - }) - .from(WebsiteHelpCenterTickets) - .where(where) - .orderBy( - desc(WebsiteHelpCenterTickets.updatedAt), - desc(WebsiteHelpCenterTickets.id), - ) - .limit(limit) - .catch(() => []); - - const userIds = [ - ...new Set( - rows.map((row) => row.userId).filter((id): id is number => id != null), - ), - ]; - const users = - userIds.length > 0 - ? await db - .select({ id: User.id, username: User.username }) - .from(User) - .where(inArray(User.id, userIds)) - .catch(() => []) - : []; - const usernameById = new Map(users.map((u) => [u.id, u.username])); - - const prefix = base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets"; - - return rows.map((row) => ({ - key: `help-${row.id}`, - kind: "help" as const, - id: String(row.id), - title: row.title, - user: - row.userId != null - ? (usernameById.get(row.userId) ?? `#${row.userId}`) - : "—", - userId: row.userId, - status: row.open ? "open" : "closed", - open: row.open, - sortAt: toSortMs(row.updatedAt ?? row.createdAt), - date: formatDate(row.updatedAt ?? row.createdAt, "date"), - href: `${prefix}/${row.id}`, - })); + return { + rows, + total, + page: pagination.page, + perPage: pagination.perPage, + lastPage: pagination.lastPage, + cmsTotal, + helpTotal, + }; } async function countCms(search: string, openOnly: boolean): Promise { @@ -236,24 +244,12 @@ async function countHelp(search: string, openOnly: boolean): Promise { const conditions: SQL[] = []; if (openOnly) conditions.push(eq(WebsiteHelpCenterTickets.open, true)); if (search) { + const pattern = `%${search}%`; const asId = /^\d+$/.test(search) ? BigInt(search) : null; - const matchingUsers = await db - .select({ id: User.id }) - .from(User) - .where(like(User.username, `%${search}%`)) - .limit(50) - .catch(() => []); const searchFilter = or( - like(WebsiteHelpCenterTickets.title, `%${search}%`), - like(WebsiteHelpCenterTickets.content, `%${search}%`), - ...(matchingUsers.length > 0 - ? [ - inArray( - WebsiteHelpCenterTickets.userId, - matchingUsers.map((u) => u.id), - ), - ] - : []), + like(WebsiteHelpCenterTickets.title, pattern), + like(WebsiteHelpCenterTickets.content, pattern), + sql`EXISTS (SELECT 1 FROM users hu WHERE hu.id = ${WebsiteHelpCenterTickets.userId} AND hu.username LIKE ${pattern})`, ...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []), ); if (searchFilter) conditions.push(searchFilter); @@ -266,62 +262,3 @@ async function countHelp(search: string, openOnly: boolean): Promise { .catch(() => [{ total: 0 }]); return Number(rows[0]?.total ?? 0); } - -/** Merged read-model over CMS desk + help-center queues (no DB merge). */ -export async function fetchUnifiedTicketInbox( - options: FetchTicketInboxOptions, -): Promise<{ - rows: TicketInboxRow[]; - total: number; - page: number; - perPage: number; - lastPage: number; - cmsTotal: number; - helpTotal: number; -}> { - const type = options.type ?? "all"; - const openOnly = options.openOnly !== false; - const search = options.search?.trim() ?? ""; - const base = options.base ?? "admin"; - - const includeCms = type === "all" || type === "cms"; - const includeHelp = type === "all" || type === "help"; - - const [cmsTotal, helpTotal] = await Promise.all([ - includeCms ? countCms(search, openOnly) : Promise.resolve(0), - includeHelp ? countHelp(search, openOnly) : Promise.resolve(0), - ]); - - const total = cmsTotal + helpTotal; - const pagination = calcPagination(total, options.page, options.perPage); - const window = pagination.offset + pagination.perPage; - - const [cmsRows, helpRows] = await Promise.all([ - includeCms - ? fetchCmsCandidates(window, search, openOnly, base) - : Promise.resolve([]), - includeHelp - ? fetchHelpCandidates(window, search, openOnly, base) - : Promise.resolve([]), - ]); - - const merged = [...cmsRows, ...helpRows].sort((a, b) => { - if (b.sortAt !== a.sortAt) return b.sortAt - a.sortAt; - return a.key < b.key ? 1 : -1; - }); - - const rows = merged.slice( - pagination.offset, - pagination.offset + pagination.perPage, - ); - - return { - rows, - total, - page: pagination.page, - perPage: pagination.perPage, - lastPage: pagination.lastPage, - cmsTotal, - helpTotal, - }; -} diff --git a/src/lib/import-url-safety.test.ts b/src/lib/import-url-safety.test.ts new file mode 100644 index 00000000..b6ecb27d --- /dev/null +++ b/src/lib/import-url-safety.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertSafeImportUrl } from "./import-url-safety"; + +vi.mock("node:dns/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + lookup: vi.fn(async (hostname) => { + if (hostname === "natty.host") { + return [{ address: "10.0.0.4", family: 4 }]; + } + if (hostname === "loopback-hook.test") { + return [{ address: "127.0.0.1", family: 4 }]; + } + if (hostname === "v6mine.host") { + return [{ address: "fd00::1", family: 6 }]; + } + if (hostname === "nope.invalid") { + throw new Error("NXDOMAIN"); + } + return [{ address: "104.18.0.1", family: 4 }]; + }), + }; +}); + +describe("assertSafeImportUrl", () => { + it("accepts a public https host", async () => { + await expect( + assertSafeImportUrl("https://images.habbo.com/dcr", "x"), + ).resolves.toBeUndefined(); + }); + + it("accepts a public IP literal", async () => { + await expect( + assertSafeImportUrl("https://8.8.8.8/path", "x"), + ).resolves.toBeUndefined(); + }); + + it("rejects a non-https URL", async () => { + await expect( + assertSafeImportUrl("http://images.habbo.com", "furnidataUrl"), + ).rejects.toThrow("furnidataUrl must be an https:// URL"); + }); + + it("rejects an invalid URL", async () => { + await expect( + assertSafeImportUrl("not a url", "nitroBaseUrl"), + ).rejects.toThrow("nitroBaseUrl is not a valid URL"); + }); + + it("rejects embedded credentials", async () => { + await expect( + assertSafeImportUrl("https://user:pass@images.habbo.com", "x"), + ).rejects.toThrow("must not embed credentials"); + }); + + it("rejects a private IP literal", async () => { + await expect( + assertSafeImportUrl("https://10.1.2.3/assets", "x"), + ).rejects.toThrow("private address"); + await expect( + assertSafeImportUrl("https://192.168.0.1/assets", "x"), + ).rejects.toThrow("private address"); + await expect( + assertSafeImportUrl("https://172.16.5.5/assets", "x"), + ).rejects.toThrow("private address"); + }); + + it("rejects a loopback alias and numeric localhost", async () => { + await expect( + assertSafeImportUrl("https://localhost/assets", "x"), + ).rejects.toThrow("local host"); + await expect( + assertSafeImportUrl("https://127.0.0.1/assets", "x"), + ).rejects.toThrow("private address"); + }); + + it("rejects a host that resolves to a private address", async () => { + await expect( + assertSafeImportUrl("https://natty.host/assets", "x"), + ).rejects.toThrow("resolves to a private address"); + }); + + it("rejects a host that resolves to loopback", async () => { + await expect( + assertSafeImportUrl("https://loopback-hook.test/assets", "x"), + ).rejects.toThrow("resolves to a private address"); + }); + + it("rejects a host that resolves to an IPv6 ULA", async () => { + await expect( + assertSafeImportUrl("https://v6mine.host/assets", "x"), + ).rejects.toThrow("resolves to a private address"); + }); + + it("rejects a host that does not resolve", async () => { + await expect( + assertSafeImportUrl("https://nope.invalid/assets", "x"), + ).rejects.toThrow("does not resolve"); + }); + + it("allows an empty url", async () => { + await expect(assertSafeImportUrl(" ", "x")).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/import-url-safety.ts b/src/lib/import-url-safety.ts new file mode 100644 index 00000000..844b558c --- /dev/null +++ b/src/lib/import-url-safety.ts @@ -0,0 +1,82 @@ +import { lookup } from "node:dns/promises"; + +function isPrivateIpv4(hex: string): boolean { + const parts = hex.split(".").map((p) => Number(p)); + if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true; + const [a, b] = parts; + if (a === 0 || a === 10) return true; + if (a === 127 || a === 255) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 192 && b === 0 && parts[2] === 0 && parts[3] === 0) return true; + return false; +} + +function isPrivateIpv6(addr: string): boolean { + const lower = addr.toLowerCase(); + if (lower.startsWith("::") || lower === "::1") return true; + if (/^fe[89ab]/.test(lower) || /^fc|^fd/.test(lower)) return true; + if (lower.includes(":") && lower.split(":").length === 4) return false; + if (/^2001:0?db8/.test(lower)) return true; + return lower === "::" || lower.endsWith(":ffff:"); +} + +function isPrivateIp(ip: string): boolean { + return ip.includes(":") ? isPrivateIpv6(ip) : isPrivateIpv4(ip); +} + +const BLOCKED_HOST_LABELS = + /^(localhost|.*\.local|.*\.localhost|.*\.internal|.*\.localdomain|.*\.lan)$/i; + +/** + * Guard a remote asset URL before it is stored or fetched. Rejects anything + * that could reach the host itself: non-https, embedded credentials, private + * IP literals, loopback/local-only hostnames and any host that resolves to a + * private or loopback address. Throws TypeError with a human message so admin + * routes can surface it as a 400. + */ +export async function assertSafeImportUrl( + url: string, + label: string, +): Promise { + const trimmed = url.trim(); + if (!trimmed) return; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new TypeError(`${label} is not a valid URL`); + } + if (parsed.protocol !== "https:") { + throw new TypeError(`${label} must be an https:// URL`); + } + if (parsed.username || parsed.password) { + throw new TypeError(`${label} must not embed credentials`); + } + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + if (BLOCKED_HOST_LABELS.test(hostname)) { + throw new TypeError(`${label} points at a local host`); + } + if (hostname) { + const looksLiteral = + hostname.includes(":") || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + if (looksLiteral) { + if (isPrivateIp(hostname.toLowerCase())) { + throw new TypeError(`${label} points at a private address`); + } + return; + } + let addresses: string[]; + try { + const result = await lookup(hostname, { all: true, verbatim: true }); + addresses = result.map((r) => r.address); + } catch { + throw new TypeError(`${label} does not resolve to a public host`); + } + if (addresses.some((ip) => isPrivateIp(ip.toLowerCase()))) { + throw new TypeError(`${label} resolves to a private address`); + } + } +} diff --git a/src/lib/services/admin-stats.ts b/src/lib/services/admin-stats.ts index 245b6e4d..3f86d68e 100644 --- a/src/lib/services/admin-stats.ts +++ b/src/lib/services/admin-stats.ts @@ -4,23 +4,27 @@ import { db } from "@/lib/db"; import { redisCache } from "@/lib/redis-cache"; /** - * Caches admin list totals when no filters are applied (no 'where' clause), - * preventing heavy COUNT(*) table scans on every request for large tables. - * Filtered queries bypass cache to ensure exact search result counts. + * Caches admin list totals, preventing heavy COUNT(*) table scans on every + * request for large tables. + * + * By default only the unfiltered (global) count is cached; filtered queries + * hit the DB so search result counts stay exact. Callers that read a stable, + * fixed-filter total (e.g. "open tickets") can opt into caching it too with + * `{ cacheFiltered: true }` and a cacheKey unique to that filter. */ export async function getCachedAdminCount( table: MySqlTable, cacheKey: string, where?: SQL | undefined, + options?: { cacheFiltered?: boolean }, ): Promise { - // Only cache the global count (where = undefined) - if (where) { + const run = async (): Promise => { const [row] = await db.select({ total: count() }).from(table).where(where); return Number(row?.total ?? 0); - } + }; - return await redisCache(`admin:count:${cacheKey}`, 60, async () => { - const [row] = await db.select({ total: count() }).from(table); - return Number(row?.total ?? 0); - }); + if (!where || options?.cacheFiltered) { + return await redisCache(`admin:count:${cacheKey}`, 60, run); + } + return await run(); } diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index 524955a4..4476e442 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -127,13 +127,16 @@ export async function loadCatalogItemsData( pageId: number, catalog: "normal" | "bc" = "normal", ): Promise { - // Load items via raw query to work around pageId Int vs VARCHAR mismatch + // Load items via raw query to work around pageId Int vs VARCHAR mismatch. + // Bind the id as a *string* constant: MySQL/MariaDB coerce a constant to the + // column's type once, so `page_id = ?` stays sargable whether the live column + // is INT or VARCHAR. Casting the column (CAST(page_id AS CHAR)) would wrap it + // in a function and force a full table scan. const pageIdStr = String(pageId); - // CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int. const offers = catalog === "bc" ? "catalog_items_bc" : "catalog_items"; const rawItems = await queryRows>(sql` SELECT * FROM ${sql.raw(offers)} - WHERE CAST(page_id AS CHAR) = ${pageIdStr} + WHERE page_id = ${pageIdStr} ORDER BY order_number ASC, id ASC `); // The BC table carries only a reference to the furniture: there are no price, diff --git a/src/lib/services/clone-bulk-import-live.test.ts b/src/lib/services/clone-bulk-import-live.test.ts index e89e511d..1f192345 100644 --- a/src/lib/services/clone-bulk-import-live.test.ts +++ b/src/lib/services/clone-bulk-import-live.test.ts @@ -14,7 +14,7 @@ const log = (msg: string) => appendFileSync(LOG, `${msg}\n`); const IMPORT_IDS = ["default-sodastudios", "default-hubbly"]; describe("clone bulk import", { - skip: process.env.RUN_CLONE_BULK_LIVE !== "1", + skip: !runLive, }, () => { beforeAll(async () => { loadEnvForLiveTests(); diff --git a/src/lib/services/clone-feasibility-live.test.ts b/src/lib/services/clone-feasibility-live.test.ts index 8f10b814..ce87c72a 100644 --- a/src/lib/services/clone-feasibility-live.test.ts +++ b/src/lib/services/clone-feasibility-live.test.ts @@ -11,7 +11,7 @@ const LOG = "/tmp/clone-feasibility.log"; const log = (msg: string) => appendFileSync(LOG, `${msg}\n`); describe("clone feasibility", { - skip: process.env.RUN_CLONE_FEASIBILITY_LIVE !== "1", + skip: !runLive, }, () => { beforeAll(async () => { loadEnvForLiveTests(); diff --git a/src/messages/ar.json b/src/messages/ar.json index 14f2de85..ea29d82b 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -1915,7 +1915,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP Management", @@ -3771,7 +3772,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Media Library", diff --git a/src/messages/bg.json b/src/messages/bg.json index 45b638ae..758f5592 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP управление", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Медийна библиотека", diff --git a/src/messages/cs.json b/src/messages/cs.json index 445123e5..95d9c61c 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "Správa IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Knihovna médií", diff --git a/src/messages/da.json b/src/messages/da.json index 2de149b8..59e0dee4 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Du kan ikke udføre handling på en bruger med samme eller højere rang." }, "ip": { "title": "IP Management", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Temaændringen kunne ikke gemmes." }, "media": { "title": "Mediebibliotek", diff --git a/src/messages/de.json b/src/messages/de.json index dc321a46..a70f7359 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -1603,7 +1603,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Sie können keine Aktion an einem Benutzer mit gleichem oder höherem Rang ausführen." }, "ip": { "title": "IP-Verwaltung", @@ -3818,7 +3819,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Die Design-Änderung konnte nicht gespeichert werden." }, "media": { "title": "Medienbibliothek", diff --git a/src/messages/el.json b/src/messages/el.json index 890db532..6bbccc00 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "Διαχείριση IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Βιβλιοθήκη πολυμέσων", diff --git a/src/messages/en.json b/src/messages/en.json index 0ff42ceb..b1f30c69 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1982,7 +1982,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP Management", @@ -4620,7 +4621,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Media Library", diff --git a/src/messages/es.json b/src/messages/es.json index 046359fa..ef1809bb 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -1603,7 +1603,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "No puedes actuar sobre un usuario con un rango igual o superior." }, "ip": { "title": "Gestión de IP", @@ -3818,7 +3819,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "No se pudo guardar el cambio de tema." }, "media": { "title": "Biblioteca multimedia", diff --git a/src/messages/fi.json b/src/messages/fi.json index f4f8225f..7dabf8c2 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -1915,7 +1915,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP Management", @@ -3771,7 +3772,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Media Library", diff --git a/src/messages/fr.json b/src/messages/fr.json index facb055e..70b4df44 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -1603,7 +1603,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Vous ne pouvez pas agir sur un utilisateur ayant un rang égal ou supérieur." }, "ip": { "title": "Gestion IP", @@ -3818,7 +3819,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "La modification du thème n’a pas pu être enregistrée." }, "media": { "title": "Médiathèque", diff --git a/src/messages/hr.json b/src/messages/hr.json index 928840e9..612b7cf1 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP upravljanje", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Mediateka", diff --git a/src/messages/hu.json b/src/messages/hu.json index ee4ba54d..cb451157 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP-kezelés", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Médiatár", diff --git a/src/messages/it.json b/src/messages/it.json index 9f731b05..42c66395 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -1951,7 +1951,8 @@ "reasonPlaceholder": "Motivo del ban", "ban": "Banna utente", "colUserId": "ID utente", - "lift": "Revoca ban" + "lift": "Revoca ban", + "rankError": "Non puoi agire su un utente con un rango pari o superiore." }, "ip": { "title": "Gestione IP", @@ -4346,7 +4347,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Non è stato possibile salvare la modifica del tema." }, "media": { "title": "Libreria media", diff --git a/src/messages/ja.json b/src/messages/ja.json index 13cb4fdd..2bb51b51 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -1915,7 +1915,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP Management", @@ -3771,7 +3772,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Media Library", diff --git a/src/messages/nl.json b/src/messages/nl.json index fdb6dd12..8463057f 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1982,7 +1982,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "U kunt geen actie uitvoeren op een gebruiker met een gelijke of hogere rang." }, "ip": { "title": "IP-beheer", @@ -4349,7 +4350,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "De themawijziging kon niet worden opgeslagen." }, "media": { "title": "Mediabibliotheek", diff --git a/src/messages/no.json b/src/messages/no.json index ca568221..27676a92 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP-administrasjon", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Mediebibliotek", diff --git a/src/messages/pl.json b/src/messages/pl.json index 38bbd266..1bb5cc55 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Nie możesz działać na użytkownika z rangą równą lub wyższą." }, "ip": { "title": "Zarządzanie IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Nie udało się zapisać zmiany motywu." }, "media": { "title": "Biblioteka multimediów", diff --git a/src/messages/pt.json b/src/messages/pt.json index 4fc2dbdc..9c0f42c7 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Não pode agir sobre um utilizador com um posto igual ou superior." }, "ip": { "title": "Gerenciamento de IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Não foi possível guardar a alteração do tema." }, "media": { "title": "Biblioteca de mídia", diff --git a/src/messages/ro.json b/src/messages/ro.json index eb644434..b8d3b2d7 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Nu poți acționa asupra unui utilizator cu un rang egal sau superior." }, "ip": { "title": "Managementul IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Modificarea temei nu a putut fi salvată." }, "media": { "title": "Biblioteca media", diff --git a/src/messages/ru.json b/src/messages/ru.json index eee5a28a..669d17f7 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -1631,7 +1631,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "Нельзя действовать на пользователя с рангом равным или выше вашего." }, "ip": { "title": "IP Management", @@ -3846,7 +3847,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "Не удалось сохранить изменение темы." }, "media": { "title": "Media Library", diff --git a/src/messages/sk.json b/src/messages/sk.json index 6d434b3a..2eca68ad 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "Správa IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Knižnica médií", diff --git a/src/messages/sr.json b/src/messages/sr.json index 556eabf6..01edd1cc 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "ИП менаџмент", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Медиа Либрари", diff --git a/src/messages/sv.json b/src/messages/sv.json index 15f0b2a1..9518fe93 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP-hantering", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Mediebibliotek", diff --git a/src/messages/tr.json b/src/messages/tr.json index c6a8c9d9..e2bfca8c 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "IP Yönetimi", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Medya Kitaplığı", diff --git a/src/messages/uk.json b/src/messages/uk.json index 647cbd4e..33ec3892 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -1633,7 +1633,8 @@ "reasonPlaceholder": "Reason for the ban", "ban": "Ban user", "colUserId": "User ID", - "lift": "Lift ban" + "lift": "Lift ban", + "rankError": "You cannot act on a user with an equal or higher rank." }, "ip": { "title": "Управління IP", @@ -3848,7 +3849,8 @@ "border2pxsolidred": "border: 2px solid red;", "CustomHTML": "Custom HTML", "AddcustomHTMLthatinjectsintothepageforthisscopeUseforcustombannersscriptsorcontentblocks": "Add custom HTML that injects into the page for this scope. Use for custom banners, scripts, or content blocks." - } + }, + "saveError": "The theme change could not be saved." }, "media": { "title": "Медіатека",