diff --git a/src/actions/admin-photos.test.ts b/src/actions/admin-photos.test.ts index 28288b50..d90175dc 100644 --- a/src/actions/admin-photos.test.ts +++ b/src/actions/admin-photos.test.ts @@ -2,36 +2,71 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { prisma } from "@/lib/prisma"; +import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; +import { logStaffActivity } from "@/lib/services/staff-activity"; import { deletePhoto } from "./admin-photos"; +const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => { + const limit = vi.fn(); + const whereSelect = vi.fn(() => ({ limit })); + const from = vi.fn(() => ({ where: whereSelect })); + const select = vi.fn(() => ({ from })); + const whereDelete = vi.fn(); + const deleteFn = vi.fn(() => ({ where: whereDelete })); + return { select, deleteFn, limit, whereDelete, whereSelect, from }; +}); + vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); -vi.mock("@/lib/prisma", () => ({ prisma: { cameraWeb: { delete: vi.fn() } } })); +vi.mock("@/lib/admin/photo-files", () => ({ + tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true), +})); +vi.mock("@/lib/services/staff-activity", () => ({ + logStaffActivity: vi.fn().mockResolvedValue(undefined), +})); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/db", () => ({ + db: { + select: (...args) => select(...args), + delete: (...args) => deleteFn(...args), + }, + CameraWeb: { id: "id", url: "url" }, +})); -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, +const fakeForm = (data) => ({ + get: (key) => data[key] ?? null, }); beforeEach(() => { vi.clearAllMocks(); + limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]); + whereDelete.mockResolvedValue(undefined); vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin", - } as never); + }); }); describe("deletePhoto", () => { it("deletes a photo and revalidates", async () => { - await deletePhoto(fakeForm({ id: "42" }) as unknown as FormData); - expect(prisma.cameraWeb.delete).toHaveBeenCalledWith({ where: { id: 42 } }); + await deletePhoto(fakeForm({ id: "42" })); + expect(select).toHaveBeenCalled(); + expect(deleteFn).toHaveBeenCalled(); + expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png"); + expect(logStaffActivity).toHaveBeenCalledWith( + expect.objectContaining({ + action: "photo_delete", + targetId: 42, + }), + ); expect(revalidatePath).toHaveBeenCalledWith("/admin/photos"); + expect(revalidatePath).toHaveBeenCalledWith("/photos"); }); it("returns early when id is not positive", async () => { - await deletePhoto(fakeForm({ id: "0" }) as unknown as FormData); - expect(prisma.cameraWeb.delete).not.toHaveBeenCalled(); + await deletePhoto(fakeForm({ id: "0" })); + expect(select).not.toHaveBeenCalled(); + expect(deleteFn).not.toHaveBeenCalled(); }); }); diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts index da1d8b19..856b21c3 100644 --- a/src/actions/admin-photos.ts +++ b/src/actions/admin-photos.ts @@ -1,9 +1,11 @@ "use server"; +import { eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; +import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; +import { CameraWeb, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { prisma } from "@/lib/prisma"; import { logStaffActivity } from "@/lib/services/staff-activity"; export async function deletePhoto(formData: FormData): Promise { @@ -11,8 +13,15 @@ export async function deletePhoto(formData: FormData): Promise { const id = Number(formData.get("id")); if (!(id > 0)) return; - try { - await prisma.cameraWeb.delete({ where: { id } }); + const [row] = await db + .select({ id: CameraWeb.id, url: CameraWeb.url }) + .from(CameraWeb) + .where(eq(CameraWeb.id, id)) + .limit(1); + + if (row) { + await db.delete(CameraWeb).where(eq(CameraWeb.id, id)); + await tryRemoveLocalPhotoFile(row.url); await logStaffActivity({ staffId: staff.id, action: "photo_delete", @@ -20,11 +29,10 @@ export async function deletePhoto(formData: FormData): Promise { targetType: "camera_web", targetId: id, }); - } catch { - // Record may have already been removed; ignore. } revalidatePath("/admin/photos"); + revalidatePath("/photos"); } export async function bulkDeletePhotos(formData: FormData): Promise { @@ -36,14 +44,27 @@ export async function bulkDeletePhotos(formData: FormData): Promise { .filter((n) => Number.isFinite(n) && n > 0); if (ids.length === 0) return; - const result = await prisma.cameraWeb.deleteMany({ - where: { id: { in: ids } }, - }); - await logStaffActivity({ - staffId: staff.id, - action: "photo_bulk_delete", - description: `Deleted ${result.count} camera photo(s)`, - targetType: "camera_web", - }); + const rows = await db + .select({ id: CameraWeb.id, url: CameraWeb.url }) + .from(CameraWeb) + .where(inArray(CameraWeb.id, ids)); + + if (rows.length > 0) { + await db.delete(CameraWeb).where( + inArray( + CameraWeb.id, + rows.map((r) => r.id), + ), + ); + await Promise.all(rows.map((r) => tryRemoveLocalPhotoFile(r.url))); + await logStaffActivity({ + staffId: staff.id, + action: "photo_bulk_delete", + description: `Deleted ${rows.length} camera photo(s)`, + targetType: "camera_web", + }); + } + revalidatePath("/admin/photos"); + revalidatePath("/photos"); } diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index d03bf746..63ff9ffa 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -1,6 +1,8 @@ "use server"; +import { eq, sql } from "drizzle-orm"; import { requirePermission } from "@/lib/admin/guard"; +import { db, Sanctions, User, UsersSettings } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import type { ActionResult } from "@/lib/safe-action-shared"; @@ -271,8 +273,8 @@ export async function bulkAdjustCurrency({ } /** - * Persist trade lock on `sanctions.trade_locked_until`. - * No first-class RCON trade-lock helper in this CMS — DB only. + * Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade` + * via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online). */ export async function setTradeLock({ userId, @@ -284,33 +286,71 @@ export async function setTradeLock({ }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const until = Math.max(0, Math.trunc(untilUnix)); + const locked = until > 0; - const existing = await prisma.sanctions.findFirst({ - where: { habboId: userId }, - select: { id: true }, - }); - if (existing) { - await prisma.sanctions.update({ - where: { id: existing.id }, - data: { tradeLockedUntil: until }, - }); - } else { - await prisma.sanctions.create({ - data: { + const [user] = await db + .select({ + id: User.id, + username: User.username, + online: User.online, + }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); + if (!user) { + return { ok: false as const, error: "User not found" }; + } + + await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: Sanctions.id }) + .from(Sanctions) + .where(eq(Sanctions.habboId, userId)) + .limit(1); + if (existing) { + await tx + .update(Sanctions) + .set({ + tradeLockedUntil: until, + ...(locked ? { reason: "Trade lock (CMS)" } : {}), + }) + .where(eq(Sanctions.id, existing.id)); + } else { + await tx.insert(Sanctions).values({ habboId: userId, tradeLockedUntil: until, - reason: until > 0 ? "Trade lock (CMS)" : "", - }, - }); + reason: locked ? "Trade lock (CMS)" : "", + }); + } + + await tx + .update(UsersSettings) + .set({ + canTrade: locked ? "0" : "1", + ...(locked + ? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` } + : {}), + }) + .where(eq(UsersSettings.userId, userId)); + }); + + await rcon.setTradeLock(userId, locked); + await rcon.alertUser( + userId, + locked + ? "Trading has been disabled by staff." + : "Trading has been re-enabled by staff.", + ); + if (user.online === "1") { + await rcon.disconnectUser(userId, user.username); } await logStaffActivity({ staffId: staff.id, - action: until > 0 ? "trade_lock" : "trade_unlock", - description: - until > 0 - ? `Trade-locked user #${userId} until ${until}` - : `Cleared trade lock for user #${userId}`, + action: locked ? "trade_lock" : "trade_unlock", + description: locked + ? `Trade-locked ${user.username} (#${userId}) until ${until}` + : `Cleared trade lock for ${user.username} (#${userId})`, targetType: "user", targetId: userId, }); diff --git a/src/actions/set-trade-lock.test.ts b/src/actions/set-trade-lock.test.ts new file mode 100644 index 00000000..002a2b40 --- /dev/null +++ b/src/actions/set-trade-lock.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; + +describe("setTradeLock drizzle + RCON contract", () => { + const src = readFileSync("src/actions/bulk-users.ts", "utf8"); + const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8"); + + it("writes sanctions + users_settings via Drizzle, not prisma facade", () => { + expect(src).toContain("@/lib/db"); + expect(src).toContain("UsersSettings"); + expect(src).toContain("Sanctions"); + expect(src).toContain("canTrade"); + expect(src).toContain("tradeLockedUntil"); + expect(src).toMatch(/export async function setTradeLock/); + const fn = src.slice(src.indexOf("export async function setTradeLock")); + expect(fn).not.toContain("prisma.sanctions"); + }); + + it("syncs live hotel via RCON settradelock + alert + disconnect", () => { + expect(rconSrc).toContain("settradelock"); + expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)"); + expect(src).toContain("rcon.setTradeLock"); + expect(src).toContain("rcon.alertUser"); + expect(src).toContain("rcon.disconnectUser"); + }); +}); + +describe("admin-photos drizzle contract", () => { + const src = readFileSync("src/actions/admin-photos.ts", "utf8"); + + it("deletes via Drizzle CameraWeb and attempts local file purge", () => { + expect(src).toContain("@/lib/db"); + expect(src).toContain("CameraWeb"); + expect(src).toContain("tryRemoveLocalPhotoFile"); + expect(src).not.toContain("@/lib/prisma"); + expect(src).toContain('revalidatePath("/photos")'); + }); +}); + +describe("tryRemoveLocalPhotoFile", () => { + it("rejects path traversal and remote CDN urls", async () => { + expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe( + false, + ); + expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false); + expect(await tryRemoveLocalPhotoFile("")).toBe(false); + }); +}); diff --git a/src/lib/admin/photo-files.ts b/src/lib/admin/photo-files.ts new file mode 100644 index 00000000..146b1b43 --- /dev/null +++ b/src/lib/admin/photo-files.ts @@ -0,0 +1,39 @@ +import { unlink } from "node:fs/promises"; +import path from "node:path"; + +/** + * Best-effort local file delete for camera photos hosted under /public. + * External CDN URLs are left alone (no purge credentials in this CMS). + */ +export async function tryRemoveLocalPhotoFile(url: string): Promise { + if (!url?.trim()) return false; + let pathname = url.trim(); + if (/^https?:\/\//i.test(pathname)) { + try { + const parsed = new URL(pathname); + const app = ( + process.env.APP_URL || + process.env.NEXT_PUBLIC_APP_URL || + "" + ).replace(/\/$/, ""); + if (!app || !pathname.startsWith(app)) return false; + pathname = parsed.pathname; + } catch { + return false; + } + } + if (!pathname.startsWith("/")) return false; + const rel = pathname.replace(/^\/+/, ""); + if (!rel || rel.includes("..")) return false; + const publicRoot = path.resolve(process.cwd(), "public"); + const full = path.resolve(publicRoot, rel); + if (!full.startsWith(publicRoot + path.sep) && full !== publicRoot) { + return false; + } + try { + await unlink(full); + return true; + } catch { + return false; + } +} diff --git a/src/lib/services/rcon.ts b/src/lib/services/rcon.ts index 0c4e8c6e..fe94e9c4 100644 --- a/src/lib/services/rcon.ts +++ b/src/lib/services/rcon.ts @@ -140,6 +140,17 @@ export class RconClient { unmuteUser(userId: number) { return this.send("unmuteuser", { user_id: userId }); } + /** + * Best-effort live trade lock sync. Polaris/Arcturus forks may expose + * `settradelock`; unknown keys are ignored by the emulator. Prefer updating + * `users_settings.can_trade` in DB and disconnecting online users. + */ + setTradeLock(userId: number, locked: boolean) { + return this.send("settradelock", { + user_id: userId, + enabled: locked ? 0 : 1, + }); + } } const globalForRcon = globalThis as unknown as { rcon?: RconClient }; diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index 07112576..a40f4e99 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -111,6 +111,16 @@ describe("staff smoke contract", () => { expect(src).toContain("bulkAdjustCurrency"); expect(src).toContain("setTradeLock"); expect(src).toContain("tradeLockedUntil"); + expect(src).toContain("UsersSettings"); + expect(src).toContain("rcon.setTradeLock"); + }); + + it("deletes photos via Drizzle with local file purge helper", () => { + const src = readFileSync("src/actions/admin-photos.ts", "utf8"); + expect(src).toContain("CameraWeb"); + expect(src).toContain("tryRemoveLocalPhotoFile"); + expect(src).toContain("@/lib/admin/photo-files"); + expect(src).not.toContain("@/lib/prisma"); }); it("guards dual ticket queues on admin and mod", () => {