feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
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:
1 parent
3de9f2906e
commit
54919d82ed
35 files changed
+1014
-44
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user