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:
1 parent
c125e91941
commit
95b4b5953d
3 files changed
+68
-4
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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."),
|
||||||
|
|||||||
Reference in new issue
Block a user