style: format code with prettier agian
Local Build and Deploy / deploy (push) Failing after 49s

This commit is contained in:
openhands committed 2026-07-13 21:41:52 +02:00
1 parent e6d7f2280b
commit 8efd032cc6
71 files changed
+6796 -3751

No files matched your search

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