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> {
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}
File diff suppressed because it is too large. Load diff
+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) {