style: format code with prettier agian

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