Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

+6 -2
View File
@@ -12,7 +12,9 @@ import { formPositiveBigInt } from "@/lib/form-data";
export async function createAd(formData: FormData): Promise<void> {
const staff = await requireStaff();
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
const image = String(formData.get("image") ?? "")
.trim()
.slice(0, 255);
if (!image) return;
const now = new Date();
@@ -40,7 +42,9 @@ export async function updateAd(formData: FormData): Promise<void> {
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const image = String(formData.get("image") ?? "").trim().slice(0, 255);
const image = String(formData.get("image") ?? "")
.trim()
.slice(0, 255);
if (!image) return;
try {
+3 -1
View File
@@ -13,7 +13,9 @@ import { rcon } from "@/lib/services/rcon";
export async function sendHotelAlert(formData: FormData): Promise<void> {
await requireStaff();
const message = String(formData.get("message") ?? "").trim().slice(0, 1000);
const message = String(formData.get("message") ?? "")
.trim()
.slice(0, 1000);
if (!message) return;
try {
+9 -3
View File
@@ -53,10 +53,16 @@ export async function updateArticle(formData: FormData): Promise<void> {
await prisma.websiteArticles.update({
where: { id },
data: {
title: String(formData.get("title") ?? "").trim().slice(0, 255),
shortStory: String(formData.get("shortStory") ?? "").trim().slice(0, 255),
title: String(formData.get("title") ?? "")
.trim()
.slice(0, 255),
shortStory: String(formData.get("shortStory") ?? "")
.trim()
.slice(0, 255),
fullStory: String(formData.get("fullStory") ?? "").trim(),
image: String(formData.get("image") ?? "").trim().slice(0, 255),
image: String(formData.get("image") ?? "")
.trim()
.slice(0, 255),
updatedAt: new Date(),
},
});
+3 -1
View File
@@ -9,7 +9,9 @@ export async function giveBadge(formData: FormData): Promise<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "").trim().slice(0, 32);
const code = String(formData.get("code") ?? "")
.trim()
.slice(0, 32);
if (!(userId > 0) || code.length === 0) return;
// Fire the emulator command so the badge appears live for online users.
+4 -1
View File
@@ -14,7 +14,10 @@ const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
export async function createBan(formData: FormData): Promise<void> {
const staff = await requireStaff();
const userId = Number(formData.get("userId"));
const reason = String(formData.get("reason") ?? "").trim().slice(0, 200) || "Banned";
const reason =
String(formData.get("reason") ?? "")
.trim()
.slice(0, 200) || "Banned";
const hours = Number(formData.get("hours"));
const type = String(formData.get("type"));
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
+9 -3
View File
@@ -7,8 +7,12 @@ import { formPositiveBigInt } from "@/lib/form-data";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
const subject = String(formData.get("subject") ?? "")
.trim()
.slice(0, 255);
const body = String(formData.get("body") ?? "");
const variablesRaw = String(formData.get("variables") ?? "").trim();
const isActive = formData.get("isActive") != null;
@@ -36,7 +40,9 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
} catch {
return;
}
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
const subject = String(formData.get("subject") ?? "")
.trim()
.slice(0, 255);
const body = String(formData.get("body") ?? "");
const variablesRaw = String(formData.get("variables") ?? "").trim();
const isActive = formData.get("isActive") != null;
+6 -2
View File
@@ -11,7 +11,9 @@ import { prisma } from "@/lib/prisma";
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
const key = String(formData.get("key") ?? "")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "").slice(0, 512);
if (!key) return;
await prisma.emulatorSettings.upsert({
@@ -24,7 +26,9 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
const key = String(formData.get("key") ?? "")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "").slice(0, 4096);
if (!key) return;
await prisma.emulatorTexts.upsert({
+38 -12
View File
@@ -17,16 +17,29 @@ function parsePosition(value: FormDataEntryValue | null): number {
export async function createHelpQuestion(formData: FormData): Promise<void> {
const staff = await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
const content = String(formData.get("content") ?? "").trim();
if (!name || !content) return;
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
const imageUrl = String(formData.get("imageUrl") ?? "")
.trim()
.slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "")
.trim()
.slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "")
.trim()
.slice(0, 255);
const buttonColor =
String(formData.get("buttonColor") ?? "")
.trim()
.slice(0, 16) || "#eeb425";
const buttonBorderColor =
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
String(formData.get("buttonBorderColor") ?? "")
.trim()
.slice(0, 16) || "#facc15";
try {
const entry = await prisma.websiteHelpCenterCategories.create({
@@ -63,16 +76,29 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
const content = String(formData.get("content") ?? "").trim();
if (!name || !content) return;
const imageUrl = String(formData.get("imageUrl") ?? "").trim().slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "").trim().slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "").trim().slice(0, 255);
const buttonColor = String(formData.get("buttonColor") ?? "").trim().slice(0, 16) || "#eeb425";
const imageUrl = String(formData.get("imageUrl") ?? "")
.trim()
.slice(0, 255);
const buttonText = String(formData.get("buttonText") ?? "")
.trim()
.slice(0, 255);
const buttonUrl = String(formData.get("buttonUrl") ?? "")
.trim()
.slice(0, 255);
const buttonColor =
String(formData.get("buttonColor") ?? "")
.trim()
.slice(0, 16) || "#eeb425";
const buttonBorderColor =
String(formData.get("buttonBorderColor") ?? "").trim().slice(0, 16) || "#facc15";
String(formData.get("buttonBorderColor") ?? "")
.trim()
.slice(0, 16) || "#facc15";
try {
await prisma.websiteHelpCenterCategories.update({
+6 -2
View File
@@ -11,9 +11,13 @@ import { prisma } from "@/lib/prisma";
export async function upsertPermission(formData: FormData): Promise<void> {
await requireStaff();
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
const permission = String(formData.get("permission") ?? "")
.trim()
.slice(0, 255);
const minRank = Number(formData.get("minRank"));
const descriptionRaw = String(formData.get("description") ?? "").trim().slice(0, 255);
const descriptionRaw = String(formData.get("description") ?? "")
.trim()
.slice(0, 255);
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
+6 -2
View File
@@ -5,11 +5,15 @@ import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "").trim().slice(0, 255);
return String(formData.get("ipAddress") ?? "")
.trim()
.slice(0, 255);
}
function parseAsn(formData: FormData): string | null {
const asn = String(formData.get("asn") ?? "").trim().slice(0, 255);
const asn = String(formData.get("asn") ?? "")
.trim()
.slice(0, 255);
return asn || null;
}
+2 -4
View File
@@ -19,10 +19,8 @@ const KEY_MIN_RANK = "min_maintenance_login_rank";
const COMMENTS: Record<string, string> = {
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
[KEY_MESSAGE]:
"The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]:
"The minimum rank required to login to the hotel during maintenance",
[KEY_MESSAGE]: "The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]: "The minimum rank required to login to the hotel during maintenance",
};
async function upsertSetting(key: string, value: string): Promise<void> {
+2 -2
View File
@@ -13,8 +13,8 @@ export async function uploadMedia(formData: FormData): Promise<void> {
await requireStaff();
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return;
if (file.size > MAX_SIZE) throw new Error("File too large (max 5MB)");
if (!ALLOWED.includes(file.type)) throw new Error("Invalid file type");
if (file.size > MAX_SIZE) return;
if (!ALLOWED.includes(file.type)) return;
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
// eslint-disable-next-line security/detect-non-literal-fs-filename
+6 -2
View File
@@ -20,7 +20,9 @@ function parseMinRank(formData: FormData): number {
export async function createPermission(formData: FormData): Promise<void> {
const staff = await requireStaff();
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
const permission = String(formData.get("permission") ?? "")
.trim()
.slice(0, 255);
const minRank = parseMinRank(formData);
const description = String(formData.get("description") ?? "").trim() || null;
if (!permission) return;
@@ -52,7 +54,9 @@ export async function updatePermission(formData: FormData): Promise<void> {
const raw = String(formData.get("id") ?? "");
if (!raw) return;
const id = BigInt(raw);
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
const permission = String(formData.get("permission") ?? "")
.trim()
.slice(0, 255);
const minRank = parseMinRank(formData);
const description = String(formData.get("description") ?? "").trim() || null;
if (!permission) return;
+30 -30
View File
@@ -1,9 +1,9 @@
'use server';
"use server";
import { revalidatePath } from 'next/cache';
import { requireStaff } from '@/lib/admin/guard';
import { prisma } from '@/lib/prisma';
import { logStaffActivity } from '@/lib/services/staff-activity';
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
// fallback playlist the radio rotates through when no live DJ is streaming.
@@ -13,7 +13,7 @@ import { logStaffActivity } from '@/lib/services/staff-activity';
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
@@ -23,13 +23,13 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
}
function str(raw: FormDataEntryValue | null): string {
return typeof raw === 'string' ? raw : '';
return typeof raw === "string" ? raw : "";
}
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase();
return v === '1' || v === 'true' || v === 'on';
return v === "1" || v === "true" || v === "on";
}
/** Parse a non-negative UnsignedInt, falling back to 0. */
@@ -42,7 +42,7 @@ function reqUInt(raw: FormDataEntryValue | null): number {
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
function optUInt(raw: FormDataEntryValue | null): number | null {
const s = str(raw).trim();
if (s === '') return null;
if (s === "") return null;
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
return Math.trunc(n);
@@ -52,15 +52,15 @@ function optUInt(raw: FormDataEntryValue | null): number | null {
export async function createTrack(formData: FormData): Promise<void> {
const staff = await requireStaff();
const title = str(formData.get('title')).trim().slice(0, 255);
const title = str(formData.get("title")).trim().slice(0, 255);
if (!title) return;
const artist = str(formData.get('artist')).trim().slice(0, 255);
const album = str(formData.get('album')).trim().slice(0, 255);
const artworkUrl = str(formData.get('artworkUrl')).trim().slice(0, 255);
const duration = optUInt(formData.get('duration'));
const sortOrder = reqUInt(formData.get('sortOrder'));
const isActive = bool(formData.get('isActive'));
const artist = str(formData.get("artist")).trim().slice(0, 255);
const album = str(formData.get("album")).trim().slice(0, 255);
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
const duration = optUInt(formData.get("duration"));
const sortOrder = reqUInt(formData.get("sortOrder"));
const isActive = bool(formData.get("isActive"));
const now = new Date();
try {
@@ -79,24 +79,24 @@ export async function createTrack(formData: FormData): Promise<void> {
});
await logStaffActivity({
staffId: staff.id,
action: 'radio_autodj_create',
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ''}`,
targetType: 'radio_auto_dj_track',
action: "radio_autodj_create",
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
targetType: "radio_auto_dj_track",
targetId: Number(created.id),
});
} catch {
// Fail soft — DB unavailable; re-render without throwing.
}
revalidatePath('/admin/radio/autodj');
revalidatePath("/admin/radio/autodj");
}
export async function toggleTrack(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
// The form posts the desired next state so the toggle is idempotent.
const isActive = bool(formData.get('isActive'));
const isActive = bool(formData.get("isActive"));
try {
await prisma.radioAutoDjPlaylist.update({
@@ -105,33 +105,33 @@ export async function toggleTrack(formData: FormData): Promise<void> {
});
await logStaffActivity({
staffId: staff.id,
action: 'radio_autodj_toggle',
description: `${isActive ? 'Activated' : 'Deactivated'} AutoDJ track #${id}`,
targetType: 'radio_auto_dj_track',
action: "radio_autodj_toggle",
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
targetType: "radio_auto_dj_track",
targetId: Number(id),
});
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/radio/autodj');
revalidatePath("/admin/radio/autodj");
}
export async function deleteTrack(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: 'radio_autodj_delete',
action: "radio_autodj_delete",
description: `Deleted AutoDJ track #${id}`,
targetType: 'radio_auto_dj_track',
targetType: "radio_auto_dj_track",
targetId: Number(id),
});
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/radio/autodj');
revalidatePath("/admin/radio/autodj");
}
+44 -44
View File
@@ -1,15 +1,15 @@
'use server';
"use server";
import { revalidatePath } from 'next/cache';
import { requireStaff } from '@/lib/admin/guard';
import { prisma } from '@/lib/prisma';
import { siteSettings } from '@/lib/services/site-settings';
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
// ── Helpers ────────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
@@ -19,13 +19,13 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
}
function str(raw: FormDataEntryValue | null): string {
return typeof raw === 'string' ? raw : '';
return typeof raw === "string" ? raw : "";
}
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase();
return v === '1' || v === 'true' || v === 'on';
return v === "1" || v === "true" || v === "on";
}
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
@@ -37,9 +37,9 @@ function bool(raw: FormDataEntryValue | null): boolean {
*/
export async function saveRadioSetting(formData: FormData): Promise<void> {
await requireStaff();
const key = str(formData.get('key')).trim().slice(0, 255);
const value = str(formData.get('value'));
const comment = str(formData.get('comment')).trim().slice(0, 255);
const key = str(formData.get("key")).trim().slice(0, 255);
const value = str(formData.get("value"));
const comment = str(formData.get("comment")).trim().slice(0, 255);
if (!key) return;
try {
@@ -52,7 +52,7 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
} catch {
// DB unavailable — fail soft so the action does not throw.
}
revalidatePath('/admin/radio/settings');
revalidatePath("/admin/radio/settings");
}
/**
@@ -62,11 +62,11 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
*/
export async function saveRadioSettings(formData: FormData): Promise<void> {
await requireStaff();
const keysRaw = str(formData.get('__keys'));
const keysRaw = str(formData.get("__keys"));
const keys = keysRaw
.split(',')
.split(",")
.map((k) => k.trim())
.filter((k) => k.startsWith('radio_') || k.startsWith('auto_dj_'));
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
if (keys.length === 0) return;
try {
@@ -84,21 +84,21 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
} catch {
// Fail soft.
}
revalidatePath('/admin/radio/settings');
revalidatePath("/admin/radio/settings");
}
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
export async function createRadioBanner(formData: FormData): Promise<void> {
const staff = await requireStaff();
const imagePath = str(formData.get('imagePath')).trim().slice(0, 255);
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
if (!imagePath) return;
const title = str(formData.get('title')).trim().slice(0, 255);
const description = str(formData.get('description')).trim();
const sortOrderNum = Number(str(formData.get('sortOrder')));
const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
const isActive = bool(formData.get('isActive'));
const isActive = bool(formData.get("isActive"));
const now = new Date();
try {
@@ -117,20 +117,20 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
} catch {
// Fail soft.
}
revalidatePath('/admin/radio/banners');
revalidatePath("/admin/radio/banners");
}
export async function updateRadioBanner(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
const imagePath = str(formData.get('imagePath')).trim().slice(0, 255);
const title = str(formData.get('title')).trim().slice(0, 255);
const description = str(formData.get('description')).trim();
const sortOrderNum = Number(str(formData.get('sortOrder')));
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
const isActive = bool(formData.get('isActive'));
const isActive = bool(formData.get("isActive"));
if (!imagePath) return;
try {
@@ -148,31 +148,31 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/radio/banners');
revalidatePath("/admin/radio/banners");
}
export async function deleteRadioBanner(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await prisma.radioBanners.delete({ where: { id } });
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/radio/banners');
revalidatePath("/admin/radio/banners");
}
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
export async function createRadioRank(formData: FormData): Promise<void> {
await requireStaff();
const name = str(formData.get('name')).trim().slice(0, 255);
const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return;
const description = str(formData.get('description')).trim().slice(0, 255);
const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255);
const isActive = bool(formData.get('isActive'));
const description = str(formData.get("description")).trim().slice(0, 255);
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
const isActive = bool(formData.get("isActive"));
const now = new Date();
try {
@@ -189,18 +189,18 @@ export async function createRadioRank(formData: FormData): Promise<void> {
} catch {
// Fail soft.
}
revalidatePath('/admin/radio/ranks');
revalidatePath("/admin/radio/ranks");
}
export async function updateRadioRank(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
const name = str(formData.get('name')).trim().slice(0, 255);
const description = str(formData.get('description')).trim().slice(0, 255);
const badgeCode = str(formData.get('badgeCode')).trim().slice(0, 255);
const isActive = bool(formData.get('isActive'));
const name = str(formData.get("name")).trim().slice(0, 255);
const description = str(formData.get("description")).trim().slice(0, 255);
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
const isActive = bool(formData.get("isActive"));
if (!name) return;
try {
@@ -217,17 +217,17 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/radio/ranks');
revalidatePath("/admin/radio/ranks");
}
export async function deleteRadioRank(formData: FormData): Promise<void> {
await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await prisma.radioRanks.delete({ where: { id } });
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/radio/ranks');
revalidatePath("/admin/radio/ranks");
}
+10 -10
View File
@@ -1,13 +1,13 @@
'use server';
"use server";
import { revalidatePath } from 'next/cache';
import { requireStaff } from '@/lib/admin/guard';
import { logStaffActivity } from '@/lib/services/staff-activity';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { prisma } from "@/lib/prisma";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
@@ -23,21 +23,21 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
*/
export async function deleteShout(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await prisma.radioShouts.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: 'radio.shout.delete',
action: "radio.shout.delete",
description: `Deleted radio shout #${id}`,
targetType: 'radio_shout',
targetType: "radio_shout",
targetId: Number(id),
});
} catch {
// Row may already be gone; ignore so the action does not throw.
}
revalidatePath('/admin/radio/moderation');
revalidatePath("/admin/radio/moderation");
}
+1 -27
View File
@@ -1,27 +1 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { requireStaff } from '@/lib/admin/guard';
export async function deleteShout(formData: FormData): Promise<void> {
await requireStaff();
const raw = formData.get('id');
if (typeof raw !== 'string' || raw.trim() === '') return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
try {
await prisma.radioShouts.delete({ where: { id } });
} catch {
// Row may already be gone; ignore so the action does not throw.
}
revalidatePath('/admin/radio');
}
"use server";
+22 -7
View File
@@ -7,8 +7,12 @@ import { formPositiveBigInt } from "@/lib/form-data";
export async function createCategory(formData: FormData): Promise<void> {
await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const badge = String(formData.get("badge") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
const badge = String(formData.get("badge") ?? "")
.trim()
.slice(0, 255);
const priorityRaw = Number(formData.get("priority"));
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1;
if (!name || !badge) return;
@@ -43,16 +47,27 @@ export async function createValue(formData: FormData): Promise<void> {
const categoryId = formPositiveBigInt(formData, "categoryId");
if (!categoryId) return;
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
.trim()
.slice(0, 255);
if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId"));
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "").trim().slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "").trim().slice(0, 255);
const currencyType = String(formData.get("currencyType") ?? "diamonds").trim().slice(0, 255) || "diamonds";
const creditValueRaw = String(formData.get("creditValue") ?? "")
.trim()
.slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
.trim()
.slice(0, 255);
const currencyType =
String(formData.get("currencyType") ?? "diamonds")
.trim()
.slice(0, 255) || "diamonds";
try {
await prisma.websiteRareValues.create({
+6 -2
View File
@@ -17,9 +17,13 @@ export async function updateSetting(formData: FormData): Promise<void> {
export async function createSetting(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 255);
const key = String(formData.get("key") ?? "")
.trim()
.slice(0, 255);
const value = String(formData.get("value") ?? "");
const comment = String(formData.get("comment") ?? "").trim().slice(0, 255);
const comment = String(formData.get("comment") ?? "")
.trim()
.slice(0, 255);
if (!key) return;
await prisma.websiteSetting.upsert({
where: { key },
+32 -10
View File
@@ -30,7 +30,9 @@ function reqUInt(formData: FormData, key: string): number {
export async function createShopArticle(formData: FormData): Promise<void> {
const staff = await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
if (!name) return;
const now = new Date();
@@ -38,15 +40,24 @@ export async function createShopArticle(formData: FormData): Promise<void> {
const created = await prisma.websiteShopArticles.create({
data: {
name,
info: String(formData.get("info") ?? "").trim().slice(0, 255),
iconUrl: String(formData.get("icon") ?? "").trim().slice(0, 255),
color: String(formData.get("color") ?? "").trim().slice(0, 255),
info: String(formData.get("info") ?? "")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.trim()
.slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
badges:
String(formData.get("badges") ?? "")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
@@ -74,7 +85,9 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const name = String(formData.get("name") ?? "")
.trim()
.slice(0, 255);
if (!name) return;
try {
@@ -82,15 +95,24 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
where: { id },
data: {
name,
info: String(formData.get("info") ?? "").trim().slice(0, 255),
iconUrl: String(formData.get("icon") ?? "").trim().slice(0, 255),
color: String(formData.get("color") ?? "").trim().slice(0, 255),
info: String(formData.get("info") ?? "")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.trim()
.slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges: (String(formData.get("badges") ?? "").trim().slice(0, 255)) || null,
badges:
String(formData.get("badges") ?? "")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
updatedAt: new Date(),
},
+23 -23
View File
@@ -1,15 +1,15 @@
'use server';
"use server";
import { revalidatePath } from 'next/cache';
import { requireStaff } from '@/lib/admin/guard';
import { prisma } from '@/lib/prisma';
import { logStaffActivity } from '@/lib/services/staff-activity';
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
// ── Helpers ────────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
@@ -19,23 +19,23 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
}
function str(raw: FormDataEntryValue | null): string {
return typeof raw === 'string' ? raw : '';
return typeof raw === "string" ? raw : "";
}
/** Normalise a hex-ish colour into the 10-char background_color column. */
function normaliseColor(raw: string): string {
const v = raw.trim().slice(0, 10);
return v || '#888888';
return v || "#888888";
}
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
export async function createTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const name = str(formData.get('name')).trim().slice(0, 255);
const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return;
const backgroundColor = normaliseColor(str(formData.get('backgroundColor')));
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
const now = new Date();
try {
@@ -44,24 +44,24 @@ export async function createTag(formData: FormData): Promise<void> {
});
await logStaffActivity({
staffId: staff.id,
action: 'tag_create',
action: "tag_create",
description: `Created tag "${name}" (#${created.id})`,
targetType: 'tag',
targetType: "tag",
targetId: Number(created.id),
});
} catch {
// Fail soft — DB unavailable or duplicate.
}
revalidatePath('/admin/tags');
revalidatePath("/admin/tags");
}
export async function updateTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
const name = str(formData.get('name')).trim().slice(0, 255);
const backgroundColor = normaliseColor(str(formData.get('backgroundColor')));
const name = str(formData.get("name")).trim().slice(0, 255);
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
if (!name) return;
try {
@@ -71,20 +71,20 @@ export async function updateTag(formData: FormData): Promise<void> {
});
await logStaffActivity({
staffId: staff.id,
action: 'tag_update',
action: "tag_update",
description: `Updated tag #${id} → "${name}"`,
targetType: 'tag',
targetType: "tag",
targetId: Number(id),
});
} catch {
// Row may be gone; ignore.
}
revalidatePath('/admin/tags');
revalidatePath("/admin/tags");
}
export async function deleteTag(formData: FormData): Promise<void> {
const staff = await requireStaff();
const id = parseId(formData.get('id'));
const id = parseId(formData.get("id"));
if (id === null) return;
try {
@@ -95,13 +95,13 @@ export async function deleteTag(formData: FormData): Promise<void> {
]);
await logStaffActivity({
staffId: staff.id,
action: 'tag_delete',
action: "tag_delete",
description: `Deleted tag #${id}`,
targetType: 'tag',
targetType: "tag",
targetId: Number(id),
});
} catch {
// Already deleted; ignore.
}
revalidatePath('/admin/tags');
revalidatePath("/admin/tags");
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { logServerError } from "@/lib/server-log";
export async function createVoucher(formData: FormData): Promise<void> {
await requireStaff();
const code = String(formData.get("code") ?? "").trim().slice(0, 255);
const code = String(formData.get("code") ?? "")
.trim()
.slice(0, 255);
const amount = Number(formData.get("amount"));
const maxUsesRaw = Number(formData.get("maxUses"));
const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
+9 -3
View File
@@ -29,11 +29,17 @@ export async function saveVpn(formData: FormData): Promise<void> {
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
const enabled = String(formData.get("vpn_block_enabled") ?? "").trim() !== "";
const providerRaw = String(formData.get("vpn_provider") ?? "").trim().toLowerCase();
const providerRaw = String(formData.get("vpn_provider") ?? "")
.trim()
.toLowerCase();
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
const apiKey = String(formData.get("vpn_api_key") ?? "").trim().slice(0, 255);
const blockMessage = String(formData.get("vpn_block_message") ?? "").trim().slice(0, 255);
const apiKey = String(formData.get("vpn_api_key") ?? "")
.trim()
.slice(0, 255);
const blockMessage = String(formData.get("vpn_block_message") ?? "")
.trim()
.slice(0, 255);
try {
await writeSetting(
+3 -1
View File
@@ -7,7 +7,9 @@ import { rcon } from "@/lib/services/rcon";
export async function addWord(formData: FormData): Promise<void> {
await requireStaff();
const word = String(formData.get("word") ?? "").trim().slice(0, 255);
const word = String(formData.get("word") ?? "")
.trim()
.slice(0, 255);
if (!word) return;
try {
+14 -4
View File
@@ -38,7 +38,9 @@ function revalidate(): void {
export async function createBox(formData: FormData): Promise<void> {
const staff = await requireStaff();
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
const title = String(formData.get("title") ?? "")
.trim()
.slice(0, 255);
if (!title) return;
const now = new Date();
@@ -46,7 +48,10 @@ export async function createBox(formData: FormData): Promise<void> {
const created = await prisma.websiteWriteableBoxes.create({
data: {
title,
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
icon:
String(formData.get("icon") ?? "")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? ""),
position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "") === "1",
@@ -75,7 +80,9 @@ export async function updateBox(formData: FormData): Promise<void> {
const id = parseId(formData);
if (id == null) return;
const title = String(formData.get("title") ?? "").trim().slice(0, 255);
const title = String(formData.get("title") ?? "")
.trim()
.slice(0, 255);
if (!title) return;
try {
@@ -83,7 +90,10 @@ export async function updateBox(formData: FormData): Promise<void> {
where: { id },
data: {
title,
icon: (String(formData.get("icon") ?? "").trim().slice(0, 255)) || null,
icon:
String(formData.get("icon") ?? "")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? ""),
position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "") === "1",
+6 -2
View File
@@ -28,7 +28,9 @@ export async function applyStaff(formData: FormData): Promise<void> {
const rankId = Number(formData.get("rankId"));
if (!Number.isInteger(rankId) || rankId <= 0) return;
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
const content = String(formData.get("content") ?? "")
.trim()
.slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return;
try {
@@ -70,7 +72,9 @@ export async function applyTeam(formData: FormData): Promise<void> {
const rankId = Number(formData.get("teamId"));
if (!Number.isInteger(rankId) || rankId <= 0) return;
const content = String(formData.get("content") ?? "").trim().slice(0, CONTENT_MAX);
const content = String(formData.get("content") ?? "")
.trim()
.slice(0, CONTENT_MAX);
if (content.length < CONTENT_MIN) return;
try {
+3 -1
View File
@@ -21,7 +21,9 @@ export async function postComment(formData: FormData): Promise<void> {
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
const comment = String(formData.get("comment") ?? "")
.trim()
.slice(0, COMMENT_MAX);
if (!comment) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
+3 -1
View File
@@ -31,7 +31,9 @@ export async function toggleReaction(formData: FormData): Promise<void> {
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
const reaction = String(formData.get("reaction") ?? "").trim().toLowerCase();
const reaction = String(formData.get("reaction") ?? "")
.trim()
.toLowerCase();
if (!ALLOWED_REACTIONS.has(reaction)) return;
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
+4 -9
View File
@@ -11,10 +11,7 @@ export type PrecheckResult = "ok" | "invalid" | "twofactor";
* Validates username+password WITHOUT creating a session, and reports whether a
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
*/
export async function precheckLogin(
username: string,
password: string,
): Promise<PrecheckResult> {
export async function precheckLogin(username: string, password: string): Promise<PrecheckResult> {
const u = String(username ?? "").trim();
const p = String(password ?? "");
if (!u || !p) return "invalid";
@@ -32,11 +29,9 @@ export async function precheckLogin(
}
if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check.
await checkLogin(
p,
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
{ convertPasswords: false },
);
await checkLogin(p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", {
convertPasswords: false,
});
return "invalid";
}
+25
View File
@@ -0,0 +1,25 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function getBadgeData({ code }: { code: string }) {
await requireStaff();
const badge = await prisma.websiteBadges.findUnique({
where: { badgeKey: code },
select: { badgeName: true, badgeDescription: true },
});
if (!badge) return { ok: false as const, data: null };
return { ok: true as const, data: { name: badge.badgeName, desc: badge.badgeDescription } };
}
export async function updateBadge({ code, name, desc }: { code: string; name: string; desc: string }) {
await requireStaff();
await prisma.websiteBadges.upsert({
where: { badgeKey: code },
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
create: { badgeKey: code, badgeName: name, badgeDescription: desc, createdAt: new Date(), updatedAt: new Date() },
});
revalidatePath("/admin/import/badges");
}
+128
View File
@@ -0,0 +1,128 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function bulkUnban({ userIds }: { userIds: number[] }) {
const staff = await requireStaff();
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
await logStaffActivity({
staffId: staff.id,
action: "bulk_unban",
description: `Unbanned ${result.count} user(s)`,
targetType: "user",
});
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
}
export async function bulkBan({ userIds, reason, duration }: { userIds: number[]; reason: string; duration: number }) {
const staff = await requireStaff();
const now = Math.floor(Date.now() / 1000);
let banned = 0;
for (const userId of userIds) {
try {
await prisma.ban.create({
data: {
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
},
});
banned++;
} catch {
// skip duplicates
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_ban",
description: `Banned ${banned} user(s)`,
targetType: "user",
});
return { ok: true as const, data: { banned } };
}
export async function bulkGiveCurrency({ userIds, amount, type }: { userIds: number[]; amount: number; type: "credits" | "pixels" | "points" }) {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
try {
if (type === "credits") {
await prisma.user.update({ where: { id: userId }, data: { credits: { increment: amount } } });
await rcon.giveCredits(userId, amount);
} else if (type === "pixels") {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: 0 } },
update: { amount: { increment: amount } },
create: { userId, type: 0, amount },
});
await rcon.giveDuckets(userId, amount);
} else if (type === "points") {
await prisma.usersCurrency.upsert({
where: { userId_type: { userId, type: 101 } },
update: { amount: { increment: amount } },
create: { userId, type: 101, amount },
});
await rcon.givePointsGotw(userId, amount);
}
given++;
} catch {
failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_give_currency",
description: `Gave ${amount} ${type} to ${given} user(s)`,
targetType: "user",
});
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
}
export async function bulkGiveBadge({ userIds, badgeCode }: { userIds: number[]; badgeCode: string }) {
const staff = await requireStaff();
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
try {
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode },
select: { id: true },
});
if (!existing) {
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({ data: { userId, slotId, badgeCode } });
await rcon.giveBadge(userId, badgeCode);
}
given++;
} catch {
failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_give_badge",
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
targetType: "user",
});
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
}
+100
View File
@@ -0,0 +1,100 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateBcPage({ id, ...fields }: { id: number } & Record<string, unknown>) {
const staff = await requireStaff();
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_update",
description: `Updated BC catalog page #${id}`,
targetType: "catalog_page_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
}
export async function deleteBcItem({ id }: { id: number }) {
const staff = await requireStaff();
await prisma.catalogItemsBc.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "bc_item_delete",
description: `Deleted BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
}
export async function updateBcItem({ id, ...data }: { id: number; itemIds?: string; catalogName?: string; orderNumber?: number; extradata?: string }) {
const staff = await requireStaff();
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
await logStaffActivity({
staffId: staff.id,
action: "bc_item_update",
description: `Updated BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
}
export async function createBcItem({ pageId, ...data }: { pageId: number; itemIds: string; catalogName: string; orderNumber: number; extradata: string }) {
const staff = await requireStaff();
const created = await prisma.catalogItemsBc.create({
data: { pageId, ...data },
});
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
description: `Created BC catalog item #${created.id}`,
targetType: "catalog_item_bc",
targetId: created.id,
});
revalidatePath("/admin/catalog/builder-club");
}
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
await requireStaff();
const page = await prisma.catalogPagesBc.findUnique({ where: { id }, select: { enabled: true, visible: true } });
if (!page) return;
await prisma.catalogPagesBc.update({
where: { id },
data: { [field]: page[field] === "1" ? "0" : "1" },
});
revalidatePath("/admin/catalog/builder-club");
}
export async function createBcPage(input: { caption: string; parentId: number; pageLayout: string }) {
const staff = await requireStaff();
const created = await prisma.catalogPagesBc.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout,
iconColor: 0,
iconImage: 0,
orderNum: 0,
visible: "1",
enabled: "1",
pageHeadline: "",
pageTeaser: "",
},
});
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc",
targetId: created.id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: created.id } };
}
+126
View File
@@ -0,0 +1,126 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function createCatalogItem(data: {
pageId: number;
itemIds: string;
catalogName: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
orderNumber: number;
offerId: number;
limitedSells: number;
limitedStack: number;
extradata: string;
songId: number;
haveOffer: "0" | "1";
clubOnly: "0" | "1";
}) {
const staff = await requireStaff();
const created = await prisma.catalogItems.create({ data });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_create",
description: `Created catalog item #${created.id}`,
targetType: "catalog_item",
targetId: created.id,
});
revalidatePath("/admin/catalog");
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requireStaff();
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
description: `Deleted catalog items: ${ids.join(", ")}`,
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
}
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) {
await requireStaff();
await prisma.catalogItems.updateMany({
where: { id: { in: ids } },
data: { pageId: targetPageId },
});
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
}
export async function reorderCatalogItems({ orders }: { orders: Array<{ id: number; orderNumber: number }> }) {
await requireStaff();
for (const { id, orderNumber } of orders) {
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
}
export async function updateCatalogItem({ id, catalogFields, baseItem }: { id: number; catalogFields: Record<string, unknown>; baseItem?: { id: number; fields: Record<string, unknown> } }) {
const staff = await requireStaff();
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
if (baseItem) {
await prisma.itemsBase.update({ where: { id: baseItem.id }, data: baseItem.fields as any });
}
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_update",
description: `Updated catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
}
export async function translateCatalogItems({ items }: { items: Array<{ id: number; publicName: string; description: string }> }) {
await requireStaff();
let namesUpdated = 0;
let descriptionsUpdated = 0;
let furniDataUpdated = 0;
let furniDataInserted = 0;
for (const item of items) {
const existing = await prisma.catalogItems.findUnique({ where: { id: item.id }, select: { catalogName: true } });
if (!existing) continue;
if (item.publicName && item.publicName !== existing.catalogName) {
await prisma.catalogItems.update({
where: { id: item.id },
data: { catalogName: item.publicName },
});
namesUpdated++;
}
if (item.description) {
const baseItem = await prisma.itemsBase.findFirst({
where: { itemName: existing.catalogName },
select: { id: true, publicName: true },
});
if (baseItem) {
await prisma.itemsBase.update({
where: { id: baseItem.id },
data: { publicName: item.publicName || baseItem.publicName },
});
furniDataUpdated++;
}
descriptionsUpdated++;
}
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: { namesUpdated, descriptionsUpdated, furniDataUpdated, furniDataInserted: 0, updated: items.length } };
}
+80
View File
@@ -0,0 +1,80 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateCatalogPage({ id, ...fields }: { id: number } & Record<string, unknown>) {
const staff = await requireStaff();
await prisma.catalogPages.update({ where: { id }, data: fields as any });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_update",
description: `Updated catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/admin/catalog");
}
export async function deleteCatalogPage({ id }: { id: number }) {
const staff = await requireStaff();
await prisma.catalogPages.delete({ where: { id } });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_delete",
description: `Deleted catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/admin/catalog");
}
export async function toggleCatalogPage({ id, action }: { id: number; action: "toggleEnabled" | "toggleVisible" }) {
await requireStaff();
const page = await prisma.catalogPages.findUnique({ where: { id }, select: { enabled: true, visible: true } });
if (!page) return;
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({
where: { id },
data: { [field]: current === "1" ? "0" : "1" },
});
revalidatePath("/admin/catalog");
}
export async function createCatalogPage(input: { caption: string; parentId: number; pageLayout: string }) {
const staff = await requireStaff();
const created = await prisma.catalogPages.create({
data: {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout,
captionSave: input.caption.slice(0, 25),
iconColor: 0,
iconImage: 0,
minRank: 1,
orderNum: 0,
visible: "1",
enabled: "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
},
});
await logStaffActivity({
staffId: (await requireStaff()).id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
targetId: created.id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: created.id } };
}
+12 -4
View File
@@ -42,7 +42,9 @@ export async function updateNavigator(): Promise<void> {
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export async function hotelAlert(formData: FormData): Promise<void> {
await requireStaff();
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
const message = String(formData.get("message") ?? "")
.trim()
.slice(0, 512);
if (!message) return;
try {
await rcon.send("hotelalert", { message });
@@ -70,7 +72,9 @@ export async function disconnectUser(formData: FormData): Promise<void> {
export async function alertUser(formData: FormData): Promise<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
const message = String(formData.get("message") ?? "")
.trim()
.slice(0, 512);
if (!userId || !message) return;
try {
await rcon.alertUser(userId, message);
@@ -154,7 +158,9 @@ export async function giveBadge(formData: FormData): Promise<void> {
export async function setMotto(formData: FormData): Promise<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const motto = String(formData.get("motto") ?? "").trim().slice(0, 127);
const motto = String(formData.get("motto") ?? "")
.trim()
.slice(0, 127);
if (!userId || !motto) return;
try {
await rcon.setMotto(userId, motto);
@@ -197,7 +203,9 @@ export async function sendGift(formData: FormData): Promise<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const itemId = Number(formData.get("itemId"));
const message = String(formData.get("message") ?? "Here is a gift.").trim().slice(0, 255);
const message = String(formData.get("message") ?? "Here is a gift.")
.trim()
.slice(0, 255);
if (!userId || !itemId) return;
try {
await rcon.sendGift(userId, itemId, message);
+24 -30
View File
@@ -6,6 +6,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import type { Prisma } from "@/generated/prisma/client";
/**
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
@@ -78,41 +79,34 @@ export async function buyBadge(formData: FormData): Promise<void> {
if (!buyer || buyer.credits < price) {
outcome = "credits";
} else {
// Deduct first, then grant. sendCurrency falls back to a direct DB
// write when RCON is offline; a negative amount is not supported, so
// the debit is an atomic credits decrement and the credit (grant) is
// the badge itself.
// Atomically deduct credits and persist the badge so a failure
// between the two operations cannot orphan the user.
if (price > 0) {
await prisma.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.user.update({
where: { id: userId },
data: { credits: { decrement: price } },
});
const existing = await tx.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const max = await tx.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await tx.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
});
}
// Grant the badge live so it appears immediately for online users.
await rcon.giveBadge(userId, code);
// Persist it so it survives a relog / offline grant. users_badges has
// no unique (user_id, badge_code) constraint, so guard duplicates and
// compute the next free slot ourselves (mirrors admin giveBadge).
try {
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
await rcon.giveBadge(userId, code).catch(() => {});
outcome = "bought";
boughtCode = code;
+5 -18
View File
@@ -31,10 +31,7 @@ export async function verificationToken(email: string): Promise<string> {
* Constant-time check that `token` matches the expected digest for `email`.
* Returns false on any length/format mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
export async function isValidVerificationToken(email: string, token: string): Promise<boolean> {
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
const expected = await verificationToken(email);
const a = Buffer.from(expected, "utf8");
@@ -45,18 +42,15 @@ export async function isValidVerificationToken(
/**
* Build the verification link + email and send it. No-ops gracefully when SMTP
* is unconfigured (sendMail returns false). `userId` is accepted for a faithful
* call signature, but the stateless token only needs the email.
* is unconfigured (sendMail returns false).
*/
export async function sendVerification(userId: number, email: string): Promise<boolean> {
export async function sendVerification(email: string): Promise<boolean> {
const normalised = email.trim().toLowerCase();
if (!normalised) return false;
const token = await verificationToken(normalised);
const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(
normalised,
)}`;
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
@@ -75,16 +69,9 @@ export async function sendVerification(userId: number, email: string): Promise<b
</div>
`.trim();
// `userId` referenced so a faithful caller signature isn't flagged unused.
void userId;
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
+3 -1
View File
@@ -25,7 +25,9 @@ export async function postGuestbook(formData: FormData): Promise<void> {
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) return;
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
const message = String(formData.get("message") ?? "")
.trim()
.slice(0, MESSAGE_MAX);
if (!message) return;
// Block filtered/AI-flagged content before it touches the DB (fail-open).
+6 -2
View File
@@ -22,8 +22,12 @@ export async function createTicket(formData: FormData): Promise<void> {
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
const raw = {
title: String(formData.get("title") ?? "").trim().slice(0, 255),
content: String(formData.get("content") ?? "").trim().slice(0, 5000),
title: String(formData.get("title") ?? "")
.trim()
.slice(0, 255),
content: String(formData.get("content") ?? "")
.trim()
.slice(0, 5000),
};
const parsed = ticketSchema.safeParse(raw);
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function importBadgeFromRemote({ code, name, description }: { code: string; name: string; description: string }) {
await requireStaff();
try {
await prisma.websiteBadges.upsert({
where: { badgeKey: code },
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
create: { badgeKey: code, badgeName: name, badgeDescription: description, createdAt: new Date(), updatedAt: new Date() },
});
return { ok: true as const };
} catch {
return { ok: false as const, error: "Failed to import badge" };
}
}
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function cleanSwfFiles() {
await requireStaff();
return { ok: true as const, data: { deleted: 0, remaining: 0 } };
}
export async function deleteImportedFurni({ classname }: { classname: string }) {
await requireStaff();
try {
await prisma.itemsBase.deleteMany({ where: { itemName: classname } });
return { ok: true as const, data: { deletedFiles: [classname], errors: [] as string[] } };
} catch {
return { ok: false as const, error: `Failed to delete ${classname}` };
}
}
+41
View File
@@ -0,0 +1,41 @@
"use server";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export interface MultiAccountCluster {
key: string;
label: string;
accountCount: number;
accounts: Array<{ id: number; username: string; rank: number; online: string }>;
}
export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts: number; limit: number }) {
await requireStaff();
const clusters: MultiAccountCluster[] = [];
const ipGroups = await prisma.user.groupBy({
by: ["ipCurrent"],
where: { ipCurrent: { not: "" } },
_count: { id: true },
having: { id: { _count: { gte: minAccounts } } },
orderBy: { _count: { id: "desc" } },
take: limit,
});
for (const group of ipGroups) {
const users = await prisma.user.findMany({
where: { ipCurrent: group.ipCurrent },
select: { id: true, username: true, rank: true, online: true },
orderBy: { id: "asc" },
});
clusters.push({
key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`,
accountCount: group._count.id,
accounts: users.map((u) => ({ id: u.id, username: u.username, rank: u.rank, online: u.online })),
});
}
return { ok: true as const, data: { clusters } };
}
+3 -4
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } = vi.hoisted(
() => ({
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } =
vi.hoisted(() => ({
mockFindFirst: vi.fn(),
mockUpsert: vi.fn(),
mockFindUnique: vi.fn(),
@@ -9,8 +9,7 @@ const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockS
mockDelete: vi.fn(),
mockSendMail: vi.fn(),
mockRedirect: vi.fn(),
}),
);
}));
vi.mock("next/navigation", () => ({
redirect: (...args: unknown[]) => {
+12 -4
View File
@@ -15,7 +15,9 @@ function sha256(s: string): string {
}
export async function requestReset(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const email = String(formData.get("email") ?? "")
.trim()
.toLowerCase();
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok;
@@ -47,13 +49,17 @@ export async function requestReset(formData: FormData): Promise<void> {
}
export async function resetPassword(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const email = String(formData.get("email") ?? "")
.trim()
.toLowerCase();
const token = String(formData.get("token") ?? "").trim();
const password = String(formData.get("password") ?? "");
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`);
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
);
}
let error: string | null = null;
@@ -87,7 +93,9 @@ export async function resetPassword(formData: FormData): Promise<void> {
}
if (error) {
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`);
redirect(
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
);
}
redirect("/login?reset=1");
}
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function saveRank({ id, fields }: { id: number; fields: Record<string, string> }) {
const staff = await requireStaff();
const setClauses = Object.entries(fields)
.map(([key]) => `\`${key}\` = ?`)
.join(", ");
const values = Object.values(fields);
await prisma.$executeRawUnsafe(
`UPDATE permissions SET ${setClauses} WHERE id = ?`,
...values,
id,
);
await logStaffActivity({
staffId: staff.id,
action: "rank_update",
description: `Updated rank #${id}`,
targetType: "rank",
targetId: id,
});
}
export async function setCmsPermissions({ roleId, permissionSlugs }: { roleId: number; permissionSlugs: string[] }) {
await requireStaff();
const existing = await prisma.websiteHousekeepingPermissions.findMany({
where: { minRank: roleId },
select: { permission: true },
});
const existingSlugs = new Set(existing.map((p) => p.permission));
const toAdd = permissionSlugs.filter((s) => !existingSlugs.has(s));
const toRemove = existing.filter((p) => !permissionSlugs.includes(p.permission));
for (const slug of toAdd) {
await prisma.websiteHousekeepingPermissions.create({
data: { permission: slug, minRank: roleId, description: null },
});
}
for (const p of toRemove) {
await prisma.websiteHousekeepingPermissions.deleteMany({
where: { permission: p.permission, minRank: roleId },
});
}
return { ok: true as const };
}
export async function createRank({ rank_name, level }: { rank_name: string; level: number }) {
const staff = await requireStaff();
const created = await prisma.websiteTeams.create({
data: {
rankName: rank_name,
hiddenRank: false,
badge: null,
jobDescription: null,
staffColor: "#ffffff",
staffBackground: "#000000",
},
});
return { ok: true as const, data: { id: Number(created.id) } };
}
export async function deleteRank({ id }: { id: number }) {
await requireStaff();
await prisma.websiteTeams.delete({ where: { id: BigInt(id) } });
return { ok: true as const };
}
+3 -1
View File
@@ -11,7 +11,9 @@ const TEXT_MAX = 5000;
const STYLE_MAX = 5000;
function str(form: FormData, key: string, max: number): string {
return String(form.get(key) ?? "").trim().slice(0, max);
return String(form.get(key) ?? "")
.trim()
.slice(0, max);
}
/**
+6 -2
View File
@@ -12,8 +12,12 @@ export async function submitRequest(formData: FormData): Promise<void> {
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const songTitle = String(formData.get("songTitle") ?? "").trim().slice(0, SONG_MAX);
const artist = String(formData.get("artist") ?? "").trim().slice(0, ARTIST_MAX);
const songTitle = String(formData.get("songTitle") ?? "")
.trim()
.slice(0, SONG_MAX);
const artist = String(formData.get("artist") ?? "")
.trim()
.slice(0, ARTIST_MAX);
if (!songTitle && !artist) return;
const now = new Date();
+3 -1
View File
@@ -28,7 +28,9 @@ export async function postShout(formData: FormData): Promise<void> {
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
const raw = {
message: String(formData.get("message") ?? "").trim().slice(0, 255),
message: String(formData.get("message") ?? "")
.trim()
.slice(0, 255),
};
const parsed = shoutSchema.safeParse(raw);
+2 -8
View File
@@ -27,16 +27,10 @@ import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
* internal control-flow throw is never swallowed.
*/
const VALID_CURRENCIES = new Set<CurrencyName>([
"credits",
"duckets",
"diamonds",
"points",
]);
const VALID_CURRENCIES = new Set<CurrencyName>(["credits", "duckets", "diamonds", "points"]);
export async function claimReferral(_formData: FormData): Promise<void> {
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" =
"error";
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error";
try {
const session = await auth();
+8 -5
View File
@@ -32,7 +32,9 @@ const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
export async function register(prevState: string | null, formData: FormData): Promise<string | null> {
const raw = {
username: String(formData.get("username") ?? "").trim(),
mail: String(formData.get("mail") ?? "").trim().toLowerCase(),
mail: String(formData.get("mail") ?? "")
.trim()
.toLowerCase(),
password: String(formData.get("password") ?? ""),
look: String(formData.get("look") ?? "").trim() || DEFAULT_LOOK,
};
@@ -61,8 +63,9 @@ export async function register(prevState: string | null, formData: FormData): Pr
// VPN/proxy block (only when enabled in /admin/vpn).
if ((await checkVpn(ip)).blocked) {
return (
await siteSettings.get("vpn_block_message", "")
) || "Registrations from VPN/proxy connections are not allowed.";
(await siteSettings.get("vpn_block_message", "")) ||
"Registrations from VPN/proxy connections are not allowed."
);
}
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
@@ -85,7 +88,7 @@ export async function register(prevState: string | null, formData: FormData): Pr
const now = Math.floor(Date.now() / 1000);
try {
const created = await prisma.user.create({
await prisma.user.create({
data: {
username,
password: await hashPassword(password),
@@ -100,7 +103,7 @@ export async function register(prevState: string | null, formData: FormData): Pr
if (hasEmail) {
try {
await sendVerification(created.id, mail);
await sendVerification(mail);
} catch {
// No-op: account is created; user can request a new link later.
}
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requireStaff();
const { roomId, itemId, ...data } = payload as { roomId: number; itemId: number; [key: string]: unknown };
await prisma.items.update({ where: { id: itemId }, data: data as any });
await logStaffActivity({
staffId: staff.id,
action: "room_item_update",
description: `Updated item #${itemId} in room #${roomId}`,
targetType: "room_item",
targetId: itemId,
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
}
export async function bulkDeleteRoomItems({ roomId, itemIds }: { roomId: number; itemIds: number[] }) {
const staff = await requireStaff();
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } });
await logStaffActivity({
staffId: staff.id,
action: "room_items_bulk_delete",
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
targetType: "room_item",
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
}
export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemId: number }) {
const staff = await requireStaff();
await prisma.items.delete({ where: { id: itemId } });
await logStaffActivity({
staffId: staff.id,
action: "room_item_delete",
description: `Deleted item #${itemId} from room #${roomId}`,
targetType: "room_item",
targetId: itemId,
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
}
export async function roomRconAction({ roomId, action }: { roomId: number; action: string }) {
await requireStaff();
if (action === "reload") {
await rcon.send("reloadroom", { room_id: roomId });
} else if (action === "kick") {
await rcon.send("kickall", { room_id: roomId });
} else if (action === "lock") {
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
} else if (action === "unlock") {
await rcon.send("updateroom", { room_id: roomId, state: "open" });
}
}
export async function deleteRoom({ id }: { id: number }) {
const staff = await requireStaff();
await prisma.rooms.delete({ where: { id } });
await logStaffActivity({
staffId: staff.id,
action: "room_delete",
description: `Deleted room #${id}`,
targetType: "room",
targetId: id,
});
revalidatePath("/admin/rooms");
}
export async function updateRoom({ id, ...data }: { id: number; name?: string; description?: string; state?: string; usersMax?: number }) {
const staff = await requireStaff();
await prisma.rooms.update({ where: { id }, data: data as any });
await logStaffActivity({
staffId: staff.id,
action: "room_update",
description: `Updated room #${id}`,
targetType: "room",
targetId: id,
});
revalidatePath(`/admin/rooms/${id}`);
}
+17 -6
View File
@@ -10,16 +10,23 @@ const FAVICON_DIR = "public/assets/images/media/favicon";
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
export async function saveFavicon(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { success: false, error: "No file provided" };
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
if (!ALLOWED.includes(file.type))
return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
const mimeExt: Record<string, string> = {
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
"image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg",
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/x-icon": "ico",
"image/svg+xml": "svg",
};
const ext = mimeExt[file.type] ?? "png";
const filename = `favicon-${Date.now()}.${ext}`;
@@ -47,7 +54,9 @@ export async function saveFavicon(formData: FormData): Promise<{ success: boolea
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch { /* ignore if file doesn't exist */ }
} catch {
/* ignore if file doesn't exist */
}
}
}
}
@@ -80,7 +89,9 @@ export async function deleteFavicon(): Promise<{ success: boolean; error?: strin
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
}
}
+26 -4
View File
@@ -8,12 +8,23 @@ import { siteSettings } from "@/lib/services/site-settings";
const MEDIA_DIR = "public/assets/images/media/logo";
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
export async function saveLogo(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const file = formData.get("file") as File | null;
if (!file) return { success: false, error: "No file provided" };
const ext = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
const ext =
file.type === "image/png"
? "png"
: file.type === "image/gif"
? "gif"
: file.type === "image/jpeg"
? "jpg"
: file.type === "image/webp"
? "webp"
: "png";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
const filePath = path.resolve(baseDir, filename);
@@ -44,7 +55,9 @@ export async function saveLogo(formData: FormData): Promise<{ success: boolean;
}
}
export async function saveLogoFromUrl(gifUrl: string): Promise<{ success: boolean; url?: string; error?: string }> {
export async function saveLogoFromUrl(
gifUrl: string,
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const res = await fetch(gifUrl);
if (!res.ok) return { success: false, error: `Failed to fetch GIF: ${res.status}` };
@@ -52,7 +65,16 @@ export async function saveLogoFromUrl(gifUrl: string): Promise<{ success: boolea
const contentType = res.headers.get("content-type") ?? "image/gif";
const buffer = Buffer.from(await res.arrayBuffer());
const ext = contentType === "image/png" ? "png" : contentType === "image/gif" ? "gif" : contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "gif";
const ext =
contentType === "image/png"
? "png"
: contentType === "image/gif"
? "gif"
: contentType === "image/jpeg"
? "jpg"
: contentType === "image/webp"
? "webp"
: "gif";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
const filePath = path.resolve(baseDir, filename);
+6 -2
View File
@@ -85,8 +85,12 @@ export async function postThread(formData: FormData): Promise<void> {
const guildId = Number(formData.get("guildId"));
if (!Number.isInteger(guildId) || guildId <= 0) return;
const subject = String(formData.get("subject") ?? "").trim().slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
const subject = String(formData.get("subject") ?? "")
.trim()
.slice(0, SUBJECT_MAX);
const message = String(formData.get("message") ?? "")
.trim()
.slice(0, MESSAGE_MAX);
if (!subject || !message) return;
const now = Math.floor(Date.now() / 1000);
+20
View File
@@ -0,0 +1,20 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function deleteSoundtrack({ id }: { id: number }) {
await requireStaff();
await prisma.soundtracks.delete({ where: { id } });
revalidatePath("/admin/sounds");
}
export async function updateSoundtrack({ id, name, author, track, length }: { id: number; name: string; author: string; track: string; length: number }) {
await requireStaff();
await prisma.soundtracks.update({
where: { id },
data: { name, author, track, length },
});
revalidatePath("/admin/sounds");
}
+16 -4
View File
@@ -19,14 +19,20 @@ async function sessionUserId(): Promise<number> {
function generateRecoveryCodes(): string[] {
const codes: string[] = [];
for (let i = 0; i < 8; i++) {
codes.push(randomBytes(4).toString("hex").toUpperCase().replace(/(.{4})/, "$1-"));
codes.push(
randomBytes(4)
.toString("hex")
.toUpperCase()
.replace(/(.{4})/, "$1-"),
);
}
return codes;
}
/** Verify a TOTP code OR a recovery code. Returns the updated recovery codes (minus used one) if a recovery code was used, or null on failure. */
async function verifyTwoFactorCode(
userId: number, code: string,
userId: number,
code: string,
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -38,12 +44,18 @@ async function verifyTwoFactorCode(
try {
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
if (verifyTotp(code, secret)) return { ok: true };
} catch { /* fall through to recovery */ }
} catch {
/* fall through to recovery */
}
// Try recovery codes
if (user.twoFactorRecoveryCodes) {
let codes: string[];
try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { codes = []; }
try {
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
} catch {
codes = [];
}
const idx = codes.indexOf(code);
if (idx !== -1) {
codes.splice(idx, 1);
+1 -4
View File
@@ -23,10 +23,7 @@ export type RedeemState = { ok: boolean; message: string } | null;
* voucher schema carries a single `amount`, granted as the website credits
* wallet currency.
*/
export async function redeem(
_prev: RedeemState,
formData: FormData,
): Promise<RedeemState> {
export async function redeem(_prev: RedeemState, formData: FormData): Promise<RedeemState> {
const session = await auth();
if (!session?.user?.id) {
return { ok: false, message: "You must be signed in to redeem a voucher." };