feat(admin): drizzle trade-lock + RCON sync and photo local purge
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
67656a9aad
commit
9854719cfd
7 files changed
+250
-45
No files matched your search
@@ -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<string, string>) => ({
|
||||
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();
|
||||
});
|
||||
});
|
||||
+35
-14
@@ -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<void> {
|
||||
@@ -11,8 +13,15 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -36,14 +44,27 @@ export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
||||
.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");
|
||||
}
|
||||
+62
-22
@@ -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<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user