Reapply "Add missing admin action files and navigation links"
This reverts commit 4d515bc400.
This commit is contained in:
1 parent
96ed768f14
commit
5b4228261a
338 files changed
+28143
-5948
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 +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";
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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." };
|
||||
|
||||
Reference in new issue
Block a user