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" /> +