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
@@ -159,3 +159,20 @@ ENTRA_TENANT_ID=
|
|||||||
ENTRA_CLIENT_ID=
|
ENTRA_CLIENT_ID=
|
||||||
ENTRA_CLIENT_SECRET=
|
ENTRA_CLIENT_SECRET=
|
||||||
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
|
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
|
||||||
@@ -49,3 +49,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# kubernetes (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert)
|
# kubernetes (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert)
|
||||||
/k8s/02-secret.yaml
|
/k8s/02-secret.yaml
|
||||||
|
|
||||||
|
# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in
|
||||||
|
# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis
|
||||||
|
/uploads
|
||||||
@@ -23,3 +23,8 @@ data:
|
|||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
|
||||||
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production".
|
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production".
|
||||||
DHL_ENVIRONMENT: "sandbox"
|
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"
|
||||||
@@ -37,6 +37,18 @@ spec:
|
|||||||
name: wabenhain-config
|
name: wabenhain-config
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: wabenhain-secrets
|
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:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
@@ -61,6 +73,20 @@ spec:
|
|||||||
memory: 256Mi
|
memory: 256Mi
|
||||||
limits:
|
limits:
|
||||||
memory: 512Mi
|
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
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -53,3 +53,15 @@ export function strOrNull(formData: FormData, key: string): string | null {
|
|||||||
const value = str(formData, key);
|
const value = str(formData, key);
|
||||||
return value === "" ? null : value;
|
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);
|
||||||
|
}
|
||||||
@@ -5,6 +5,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 { CONTENT_REGISTRY } from "@/lib/content-registry";
|
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||||
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +34,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
} else {
|
} else {
|
||||||
const value = formData.get(def.key);
|
const value = formData.get(def.key);
|
||||||
if (value === null) continue;
|
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 <img>/next/image src ausgeliefert zu werden.
|
||||||
|
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updates.push({ key: def.key, valueDe, valueEn: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { revalidatePath } from "next/cache";
|
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 { requireAdmin, str, num } from "./_shared";
|
import { isValidUploadPath } from "@/lib/uploads";
|
||||||
|
import { requireAdmin, str, num, strArray } from "./_shared";
|
||||||
|
|
||||||
export async function createHarvestAction(formData: FormData): Promise<void> {
|
export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -25,6 +26,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
|||||||
harvestDate: new Date(str(formData, "harvestDate")),
|
harvestDate: new Date(str(formData, "harvestDate")),
|
||||||
location: str(formData, "location") || "Altenmünster",
|
location: str(formData, "location") || "Altenmünster",
|
||||||
description: str(formData, "description"),
|
description: str(formData, "description"),
|
||||||
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isPublished,
|
isPublished,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -54,6 +56,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
|||||||
harvestDate: new Date(str(formData, "harvestDate")),
|
harvestDate: new Date(str(formData, "harvestDate")),
|
||||||
location: str(formData, "location") || "Altenmünster",
|
location: str(formData, "location") || "Altenmünster",
|
||||||
description: str(formData, "description"),
|
description: str(formData, "description"),
|
||||||
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isPublished,
|
isPublished,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ 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 { 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<void> {
|
export async function createProductAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
@@ -48,6 +49,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
|||||||
weightGrams: numbers.weightGrams,
|
weightGrams: numbers.weightGrams,
|
||||||
priceCents: numbers.priceCents,
|
priceCents: numbers.priceCents,
|
||||||
pricePerKgCents: numbers.pricePerKgCents,
|
pricePerKgCents: numbers.pricePerKgCents,
|
||||||
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isActive: formData.get("isActive") === "on",
|
isActive: formData.get("isActive") === "on",
|
||||||
inventory: { create: { quantity: numbers.quantity } },
|
inventory: { create: { quantity: numbers.quantity } },
|
||||||
},
|
},
|
||||||
@@ -100,6 +102,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
|||||||
weightGrams: numbers.weightGrams,
|
weightGrams: numbers.weightGrams,
|
||||||
priceCents: numbers.priceCents,
|
priceCents: numbers.priceCents,
|
||||||
pricePerKgCents: numbers.pricePerKgCents,
|
pricePerKgCents: numbers.pricePerKgCents,
|
||||||
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||||
isActive: formData.get("isActive") === "on",
|
isActive: formData.get("isActive") === "on",
|
||||||
inventory: {
|
inventory: {
|
||||||
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { asStringArray } from "@/lib/json-array";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { AdminImageListField } from "@/components/admin-image-list-field";
|
||||||
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
|
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
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]">) {
|
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { id } = await props.params;
|
const { id } = await props.params;
|
||||||
const harvest = await prisma.harvest.findUnique({ where: { id } });
|
const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
|
||||||
if (!harvest) notFound();
|
if (!rawHarvest) notFound();
|
||||||
|
const harvest = { ...rawHarvest, images: asStringArray(rawHarvest.images) };
|
||||||
|
|
||||||
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
|
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"
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<AdminImageListField
|
||||||
|
name="images"
|
||||||
|
label={t.adminHarvestEditPage.imagesLabel}
|
||||||
|
initialValues={harvest.images}
|
||||||
|
/>
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
||||||
{t.adminHarvestEditPage.isPublishedLabel}
|
{t.adminHarvestEditPage.isPublishedLabel}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { AdminImageField } from "@/components/admin-image-field";
|
||||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||||
import { updateSiteContentAction } from "../actions";
|
import { updateSiteContentAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -12,6 +13,10 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
|||||||
"list-titled": 5,
|
"list-titled": 5,
|
||||||
"list-numbered": 6,
|
"list-numbered": 6,
|
||||||
"richtext-sections": 14,
|
"richtext-sections": 14,
|
||||||
|
// Bild-Feld hat kein <textarea>, nutzt ROWS_BY_TYPE also nicht — Wert nur
|
||||||
|
// vorhanden, damit dieses Record<ContentFieldDef["type"], number> weiter
|
||||||
|
// vollstaendig ist.
|
||||||
|
image: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
||||||
@@ -61,6 +66,16 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
|||||||
const label = locale === "de" ? def.labelDe : def.labelEn;
|
const label = locale === "de" ? def.labelDe : def.labelEn;
|
||||||
|
|
||||||
if (!def.localized) {
|
if (!def.localized) {
|
||||||
|
if (def.type === "image") {
|
||||||
|
return (
|
||||||
|
<AdminImageField
|
||||||
|
key={def.key}
|
||||||
|
name={def.key}
|
||||||
|
label={label}
|
||||||
|
initialValue={row?.valueDe ?? ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<label key={def.key} className="block text-sm">
|
<label key={def.key} className="block text-sm">
|
||||||
<span className="text-ink/70">{label}</span>
|
<span className="text-ink/70">{label}</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
import { asStringArray } from "@/lib/json-array";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { AdminImageListField } from "@/components/admin-image-list-field";
|
||||||
import { updateProductAction } from "../../actions";
|
import { updateProductAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -14,7 +15,11 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
include: { harvest: true, batch: true, inventory: true },
|
include: { harvest: true, batch: true, inventory: true },
|
||||||
});
|
});
|
||||||
if (!rawProduct) notFound();
|
if (!rawProduct) notFound();
|
||||||
const product = { ...rawProduct, tasteTags: asStringArray(rawProduct.tasteTags) };
|
const product = {
|
||||||
|
...rawProduct,
|
||||||
|
tasteTags: asStringArray(rawProduct.tasteTags),
|
||||||
|
images: asStringArray(rawProduct.images),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
@@ -51,6 +56,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
defaultValue={product.tasteTags.join(", ")}
|
defaultValue={product.tasteTags.join(", ")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdminImageListField
|
||||||
|
name="images"
|
||||||
|
label={t.adminProductEditPage.imagesLabel}
|
||||||
|
initialValues={product.images}
|
||||||
|
/>
|
||||||
|
|
||||||
{product.category === "HONIG" ? (
|
{product.category === "HONIG" ? (
|
||||||
<>
|
<>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { AdminImageListField } from "@/components/admin-image-list-field";
|
||||||
import { createProductAction } from "../../actions";
|
import { createProductAction } from "../../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -64,6 +65,13 @@ export default async function AdminProductNewPage() {
|
|||||||
label={t.adminProductNewPage.tasteTagsLabel}
|
label={t.adminProductNewPage.tasteTagsLabel}
|
||||||
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
|
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdminImageListField
|
||||||
|
name="images"
|
||||||
|
label={t.adminProductNewPage.imagesLabel}
|
||||||
|
initialValues={[]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
name="consistency"
|
name="consistency"
|
||||||
label={t.adminProductNewPage.consistencyLabel}
|
label={t.adminProductNewPage.consistencyLabel}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import type { Customer } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Autorisierung fuer Route-Handler (app/api/admin/**\/route.ts).
|
||||||
|
* requireAdmin() in admin/actions/_shared.ts ist fuer Server Actions
|
||||||
|
* geschrieben (wirft + Next.js redirect) — ein Route-Handler ist ein
|
||||||
|
* normaler POST-/GET-Endpunkt und muss stattdessen eine normale Response
|
||||||
|
* zurueckgeben. Dieselbe autoritative Pruefung wie requireAdmin() (Rolle +
|
||||||
|
* 2FA-Pflicht direkt gegen die Datenbank, nicht nur das JWT-Claim).
|
||||||
|
*
|
||||||
|
* Aufrufer pruefen `"response" in result` und geben diese Response direkt
|
||||||
|
* zurueck, sonst `result.admin` verwenden.
|
||||||
|
*/
|
||||||
|
export async function requireAdminForApi(): Promise<
|
||||||
|
{ admin: Customer } | { response: NextResponse }
|
||||||
|
> {
|
||||||
|
const admin = await getCurrentAdmin();
|
||||||
|
if (!admin) {
|
||||||
|
return { response: NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
if (!admin.twoFactorEnabled) {
|
||||||
|
return {
|
||||||
|
response: NextResponse.json(
|
||||||
|
{ error: "Zwei-Faktor-Authentifizierung ist für Admin-Konten erforderlich." },
|
||||||
|
{ status: 403 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { admin };
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { isTrustedOrigin } from "@/lib/csrf";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import {
|
||||||
|
UPLOAD_DIR,
|
||||||
|
MAX_UPLOAD_BYTES,
|
||||||
|
extensionForFormat,
|
||||||
|
generateUploadFilename,
|
||||||
|
reencodeImage,
|
||||||
|
} from "@/lib/uploads";
|
||||||
|
import { requireAdminForApi } from "../_shared";
|
||||||
|
|
||||||
|
// node:fs braucht die Node-Runtime (nicht Edge) — ohnehin der Next.js-
|
||||||
|
// Standard fuer Route-Handler, hier bewusst explizit dokumentiert.
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only Upload-Endpunkt fuer Bilder (Inhalte-/Produkt-/Erntenfotos,
|
||||||
|
* siehe lib/content-registry.ts "image"-Feldtyp bzw. admin/produkte,
|
||||||
|
* admin/ernten). Nimmt genau eine Bilddatei per multipart/form-data
|
||||||
|
* entgegen, validiert sie ueber ihre tatsaechlichen Bilddaten (nicht ueber
|
||||||
|
* Client-Dateiname/MIME-Type), re-enkodiert sie (validiert Pixel-Daten
|
||||||
|
* vollstaendig + entfernt eingebettete Metadaten als Nebeneffekt, siehe
|
||||||
|
* lib/uploads.ts) und speichert sie unter einem zufaelligen Dateinamen
|
||||||
|
* unter UPLOAD_DIR. Liefert den oeffentlichen Pfad zurueck, unter dem
|
||||||
|
* app/uploads/[...path]/route.ts die Datei ausliefert.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Route-Handler haben keinen eingebauten CSRF-Schutz — siehe lib/csrf.ts.
|
||||||
|
// Ohne diese Pruefung koennte eine fremde Seite, waehrend ein Admin
|
||||||
|
// eingeloggt ist, per gefaelschtem Cross-Site-POST beliebige Dateien in
|
||||||
|
// UPLOAD_DIR schreiben.
|
||||||
|
if (!isTrustedOrigin(request)) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await requireAdminForApi();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { admin } = auth;
|
||||||
|
|
||||||
|
if (!rateLimit(`admin-upload:${admin.id}`, 30, 60 * 60 * 1000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Zu viele Uploads. Bitte versuche es später erneut." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData().catch(() => null);
|
||||||
|
const file = formData?.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "Keine Datei übermittelt." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size === 0) {
|
||||||
|
return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Die Datei ist zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).` },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Bildformat ausschliesslich aus den tatsaechlichen Bilddaten erkennen
|
||||||
|
// (Magic-Bytes via sharp) — nie aus dem Client-Dateinamen oder dem
|
||||||
|
// vom Client gesendeten Content-Type, beide sind frei faelschbar.
|
||||||
|
let format: string | undefined;
|
||||||
|
try {
|
||||||
|
format = (await sharp(buffer).metadata()).format;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Die Datei ist kein gültiges Bild." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = extensionForFormat(format);
|
||||||
|
if (!extension) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Nicht unterstütztes Bildformat. Erlaubt: JPEG, PNG, WebP, AVIF." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output: Buffer;
|
||||||
|
try {
|
||||||
|
output = await reencodeImage(buffer, extension);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Das Bild konnte nicht verarbeitet werden." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
const filename = generateUploadFilename(extension);
|
||||||
|
await writeFile(path.join(UPLOAD_DIR, filename), output);
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "media.upload",
|
||||||
|
entityType: "Upload",
|
||||||
|
entityId: filename,
|
||||||
|
detail: `${extension}, ${Math.ceil(output.length / 1024)} KB`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ path: `/uploads/${filename}` });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container } from "@/components/ui";
|
import { Container } from "@/components/ui";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
import { ShieldIcon } from "@/components/account-ui";
|
import { ShieldIcon } from "@/components/account-ui";
|
||||||
import { ProductTeaserLink } from "@/components/product-card";
|
import { ProductTeaserLink } from "@/components/product-card";
|
||||||
@@ -51,8 +51,20 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
|||||||
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
|
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||||
|
|
||||||
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||||
<PlaceholderImage tone="honey" className="aspect-square" label={`${batch.harvest.type} ${batch.harvest.year}`} />
|
<ContentImage
|
||||||
<PlaceholderImage tone="forest" className="aspect-square" label={batch.harvest.location} />
|
src={batch.harvest.images[0] ?? null}
|
||||||
|
alt={`${batch.harvest.type} ${batch.harvest.year}`}
|
||||||
|
tone="honey"
|
||||||
|
className="aspect-square"
|
||||||
|
label={`${batch.harvest.type} ${batch.harvest.year}`}
|
||||||
|
/>
|
||||||
|
<ContentImage
|
||||||
|
src={batch.harvest.images[1] ?? null}
|
||||||
|
alt={batch.harvest.location}
|
||||||
|
tone="forest"
|
||||||
|
className="aspect-square"
|
||||||
|
label={batch.harvest.location}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-14">
|
<div className="mt-14">
|
||||||
@@ -85,7 +97,12 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
|||||||
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p>
|
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p>
|
||||||
<div className="grid sm:grid-cols-2 gap-6">
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
{batch.products.map((product) => (
|
{batch.products.map((product) => (
|
||||||
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
|
<ProductTeaserLink
|
||||||
|
key={product.id}
|
||||||
|
slug={product.slug}
|
||||||
|
name={product.name}
|
||||||
|
image={product.images[0] ?? null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container } from "@/components/ui";
|
import { Container } from "@/components/ui";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ProductTeaserLink } from "@/components/product-card";
|
import { ProductTeaserLink } from "@/components/product-card";
|
||||||
import { getHarvestByYearType } from "@/lib/data";
|
import { getHarvestByYearType } from "@/lib/data";
|
||||||
@@ -44,7 +44,13 @@ export default async function HarvestDetailPage(
|
|||||||
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||||
</Container>
|
</Container>
|
||||||
<Container className="grid md:grid-cols-2 gap-14">
|
<Container className="grid md:grid-cols-2 gap-14">
|
||||||
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={harvest.location} />
|
<ContentImage
|
||||||
|
src={harvest.images[0] ?? null}
|
||||||
|
alt={harvest.type}
|
||||||
|
tone="forest"
|
||||||
|
className="aspect-[4/5] w-full"
|
||||||
|
label={harvest.location}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||||
{harvest.year} · {formatDate(harvest.harvestDate)}
|
{harvest.year} · {formatDate(harvest.harvestDate)}
|
||||||
@@ -71,7 +77,12 @@ export default async function HarvestDetailPage(
|
|||||||
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="mt-4 grid gap-3">
|
||||||
{harvest.products.map((product) => (
|
{harvest.products.map((product) => (
|
||||||
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
|
<ProductTeaserLink
|
||||||
|
key={product.id}
|
||||||
|
slug={product.slug}
|
||||||
|
name={product.name}
|
||||||
|
image={product.images[0] ?? null}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { getHarvests } from "@/lib/data";
|
import { getHarvests } from "@/lib/data";
|
||||||
import { formatMonthYear } from "@/lib/format";
|
import { formatMonthYear } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -41,7 +41,9 @@ export default async function ErntenPage() {
|
|||||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||||
className="group"
|
className="group"
|
||||||
>
|
>
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={harvest.images[0] ?? null}
|
||||||
|
alt={harvest.type}
|
||||||
tone="forest"
|
tone="forest"
|
||||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||||
label={formatMonthYear(harvest.harvestDate)}
|
label={formatMonthYear(harvest.harvestDate)}
|
||||||
|
|||||||
+2
-1
@@ -29,7 +29,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const [heroContent, company] = await Promise.all([
|
const [heroContent, company] = await Promise.all([
|
||||||
getContentValues(["home.hero.intro", "home.hero.footnote"], locale),
|
getContentValues(["home.hero.intro", "home.hero.footnote", "home.hero.image"], locale),
|
||||||
getCompanyInfo(),
|
getCompanyInfo(),
|
||||||
]);
|
]);
|
||||||
const products = await getActiveProducts();
|
const products = await getActiveProducts();
|
||||||
@@ -48,6 +48,7 @@ export default async function HomePage() {
|
|||||||
intro={heroContent["home.hero.intro"]}
|
intro={heroContent["home.hero.intro"]}
|
||||||
footnote={heroContent["home.hero.footnote"]}
|
footnote={heroContent["home.hero.footnote"]}
|
||||||
location={company.location}
|
location={company.location}
|
||||||
|
image={heroContent["home.hero.image"] || null}
|
||||||
/>
|
/>
|
||||||
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
||||||
<OriginStory />
|
<OriginStory />
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { contentTypeForExtension, resolveUploadPath } from "@/lib/uploads";
|
||||||
|
|
||||||
|
// node:fs braucht die Node-Runtime (nicht Edge).
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// Ein Jahr, unveraenderlich: jeder Upload bekommt einen neuen zufaelligen
|
||||||
|
// Dateinamen (siehe lib/uploads.ts generateUploadFilename) — der Inhalt
|
||||||
|
// unter einem einmal ausgelieferten Pfad aendert sich also nie mehr.
|
||||||
|
const CACHE_CONTROL = "public, max-age=31536000, immutable";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos) aus, die unter
|
||||||
|
* UPLOAD_DIR liegen. Next.js' statischer public/-Ordner wird zur Build-Zeit
|
||||||
|
* fest ins Docker-Image gebacken und kann keine zur Laufzeit hinzugefuegten
|
||||||
|
* Dateien ausliefern — dieser dynamische Route-Handler streamt sie
|
||||||
|
* stattdessen direkt von der Platte (in Produktion ein SMB-Mount, siehe
|
||||||
|
* UPLOAD_DIR in lib/uploads.ts).
|
||||||
|
*
|
||||||
|
* Oeffentlich erreichbar ohne Admin-Pruefung (die Bilder sind fuer die
|
||||||
|
* oeffentlichen Shop-/Homepage-Seiten bestimmt) — geschuetzt wird stattdessen
|
||||||
|
* NUR das Schreiben (app/api/admin/upload/route.ts).
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path: segments } = await params;
|
||||||
|
|
||||||
|
const resolved = resolveUploadPath(segments);
|
||||||
|
if (!resolved) {
|
||||||
|
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = resolved.slice(resolved.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
// Content-Type kommt ausschliesslich aus dieser festen Zuordnung nach
|
||||||
|
// Dateiendung, nie aus dem Dateiinhalt oder -namen selbst — verhindert,
|
||||||
|
// dass eine ausgelieferte Datei je als text/html o.ae. interpretiert
|
||||||
|
// werden koennte. Zugleich der eigentliche Allow-List-Filter: nur
|
||||||
|
// Endungen, die unser eigener Upload-Handler erzeugt, werden ueberhaupt
|
||||||
|
// ausgeliefert; alles andere unter UPLOAD_DIR bleibt ueber diese Route
|
||||||
|
// unerreichbar.
|
||||||
|
const contentType = contentTypeForExtension(extension);
|
||||||
|
if (!contentType) {
|
||||||
|
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Buffer;
|
||||||
|
try {
|
||||||
|
data = await readFile(resolved);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(data), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": "inline",
|
||||||
|
// Verhindert, dass der Browser den deklarierten Content-Type anhand
|
||||||
|
// des Inhalts "korrigiert" (MIME-Sniffing) — zusaetzliche Absicherung
|
||||||
|
// neben der festen Content-Type-Zuordnung oben.
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": CACHE_CONTROL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, type ChangeEvent } from "react";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
|
interface AdminImageFieldProps {
|
||||||
|
/** Formularfeld-Name, unter dem der aktuelle Bildpfad beim Speichern des
|
||||||
|
* umgebenden <form> mitgeschickt wird (siehe admin/actions/content.ts,
|
||||||
|
* das jeden im FormData vorhandenen Registry-Key generisch verarbeitet). */
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
initialValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelnes Bild-Upload-Feld fuer admin/inhalte (Content-Registry-Feldtyp
|
||||||
|
* "image"). Laedt die Datei sofort per fetch() an app/api/admin/upload
|
||||||
|
* hoch und haelt den zurueckgegebenen Pfad in einem versteckten
|
||||||
|
* Formularfeld — das eigentliche Speichern passiert weiterhin ganz normal
|
||||||
|
* ueber das umgebende <form action={updateSiteContentAction}>, dieses
|
||||||
|
* Feld reicht nur den fertigen Upload-Pfad hindurch.
|
||||||
|
*/
|
||||||
|
export function AdminImageField({ name, label, initialValue }: AdminImageFieldProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
setValue(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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-ink/70">{label}</span>
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
<div className="mt-2 flex items-center gap-4">
|
||||||
|
{value ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht.
|
||||||
|
<img src={value} alt="" className="h-20 w-20 object-cover border border-ink/10" />
|
||||||
|
) : (
|
||||||
|
<div className="h-20 w-20 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1">
|
||||||
|
{t.adminImageField.none}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<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}
|
||||||
|
{value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue("")}
|
||||||
|
className="mt-1 text-xs text-ink/50 underline"
|
||||||
|
>
|
||||||
|
{t.adminImageField.remove}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (aktuell nie
|
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (kommt aus
|
||||||
* der Fall, siehe README — echte Produktfotografie fehlt noch), ansonsten
|
* Product.images[0], siehe toCartItem() in product-card.tsx), ansonsten
|
||||||
* denselben Honig-Platzhalter wie ProductCard fuer Produkte ohne Foto.
|
* denselben Honig-Platzhalter wie ProductCard fuer Produkte ohne Foto.
|
||||||
*/
|
*/
|
||||||
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
|
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
|
||||||
|
interface ContentImageProps {
|
||||||
|
/** Hochgeladener Bildpfad (z.B. "/uploads/<id>.webp") oder null/leer -> Platzhalter. */
|
||||||
|
src: string | null | undefined;
|
||||||
|
alt: string;
|
||||||
|
/** Groessen-/Seitenverhaeltnis-Klassen (z.B. "aspect-[4/5] w-full") — gilt fuer Platzhalter UND Bild-Wrapper gleichermassen. */
|
||||||
|
className: string;
|
||||||
|
tone?: "forest" | "cream" | "honey";
|
||||||
|
label?: string;
|
||||||
|
sizes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt ein hochgeladenes Bild an, sobald eines gepflegt ist, sonst denselben
|
||||||
|
* PlaceholderImage-Platzhalter wie zuvor an dieser Stelle — nie ein kaputtes
|
||||||
|
* <img>. Uploads liegen same-origin unter /uploads/... (siehe
|
||||||
|
* app/uploads/[...path]/route.ts) und brauchen deshalb KEINEN
|
||||||
|
* next.config.ts-remotePatterns-Eintrag: remotePatterns/domains werden nur
|
||||||
|
* fuer externe (cross-origin) Bildquellen benoetigt, next/image behandelt
|
||||||
|
* einen mit "/" beginnenden src immer als lokal und ruft dafuer intern die
|
||||||
|
* eigene Optimierungsroute auf.
|
||||||
|
*/
|
||||||
|
export function ContentImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
tone = "forest",
|
||||||
|
label,
|
||||||
|
sizes = "100vw",
|
||||||
|
}: ContentImageProps) {
|
||||||
|
if (!src) {
|
||||||
|
return <PlaceholderImage tone={tone} className={className} label={label} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`relative overflow-hidden ${className}`}>
|
||||||
|
<Image src={src} alt={alt} fill sizes={sizes} className="object-cover" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { motion, useReducedMotion, type Variants } from "motion/react";
|
import { motion, useReducedMotion, type Variants } from "motion/react";
|
||||||
import { Container, LinkButton } from "@/components/ui";
|
import { Container, LinkButton } from "@/components/ui";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
const sectionVariants: Variants = {
|
const sectionVariants: Variants = {
|
||||||
@@ -28,10 +28,12 @@ export function Hero({
|
|||||||
intro,
|
intro,
|
||||||
footnote,
|
footnote,
|
||||||
location,
|
location,
|
||||||
|
image,
|
||||||
}: {
|
}: {
|
||||||
intro: string;
|
intro: string;
|
||||||
footnote: string;
|
footnote: string;
|
||||||
location: string;
|
location: string;
|
||||||
|
image: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
@@ -43,7 +45,9 @@ export function Hero({
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
variants={sectionVariants}
|
variants={sectionVariants}
|
||||||
>
|
>
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={image}
|
||||||
|
alt=""
|
||||||
tone="forest"
|
tone="forest"
|
||||||
className="h-[78vh] min-h-[520px] w-full"
|
className="h-[78vh] min-h-[520px] w-full"
|
||||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
|
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
|
||||||
import { formatPrice, formatDate } from "@/lib/format";
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -44,7 +44,12 @@ export async function OriginStory() {
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const content = await getContentValues(
|
const content = await getContentValues(
|
||||||
["home.originStory.paragraph1", "home.originStory.paragraph2", "home.originStory.facts"],
|
[
|
||||||
|
"home.originStory.paragraph1",
|
||||||
|
"home.originStory.paragraph2",
|
||||||
|
"home.originStory.facts",
|
||||||
|
"home.originStory.image",
|
||||||
|
],
|
||||||
locale
|
locale
|
||||||
);
|
);
|
||||||
const facts = parseListSimple(content["home.originStory.facts"]);
|
const facts = parseListSimple(content["home.originStory.facts"]);
|
||||||
@@ -52,7 +57,9 @@ export async function OriginStory() {
|
|||||||
<section className="py-24 bg-cream/60">
|
<section className="py-24 bg-cream/60">
|
||||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<ScrollReveal>
|
<ScrollReveal>
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={content["home.originStory.image"] || null}
|
||||||
|
alt=""
|
||||||
tone="forest"
|
tone="forest"
|
||||||
className="aspect-[4/5] w-full"
|
className="aspect-[4/5] w-full"
|
||||||
label={t.home.originStory.eyebrow}
|
label={t.home.originStory.eyebrow}
|
||||||
@@ -126,7 +133,9 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
|||||||
<section className="py-24 bg-forest text-cream">
|
<section className="py-24 bg-forest text-cream">
|
||||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||||
<ScrollReveal>
|
<ScrollReveal>
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={product.images[0] ?? null}
|
||||||
|
alt={product.name}
|
||||||
tone="honey"
|
tone="honey"
|
||||||
className="aspect-square w-full"
|
className="aspect-square w-full"
|
||||||
label={product.name}
|
label={product.name}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { StarRating } from "@/components/star-rating";
|
import { StarRating } from "@/components/star-rating";
|
||||||
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -47,7 +47,11 @@ export async function RegionalSection() {
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const content = await getContentValues(
|
const content = await getContentValues(
|
||||||
["home.regionalSection.paragraph1", "home.regionalSection.paragraph2"],
|
[
|
||||||
|
"home.regionalSection.paragraph1",
|
||||||
|
"home.regionalSection.paragraph2",
|
||||||
|
"home.regionalSection.image",
|
||||||
|
],
|
||||||
locale
|
locale
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +74,13 @@ export async function RegionalSection() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
<ScrollReveal className="order-1 md:order-2" delay={0.1}>
|
<ScrollReveal className="order-1 md:order-2" delay={0.1}>
|
||||||
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={t.home.regionalSection.eyebrow} />
|
<ContentImage
|
||||||
|
src={content["home.regionalSection.image"] || null}
|
||||||
|
alt=""
|
||||||
|
tone="forest"
|
||||||
|
className="aspect-[4/5] w-full"
|
||||||
|
label={t.home.regionalSection.eyebrow}
|
||||||
|
/>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
@@ -111,12 +121,17 @@ export async function Sustainability() {
|
|||||||
export async function BusinessTeaser() {
|
export async function BusinessTeaser() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const content = await getContentValues(["home.businessTeaser.paragraph"], locale);
|
const content = await getContentValues(
|
||||||
|
["home.businessTeaser.paragraph", "home.businessTeaser.image"],
|
||||||
|
locale
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<section className="py-24">
|
<section className="py-24">
|
||||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<ScrollReveal>
|
<ScrollReveal>
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={content["home.businessTeaser.image"] || null}
|
||||||
|
alt=""
|
||||||
tone="honey"
|
tone="honey"
|
||||||
className="aspect-[4/5] w-full"
|
className="aspect-[4/5] w-full"
|
||||||
label={t.home.businessTeaser.eyebrow}
|
label={t.home.businessTeaser.eyebrow}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||||
import { StarRating } from "@/components/star-rating";
|
import { StarRating } from "@/components/star-rating";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
@@ -27,14 +27,22 @@ const categoryTone: Record<ProductCategory, "forest" | "cream" | "honey"> = {
|
|||||||
* Ernte) — nur Bild, Name und Link, ohne Preis/Bestand/Warenkorb-Funktion.
|
* Ernte) — nur Bild, Name und Link, ohne Preis/Bestand/Warenkorb-Funktion.
|
||||||
* Diese Seiten geht es um Nachweis/Herkunft, nicht ums Verkaufen.
|
* Diese Seiten geht es um Nachweis/Herkunft, nicht ums Verkaufen.
|
||||||
*/
|
*/
|
||||||
export function ProductTeaserLink({ slug, name }: { slug: string; name: string }) {
|
export function ProductTeaserLink({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
image?: string | null;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/honig/${slug}`}
|
href={`/honig/${slug}`}
|
||||||
className="group flex items-center gap-4 border border-ink/10 p-4 transition-colors hover:border-forest"
|
className="group flex items-center gap-4 border border-ink/10 p-4 transition-colors hover:border-forest"
|
||||||
>
|
>
|
||||||
<PlaceholderImage tone="honey" className="w-16 h-16 flex-shrink-0" />
|
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-forest">{name}</span>
|
<span className="block text-forest">{name}</span>
|
||||||
<span className="block mt-1 text-sm text-ink/60 group-hover:text-forest transition-colors">
|
<span className="block mt-1 text-sm text-ink/60 group-hover:text-forest transition-colors">
|
||||||
@@ -180,7 +188,9 @@ export function ProductCard({
|
|||||||
href={`/honig/${product.slug}`}
|
href={`/honig/${product.slug}`}
|
||||||
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
|
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
|
||||||
>
|
>
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={product.images[0] ?? null}
|
||||||
|
alt={product.name}
|
||||||
tone={categoryTone[product.category]}
|
tone={categoryTone[product.category]}
|
||||||
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
|
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
|
||||||
label={
|
label={
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ function isImageSlide(slide: Slide): slide is { src: string } {
|
|||||||
return "src" in slide;
|
return "src" in slide;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solange keine echte Produktfotografie hinterlegt ist (product.images ist
|
// Solange fuer ein Produkt keine echte Produktfotografie hinterlegt ist
|
||||||
// aktuell bei allen Produkten leer, siehe README), zeigt die Galerie diese
|
// (admin/produkte -> Bilder, siehe AdminImageListField), zeigt die Galerie
|
||||||
// vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt werden,
|
// diese vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt
|
||||||
// ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
|
// werden, ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
|
||||||
const PLACEHOLDER_SLIDES: Slide[] = [
|
const PLACEHOLDER_SLIDES: Slide[] = [
|
||||||
{ tone: "honey", label: "Glas frontal" },
|
{ tone: "honey", label: "Glas frontal" },
|
||||||
{ tone: "cream", label: "Glas seitlich" },
|
{ tone: "cream", label: "Glas seitlich" },
|
||||||
|
|||||||
@@ -914,6 +914,7 @@ export const de: Dictionary = {
|
|||||||
harvestDateLabel: "Erntedatum",
|
harvestDateLabel: "Erntedatum",
|
||||||
locationLabel: "Standort",
|
locationLabel: "Standort",
|
||||||
descriptionLabel: "Beschreibung",
|
descriptionLabel: "Beschreibung",
|
||||||
|
imagesLabel: "Erntebilder",
|
||||||
isPublishedLabel: "Öffentlich sichtbar",
|
isPublishedLabel: "Öffentlich sichtbar",
|
||||||
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.",
|
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.",
|
||||||
draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.",
|
draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.",
|
||||||
@@ -952,6 +953,7 @@ export const de: Dictionary = {
|
|||||||
stockLabel: "Lagerbestand (Gläser)",
|
stockLabel: "Lagerbestand (Gläser)",
|
||||||
priceLabel: "Preis (Cent)",
|
priceLabel: "Preis (Cent)",
|
||||||
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
||||||
|
imagesLabel: "Produktbilder",
|
||||||
visibleInShop: "Im Shop sichtbar",
|
visibleInShop: "Im Shop sichtbar",
|
||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
},
|
},
|
||||||
@@ -981,6 +983,7 @@ export const de: Dictionary = {
|
|||||||
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
||||||
pricePlaceholder: "990",
|
pricePlaceholder: "990",
|
||||||
pricePerKgPlaceholder: "1980",
|
pricePerKgPlaceholder: "1980",
|
||||||
|
imagesLabel: "Produktbilder",
|
||||||
visibleInShop: "Im Shop sichtbar",
|
visibleInShop: "Im Shop sichtbar",
|
||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
},
|
},
|
||||||
@@ -1118,6 +1121,13 @@ export const de: Dictionary = {
|
|||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
saved: "Gespeichert.",
|
saved: "Gespeichert.",
|
||||||
},
|
},
|
||||||
|
adminImageField: {
|
||||||
|
none: "Kein Bild",
|
||||||
|
uploading: "Wird hochgeladen…",
|
||||||
|
remove: "Bild entfernen",
|
||||||
|
removeImageAt: (position) => `Bild ${position} entfernen`,
|
||||||
|
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||||
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
navOrders: "Bestellungen",
|
navOrders: "Bestellungen",
|
||||||
|
|||||||
@@ -914,6 +914,7 @@ export const en: Dictionary = {
|
|||||||
harvestDateLabel: "Harvest date",
|
harvestDateLabel: "Harvest date",
|
||||||
locationLabel: "Location",
|
locationLabel: "Location",
|
||||||
descriptionLabel: "Description",
|
descriptionLabel: "Description",
|
||||||
|
imagesLabel: "Harvest images",
|
||||||
isPublishedLabel: "Publicly visible",
|
isPublishedLabel: "Publicly visible",
|
||||||
isPublishedHint: "Controls whether this harvest shows up on the website (shop, /ernten).",
|
isPublishedHint: "Controls whether this harvest shows up on the website (shop, /ernten).",
|
||||||
draftNotice: "This harvest is currently only visible in the admin area (e.g. imported from ApiCore). Please review the details, then publish it.",
|
draftNotice: "This harvest is currently only visible in the admin area (e.g. imported from ApiCore). Please review the details, then publish it.",
|
||||||
@@ -952,6 +953,7 @@ export const en: Dictionary = {
|
|||||||
stockLabel: "Inventory (jars)",
|
stockLabel: "Inventory (jars)",
|
||||||
priceLabel: "Price (cents)",
|
priceLabel: "Price (cents)",
|
||||||
pricePerKgLabel: "Base price per kg (cents)",
|
pricePerKgLabel: "Base price per kg (cents)",
|
||||||
|
imagesLabel: "Product images",
|
||||||
visibleInShop: "Visible in shop",
|
visibleInShop: "Visible in shop",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
},
|
},
|
||||||
@@ -981,6 +983,7 @@ export const en: Dictionary = {
|
|||||||
pricePerKgLabel: "Base price per kg (cents)",
|
pricePerKgLabel: "Base price per kg (cents)",
|
||||||
pricePlaceholder: "990",
|
pricePlaceholder: "990",
|
||||||
pricePerKgPlaceholder: "1980",
|
pricePerKgPlaceholder: "1980",
|
||||||
|
imagesLabel: "Product images",
|
||||||
visibleInShop: "Visible in shop",
|
visibleInShop: "Visible in shop",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
},
|
},
|
||||||
@@ -1118,6 +1121,13 @@ export const en: Dictionary = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
},
|
},
|
||||||
|
adminImageField: {
|
||||||
|
none: "No image",
|
||||||
|
uploading: "Uploading…",
|
||||||
|
remove: "Remove image",
|
||||||
|
removeImageAt: (position) => `Remove image ${position}`,
|
||||||
|
uploadError: "The upload failed.",
|
||||||
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
navOrders: "Orders",
|
navOrders: "Orders",
|
||||||
|
|||||||
@@ -615,6 +615,7 @@ export interface Dictionary {
|
|||||||
harvestDateLabel: string;
|
harvestDateLabel: string;
|
||||||
locationLabel: string;
|
locationLabel: string;
|
||||||
descriptionLabel: string;
|
descriptionLabel: string;
|
||||||
|
imagesLabel: string;
|
||||||
isPublishedLabel: string;
|
isPublishedLabel: string;
|
||||||
isPublishedHint: string;
|
isPublishedHint: string;
|
||||||
draftNotice: string;
|
draftNotice: string;
|
||||||
@@ -653,6 +654,7 @@ export interface Dictionary {
|
|||||||
stockLabel: string;
|
stockLabel: string;
|
||||||
priceLabel: string;
|
priceLabel: string;
|
||||||
pricePerKgLabel: string;
|
pricePerKgLabel: string;
|
||||||
|
imagesLabel: string;
|
||||||
visibleInShop: string;
|
visibleInShop: string;
|
||||||
save: string;
|
save: string;
|
||||||
};
|
};
|
||||||
@@ -681,6 +683,7 @@ export interface Dictionary {
|
|||||||
pricePerKgLabel: string;
|
pricePerKgLabel: string;
|
||||||
pricePlaceholder: string;
|
pricePlaceholder: string;
|
||||||
pricePerKgPlaceholder: string;
|
pricePerKgPlaceholder: string;
|
||||||
|
imagesLabel: string;
|
||||||
visibleInShop: string;
|
visibleInShop: string;
|
||||||
create: string;
|
create: string;
|
||||||
};
|
};
|
||||||
@@ -817,6 +820,15 @@ export interface Dictionary {
|
|||||||
save: string;
|
save: string;
|
||||||
saved: string;
|
saved: string;
|
||||||
};
|
};
|
||||||
|
/** Gemeinsame Texte fuer AdminImageField/AdminImageListField (Bild-Upload
|
||||||
|
* in admin/inhalte, admin/produkte, admin/ernten). */
|
||||||
|
adminImageField: {
|
||||||
|
none: string;
|
||||||
|
uploading: string;
|
||||||
|
remove: string;
|
||||||
|
removeImageAt: (position: number) => string;
|
||||||
|
uploadError: string;
|
||||||
|
};
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: string;
|
navDashboard: string;
|
||||||
navOrders: string;
|
navOrders: string;
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type ContentFieldType =
|
|||||||
| "list-simple"
|
| "list-simple"
|
||||||
| "list-titled"
|
| "list-titled"
|
||||||
| "list-numbered"
|
| "list-numbered"
|
||||||
| "richtext-sections";
|
| "richtext-sections"
|
||||||
|
| "image";
|
||||||
|
|
||||||
export interface ContentFieldDef {
|
export interface ContentFieldDef {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -67,6 +68,21 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
|||||||
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
|
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
|
||||||
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
|
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
|
||||||
|
|
||||||
|
// --- Homepage-Bilder (nicht lokalisiert — ein Bild gilt fuer beide
|
||||||
|
// Sprachen; Wert ist der von app/api/admin/upload/route.ts zurueckgegebene
|
||||||
|
// Pfad, z.B. "/uploads/<zufaellig>.webp". Leer = kein Bild gepflegt, die
|
||||||
|
// Komponente zeigt dann weiterhin den bisherigen CSS-Platzhalter, siehe
|
||||||
|
// components/content-image.tsx.) Deckt jede Stelle in hero.tsx/
|
||||||
|
// home-sections-a.tsx/home-sections-b.tsx ab, die ein festes,
|
||||||
|
// redaktionelles Bild zeigt — der homepage-Produkt-Spotlight-Abschnitt
|
||||||
|
// (ProductSpotlight) ist bewusst NICHT hier: er zeigt kein festes Bild,
|
||||||
|
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
|
||||||
|
// dessen eigenes Product.images (siehe admin/produkte).
|
||||||
|
{ key: "home.hero.image", group: "home", labelDe: "Hero: Bild", labelEn: "Hero: Image", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
{ key: "home.originStory.image", group: "home", labelDe: "Herkunft: Bild", labelEn: "Origin: Image", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
{ key: "home.regionalSection.image", group: "home", labelDe: "Regional: Bild", labelEn: "Regional: Image", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
{ key: "home.businessTeaser.image", group: "home", labelDe: "Geschäftskunden-Hinweis: Bild", labelEn: "Business teaser: Image", type: "image", localized: false, getDefault: () => "" },
|
||||||
|
|
||||||
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
||||||
// angezeigt, siehe components/site-footer.tsx Footer()) ---
|
// angezeigt, siehe components/site-footer.tsx Footer()) ---
|
||||||
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
||||||
|
|||||||
+32
-6
@@ -21,6 +21,16 @@ function normalizeProduct<T extends { tasteTags: unknown; images: unknown }>(
|
|||||||
return { ...product, tasteTags: asStringArray(product.tasteTags), images: asStringArray(product.images) };
|
return { ...product, tasteTags: asStringArray(product.tasteTags), images: asStringArray(product.images) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dasselbe Prinzip wie normalizeProduct oben, nur fuer das images-Feld
|
||||||
|
* alleine — wiederverwendbar fuer Harvest UND fuer die (verschachtelten)
|
||||||
|
* Product-Eintraege in den Harvest-/Batch-Funktionen unten, die keine
|
||||||
|
* tasteTags-Normalisierung brauchen. */
|
||||||
|
function withImages<T extends { images: unknown }>(item: T): Omit<T, "images"> & { images: string[] } {
|
||||||
|
return { ...item, images: asStringArray(item.images) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarvestWithImages = Omit<Harvest, "images"> & { images: string[] };
|
||||||
|
|
||||||
export type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit";
|
export type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit";
|
||||||
|
|
||||||
export interface GetActiveProductsOptions {
|
export interface GetActiveProductsOptions {
|
||||||
@@ -106,12 +116,13 @@ export async function getProductsByIds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
|
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
|
||||||
export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> {
|
export async function getHarvests(): Promise<(HarvestWithImages & { batches: Batch[] })[]> {
|
||||||
return prisma.harvest.findMany({
|
const harvests = await prisma.harvest.findMany({
|
||||||
where: { isPublished: true },
|
where: { isPublished: true },
|
||||||
include: { batches: true },
|
include: { batches: true },
|
||||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||||
});
|
});
|
||||||
|
return harvests.map(withImages);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,19 +134,34 @@ export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]>
|
|||||||
export async function getHarvestByYearType(
|
export async function getHarvestByYearType(
|
||||||
year: number,
|
year: number,
|
||||||
type: string
|
type: string
|
||||||
): Promise<(Harvest & { batches: Batch[]; products: Product[] }) | null> {
|
): Promise<
|
||||||
|
| (HarvestWithImages & {
|
||||||
|
batches: Batch[];
|
||||||
|
products: (Omit<Product, "images"> & { images: string[] })[];
|
||||||
|
})
|
||||||
|
| null
|
||||||
|
> {
|
||||||
const harvest = await prisma.harvest.findUnique({
|
const harvest = await prisma.harvest.findUnique({
|
||||||
where: { year_type: { year, type } },
|
where: { year_type: { year, type } },
|
||||||
include: { batches: true, products: true },
|
include: { batches: true, products: true },
|
||||||
});
|
});
|
||||||
return harvest && harvest.isPublished ? harvest : null;
|
if (!harvest || !harvest.isPublished) return null;
|
||||||
|
return { ...withImages(harvest), products: harvest.products.map(withImages) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBatchByCode(
|
export async function getBatchByCode(
|
||||||
code: string
|
code: string
|
||||||
): Promise<(Batch & { harvest: Harvest; products: Product[] }) | null> {
|
): Promise<
|
||||||
return prisma.batch.findUnique({
|
| (Batch & {
|
||||||
|
harvest: HarvestWithImages;
|
||||||
|
products: (Omit<Product, "images"> & { images: string[] })[];
|
||||||
|
})
|
||||||
|
| null
|
||||||
|
> {
|
||||||
|
const batch = await prisma.batch.findUnique({
|
||||||
where: { code },
|
where: { code },
|
||||||
include: { harvest: true, products: true },
|
include: { harvest: true, products: true },
|
||||||
});
|
});
|
||||||
|
if (!batch) return null;
|
||||||
|
return { ...batch, harvest: withImages(batch.harvest), products: batch.products.map(withImages) };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
UPLOAD_DIR,
|
||||||
|
extensionForFormat,
|
||||||
|
contentTypeForExtension,
|
||||||
|
generateUploadFilename,
|
||||||
|
isValidUploadPath,
|
||||||
|
resolveUploadPath,
|
||||||
|
} from "./uploads";
|
||||||
|
|
||||||
|
describe("extensionForFormat", () => {
|
||||||
|
test("maps sharp's jpeg format to the jpg extension", () => {
|
||||||
|
expect(extensionForFormat("jpeg")).toBe("jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes png/webp/avif through unchanged", () => {
|
||||||
|
expect(extensionForFormat("png")).toBe("png");
|
||||||
|
expect(extensionForFormat("webp")).toBe("webp");
|
||||||
|
expect(extensionForFormat("avif")).toBe("avif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an unsupported format (e.g. gif/svg/tiff)", () => {
|
||||||
|
expect(extensionForFormat("gif")).toBeNull();
|
||||||
|
expect(extensionForFormat("svg")).toBeNull();
|
||||||
|
expect(extensionForFormat("tiff")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an undefined format", () => {
|
||||||
|
expect(extensionForFormat(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contentTypeForExtension", () => {
|
||||||
|
test("returns the correct MIME type for each allowed extension", () => {
|
||||||
|
expect(contentTypeForExtension("jpg")).toBe("image/jpeg");
|
||||||
|
expect(contentTypeForExtension("png")).toBe("image/png");
|
||||||
|
expect(contentTypeForExtension("webp")).toBe("image/webp");
|
||||||
|
expect(contentTypeForExtension("avif")).toBe("image/avif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for an extension outside the fixed allow-list", () => {
|
||||||
|
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
|
||||||
|
// siehe app/uploads/[...path]/route.ts.
|
||||||
|
expect(contentTypeForExtension("html")).toBeNull();
|
||||||
|
expect(contentTypeForExtension("svg")).toBeNull();
|
||||||
|
expect(contentTypeForExtension("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateUploadFilename", () => {
|
||||||
|
test("produces a 32-char lowercase hex name with the given extension", () => {
|
||||||
|
const name = generateUploadFilename("webp");
|
||||||
|
expect(name).toMatch(/^[a-f0-9]{32}\.webp$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never reuses the same name across calls (collision-resistant)", () => {
|
||||||
|
const names = new Set(Array.from({ length: 50 }, () => generateUploadFilename("jpg")));
|
||||||
|
expect(names.size).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidUploadPath", () => {
|
||||||
|
test("accepts a path in exactly the shape the upload route produces", () => {
|
||||||
|
expect(isValidUploadPath(`/uploads/${generateUploadFilename("png")}`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a path traversal attempt", () => {
|
||||||
|
expect(isValidUploadPath("/uploads/../../etc/passwd")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an absolute external URL (not an upload-endpoint path)", () => {
|
||||||
|
expect(isValidUploadPath("https://attacker.example/x.png")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a javascript: pseudo-protocol value", () => {
|
||||||
|
expect(isValidUploadPath("javascript:alert(1)")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a client-chosen filename instead of the generated random one", () => {
|
||||||
|
expect(isValidUploadPath("/uploads/my-photo.png")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an extension outside the allow-list", () => {
|
||||||
|
expect(isValidUploadPath("/uploads/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.svg")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an empty string", () => {
|
||||||
|
expect(isValidUploadPath("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveUploadPath", () => {
|
||||||
|
test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
|
||||||
|
const resolved = resolveUploadPath(["abc123.png"]);
|
||||||
|
expect(resolved).toBe(path.join(UPLOAD_DIR, "abc123.png"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves nested-looking but safe segments inside UPLOAD_DIR", () => {
|
||||||
|
const resolved = resolveUploadPath(["sub", "abc123.png"]);
|
||||||
|
expect(resolved).toBe(path.join(UPLOAD_DIR, "sub", "abc123.png"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a '..' segment (path traversal attempt)", () => {
|
||||||
|
expect(resolveUploadPath(["..", "..", "etc", "passwd"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a segment that is a single '.'", () => {
|
||||||
|
expect(resolveUploadPath([".", "abc123.png"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an empty segment", () => {
|
||||||
|
expect(resolveUploadPath([""])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an encoded traversal baked into one segment", () => {
|
||||||
|
// Next.js liefert Catch-all-Segmente bereits prozent-dekodiert an — ein
|
||||||
|
// Segment kann trotzdem woertlich ".." enthalten, wenn jemand die Route
|
||||||
|
// direkt (nicht ueber generierte Links) aufruft.
|
||||||
|
expect(resolveUploadPath(["a", "..", "..", "b"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root-Verzeichnis fuer alle hochgeladenen Bilder (Inhalte-Bilder, Produkt-/
|
||||||
|
* Erntenfotos). In Produktion zeigt UPLOAD_DIR auf ein von aussen gemountetes
|
||||||
|
* Volume (siehe k8s/05-app-deployment.yaml, volumeMounts "uploads") — diese
|
||||||
|
* Datei und die Upload-/Serving-Route-Handler wissen nicht, WAS dieses
|
||||||
|
* Volume ist (SMB/NFS/hostPath/...), sie lesen/schreiben ganz normal per
|
||||||
|
* node:fs unter diesem Pfad. Lokal reicht der Default "./uploads" (relativ
|
||||||
|
* zum Arbeitsverzeichnis, per .gitignore ausgeschlossen).
|
||||||
|
*
|
||||||
|
* turbopackIgnore: UPLOAD_DIR haengt von einer Laufzeit-Env-Var ab, die zur
|
||||||
|
* Build-Zeit weder existiert noch existieren soll — ohne den Kommentar
|
||||||
|
* versucht Turbopacks statische Datei-Tracing-Analyse (fuer
|
||||||
|
* next.config.ts output:"standalone") vorsichtshalber, das GESAMTE
|
||||||
|
* Projektverzeichnis in den Server-Output aufzunehmen, weil sie den
|
||||||
|
* dynamischen Pfad nicht statisch aufloesen kann (siehe Build-Warnung ohne
|
||||||
|
* diesen Kommentar). UPLOAD_DIR zeigt in Produktion ohnehin auf ein
|
||||||
|
* gemountetes Volume ausserhalb des Projektbaums, das nie Teil des
|
||||||
|
* Docker-Images sein soll.
|
||||||
|
*/
|
||||||
|
export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8 MB: grosszuegig genug fuer ein einzelnes unkomprimiertes Foto aus einer
|
||||||
|
* aktuellen Smartphone-Kamera nach Export, aber klein genug, um den Server
|
||||||
|
* nicht mit riesigen Uploads zu belasten — die Datei wird vollstaendig in
|
||||||
|
* den Speicher gelesen (kein Streaming), um sie mit sharp zu validieren und
|
||||||
|
* neu zu enkodieren.
|
||||||
|
*/
|
||||||
|
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes,
|
||||||
|
* siehe extensionForFormat) — das ist die einzige Quelle der Wahrheit fuer
|
||||||
|
* erlaubte Formate. Der vom Client gesendete Dateiname/MIME-Type wird dafuer
|
||||||
|
* NIE herangezogen, beides ist frei faelschbar.
|
||||||
|
*/
|
||||||
|
const EXTENSION_BY_FORMAT: Record<string, AllowedImageExtension> = {
|
||||||
|
jpeg: "jpg",
|
||||||
|
png: "png",
|
||||||
|
webp: "webp",
|
||||||
|
avif: "avif",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extensionForFormat(format: string | undefined): AllowedImageExtension | null {
|
||||||
|
if (!format) return null;
|
||||||
|
return EXTENSION_BY_FORMAT[format] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits
|
||||||
|
* validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client-
|
||||||
|
* Header. Verhindert, dass eine ausgelieferte Datei je als text/html o.ae.
|
||||||
|
* interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe
|
||||||
|
* app/uploads/[...path]/route.ts.
|
||||||
|
*/
|
||||||
|
const CONTENT_TYPE_BY_EXTENSION: Record<AllowedImageExtension, string> = {
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp",
|
||||||
|
avif: "image/avif",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function contentTypeForExtension(extension: string): string | null {
|
||||||
|
return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen zufaelligen, nicht erratbaren Dateinamen — nie der vom
|
||||||
|
* Client gesendete Originalname wird verwendet oder auch nur einbezogen
|
||||||
|
* (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen)
|
||||||
|
* sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite.
|
||||||
|
*/
|
||||||
|
export function generateUploadFilename(extension: AllowedImageExtension): string {
|
||||||
|
return `${randomBytes(16).toString("hex")}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft, ob ein gespeicherter Bildpfad exakt der Form entspricht, die
|
||||||
|
* app/api/admin/upload/route.ts erzeugt. Wird beim Speichern von Admin-
|
||||||
|
* Formularfeldern verwendet, die einen Upload-Pfad entgegennehmen (Inhalte-
|
||||||
|
* Bild, Produkt-/Erntenbilder) — schuetzt davor, dass ein manipuliertes
|
||||||
|
* Hidden-Feld einen beliebigen String (z.B. "javascript:..." oder eine
|
||||||
|
* externe URL) ungeprueft in die Datenbank und damit in ein <img src>/
|
||||||
|
* next/image auf einer oeffentlichen Seite schreibt.
|
||||||
|
*/
|
||||||
|
export function isValidUploadPath(value: string): boolean {
|
||||||
|
return UPLOAD_PATH_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen
|
||||||
|
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-
|
||||||
|
* normalisieren, kennt dabei aber keine Grenze — ein Segment wie "..","..",
|
||||||
|
* "etc","passwd" koennte sich so bis vor UPLOAD_DIR heraus navigieren. Der
|
||||||
|
* abschliessende Praefix-Vergleich gegen den aufgeloesten UPLOAD_DIR-Pfad
|
||||||
|
* ist die eigentliche Schranke. Gibt null zurueck, wenn der aufgeloeste Pfad
|
||||||
|
* UPLOAD_DIR verlaesst.
|
||||||
|
*/
|
||||||
|
export function resolveUploadPath(segments: string[]): string | null {
|
||||||
|
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// turbopackIgnore: siehe Kommentar bei UPLOAD_DIR oben — derselbe Grund
|
||||||
|
// (Laufzeit-abhaengiger Pfad, soll nicht ins Server-Bundle getraced werden).
|
||||||
|
const resolved = path.resolve(path.join(/*turbopackIgnore: true*/ UPLOAD_DIR, ...segments));
|
||||||
|
const withSep = UPLOAD_DIR.endsWith(path.sep) ? UPLOAD_DIR : UPLOAD_DIR + path.sep;
|
||||||
|
if (resolved !== UPLOAD_DIR && !resolved.startsWith(withSep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enkodiert ein Bild in sein Zielformat. Dient zwei Zwecken: (1) validiert
|
||||||
|
* die tatsaechlichen Pixel-Daten ein zweites Mal vollstaendig (nicht nur den
|
||||||
|
* Header/die Magic-Bytes wie die metadata()-Pruefung im Upload-Handler), und
|
||||||
|
* (2) entfernt als Nebeneffekt eingebettete Metadaten (EXIF/ICC/XMP) — sharp
|
||||||
|
* uebernimmt sie beim Enkodieren nur, wenn withMetadata() explizit
|
||||||
|
* aufgerufen wird, was hier bewusst NICHT geschieht. .rotate() ohne
|
||||||
|
* Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus,
|
||||||
|
* BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst
|
||||||
|
* erschiene ein am Handy "hochkant" aufgenommenes Foto nach dem Entfernen
|
||||||
|
* der Metadaten seitlich verdreht.
|
||||||
|
*/
|
||||||
|
export async function reencodeImage(buffer: Buffer, extension: AllowedImageExtension): Promise<Buffer> {
|
||||||
|
const image = sharp(buffer).rotate();
|
||||||
|
switch (extension) {
|
||||||
|
case "jpg":
|
||||||
|
return image.jpeg({ quality: 85 }).toBuffer();
|
||||||
|
case "png":
|
||||||
|
return image.png().toBuffer();
|
||||||
|
case "webp":
|
||||||
|
return image.webp({ quality: 85 }).toBuffer();
|
||||||
|
case "avif":
|
||||||
|
return image.avif({ quality: 60 }).toBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user