fix(admin): Kategorie und Slug beim Produkt-Anlegen serverseitig pruefen

Die Kategorie kam bisher nur per Typ-Cast aus dem Formular; ein
manipulierter Wert endete als unbehandelte Prisma-Ausnahme. Ein eigener
Slug wurde ungeprueft zur oeffentlichen Adresse, inklusive Schraegstrichen
und Sonderzeichen. Neues adminProductIdentitySchema: Kategorie per Enum,
Slug auf a-z, 0-9 und Bindestrich (Umlaute umschrieben, max. 80 Zeichen),
leere Adresse wird mit Meldung abgelehnt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 20:43:54 +02:00
1 parent c125e91941
commit 95b4b5953d
3 files changed
+68 -4

No files matched your search

+10 -3
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { notifyStockAvailable } from "@/lib/stock-notifications"; import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation"; import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash"; import { succeed } from "@/lib/flash";
@@ -29,7 +29,15 @@ async function assertPricedIfActive(redirectPath: string, isActive: boolean, pri
export async function createProductAction(formData: FormData): Promise<void> { export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-"); const parsedIdentity = adminProductIdentitySchema.safeParse({
name: str(formData, "name"),
slug: str(formData, "slug"),
category: str(formData, "category") || "HONIG",
});
if (!parsedIdentity.success) {
return fail("/admin/produkte/neu", parsedIdentity.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const { slug, category } = parsedIdentity.data;
const parsedNumbers = adminProductNumbersSchema.safeParse({ const parsedNumbers = adminProductNumbersSchema.safeParse({
intensityMild: str(formData, "intensityMild"), intensityMild: str(formData, "intensityMild"),
@@ -49,7 +57,6 @@ export async function createProductAction(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") === "on"; const isActive = formData.get("isActive") === "on";
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents); await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien // davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen. // ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema } from "./validation"; import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema, adminProductIdentitySchema } from "./validation";
describe("registerSchema", () => { describe("registerSchema", () => {
test("accepts a valid registration", () => { test("accepts a valid registration", () => {
@@ -182,3 +182,27 @@ describe("discountCodeSchema", () => {
if (result.success) expect(result.data.code).toBe("SOMMER15"); if (result.success) expect(result.data.code).toBe("SOMMER15");
}); });
}); });
describe("adminProductIdentitySchema", () => {
test("bildet den Slug aus dem Namen, wenn keiner angegeben ist", () => {
const result = adminProductIdentitySchema.safeParse({ name: "Wald Honig 2026", slug: "", category: "HONIG" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.slug).toBe("wald-honig-2026");
});
test("bereinigt einen eigenen Slug statt Schraegstriche oder Sonderzeichen durchzulassen", () => {
const result = adminProductIdentitySchema.safeParse({ name: "X", slug: "../Admin/<b>Honig</b>", category: "KERZEN" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.slug).toBe("admin-b-honig-b");
});
test("lehnt eine unbekannte Kategorie ab", () => {
const result = adminProductIdentitySchema.safeParse({ name: "X", slug: "", category: "GIFT" });
expect(result.success).toBe(false);
});
test("lehnt einen Namen ohne verwertbare Zeichen fuer den Slug ab", () => {
const result = adminProductIdentitySchema.safeParse({ name: "???", slug: "", category: "HONIG" });
expect(result.success).toBe(false);
});
});
+33
View File
@@ -129,6 +129,39 @@ export const adminProductNumbersSchema = z.object({
}); });
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>; export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
const PRODUCT_SLUG_MAX_LENGTH = 80;
/** Slug als oeffentliche Adresse (/honig/<slug>): nur a-z, 0-9 und Bindestrich. */
function toProductSlug(value: string): string {
return value
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, PRODUCT_SLUG_MAX_LENGTH)
.replace(/-+$/, "");
}
// Kategorie und Slug beim Anlegen eines Produkts. Frueher kam die Kategorie
// nur per Typ-Cast aus dem Formular (ein manipulierter Wert endete als
// unbehandelte Prisma-Ausnahme), und ein eigener Slug wurde ungeprueft zur
// oeffentlichen Adresse — samt Schraegstrichen und Sonderzeichen.
export const adminProductIdentitySchema = z
.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
slug: z.string().trim().default(""),
category: z.enum(["HONIG", "KERZEN", "PROPOLIS"], { message: "Unbekannte Kategorie." }),
})
.transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) }))
.refine((input) => input.slug.length > 0, {
message: "Aus Name bzw. Slug laesst sich keine Adresse bilden. Bitte einen Slug mit Buchstaben oder Ziffern eintragen.",
path: ["slug"],
});
export type AdminProductIdentityInput = z.infer<typeof adminProductIdentitySchema>;
export const shippingTierSchema = z export const shippingTierSchema = z
.object({ .object({
minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."), minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."),