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

Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter
UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen
Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per
Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads).

- lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den
  tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus
  Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen
  (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten
  vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als
  Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus
  einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen.
- app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- +
  Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF).
- app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus
  (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine
  zur Laufzeit hinzugefuegten Dateien bedienen) — mit
  Path-Traversal-Schutz und nosniff-Header.
- content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite-
  Grafiken, admin/inhalte hat dafuer ein Upload-Widget.
- Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer
  Product.images/Harvest.images (beide Felder existierten im Schema,
  hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten
  (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt
  Platzhalter, sobald vorhanden.
- Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml
  (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche
  Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie
  gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt.

Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm
build clean. Upload-/Serving-Pipeline live gegen einen laufenden
Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung,
Format-Ablehnung) — ein vollstaendiger authentifizierter Upload
end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live
durchgespielt, nur per Code-Review verifiziert.

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

No files matched your search

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