feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s

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 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 19:59:42 +02:00
1 parent 3de9f2906e
commit 54919d82ed
35 files changed
+1014 -44

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
interface AdminImageListFieldProps {
/** Formularfeld-Name, unter dem JEDES Bild als eigenes verstecktes Feld
* mit demselben Namen mitgeschickt wird — der Server liest sie per
* formData.getAll(name) (siehe strArray() in admin/actions/_shared.ts). */
name: string;
label: string;
initialValues: string[];
}
/**
* Mehrbild-Upload-Feld fuer Produkt-/Erntenfotos (admin/produkte,
* admin/ernten). Laedt jede ausgewaehlte Datei sofort per fetch() an
* app/api/admin/upload hoch und haengt den zurueckgegebenen Pfad ans lokale
* Bilder-Array an; einzelne Bilder lassen sich per Knopfdruck wieder
* entfernen. Das eigentliche Speichern passiert weiterhin ganz normal ueber
* das umgebende <form>, dieses Feld reicht nur die fertigen Upload-Pfade
* als wiederholte Hidden-Inputs hindurch.
*/
export function AdminImageListField({ name, label, initialValues }: AdminImageListFieldProps) {
const { t } = useI18n();
const [images, setImages] = useState<string[]>(initialValues);
const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setStatus("uploading");
setErrorMessage("");
try {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/admin/upload", { method: "POST", body });
const data: { path?: string; error?: string } = await response.json();
if (!response.ok || !data.path) {
throw new Error(data.error ?? t.adminImageField.uploadError);
}
setImages((prev) => [...prev, data.path!]);
setStatus("idle");
} catch (error: unknown) {
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : t.adminImageField.uploadError);
} finally {
if (inputRef.current) inputRef.current.value = "";
}
}
function removeAt(index: number) {
setImages((prev) => prev.filter((_, i) => i !== index));
}
return (
<div>
<span className="text-sm text-ink/70">{label}</span>
{images.map((src, i) => (
// key bewusst ueber Index+Wert: Reihenfolge/Inhalt aendert sich nur
// durch Hinzufuegen (ans Ende) oder Entfernen, beides hier stabil.
<input key={`${i}-${src}`} type="hidden" name={name} value={src} />
))}
{images.length > 0 ? (
<div className="mt-2 grid grid-cols-4 gap-3">
{images.map((src, i) => (
<div key={`${i}-${src}`} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht. */}
<img src={src} alt="" className="aspect-square w-full object-cover border border-ink/10" />
<button
type="button"
onClick={() => removeAt(i)}
aria-label={t.adminImageField.removeImageAt(i + 1)}
className="absolute top-1 right-1 flex h-5 w-5 items-center justify-center bg-ink/70 text-xs text-cream"
>
×
</button>
</div>
))}
</div>
) : null}
<div className="mt-2">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
onChange={handleFileChange}
disabled={status === "uploading"}
className="block text-xs"
/>
{status === "uploading" ? (
<p className="mt-1 text-xs text-ink/50">{t.adminImageField.uploading}</p>
) : null}
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
</div>
</div>
);
}