From 54919d82ed2c25c92f880ee0ad23e0862178828b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 4 Sep 2026 19:59:42 +0200 Subject: [PATCH] feat: Bild-Upload fuer Inhalte, Produkte und Ernten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads). - lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen. - app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- + Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF). - app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine zur Laufzeit hinzugefuegten Dateien bedienen) — mit Path-Traversal-Schutz und nosniff-Header. - content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite- Grafiken, admin/inhalte hat dafuer ein Upload-Widget. - Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer Product.images/Harvest.images (beide Felder existierten im Schema, hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt Platzhalter, sobald vorhanden. - Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt. Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm build clean. Upload-/Serving-Pipeline live gegen einen laufenden Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung, Format-Ablehnung) — ein vollstaendiger authentifizierter Upload end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live durchgespielt, nur per Code-Review verifiziert. Co-Authored-By: Claude Sonnet 5 --- .env.example | 17 +++ .gitignore | 4 + k8s/01-configmap.yaml | 5 + k8s/05-app-deployment.yaml | 26 ++++ src/app/admin/actions/_shared.ts | 12 ++ src/app/admin/actions/content.ts | 12 +- src/app/admin/actions/harvests-batches.ts | 5 +- src/app/admin/actions/products.ts | 5 +- src/app/admin/ernten/[id]/page.tsx | 12 +- src/app/admin/inhalte/page.tsx | 15 +++ src/app/admin/produkte/[id]/page.tsx | 13 +- src/app/admin/produkte/neu/page.tsx | 8 ++ src/app/api/admin/_shared.ts | 32 +++++ src/app/api/admin/upload/route.ts | 106 ++++++++++++++++ src/app/charge/[code]/page.tsx | 25 +++- src/app/ernten/[year]/[type]/page.tsx | 17 ++- src/app/ernten/page.tsx | 6 +- src/app/page.tsx | 3 +- src/app/uploads/[...path]/route.ts | 67 ++++++++++ src/components/admin-image-field.tsx | 92 ++++++++++++++ src/components/admin-image-list-field.tsx | 100 +++++++++++++++ src/components/cart-item-thumbnail.tsx | 4 +- src/components/content-image.tsx | 41 ++++++ src/components/hero.tsx | 8 +- src/components/home-sections-a.tsx | 17 ++- src/components/home-sections-b.tsx | 25 +++- src/components/product-card.tsx | 18 ++- src/components/product-gallery.tsx | 8 +- src/i18n/dictionaries/de.ts | 10 ++ src/i18n/dictionaries/en.ts | 10 ++ src/i18n/dictionary-type.ts | 12 ++ src/lib/content-registry.ts | 18 ++- src/lib/data.ts | 38 +++++- src/lib/uploads.test.ts | 122 ++++++++++++++++++ src/lib/uploads.ts | 145 ++++++++++++++++++++++ 35 files changed, 1014 insertions(+), 44 deletions(-) create mode 100644 src/app/api/admin/_shared.ts create mode 100644 src/app/api/admin/upload/route.ts create mode 100644 src/app/uploads/[...path]/route.ts create mode 100644 src/components/admin-image-field.tsx create mode 100644 src/components/admin-image-list-field.tsx create mode 100644 src/components/content-image.tsx create mode 100644 src/lib/uploads.test.ts create mode 100644 src/lib/uploads.ts diff --git a/.env.example b/.env.example index 2daf508..bb9e886 100644 --- a/.env.example +++ b/.env.example @@ -159,3 +159,20 @@ ENTRA_TENANT_ID= ENTRA_CLIENT_ID= ENTRA_CLIENT_SECRET= ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback + +# Verzeichnis fuer hochgeladene Bilder (Inhalte-Bild in admin/inhalte, +# Produkt-/Erntenfotos in admin/produkte bzw. admin/ernten — siehe +# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts). Die App +# liest/schreibt hier ganz normal per node:fs; sie weiss nichts davon, WAS +# dieses Verzeichnis in Produktion ist (siehe unten). +# +# Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per +# .gitignore ausgeschlossen — beim ersten Upload automatisch angelegt). +# +# In Produktion (Kubernetes, siehe k8s/05-app-deployment.yaml) zeigt +# UPLOAD_DIR stattdessen auf einen Volume-Mount, damit hochgeladene Bilder +# einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret +# ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des +# PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath +# zeigen. +UPLOAD_DIR=./uploads diff --git a/.gitignore b/.gitignore index b18a2e4..ea45311 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ next-env.d.ts # kubernetes (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert) /k8s/02-secret.yaml + +# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in +# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis +/uploads diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index 238503c..728b073 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -23,3 +23,8 @@ data: NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" # "sandbox" bis DHL-Versandmarken getestet sind, danach "production". DHL_ENVIRONMENT: "sandbox" + # Verzeichnis fuer hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos, + # siehe app/api/admin/upload/route.ts) — MUSS exakt dem mountPath des + # "uploads"-Volumes in 05-app-deployment.yaml entsprechen. Die App selbst + # weiss nicht, was fuer ein Volume dort gemountet ist; siehe .env.example. + UPLOAD_DIR: "/app/uploads" diff --git a/k8s/05-app-deployment.yaml b/k8s/05-app-deployment.yaml index d828fbf..42a54ce 100644 --- a/k8s/05-app-deployment.yaml +++ b/k8s/05-app-deployment.yaml @@ -37,6 +37,18 @@ spec: name: wabenhain-config - secretRef: name: wabenhain-secrets + volumeMounts: + # Muss auf denselben Pfad zeigen wie UPLOAD_DIR in + # 01-configmap.yaml. Das "uploads"-Volume selbst wird HIER + # bewusst nicht definiert (kein PV/PVC in diesem Manifest) — das + # haengt vom tatsaechlichen Storage-Backend ab (SMB/NFS/ + # hostPath/...), das die Betreiberin bereitstellt. Unten im + # "volumes:"-Block einen passenden Eintrag mit demselben Namen + # ("uploads") ergaenzen, z.B. ueber eine + # persistentVolumeClaim.claimName, sonst startet der Pod nicht + # (fehlende Volume-Referenz). + - name: uploads + mountPath: /app/uploads readinessProbe: httpGet: path: / @@ -61,6 +73,20 @@ spec: memory: 256Mi limits: memory: 512Mi + volumes: + # Platzhalter — die Betreiberin ergaenzt hier ihre eigene + # Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen + # ein SMB-/NFS-Backend oder einen hostPath gebunden ist), etwa: + # - name: uploads + # persistentVolumeClaim: + # claimName: wabenhain-uploads-pvc + - name: uploads + # CHANGE-ME: obige emptyDir-Zeile durch die echte Volume-Quelle + # ersetzen. emptyDir ist NICHT persistent (Inhalt geht bei jedem + # Pod-Neustart verloren) und dient hier nur als Platzhalter, damit + # dieses Manifest ohne weitere Aenderung "kubectl apply"-faehig + # bleibt. + emptyDir: {} --- apiVersion: v1 kind: Service diff --git a/src/app/admin/actions/_shared.ts b/src/app/admin/actions/_shared.ts index f31c848..13f20fe 100644 --- a/src/app/admin/actions/_shared.ts +++ b/src/app/admin/actions/_shared.ts @@ -53,3 +53,15 @@ export function strOrNull(formData: FormData, key: string): string | null { const value = str(formData, key); return value === "" ? null : value; } + +/** + * Alle Werte eines wiederholten Formularfelds (z.B. mehrere "images"-Hidden- + * Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben + * Namen) als getrimmtes String-Array ohne leere Eintraege. + */ +export function strArray(formData: FormData, key: string): string[] { + return formData + .getAll(key) + .map((value) => String(value).trim()) + .filter(Boolean); +} diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts index f4c3ed7..dbce963 100644 --- a/src/app/admin/actions/content.ts +++ b/src/app/admin/actions/content.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { CONTENT_REGISTRY } from "@/lib/content-registry"; +import { isValidUploadPath } from "@/lib/uploads"; import { requireAdmin } from "./_shared"; /** @@ -33,7 +34,16 @@ export async function updateSiteContentAction(formData: FormData): Promise } else { const value = formData.get(def.key); if (value === null) continue; - updates.push({ key: def.key, valueDe: String(value), valueEn: null }); + const valueDe = String(value); + // Bild-Felder duerfen nur einen Pfad enthalten, den unser eigener + // Upload-Endpunkt tatsaechlich erzeugt hat (siehe lib/uploads.ts) — + // ein manipuliertes Hidden-Feld (z.B. eine externe URL oder ein + // "javascript:"-Wert) wird ignoriert statt ungeprueft gespeichert und + // spaeter als /next/image src ausgeliefert zu werden. + if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) { + continue; + } + updates.push({ key: def.key, valueDe, valueEn: null }); } } diff --git a/src/app/admin/actions/harvests-batches.ts b/src/app/admin/actions/harvests-batches.ts index 82c7d37..d7f4152 100644 --- a/src/app/admin/actions/harvests-batches.ts +++ b/src/app/admin/actions/harvests-batches.ts @@ -4,7 +4,8 @@ import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; -import { requireAdmin, str, num } from "./_shared"; +import { isValidUploadPath } from "@/lib/uploads"; +import { requireAdmin, str, num, strArray } from "./_shared"; export async function createHarvestAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -25,6 +26,7 @@ export async function createHarvestAction(formData: FormData): Promise { harvestDate: new Date(str(formData, "harvestDate")), location: str(formData, "location") || "Altenmünster", description: str(formData, "description"), + images: strArray(formData, "images").filter(isValidUploadPath), isPublished, }, }); @@ -54,6 +56,7 @@ export async function updateHarvestAction(formData: FormData): Promise { harvestDate: new Date(str(formData, "harvestDate")), location: str(formData, "location") || "Altenmünster", description: str(formData, "description"), + images: strArray(formData, "images").filter(isValidUploadPath), isPublished, }, }); diff --git a/src/app/admin/actions/products.ts b/src/app/admin/actions/products.ts index 4040233..ad22800 100644 --- a/src/app/admin/actions/products.ts +++ b/src/app/admin/actions/products.ts @@ -6,7 +6,8 @@ import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { notifyStockAvailable } from "@/lib/stock-notifications"; import { adminProductNumbersSchema } from "@/lib/validation"; -import { requireAdmin, fail, str, strOrNull } from "./_shared"; +import { isValidUploadPath } from "@/lib/uploads"; +import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; export async function createProductAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -48,6 +49,7 @@ export async function createProductAction(formData: FormData): Promise { weightGrams: numbers.weightGrams, priceCents: numbers.priceCents, pricePerKgCents: numbers.pricePerKgCents, + images: strArray(formData, "images").filter(isValidUploadPath), isActive: formData.get("isActive") === "on", inventory: { create: { quantity: numbers.quantity } }, }, @@ -100,6 +102,7 @@ export async function updateProductAction(formData: FormData): Promise { weightGrams: numbers.weightGrams, priceCents: numbers.priceCents, pricePerKgCents: numbers.pricePerKgCents, + images: strArray(formData, "images").filter(isValidUploadPath), isActive: formData.get("isActive") === "on", inventory: { upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } }, diff --git a/src/app/admin/ernten/[id]/page.tsx b/src/app/admin/ernten/[id]/page.tsx index 9806db5..d0dcf77 100644 --- a/src/app/admin/ernten/[id]/page.tsx +++ b/src/app/admin/ernten/[id]/page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/prisma"; +import { asStringArray } from "@/lib/json-array"; import { BackLink } from "@/components/back-link"; +import { AdminImageListField } from "@/components/admin-image-list-field"; import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -8,8 +10,9 @@ import { getDictionary } from "@/i18n/get-dictionary"; export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) { const t = getDictionary(await getLocale()); const { id } = await props.params; - const harvest = await prisma.harvest.findUnique({ where: { id } }); - if (!harvest) notFound(); + const rawHarvest = await prisma.harvest.findUnique({ where: { id } }); + if (!rawHarvest) notFound(); + const harvest = { ...rawHarvest, images: asStringArray(rawHarvest.images) }; const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10); @@ -82,6 +85,11 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> +