This commit is contained in:
1 parent
e6d7f2280b
commit
8efd032cc6
71 files changed
+6796
-3751
No files matched your search
Generated
+5928
-3417
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,8 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
export async function createAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const image = String(formData.get("image") ?? "").normalize("NFC")
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!image) return;
|
||||
@@ -42,7 +43,8 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
const image = String(formData.get("image") ?? "").normalize("NFC")
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
@@ -13,7 +13,8 @@ import { rcon } from "@/lib/services/rcon";
|
||||
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const message = String(formData.get("message") ?? "").normalize("NFC")
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
if (!message) return;
|
||||
|
||||
@@ -19,10 +19,18 @@ async function uniqueSlug(title: string): Promise<string> {
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const title = String(formData.get("title") ?? "").normalize("NFC").trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "").normalize("NFC").trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "").normalize("NFC").trim();
|
||||
const image = String(formData.get("image") ?? "").normalize("NFC").trim();
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
@@ -53,14 +61,19 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: String(formData.get("title") ?? "").normalize("NFC")
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
shortStory: String(formData.get("shortStory") ?? "").normalize("NFC")
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "").normalize("NFC").trim(),
|
||||
image: String(formData.get("image") ?? "").normalize("NFC")
|
||||
fullStory: String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -27,7 +27,9 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
back("error", "Badge upload directory not configured");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").normalize("NFC").trim();
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!CODE_RE.test(code)) {
|
||||
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const code = String(formData.get("code") ?? "").normalize("NFC")
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 32);
|
||||
if (!(userId > 0) || code.length === 0) return;
|
||||
|
||||
@@ -15,7 +15,8 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const reason =
|
||||
String(formData.get("reason") ?? "").normalize("NFC")
|
||||
String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 200) || "Banned";
|
||||
const hours = Number(formData.get("hours"));
|
||||
|
||||
@@ -7,14 +7,18 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "").normalize("NFC")
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "").normalize("NFC").trim();
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!name || !subject || !body) return;
|
||||
|
||||
@@ -40,11 +44,14 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const subject = String(formData.get("subject") ?? "").normalize("NFC")
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "").normalize("NFC").trim();
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!subject || !body) return;
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "").normalize("NFC")
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC").slice(0, 512);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 512);
|
||||
if (!key) return;
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
@@ -26,10 +29,13 @@ export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
|
||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "").normalize("NFC")
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC").slice(0, 4096);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await prisma.emulatorTexts.upsert({
|
||||
where: { key },
|
||||
|
||||
+30
-14
@@ -17,27 +17,35 @@ function parsePosition(value: FormDataEntryValue | null): number {
|
||||
|
||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").normalize("NFC").trim();
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "").normalize("NFC")
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "").normalize("NFC")
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "").normalize("NFC")
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "").normalize("NFC")
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "").normalize("NFC")
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
|
||||
@@ -76,27 +84,35 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "").normalize("NFC").trim();
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "").normalize("NFC")
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "").normalize("NFC")
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "").normalize("NFC")
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "").normalize("NFC")
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "").normalize("NFC")
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ import { prisma } from "@/lib/prisma";
|
||||
export async function upsertPermission(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const permission = String(formData.get("permission") ?? "").normalize("NFC")
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = Number(formData.get("minRank"));
|
||||
const descriptionRaw = String(formData.get("description") ?? "").normalize("NFC")
|
||||
const descriptionRaw = String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
||||
|
||||
+10
-4
@@ -5,13 +5,15 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseIp(formData: FormData): string {
|
||||
return String(formData.get("ipAddress") ?? "").normalize("NFC")
|
||||
return String(formData.get("ipAddress") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
function parseAsn(formData: FormData): string | null {
|
||||
const asn = String(formData.get("asn") ?? "").normalize("NFC")
|
||||
const asn = String(formData.get("asn") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return asn || null;
|
||||
@@ -30,7 +32,9 @@ export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
|
||||
export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
@@ -49,7 +53,9 @@ export async function addBlacklist(formData: FormData): Promise<void> {
|
||||
|
||||
export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
|
||||
@@ -43,7 +43,9 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
|
||||
// Coerce the rank to a non-negative integer; fall back to AtomCMS's default
|
||||
// of 5 when the field is blank or garbage.
|
||||
const rawRank = String(formData.get("min_rank") ?? "").normalize("NFC").trim();
|
||||
const rawRank = String(formData.get("min_rank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const parsedRank = Number.parseInt(rawRank, 10);
|
||||
const minRank = Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
|
||||
|
||||
|
||||
@@ -14,17 +14,25 @@ import { logServerError } from "@/lib/server-log";
|
||||
// created_at/updated_at are managed here.
|
||||
|
||||
function parseMinRank(formData: FormData): number {
|
||||
const n = Number(String(formData.get("minRank") ?? "").normalize("NFC").trim());
|
||||
const n = Number(
|
||||
String(formData.get("minRank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
);
|
||||
return Number.isInteger(n) && n >= 0 ? n : 1;
|
||||
}
|
||||
|
||||
export async function createPermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const permission = String(formData.get("permission") ?? "").normalize("NFC")
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description = String(formData.get("description") ?? "").normalize("NFC").trim() || null;
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
const now = new Date();
|
||||
@@ -54,11 +62,15 @@ export async function updatePermission(formData: FormData): Promise<void> {
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
const id = BigInt(raw);
|
||||
const permission = String(formData.get("permission") ?? "").normalize("NFC")
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description = String(formData.get("description") ?? "").normalize("NFC").trim() || null;
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,10 +7,12 @@ import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "").normalize("NFC")
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const priorityRaw = Number(formData.get("priority"));
|
||||
@@ -47,10 +49,12 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
if (!categoryId) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "").normalize("NFC")
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
@@ -58,10 +62,12 @@ export async function createValue(formData: FormData): Promise<void> {
|
||||
const itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "").normalize("NFC")
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "").normalize("NFC")
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
|
||||
@@ -7,7 +7,9 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function updateSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "").normalize("NFC").trim();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
@@ -17,11 +19,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") ?? "").normalize("NFC")
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
const comment = String(formData.get("comment") ?? "").normalize("NFC")
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
@@ -36,7 +40,9 @@ export async function createSetting(formData: FormData): Promise<void> {
|
||||
|
||||
export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "").normalize("NFC").trim();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
siteSettings.reload();
|
||||
|
||||
+23
-11
@@ -14,7 +14,9 @@ import { logServerError } from "@/lib/server-log";
|
||||
|
||||
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
|
||||
function optUInt(formData: FormData, key: string): number | null {
|
||||
const raw = String(formData.get(key) ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
@@ -30,7 +32,8 @@ 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") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
@@ -40,13 +43,16 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "").normalize("NFC")
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "").normalize("NFC")
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "").normalize("NFC")
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
@@ -55,7 +61,8 @@ export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "").normalize("NFC")
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
@@ -85,7 +92,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC")
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
@@ -95,13 +103,16 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "").normalize("NFC")
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "").normalize("NFC")
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "").normalize("NFC")
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
@@ -110,7 +121,8 @@ export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "").normalize("NFC")
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
|
||||
@@ -7,12 +7,21 @@ import { prisma } from "@/lib/prisma";
|
||||
export async function createTeam(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const rankName = String(formData.get("rankName") ?? "").normalize("NFC").trim();
|
||||
const rankName = String(formData.get("rankName") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!rankName) return;
|
||||
|
||||
const badge = String(formData.get("badge") ?? "").normalize("NFC").trim();
|
||||
const jobDescription = String(formData.get("jobDescription") ?? "").normalize("NFC").trim();
|
||||
const staffColor = String(formData.get("staffColor") ?? "").normalize("NFC").trim() || "#327fa8";
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const jobDescription = String(formData.get("jobDescription") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const staffColor =
|
||||
String(formData.get("staffColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || "#327fa8";
|
||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||
|
||||
const now = new Date();
|
||||
|
||||
+41
-12
@@ -36,30 +36,49 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const dbKey = settingKey(key, mode);
|
||||
const raw = String(formData.get(dbKey) ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get(dbKey) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
|
||||
}
|
||||
}
|
||||
const ADMIN_KEYS = ["admin_canvas", "admin_surface", "admin_text", "admin_text_muted", "admin_border", "admin_sidebar_bg"];
|
||||
const ADMIN_KEYS = [
|
||||
"admin_canvas",
|
||||
"admin_surface",
|
||||
"admin_text",
|
||||
"admin_text_muted",
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
];
|
||||
for (const key of ADMIN_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
|
||||
}
|
||||
|
||||
const radius = String(formData.get("border_radius") ?? "").normalize("NFC").trim();
|
||||
const radius = String(formData.get("border_radius") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||
|
||||
// Typography
|
||||
const font = String(formData.get("font_family") ?? "").normalize("NFC").trim();
|
||||
const font = String(formData.get("font_family") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (font in FONTS) await writeSetting("font_family", font);
|
||||
for (const key of HEADING_KEYS) {
|
||||
const v = String(formData.get(key) ?? "").normalize("NFC").trim();
|
||||
const v = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
|
||||
}
|
||||
|
||||
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
|
||||
if (formData.has("custom_css")) {
|
||||
const cssRaw = String(formData.get("custom_css") ?? "").normalize("NFC").slice(0, CUSTOM_CSS_MAX);
|
||||
const cssRaw = String(formData.get("custom_css") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, CUSTOM_CSS_MAX);
|
||||
await writeSetting("custom_css", cssRaw);
|
||||
}
|
||||
|
||||
@@ -101,7 +120,9 @@ export async function applyPreset(formData: FormData): Promise<void> {
|
||||
|
||||
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC").trim();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
@@ -120,7 +141,9 @@ export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||
|
||||
export async function applyCustomTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) redirect("/admin/theme");
|
||||
@@ -144,8 +167,12 @@ export async function applyCustomTheme(formData: FormData): Promise<void> {
|
||||
|
||||
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC").trim();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id || !name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
@@ -159,7 +186,9 @@ export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||
|
||||
export async function deleteCustomTheme(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
try {
|
||||
await deleteCustomThemeStore(id);
|
||||
|
||||
@@ -41,9 +41,15 @@ export async function updateUser(formData: FormData): Promise<void> {
|
||||
if (!existing) return;
|
||||
|
||||
// users row — only existing, safe columns.
|
||||
const mailRaw = String(formData.get("mail") ?? "").normalize("NFC").trim();
|
||||
const motto = String(formData.get("motto") ?? "").normalize("NFC").slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "").normalize("NFC").slice(0, 256);
|
||||
const mailRaw = String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 256);
|
||||
const rank = toInt(formData.get("rank"), 1);
|
||||
const credits = toInt(formData.get("credits"), 0);
|
||||
const pixels = toInt(formData.get("pixels"), 0);
|
||||
|
||||
@@ -30,7 +30,9 @@ export async function giveCurrency(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") ?? "").normalize("NFC").slice(0, 127);
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
if (userId > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { motto } });
|
||||
await rcon.setMotto(userId, motto);
|
||||
@@ -59,7 +61,9 @@ export async function setRank(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") ?? "").normalize("NFC").trim();
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (userId > 0 && message) await rcon.alertUser(userId, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { logServerError } from "@/lib/server-log";
|
||||
export async function createVoucher(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
|
||||
const code = String(formData.get("code") ?? "").normalize("NFC")
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(formData.get("amount"));
|
||||
@@ -18,7 +19,9 @@ export async function createVoucher(formData: FormData): Promise<void> {
|
||||
|
||||
if (!code || !(amount > 0)) return;
|
||||
|
||||
const expiresRaw = String(formData.get("expiresAt") ?? "").normalize("NFC").trim();
|
||||
const expiresRaw = String(formData.get("expiresAt") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
let expiresAt: Date | null = null;
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
|
||||
@@ -27,17 +27,23 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||
const enabled = String(formData.get("vpn_block_enabled") ?? "").normalize("NFC").trim() !== "";
|
||||
const enabled =
|
||||
String(formData.get("vpn_block_enabled") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() !== "";
|
||||
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "").normalize("NFC")
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "").normalize("NFC")
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "").normalize("NFC")
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function addWord(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const word = String(formData.get("word") ?? "").normalize("NFC")
|
||||
const word = String(formData.get("word") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!word) return;
|
||||
|
||||
@@ -11,7 +11,9 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a non-negative Int form value, falling back to 0. */
|
||||
function reqInt(formData: FormData, key: string): number {
|
||||
const raw = String(formData.get(key) ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return 0;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
@@ -20,7 +22,9 @@ function reqInt(formData: FormData, key: string): number {
|
||||
|
||||
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
|
||||
function parseId(formData: FormData): bigint | null {
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return BigInt(raw);
|
||||
@@ -38,7 +42,8 @@ function revalidate(): void {
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
|
||||
const title = String(formData.get("title") ?? "").normalize("NFC")
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
@@ -49,7 +54,8 @@ export async function createBox(formData: FormData): Promise<void> {
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "").normalize("NFC")
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
@@ -80,7 +86,8 @@ export async function updateBox(formData: FormData): Promise<void> {
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
const title = String(formData.get("title") ?? "").normalize("NFC")
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
@@ -91,7 +98,8 @@ export async function updateBox(formData: FormData): Promise<void> {
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "").normalize("NFC")
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
|
||||
@@ -28,7 +28,8 @@ 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") ?? "").normalize("NFC")
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
@@ -72,7 +73,8 @@ 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") ?? "").normalize("NFC")
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
@@ -21,7 +21,8 @@ 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") ?? "").normalize("NFC")
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, COMMENT_MAX);
|
||||
if (!comment) return;
|
||||
@@ -29,7 +30,9 @@ export async function postComment(formData: FormData): Promise<void> {
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(comment)).ok) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "").normalize("NFC").trim();
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
|
||||
@@ -31,12 +31,15 @@ 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") ?? "").normalize("NFC")
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "").normalize("NFC").trim();
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
|
||||
@@ -12,7 +12,9 @@ export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
||||
* 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> {
|
||||
const u = String(username ?? "").normalize("NFC").trim();
|
||||
const u = String(username ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const p = String(password ?? "");
|
||||
if (!u || !p) return "invalid";
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ 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") ?? "").normalize("NFC")
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!message) return;
|
||||
@@ -58,7 +59,9 @@ export async function hotelAlert(formData: FormData): Promise<void> {
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC").trim();
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !username) return;
|
||||
try {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
@@ -72,7 +75,8 @@ 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") ?? "").normalize("NFC")
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!userId || !message) return;
|
||||
@@ -144,7 +148,9 @@ export async function giveDiamonds(formData: FormData): Promise<void> {
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const badge = String(formData.get("badge") ?? "").normalize("NFC").trim();
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !badge) return;
|
||||
try {
|
||||
await rcon.giveBadge(userId, badge);
|
||||
@@ -158,7 +164,8 @@ 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") ?? "").normalize("NFC")
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 127);
|
||||
if (!userId || !motto) return;
|
||||
@@ -188,7 +195,9 @@ export async function setRank(formData: FormData): Promise<void> {
|
||||
export async function executeCommand(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const command = String(formData.get("command") ?? "").normalize("NFC").trim();
|
||||
const command = String(formData.get("command") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !command) return;
|
||||
try {
|
||||
await rcon.executeCommand(userId, command);
|
||||
|
||||
@@ -50,7 +50,9 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
|
||||
// The form posts the badge row id; everything else (price, code) is resolved
|
||||
// server-side from trusted data — never from the client.
|
||||
const rawId = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const rawId = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||
|
||||
@@ -25,7 +25,8 @@ 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") ?? "").normalize("NFC")
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
@@ -34,7 +35,9 @@ export async function postGuestbook(formData: FormData): Promise<void> {
|
||||
if (!(await isAllowed(message)).ok) return;
|
||||
|
||||
// Optional: used only to revalidate the correct profile route.
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC").trim();
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
|
||||
@@ -22,10 +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") ?? "").normalize("NFC")
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "").normalize("NFC")
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,8 @@ function sha256(s: string): string {
|
||||
}
|
||||
|
||||
export async function requestReset(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "").normalize("NFC")
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
@@ -49,10 +50,13 @@ export async function requestReset(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
export async function resetPassword(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "").normalize("NFC")
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const token = String(formData.get("token") ?? "").normalize("NFC").trim();
|
||||
const token = String(formData.get("token") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const password = String(formData.get("password") ?? "").normalize("NFC");
|
||||
|
||||
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
|
||||
|
||||
@@ -11,7 +11,8 @@ const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
function str(form: FormData, key: string, max: number): string {
|
||||
return String(form.get(key) ?? "").normalize("NFC")
|
||||
return String(form.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
@@ -12,10 +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") ?? "").normalize("NFC")
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "").normalize("NFC")
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) return;
|
||||
|
||||
@@ -28,7 +28,8 @@ 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") ?? "").normalize("NFC")
|
||||
message: String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
};
|
||||
|
||||
@@ -31,12 +31,18 @@ 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") ?? "").normalize("NFC").trim(),
|
||||
mail: String(formData.get("mail") ?? "").normalize("NFC")
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
mail: String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
password: String(formData.get("password") ?? "").normalize("NFC"),
|
||||
look: String(formData.get("look") ?? "").normalize("NFC").trim() || DEFAULT_LOOK,
|
||||
look:
|
||||
String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || DEFAULT_LOOK,
|
||||
};
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
|
||||
@@ -61,7 +61,9 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
|
||||
|
||||
// Optional: revalidate the target profile if a username was supplied, purely
|
||||
// to refresh any request-state UI rendered there.
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC").trim();
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
}
|
||||
|
||||
@@ -85,10 +87,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") ?? "").normalize("NFC")
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "").normalize("NFC")
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) return;
|
||||
|
||||
@@ -10,7 +10,30 @@ import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum(["en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro", "hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru"]),
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@ export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
|
||||
if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
|
||||
|
||||
const code = String(formData.get("code") ?? "").normalize("NFC").trim();
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
@@ -107,7 +109,9 @@ export async function disableTwoFactor(formData: FormData): Promise<void> {
|
||||
|
||||
if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
|
||||
|
||||
const code = String(formData.get("code") ?? "").normalize("NFC").trim();
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
@@ -13,28 +13,27 @@ const mottoSchema = z.object({
|
||||
motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
|
||||
});
|
||||
|
||||
const updateMottoAction = authAction(
|
||||
{ schema: mottoSchema },
|
||||
async (ctx) => {
|
||||
try {
|
||||
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } });
|
||||
} catch {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
|
||||
try {
|
||||
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } });
|
||||
} catch {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
try {
|
||||
await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
revalidatePath("/settings");
|
||||
return actionOk();
|
||||
});
|
||||
|
||||
export async function updateMotto(formData: FormData): Promise<void> {
|
||||
const motto = String(formData.get("motto") ?? "").normalize("NFC").slice(0, MOTTO_MAX);
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, MOTTO_MAX);
|
||||
await updateMottoAction({ motto });
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ export async function redeem(_prev: RedeemState, formData: FormData): Promise<Re
|
||||
return { ok: false, message: "Your session is invalid. Please sign in again." };
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").normalize("NFC").trim();
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!code) {
|
||||
return { ok: false, message: "Please enter a voucher code." };
|
||||
}
|
||||
|
||||
@@ -12,10 +12,25 @@ export default async function ImportPage() {
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ href: "/admin/import/badges", label: t("badges"), icon: BadgeCheck, description: t("badgesDescription") },
|
||||
{
|
||||
href: "/admin/import/badges",
|
||||
label: t("badges"),
|
||||
icon: BadgeCheck,
|
||||
description: t("badgesDescription"),
|
||||
},
|
||||
{ href: "/admin/import/furni", label: t("furni"), icon: Puzzle, description: t("furniDescription") },
|
||||
{ href: "/admin/import/clothing", label: t("clothing"), icon: Shirt, description: t("clothingDescription") },
|
||||
{ href: "/admin/import/effects", label: t("effects"), icon: Dumbbell, description: t("effectsDescription") },
|
||||
{
|
||||
href: "/admin/import/clothing",
|
||||
label: t("clothing"),
|
||||
icon: Shirt,
|
||||
description: t("clothingDescription"),
|
||||
},
|
||||
{
|
||||
href: "/admin/import/effects",
|
||||
label: t("effects"),
|
||||
icon: Dumbbell,
|
||||
description: t("effectsDescription"),
|
||||
},
|
||||
{ href: "/admin/import/pets", label: t("pets"), icon: Cat, description: t("petsDescription") },
|
||||
{ href: "/admin/import/clone", label: t("clone"), icon: Copy, description: t("cloneDescription") },
|
||||
{ href: "/admin/import/repair", label: t("repair"), icon: Wrench, description: t("repairDescription") },
|
||||
|
||||
@@ -183,7 +183,10 @@ async function Sidebar({ staff }: { staff: { id: number; username: string; rank:
|
||||
const navGroups = getNavGroups(t);
|
||||
|
||||
return (
|
||||
<aside data-admin className="sticky top-0 self-start h-auto lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col">
|
||||
<aside
|
||||
data-admin
|
||||
className="sticky top-0 self-start h-auto lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
|
||||
<span
|
||||
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--color-primary-foreground-readable)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
|
||||
|
||||
@@ -74,13 +74,30 @@ export function ColorField({
|
||||
name={name}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
style={{ width: 42, height: 36, padding: 0, border: "none", background: "none", cursor: "pointer", flexShrink: 0 }}
|
||||
style={{
|
||||
width: 42,
|
||||
height: 36,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "0.5rem" }}>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "0.5rem" }}
|
||||
>
|
||||
<span style={{ fontSize: "0.82rem", fontWeight: 600, lineHeight: 1.2 }}>{field.label}</span>
|
||||
{ratio != null ? (
|
||||
<span style={{ fontSize: "0.7rem", fontWeight: 700, color: ok ? "#22c55e" : "#ef4444", whiteSpace: "nowrap" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
color: ok ? "#22c55e" : "#ef4444",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{ratio.toFixed(1)}:1 {ok ? "✓" : "⚠"}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
+171
-41
@@ -1,5 +1,11 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { applyCustomTheme, applyPreset, deleteCustomTheme, saveCustomTheme, saveTheme } from "@/actions/admin-theme";
|
||||
import {
|
||||
applyCustomTheme,
|
||||
applyPreset,
|
||||
deleteCustomTheme,
|
||||
saveCustomTheme,
|
||||
saveTheme,
|
||||
} from "@/actions/admin-theme";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { FONTS, PRESETS } from "@/lib/theme-presets";
|
||||
import { listCustomThemes } from "@/lib/theme-custom-store";
|
||||
@@ -10,15 +16,58 @@ export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Theme" };
|
||||
|
||||
const COLOR_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string }[] = [
|
||||
{ key: "color_background", label: "Page background", def: "#f8fafc", desc: "Main page background behind all content" },
|
||||
{ key: "color_surface", label: "Card / surface", def: "#ffffff", desc: "Cards, panels, and elevated surfaces" },
|
||||
{ key: "color_dropdown", label: "Dropdown menu", def: "#ffffff", desc: "Dropdown menus and input backgrounds" },
|
||||
{
|
||||
key: "color_background",
|
||||
label: "Page background",
|
||||
def: "#f8fafc",
|
||||
desc: "Main page background behind all content",
|
||||
},
|
||||
{
|
||||
key: "color_surface",
|
||||
label: "Card / surface",
|
||||
def: "#ffffff",
|
||||
desc: "Cards, panels, and elevated surfaces",
|
||||
},
|
||||
{
|
||||
key: "color_dropdown",
|
||||
label: "Dropdown menu",
|
||||
def: "#ffffff",
|
||||
desc: "Dropdown menus and input backgrounds",
|
||||
},
|
||||
{ key: "color_navbar", label: "Navbar bar", def: "#ffffff", desc: "Top navigation bar background" },
|
||||
{ key: "color_navbar_text", label: "Navbar text", def: "#1e293b", desc: "Text color in the navigation bar", bgKey: "color_navbar" },
|
||||
{ key: "color_text", label: "Body text", def: "#0f172a", desc: "Main body text — must contrast with background/surface", bgKey: "color_surface" },
|
||||
{ key: "color_text_muted", label: "Muted text", def: "#64748b", desc: "Secondary text, labels, hints — lighter than body text", bgKey: "color_surface" },
|
||||
{ key: "color_primary", label: "Primary accent", def: "#f59e0b", desc: "Main brand color, highlights, active elements" },
|
||||
{ key: "color_accent", label: "Secondary accent", def: "#10b981", desc: "Secondary brand color, success-oriented highlights" },
|
||||
{
|
||||
key: "color_navbar_text",
|
||||
label: "Navbar text",
|
||||
def: "#1e293b",
|
||||
desc: "Text color in the navigation bar",
|
||||
bgKey: "color_navbar",
|
||||
},
|
||||
{
|
||||
key: "color_text",
|
||||
label: "Body text",
|
||||
def: "#0f172a",
|
||||
desc: "Main body text — must contrast with background/surface",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_text_muted",
|
||||
label: "Muted text",
|
||||
def: "#64748b",
|
||||
desc: "Secondary text, labels, hints — lighter than body text",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{
|
||||
key: "color_primary",
|
||||
label: "Primary accent",
|
||||
def: "#f59e0b",
|
||||
desc: "Main brand color, highlights, active elements",
|
||||
},
|
||||
{
|
||||
key: "color_accent",
|
||||
label: "Secondary accent",
|
||||
def: "#10b981",
|
||||
desc: "Secondary brand color, success-oriented highlights",
|
||||
},
|
||||
{ key: "border_color", label: "Border (golden)", def: "#eeb425", desc: "Borders, dividers, outlines" },
|
||||
{ key: "color_success", label: "Success", def: "#16a34a", desc: "Positive status (green)" },
|
||||
{ key: "color_warning", label: "Warning", def: "#eab308", desc: "Warning status (yellow)" },
|
||||
@@ -27,21 +76,81 @@ const COLOR_FIELDS: { key: string; label: string; def: string; desc: string; bgK
|
||||
];
|
||||
|
||||
const BUTTON_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string }[] = [
|
||||
{ key: "button_primary_color", label: "Primary button", def: "#f59e0b", desc: "Main call-to-action button background" },
|
||||
{ key: "button_text_color", label: "Button text", def: "#1e293b", desc: "Text on primary buttons — must contrast with button color", bgKey: "button_primary_color" },
|
||||
{ key: "button_secondary_color", label: "Secondary button", def: "#22c55e", desc: "Secondary action button background" },
|
||||
{ key: "button_secondary_text_color", label: "Secondary text", def: "#ffffff", desc: "Text on secondary buttons", bgKey: "button_secondary_color" },
|
||||
{ key: "button_danger_color", label: "Danger button", def: "#ef4444", desc: "Destructive action button (delete, ban)" },
|
||||
{ key: "button_danger_text_color", label: "Danger text", def: "#ffffff", desc: "Text on danger buttons", bgKey: "button_danger_color" },
|
||||
{ key: "button_outline_color", label: "Outline border", def: "#eeb425", desc: "Outline button border color" },
|
||||
{ key: "button_outline_text_color", label: "Outline text", def: "#1a1a2e", desc: "Text on outline buttons", bgKey: "color_surface" },
|
||||
{
|
||||
key: "button_primary_color",
|
||||
label: "Primary button",
|
||||
def: "#f59e0b",
|
||||
desc: "Main call-to-action button background",
|
||||
},
|
||||
{
|
||||
key: "button_text_color",
|
||||
label: "Button text",
|
||||
def: "#1e293b",
|
||||
desc: "Text on primary buttons — must contrast with button color",
|
||||
bgKey: "button_primary_color",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_color",
|
||||
label: "Secondary button",
|
||||
def: "#22c55e",
|
||||
desc: "Secondary action button background",
|
||||
},
|
||||
{
|
||||
key: "button_secondary_text_color",
|
||||
label: "Secondary text",
|
||||
def: "#ffffff",
|
||||
desc: "Text on secondary buttons",
|
||||
bgKey: "button_secondary_color",
|
||||
},
|
||||
{
|
||||
key: "button_danger_color",
|
||||
label: "Danger button",
|
||||
def: "#ef4444",
|
||||
desc: "Destructive action button (delete, ban)",
|
||||
},
|
||||
{
|
||||
key: "button_danger_text_color",
|
||||
label: "Danger text",
|
||||
def: "#ffffff",
|
||||
desc: "Text on danger buttons",
|
||||
bgKey: "button_danger_color",
|
||||
},
|
||||
{
|
||||
key: "button_outline_color",
|
||||
label: "Outline border",
|
||||
def: "#eeb425",
|
||||
desc: "Outline button border color",
|
||||
},
|
||||
{
|
||||
key: "button_outline_text_color",
|
||||
label: "Outline text",
|
||||
def: "#1a1a2e",
|
||||
desc: "Text on outline buttons",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
{ key: "link_color", label: "Link", def: "#eeb425", desc: "Hyperlink text color", bgKey: "color_surface" },
|
||||
{ key: "link_hover_color", label: "Link hover", def: "#cf9d15", desc: "Hyperlink text on hover", bgKey: "color_surface" },
|
||||
{
|
||||
key: "link_hover_color",
|
||||
label: "Link hover",
|
||||
def: "#cf9d15",
|
||||
desc: "Hyperlink text on hover",
|
||||
bgKey: "color_surface",
|
||||
},
|
||||
];
|
||||
|
||||
const GRADIENT_FIELDS: { key: string; label: string; def: string; desc: string }[] = [
|
||||
{ key: "gradient_from", label: "Gradient start", def: "#f59e0b", desc: "Left/top color of card header and hero gradients" },
|
||||
{ key: "gradient_to", label: "Gradient end", def: "#10b981", desc: "Right/bottom color of card header and hero gradients" },
|
||||
{
|
||||
key: "gradient_from",
|
||||
label: "Gradient start",
|
||||
def: "#f59e0b",
|
||||
desc: "Left/top color of card header and hero gradients",
|
||||
},
|
||||
{
|
||||
key: "gradient_to",
|
||||
label: "Gradient end",
|
||||
def: "#10b981",
|
||||
desc: "Right/bottom color of card header and hero gradients",
|
||||
},
|
||||
];
|
||||
|
||||
const HEADINGS: { key: string; label: string; def: string }[] = [
|
||||
@@ -52,9 +161,26 @@ const HEADINGS: { key: string; label: string; def: string }[] = [
|
||||
|
||||
const ADMIN_FIELDS: { key: string; label: string; def: string; desc: string; bgKey?: string }[] = [
|
||||
{ key: "admin_canvas", label: "Page background", def: "#0f172a", desc: "Main admin page background" },
|
||||
{ key: "admin_surface", label: "Card / panel", def: "#1e293b", desc: "Admin cards, tables, and panel backgrounds" },
|
||||
{ key: "admin_text", label: "Body text", def: "#f1f5f9", desc: "Main admin text — must contrast with canvas/surface", bgKey: "admin_canvas" },
|
||||
{ key: "admin_text_muted", label: "Muted text", def: "#94a3b8", desc: "Secondary labels, hints, descriptions", bgKey: "admin_canvas" },
|
||||
{
|
||||
key: "admin_surface",
|
||||
label: "Card / panel",
|
||||
def: "#1e293b",
|
||||
desc: "Admin cards, tables, and panel backgrounds",
|
||||
},
|
||||
{
|
||||
key: "admin_text",
|
||||
label: "Body text",
|
||||
def: "#f1f5f9",
|
||||
desc: "Main admin text — must contrast with canvas/surface",
|
||||
bgKey: "admin_canvas",
|
||||
},
|
||||
{
|
||||
key: "admin_text_muted",
|
||||
label: "Muted text",
|
||||
def: "#94a3b8",
|
||||
desc: "Secondary labels, hints, descriptions",
|
||||
bgKey: "admin_canvas",
|
||||
},
|
||||
{ key: "admin_border", label: "Border", def: "#334155", desc: "Table borders, dividers, input outlines" },
|
||||
{ key: "admin_sidebar_bg", label: "Sidebar", def: "#0f172a", desc: "Left navigation sidebar background" },
|
||||
];
|
||||
@@ -73,11 +199,7 @@ interface ThemeField {
|
||||
bgKey?: string;
|
||||
}
|
||||
|
||||
function colorFields(
|
||||
fields: ThemeField[],
|
||||
current: Record<string, string>,
|
||||
suffix = "",
|
||||
) {
|
||||
function colorFields(fields: ThemeField[], current: Record<string, string>, suffix = "") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -108,7 +230,14 @@ function colorFields(
|
||||
export default async function AdminTheme({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ saved?: string; preset?: string; savedTheme?: string; theme?: string; renamed?: string; deletedTheme?: string }>;
|
||||
searchParams: Promise<{
|
||||
saved?: string;
|
||||
preset?: string;
|
||||
savedTheme?: string;
|
||||
theme?: string;
|
||||
renamed?: string;
|
||||
deletedTheme?: string;
|
||||
}>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.theme");
|
||||
|
||||
@@ -170,9 +299,7 @@ export default async function AdminTheme({
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">Theme saved as preset.</div>
|
||||
) : null}
|
||||
{sp.theme ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">
|
||||
Applied theme “{sp.theme}”.
|
||||
</div>
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">Applied theme “{sp.theme}”.</div>
|
||||
) : null}
|
||||
{sp.renamed ? (
|
||||
<div className="mb-4 p-3 rounded-lg theme-status-success text-xs">Theme renamed.</div>
|
||||
@@ -213,8 +340,8 @@ export default async function AdminTheme({
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">Your saved themes</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3">
|
||||
Save the current configuration as a named preset, then load it anytime. Perfect for keeping multiple brand
|
||||
themes ready to switch.
|
||||
Save the current configuration as a named preset, then load it anytime. Perfect for keeping multiple
|
||||
brand themes ready to switch.
|
||||
</p>
|
||||
<form action={saveCustomTheme} className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<input
|
||||
@@ -277,14 +404,15 @@ export default async function AdminTheme({
|
||||
<form action={saveTheme}>
|
||||
<h2 className="mt-6 text-lg font-extrabold">☀ Light mode</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
|
||||
These colors are used when visitors view your site in light mode (default). Make sure body text contrasts well
|
||||
with both the page background and card surface.
|
||||
These colors are used when visitors view your site in light mode (default). Make sure body text
|
||||
contrasts well with both the page background and card surface.
|
||||
</p>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">Page & text colors</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">
|
||||
Backgrounds, surfaces, and text colors. Text colors must contrast with their backgrounds for readability.
|
||||
Backgrounds, surfaces, and text colors. Text colors must contrast with their backgrounds for
|
||||
readability.
|
||||
</p>
|
||||
<div className="admin-card">{colorFields(COLOR_FIELDS, current)}</div>
|
||||
</section>
|
||||
@@ -292,7 +420,8 @@ export default async function AdminTheme({
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">Buttons & links</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">
|
||||
Button backgrounds, text on buttons, and link colors. Button text must contrast with its button background.
|
||||
Button backgrounds, text on buttons, and link colors. Button text must contrast with its button
|
||||
background.
|
||||
</p>
|
||||
<div className="admin-card">{colorFields(BUTTON_FIELDS, current)}</div>
|
||||
</section>
|
||||
@@ -307,8 +436,8 @@ export default async function AdminTheme({
|
||||
|
||||
<h2 className="mt-8 text-lg font-extrabold">🛠 Admin panel (HK)</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
|
||||
Override colors for the Housekeeping admin panel. These apply across both light and dark modes. Leave empty to
|
||||
use auto-derived defaults.
|
||||
Override colors for the Housekeeping admin panel. These apply across both light and dark modes.
|
||||
Leave empty to use auto-derived defaults.
|
||||
</p>
|
||||
<section className="mt-4">
|
||||
<div className="admin-card">{colorFields(ADMIN_FIELDS, current)}</div>
|
||||
@@ -316,7 +445,8 @@ export default async function AdminTheme({
|
||||
|
||||
<h2 className="mt-8 text-lg font-extrabold">🌙 Dark mode</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-3 mt-1">
|
||||
These colors are used when visitors switch to dark mode. Dark backgrounds need lighter text for good contrast.
|
||||
These colors are used when visitors switch to dark mode. Dark backgrounds need lighter text for good
|
||||
contrast.
|
||||
</p>
|
||||
<section className="mt-4">
|
||||
<h3 className="admin-section-title">Page & text colors</h3>
|
||||
|
||||
@@ -2,7 +2,30 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { TranslationsClient } from "../translations-client";
|
||||
|
||||
const LOCALES = ["en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro", "hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru"] as const;
|
||||
const LOCALES = [
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
] as const;
|
||||
|
||||
export default async function CmsTranslationsPage() {
|
||||
const messagesDir = path.join(process.cwd(), "messages");
|
||||
|
||||
@@ -8,7 +8,29 @@ import { Input } from "@/components/ui/input";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { flattenObject, unflattenObject } from "@/lib/translations-utils";
|
||||
|
||||
type Locale = "en" | "it" | "nl" | "de" | "fr" | "es" | "pt" | "pl" | "sv" | "tr" | "ro" | "hu" | "cs" | "sk" | "da" | "no" | "el" | "bg" | "hr" | "sr" | "uk" | "ru";
|
||||
type Locale =
|
||||
| "en"
|
||||
| "it"
|
||||
| "nl"
|
||||
| "de"
|
||||
| "fr"
|
||||
| "es"
|
||||
| "pt"
|
||||
| "pl"
|
||||
| "sv"
|
||||
| "tr"
|
||||
| "ro"
|
||||
| "hu"
|
||||
| "cs"
|
||||
| "sk"
|
||||
| "da"
|
||||
| "no"
|
||||
| "el"
|
||||
| "bg"
|
||||
| "hr"
|
||||
| "sr"
|
||||
| "uk"
|
||||
| "ru";
|
||||
|
||||
const LOCALES: { code: Locale; label: string }[] = [
|
||||
{ code: "en", label: "EN" },
|
||||
|
||||
+11
-2
@@ -120,7 +120,14 @@ body {
|
||||
}
|
||||
|
||||
/* Mobile-friendly improvements for every screen */
|
||||
button, a, input, select, textarea, summary, [role="button"], [role="menuitem"] {
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
summary,
|
||||
[role="button"],
|
||||
[role="menuitem"] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@@ -131,7 +138,9 @@ button, a, input, select, textarea, summary, [role="button"], [role="menuitem"]
|
||||
|
||||
/* Prevent iOS zoom on input focus */
|
||||
@media (max-width: 48rem) {
|
||||
input, select, textarea {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
|
||||
export function AdminMobileWrapper({
|
||||
sidebar,
|
||||
children,
|
||||
}: {
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export function AdminMobileWrapper({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +18,10 @@ export function AdminMobileWrapper({
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-canvas)] flex flex-col lg:flex-row">
|
||||
{/* Mobile header */}
|
||||
<div className="lg:hidden sticky top-0 z-50 flex items-center gap-3 px-4 py-3 border-b" style={{ backgroundColor: "var(--admin-sidebar-background)", borderColor: "var(--admin-border)" }}>
|
||||
<div
|
||||
className="lg:hidden sticky top-0 z-50 flex items-center gap-3 px-4 py-3 border-b"
|
||||
style={{ backgroundColor: "var(--admin-sidebar-background)", borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
@@ -33,13 +30,13 @@ export function AdminMobileWrapper({
|
||||
>
|
||||
{open ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
<span className="text-sm font-bold" style={{ color: "var(--admin-sidebar-text-readable)" }}>Admin Panel</span>
|
||||
<span className="text-sm font-bold" style={{ color: "var(--admin-sidebar-text-readable)" }}>
|
||||
Admin Panel
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Backdrop */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden bg-black/50" onClick={() => setOpen(false)} />
|
||||
)}
|
||||
{open && <div className="fixed inset-0 z-40 lg:hidden bg-black/50" onClick={() => setOpen(false)} />}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
@@ -51,9 +48,7 @@ export function AdminMobileWrapper({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,8 +97,8 @@ export function ItemsShopPreview({
|
||||
|
||||
{/* Limited */}
|
||||
{item.limitedStack > 0 && (
|
||||
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
|
||||
LTD {item.limitedSells}/{item.limitedStack}
|
||||
<Badge className="absolute bottom-1 left-1 h-4 border-0 bg-[var(--admin-accent)] px-1 text-[8px] text-[var(--admin-accent-foreground)]">
|
||||
LTD {item.limitedSells}/{item.limitedStack}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,11 @@ export function RenameTheme({ id, name }: { id: string; name: string }) {
|
||||
<button type="submit" className="text-xs font-semibold text-[var(--admin-accent)]">
|
||||
OK
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditing(false)} className="text-xs text-[var(--admin-text-muted)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-xs text-[var(--admin-text-muted)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -122,7 +122,8 @@ export function LanguageSwitcher() {
|
||||
}`}
|
||||
style={{
|
||||
color: "var(--color-text-readable, var(--color-text))",
|
||||
background: l.code === locale ? "color-mix(in srgb, var(--color-primary) 10%, transparent)" : "none",
|
||||
background:
|
||||
l.code === locale ? "color-mix(in srgb, var(--color-primary) 10%, transparent)" : "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
width: "calc(100% - 8px)",
|
||||
@@ -134,7 +135,9 @@ export function LanguageSwitcher() {
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
l.code === locale ? "color-mix(in srgb, var(--color-primary) 10%, transparent)" : "transparent";
|
||||
l.code === locale
|
||||
? "color-mix(in srgb, var(--color-primary) 10%, transparent)"
|
||||
: "transparent";
|
||||
e.currentTarget.style.color = "var(--color-text)";
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,10 @@ export async function SiteHeader() {
|
||||
header && header !== "/assets/images/background.png" ? header : "/assets/images/banner.png";
|
||||
|
||||
return (
|
||||
<div className="site-header relative w-full overflow-hidden" style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}>
|
||||
<div
|
||||
className="site-header relative w-full overflow-hidden"
|
||||
style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}
|
||||
>
|
||||
{/* Background image */}
|
||||
<div className="absolute inset-0" style={{ background: `url(${bannerUrl}) center/cover no-repeat` }} />
|
||||
|
||||
|
||||
@@ -155,18 +155,21 @@ export async function ThemeVars() {
|
||||
) as ThemePalette;
|
||||
const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides);
|
||||
|
||||
const lightAdmin = deriveAdminPalette({
|
||||
color_background: safeBackground,
|
||||
color_surface: safeSurface,
|
||||
color_text: safe(text, "#0f172a"),
|
||||
color_text_muted: safe(textMuted, "#64748b"),
|
||||
color_primary: safe(primary, "#f59e0b"),
|
||||
color_accent: safe(accent, "#10b981"),
|
||||
color_success: safe(success, "#16a34a"),
|
||||
color_warning: safe(warning, "#eab308"),
|
||||
color_error: safe(error, "#ef4444"),
|
||||
color_info: safe(info, "#0ea5e9"),
|
||||
} as ThemePalette, adminOverrides);
|
||||
const lightAdmin = deriveAdminPalette(
|
||||
{
|
||||
color_background: safeBackground,
|
||||
color_surface: safeSurface,
|
||||
color_text: safe(text, "#0f172a"),
|
||||
color_text_muted: safe(textMuted, "#64748b"),
|
||||
color_primary: safe(primary, "#f59e0b"),
|
||||
color_accent: safe(accent, "#10b981"),
|
||||
color_success: safe(success, "#16a34a"),
|
||||
color_warning: safe(warning, "#eab308"),
|
||||
color_error: safe(error, "#ef4444"),
|
||||
color_info: safe(info, "#0ea5e9"),
|
||||
} as ThemePalette,
|
||||
adminOverrides,
|
||||
);
|
||||
|
||||
const css = `:root{${adminPaletteCss(lightAdmin)}
|
||||
--color-primary:${safe(primary, "#f59e0b")};
|
||||
|
||||
+24
-1
@@ -3,7 +3,30 @@ import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
|
||||
// the Accept-Language header, and finally English as fallback.
|
||||
export const SUPPORTED_LOCALES = ["en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro", "hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru"] as const;
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function requireStaff(): Promise<StaffUser> {
|
||||
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
@@ -16,7 +16,10 @@ type AdminHandler = (
|
||||
routeContext: RouteContext,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export function withAdmin(options: { permission?: string; requireCsrf?: boolean; maxBodyBytes?: number }, handler: AdminHandler) {
|
||||
export function withAdmin(
|
||||
options: { permission?: string; requireCsrf?: boolean; maxBodyBytes?: number },
|
||||
handler: AdminHandler,
|
||||
) {
|
||||
return async (request: NextRequest, routeContext: RouteContext = {}) => {
|
||||
if (options.requireCsrf === true && MUTATING_METHODS.has(request.method)) {
|
||||
const csrfToken = request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token") ?? "";
|
||||
@@ -30,7 +33,10 @@ export function withAdmin(options: { permission?: string; requireCsrf?: boolean;
|
||||
const contentLength = request.headers.get("content-length");
|
||||
const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
||||
if (contentLength && Number(contentLength) > maxBytes) {
|
||||
return NextResponse.json({ ok: false, error: `Request body exceeds ${maxBytes} bytes` }, { status: 413 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Request body exceeds ${maxBytes} bytes` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,23 @@ import { auth } from "@/lib/auth";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { getRequestId, runWithStore, createStore, setContextUserId } from "./request-context";
|
||||
import { NotFoundError, UnauthorizedError, ForbiddenError, ValidationError, RateLimitError, DatabaseError } from "./errors";
|
||||
import type { ActionResult, ActionSuccess, ActionFailure, AppSession, AdminActionContext, IpAddress, RequestId } from "./types";
|
||||
import {
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
DatabaseError,
|
||||
} from "./errors";
|
||||
import type {
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
ActionFailure,
|
||||
AppSession,
|
||||
AdminActionContext,
|
||||
IpAddress,
|
||||
RequestId,
|
||||
} from "./types";
|
||||
import { extractClientIpAsync } from "./security";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
@@ -34,9 +49,7 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AdminOpts<TSchema>,
|
||||
handler: ActionHandler<TSchema>,
|
||||
) {
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
return async (input: TSchema extends z.ZodType ? z.input<TSchema> : void): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
@@ -83,7 +96,8 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
...(opts.schema ? { data: data as z.infer<NonNullable<TSchema>> } : {}),
|
||||
} as AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
} as AdminActionContext &
|
||||
(TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
@@ -102,11 +116,13 @@ interface AuthOpts<TSchema extends z.ZodType | undefined> {
|
||||
|
||||
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AuthOpts<TSchema>,
|
||||
handler: (ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>)) => Promise<ActionResult>,
|
||||
handler: (
|
||||
ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>),
|
||||
) => Promise<ActionResult>,
|
||||
) {
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
return async (input: TSchema extends z.ZodType ? z.input<TSchema> : void): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
@@ -136,7 +152,9 @@ export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
session: session as unknown as AppSession,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
} as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
} as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType
|
||||
? { data: z.infer<TSchema> }
|
||||
: Record<string, never>);
|
||||
|
||||
if (opts.schema) {
|
||||
(ctx as Record<string, unknown>).data = data as z.infer<NonNullable<TSchema>>;
|
||||
@@ -171,12 +189,16 @@ export function handleActionError(error: unknown): ActionFailure {
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" || error.name === "PrismaClientKnownRequestError") &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" ||
|
||||
error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return fail("Not found");
|
||||
}
|
||||
|
||||
console.error("[Action error]", error instanceof Error ? { message: error.message, name: error.name } : error);
|
||||
console.error(
|
||||
"[Action error]",
|
||||
error instanceof Error ? { message: error.message, name: error.name } : error,
|
||||
);
|
||||
return fail("Internal server error");
|
||||
}
|
||||
@@ -38,13 +38,23 @@ export class DbService {
|
||||
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? [{ emit: "event", level: "query" }, { emit: "event", level: "error" }] : [{ emit: "event", level: "error" }],
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
]
|
||||
: [{ emit: "event", level: "error" }],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
this.client.$on("query" as never, (e: unknown) => {
|
||||
const ev = e as { query: string; duration: number };
|
||||
logger.debug("DB query", { query: ev.query.slice(0, 200), durationMs: ev.duration, requestId: getRequestId() });
|
||||
logger.debug("DB query", {
|
||||
query: ev.query.slice(0, 200),
|
||||
durationMs: ev.duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +84,13 @@ export class DbService {
|
||||
} catch (cause) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const message = cause instanceof Error ? cause.message : "Unknown database error";
|
||||
this.healthCache = { ok: false, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0, error: message };
|
||||
this.healthCache = {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
error: message,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
@@ -89,7 +105,9 @@ export class DbService {
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends">) => Promise<T>): Promise<T> {
|
||||
async transaction<T>(
|
||||
fn: (tx: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends">) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$transaction(fn);
|
||||
} catch (cause) {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
export {
|
||||
adminAction,
|
||||
authAction,
|
||||
actionOk,
|
||||
actionError,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
export { adminAction, authAction, actionOk, actionError, handleActionError } from "./action";
|
||||
|
||||
export {
|
||||
DbService,
|
||||
db,
|
||||
} from "./database";
|
||||
export { DbService, db } from "./database";
|
||||
|
||||
export {
|
||||
safeRedirect,
|
||||
@@ -44,12 +35,7 @@ export {
|
||||
runWithStore,
|
||||
} from "./request-context";
|
||||
|
||||
export {
|
||||
chain,
|
||||
withRequestContext,
|
||||
protectAdminRoutes,
|
||||
addSecurityHeaders,
|
||||
} from "./middleware";
|
||||
export { chain, withRequestContext, protectAdminRoutes, addSecurityHeaders } from "./middleware";
|
||||
|
||||
export {
|
||||
username,
|
||||
|
||||
@@ -53,8 +53,9 @@ export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
||||
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
|
||||
const authToken = req.cookies.get("next-auth.session-token")?.value
|
||||
?? req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
const authToken =
|
||||
req.cookies.get("next-auth.session-token")?.value ??
|
||||
req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
|
||||
if (!authToken) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function getRequestStore(): RequestStore | null {
|
||||
}
|
||||
|
||||
export function getRequestId(): RequestId {
|
||||
return als.getStore()?.requestId ?? (generateRequestId());
|
||||
return als.getStore()?.requestId ?? generateRequestId();
|
||||
}
|
||||
|
||||
export function getClientIp(): IpAddress {
|
||||
|
||||
@@ -9,15 +9,21 @@ const CSRF_BYTES = 32;
|
||||
const CSRF_COOKIE = "__Host-csrf-token";
|
||||
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
|
||||
|
||||
const ALLOWED_HOSTS: ReadonlySet<string> = new Set([
|
||||
env.APP_URL ? new URL(env.APP_URL).host : "",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
].filter(Boolean));
|
||||
const ALLOWED_HOSTS: ReadonlySet<string> = new Set(
|
||||
[env.APP_URL ? new URL(env.APP_URL).host : "", "localhost", "127.0.0.1"].filter(Boolean),
|
||||
);
|
||||
|
||||
const SAFE_REDIRECT_PATHS = new Set([
|
||||
"/login", "/register", "/forgot", "/reset", "/verify",
|
||||
"/banned", "/maintenance", "/", "/me", "/settings",
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/verify",
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/",
|
||||
"/me",
|
||||
"/settings",
|
||||
]);
|
||||
|
||||
function isSafePath(path: string): boolean {
|
||||
@@ -42,7 +48,15 @@ export function redirectSafe(destination: string, fallback: string = "/"): never
|
||||
redirect(safeRedirect(destination, fallback));
|
||||
}
|
||||
|
||||
function csrfCookieOpts(): { name: string; value: string; httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } {
|
||||
function csrfCookieOpts(): {
|
||||
name: string;
|
||||
value: string;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
path: string;
|
||||
maxAge: number;
|
||||
} {
|
||||
return {
|
||||
name: CSRF_COOKIE,
|
||||
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
||||
@@ -106,7 +120,10 @@ export function canonicalizeFormValue(value: FormDataEntryValue | null, maxLen?:
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
}
|
||||
|
||||
export function canonicalizeFormData(formData: FormData, fields: Record<string, number | undefined>): Record<string, string> {
|
||||
export function canonicalizeFormData(
|
||||
formData: FormData,
|
||||
fields: Record<string, number | undefined>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]),
|
||||
);
|
||||
@@ -115,13 +132,11 @@ export function canonicalizeFormData(formData: FormData, fields: Record<string,
|
||||
export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
return (h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
) as IpAddress;
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ export const slug = z
|
||||
.refine((v) => !v.startsWith("-") && !v.endsWith("-"), "Slug must not start or end with a hyphen")
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const url = z
|
||||
.string()
|
||||
.url("Invalid URL")
|
||||
.max(2048, "URL must be at most 2048 characters");
|
||||
export const url = z.string().url("Invalid URL").max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
@@ -50,25 +47,13 @@ export const look = z
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
|
||||
export const positiveInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.positive("Must be positive");
|
||||
export const positiveInt = z.number().int("Must be a whole number").positive("Must be positive");
|
||||
|
||||
export const nonNegativeInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.nonnegative("Must not be negative");
|
||||
export const nonNegativeInt = z.number().int("Must be a whole number").nonnegative("Must not be negative");
|
||||
|
||||
export const bigIntString = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Must be a numeric string")
|
||||
.transform(BigInt);
|
||||
export const bigIntString = z.string().regex(/^\d+$/, "Must be a numeric string").transform(BigInt);
|
||||
|
||||
export const idParam = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "ID must be numeric")
|
||||
.transform(Number);
|
||||
export const idParam = z.string().regex(/^\d+$/, "ID must be numeric").transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
|
||||
@@ -2,8 +2,7 @@ import { ZodError } from "zod";
|
||||
import { handleActionError as foundationHandle } from "@/lib/foundation/action";
|
||||
|
||||
export type ActionResult<T = Record<string, unknown>> =
|
||||
| { ok: true; data?: T }
|
||||
| { ok: false; error: string; fieldErrors?: Record<string, string[]> };
|
||||
{ ok: true; data?: T } | { ok: false; error: string; fieldErrors?: Record<string, string[]> };
|
||||
|
||||
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
|
||||
return { ok: true, data: data ?? ({} as T) };
|
||||
|
||||
@@ -24,12 +24,12 @@ function cleanupStaleEntries(): void {
|
||||
if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
|
||||
if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) {
|
||||
recentlyBlocked.clear();
|
||||
|
||||
@@ -56,13 +56,21 @@ export function deriveAdminPalette(
|
||||
...defaults,
|
||||
...overrides,
|
||||
accent,
|
||||
accentText: readableColor(accent, [overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface, overrides.surfaceElevated ?? defaults.surfaceElevated]),
|
||||
accentText: readableColor(accent, [
|
||||
overrides.canvas ?? defaults.canvas,
|
||||
overrides.surface ?? defaults.surface,
|
||||
overrides.surfaceElevated ?? defaults.surfaceElevated,
|
||||
]),
|
||||
accentForeground: readableColor("#ffffff", [accent]),
|
||||
success: overrides.success ?? defaults.success,
|
||||
warning: overrides.warning ?? defaults.warning,
|
||||
error: overrides.error ?? defaults.error,
|
||||
info: overrides.info ?? defaults.info,
|
||||
sidebarText: readableColor(overrides.text ?? defaults.text, [overrides.sidebar ?? defaults.sidebar, overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface]),
|
||||
sidebarText: readableColor(overrides.text ?? defaults.text, [
|
||||
overrides.sidebar ?? defaults.sidebar,
|
||||
overrides.canvas ?? defaults.canvas,
|
||||
overrides.surface ?? defaults.surface,
|
||||
]),
|
||||
overlay: defaults.overlay,
|
||||
focusRing: accent,
|
||||
};
|
||||
|
||||
+11
-2
@@ -1,4 +1,9 @@
|
||||
import { deriveAdminPalette, derivePublicForegrounds, readableColor, type AdminPalette } from "@/lib/theme-contrast";
|
||||
import {
|
||||
deriveAdminPalette,
|
||||
derivePublicForegrounds,
|
||||
readableColor,
|
||||
type AdminPalette,
|
||||
} from "@/lib/theme-contrast";
|
||||
import type { ThemePalette } from "@/lib/theme-presets";
|
||||
|
||||
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
||||
@@ -62,7 +67,11 @@ export function adminPaletteCss(admin: AdminPalette): string {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function themePaletteCss(selector: string, palette: ThemePalette, adminOverrides?: Partial<AdminPalette>): string {
|
||||
export function themePaletteCss(
|
||||
selector: string,
|
||||
palette: ThemePalette,
|
||||
adminOverrides?: Partial<AdminPalette>,
|
||||
): string {
|
||||
const semantic = derivePublicForegrounds(palette);
|
||||
const admin = deriveAdminPalette(palette, adminOverrides);
|
||||
const declarations = Object.entries(CSS_VARIABLES).map(
|
||||
|
||||
@@ -27,15 +27,12 @@ const EXTRA_KEYS = [
|
||||
"theme_preset",
|
||||
];
|
||||
|
||||
const ALL_KEYS: string[] = [
|
||||
...THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]),
|
||||
...EXTRA_KEYS,
|
||||
];
|
||||
const ALL_KEYS: string[] = [...THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]), ...EXTRA_KEYS];
|
||||
|
||||
function fallbackFor(key: string): string {
|
||||
const preset = PRESETS["Atom (golden)"];
|
||||
if (key.endsWith("_dark")) {
|
||||
const base = key.slice(0, -("_dark".length)) as ThemeColorKey;
|
||||
const base = key.slice(0, -"_dark".length) as ThemeColorKey;
|
||||
return preset.dark[base] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
|
||||
}
|
||||
if (key in preset.light) {
|
||||
@@ -87,7 +84,11 @@ async function persist(themes: CustomTheme[]): Promise<void> {
|
||||
siteSettings.reload();
|
||||
}
|
||||
|
||||
export async function upsertCustomTheme(name: string, settings: Record<string, string>, id?: string): Promise<CustomTheme> {
|
||||
export async function upsertCustomTheme(
|
||||
name: string,
|
||||
settings: Record<string, string>,
|
||||
id?: string,
|
||||
): Promise<CustomTheme> {
|
||||
const themes = await listCustomThemes();
|
||||
const trimmed = name.trim() || "Untitled theme";
|
||||
if (id) {
|
||||
|
||||
Reference in new issue
Block a user