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_SECRET=
|
||||
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
|
||||
|
||||
# Verzeichnis fuer hochgeladene Bilder (Inhalte-Bild in admin/inhalte,
|
||||
# Produkt-/Erntenfotos in admin/produkte bzw. admin/ernten — siehe
|
||||
# app/api/admin/upload/route.ts und app/uploads/[...path]/route.ts). Die App
|
||||
# liest/schreibt hier ganz normal per node:fs; sie weiss nichts davon, WAS
|
||||
# dieses Verzeichnis in Produktion ist (siehe unten).
|
||||
#
|
||||
# Lokal reicht der Default "./uploads" (relativ zum Projektverzeichnis, per
|
||||
# .gitignore ausgeschlossen — beim ersten Upload automatisch angelegt).
|
||||
#
|
||||
# In Produktion (Kubernetes, siehe k8s/05-app-deployment.yaml) zeigt
|
||||
# UPLOAD_DIR stattdessen auf einen Volume-Mount, damit hochgeladene Bilder
|
||||
# einen Pod-Neustart/-Redeploy ueberleben — welche Art Volume das konkret
|
||||
# ist (SMB/NFS/hostPath/...), entscheidet die Betreiberin beim Anlegen des
|
||||
# PersistentVolume; UPLOAD_DIR muss nur exakt auf den gewaehlten mountPath
|
||||
# zeigen.
|
||||
UPLOAD_DIR=./uploads
|
||||
@@ -49,3 +49,7 @@ next-env.d.ts
|
||||
|
||||
# kubernetes (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert)
|
||||
/k8s/02-secret.yaml
|
||||
|
||||
# lokale Datei-Uploads (UPLOAD_DIR-Default, siehe .env.example) — in
|
||||
# Produktion ein Volume-Mount, hier nur ein lokales Testverzeichnis
|
||||
/uploads
|
||||
@@ -23,3 +23,8 @@ data:
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
|
||||
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production".
|
||||
DHL_ENVIRONMENT: "sandbox"
|
||||
# Verzeichnis fuer hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos,
|
||||
# siehe app/api/admin/upload/route.ts) — MUSS exakt dem mountPath des
|
||||
# "uploads"-Volumes in 05-app-deployment.yaml entsprechen. Die App selbst
|
||||
# weiss nicht, was fuer ein Volume dort gemountet ist; siehe .env.example.
|
||||
UPLOAD_DIR: "/app/uploads"
|
||||
@@ -37,6 +37,18 @@ spec:
|
||||
name: wabenhain-config
|
||||
- secretRef:
|
||||
name: wabenhain-secrets
|
||||
volumeMounts:
|
||||
# Muss auf denselben Pfad zeigen wie UPLOAD_DIR in
|
||||
# 01-configmap.yaml. Das "uploads"-Volume selbst wird HIER
|
||||
# bewusst nicht definiert (kein PV/PVC in diesem Manifest) — das
|
||||
# haengt vom tatsaechlichen Storage-Backend ab (SMB/NFS/
|
||||
# hostPath/...), das die Betreiberin bereitstellt. Unten im
|
||||
# "volumes:"-Block einen passenden Eintrag mit demselben Namen
|
||||
# ("uploads") ergaenzen, z.B. ueber eine
|
||||
# persistentVolumeClaim.claimName, sonst startet der Pod nicht
|
||||
# (fehlende Volume-Referenz).
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
@@ -61,6 +73,20 @@ spec:
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
volumes:
|
||||
# Platzhalter — die Betreiberin ergaenzt hier ihre eigene
|
||||
# Volume-Quelle fuer den Namen "uploads" (z.B. ein PVC, das gegen
|
||||
# ein SMB-/NFS-Backend oder einen hostPath gebunden ist), etwa:
|
||||
# - name: uploads
|
||||
# persistentVolumeClaim:
|
||||
# claimName: wabenhain-uploads-pvc
|
||||
- name: uploads
|
||||
# CHANGE-ME: obige emptyDir-Zeile durch die echte Volume-Quelle
|
||||
# ersetzen. emptyDir ist NICHT persistent (Inhalt geht bei jedem
|
||||
# Pod-Neustart verloren) und dient hier nur als Platzhalter, damit
|
||||
# dieses Manifest ohne weitere Aenderung "kubectl apply"-faehig
|
||||
# bleibt.
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -53,3 +53,15 @@ export function strOrNull(formData: FormData, key: string): string | null {
|
||||
const value = str(formData, key);
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Werte eines wiederholten Formularfelds (z.B. mehrere "images"-Hidden-
|
||||
* Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben
|
||||
* Namen) als getrimmtes String-Array ohne leere Eintraege.
|
||||
*/
|
||||
export function strArray(formData: FormData, key: string): string[] {
|
||||
return formData
|
||||
.getAll(key)
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
} else {
|
||||
const value = formData.get(def.key);
|
||||
if (value === null) continue;
|
||||
updates.push({ key: def.key, valueDe: String(value), valueEn: null });
|
||||
const valueDe = String(value);
|
||||
// Bild-Felder duerfen nur einen Pfad enthalten, den unser eigener
|
||||
// Upload-Endpunkt tatsaechlich erzeugt hat (siehe lib/uploads.ts) —
|
||||
// ein manipuliertes Hidden-Feld (z.B. eine externe URL oder ein
|
||||
// "javascript:"-Wert) wird ignoriert statt ungeprueft gespeichert und
|
||||
// spaeter als <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 { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str, num } from "./_shared";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin, str, num, strArray } from "./_shared";
|
||||
|
||||
export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -25,6 +26,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
harvestDate: new Date(str(formData, "harvestDate")),
|
||||
location: str(formData, "location") || "Altenmünster",
|
||||
description: str(formData, "description"),
|
||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||
isPublished,
|
||||
},
|
||||
});
|
||||
@@ -54,6 +56,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
harvestDate: new Date(str(formData, "harvestDate")),
|
||||
location: str(formData, "location") || "Altenmünster",
|
||||
description: str(formData, "description"),
|
||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||
isPublished,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||
import { adminProductNumbersSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str, strOrNull } from "./_shared";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||
|
||||
export async function createProductAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -48,6 +49,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
weightGrams: numbers.weightGrams,
|
||||
priceCents: numbers.priceCents,
|
||||
pricePerKgCents: numbers.pricePerKgCents,
|
||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||
isActive: formData.get("isActive") === "on",
|
||||
inventory: { create: { quantity: numbers.quantity } },
|
||||
},
|
||||
@@ -100,6 +102,7 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
weightGrams: numbers.weightGrams,
|
||||
priceCents: numbers.priceCents,
|
||||
pricePerKgCents: numbers.pricePerKgCents,
|
||||
images: strArray(formData, "images").filter(isValidUploadPath),
|
||||
isActive: formData.get("isActive") === "on",
|
||||
inventory: {
|
||||
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminImageListField } from "@/components/admin-image-list-field";
|
||||
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -8,8 +10,9 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const harvest = await prisma.harvest.findUnique({ where: { id } });
|
||||
if (!harvest) notFound();
|
||||
const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
|
||||
if (!rawHarvest) notFound();
|
||||
const harvest = { ...rawHarvest, images: asStringArray(rawHarvest.images) };
|
||||
|
||||
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
|
||||
|
||||
@@ -82,6 +85,11 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<AdminImageListField
|
||||
name="images"
|
||||
label={t.adminHarvestEditPage.imagesLabel}
|
||||
initialValues={harvest.images}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
||||
{t.adminHarvestEditPage.isPublishedLabel}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminImageField } from "@/components/admin-image-field";
|
||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||
import { updateSiteContentAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -12,6 +13,10 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||
"list-titled": 5,
|
||||
"list-numbered": 6,
|
||||
"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 }>> {
|
||||
@@ -61,6 +66,16 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
const label = locale === "de" ? def.labelDe : def.labelEn;
|
||||
|
||||
if (!def.localized) {
|
||||
if (def.type === "image") {
|
||||
return (
|
||||
<AdminImageField
|
||||
key={def.key}
|
||||
name={def.key}
|
||||
label={label}
|
||||
initialValue={row?.valueDe ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label key={def.key} className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminImageListField } from "@/components/admin-image-list-field";
|
||||
import { updateProductAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
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 },
|
||||
});
|
||||
if (!rawProduct) notFound();
|
||||
const product = { ...rawProduct, tasteTags: asStringArray(rawProduct.tasteTags) };
|
||||
const product = {
|
||||
...rawProduct,
|
||||
tasteTags: asStringArray(rawProduct.tasteTags),
|
||||
images: asStringArray(rawProduct.images),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
@@ -51,6 +56,12 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
||||
defaultValue={product.tasteTags.join(", ")}
|
||||
/>
|
||||
|
||||
<AdminImageListField
|
||||
name="images"
|
||||
label={t.adminProductEditPage.imagesLabel}
|
||||
initialValues={product.images}
|
||||
/>
|
||||
|
||||
{product.category === "HONIG" ? (
|
||||
<>
|
||||
<Field
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminImageListField } from "@/components/admin-image-list-field";
|
||||
import { createProductAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -64,6 +65,13 @@ export default async function AdminProductNewPage() {
|
||||
label={t.adminProductNewPage.tasteTagsLabel}
|
||||
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
|
||||
/>
|
||||
|
||||
<AdminImageListField
|
||||
name="images"
|
||||
label={t.adminProductNewPage.imagesLabel}
|
||||
initialValues={[]}
|
||||
/>
|
||||
|
||||
<Field
|
||||
name="consistency"
|
||||
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 { notFound } from "next/navigation";
|
||||
import { Container } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
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>
|
||||
|
||||
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||
<PlaceholderImage tone="honey" className="aspect-square" label={`${batch.harvest.type} ${batch.harvest.year}`} />
|
||||
<PlaceholderImage tone="forest" className="aspect-square" label={batch.harvest.location} />
|
||||
<ContentImage
|
||||
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 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>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ProductTeaserLink } from "@/components/product-card";
|
||||
import { getHarvestByYearType } from "@/lib/data";
|
||||
@@ -44,7 +44,13 @@ export default async function HarvestDetailPage(
|
||||
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||
</Container>
|
||||
<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>
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
{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>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { getHarvests } from "@/lib/data";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -41,7 +41,9 @@ export default async function ErntenPage() {
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group"
|
||||
>
|
||||
<PlaceholderImage
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
label={formatMonthYear(harvest.harvestDate)}
|
||||
|
||||
+2
-1
@@ -29,7 +29,7 @@ export const dynamic = "force-dynamic";
|
||||
export default async function HomePage() {
|
||||
const locale = await getLocale();
|
||||
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(),
|
||||
]);
|
||||
const products = await getActiveProducts();
|
||||
@@ -48,6 +48,7 @@ export default async function HomePage() {
|
||||
intro={heroContent["home.hero.intro"]}
|
||||
footnote={heroContent["home.hero.footnote"]}
|
||||
location={company.location}
|
||||
image={heroContent["home.hero.image"] || null}
|
||||
/>
|
||||
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
||||
<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";
|
||||
|
||||
/**
|
||||
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (aktuell nie
|
||||
* der Fall, siehe README — echte Produktfotografie fehlt noch), ansonsten
|
||||
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (kommt aus
|
||||
* Product.images[0], siehe toCartItem() in product-card.tsx), ansonsten
|
||||
* denselben Honig-Platzhalter wie ProductCard fuer Produkte ohne Foto.
|
||||
*/
|
||||
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 { Container, LinkButton } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
const sectionVariants: Variants = {
|
||||
@@ -28,10 +28,12 @@ export function Hero({
|
||||
intro,
|
||||
footnote,
|
||||
location,
|
||||
image,
|
||||
}: {
|
||||
intro: string;
|
||||
footnote: string;
|
||||
location: string;
|
||||
image: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
@@ -43,7 +45,9 @@ export function Hero({
|
||||
animate="visible"
|
||||
variants={sectionVariants}
|
||||
>
|
||||
<PlaceholderImage
|
||||
<ContentImage
|
||||
src={image}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="h-[78vh] min-h-[520px] w-full"
|
||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
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 { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -44,7 +44,12 @@ export async function OriginStory() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
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
|
||||
);
|
||||
const facts = parseListSimple(content["home.originStory.facts"]);
|
||||
@@ -52,7 +57,9 @@ export async function OriginStory() {
|
||||
<section className="py-24 bg-cream/60">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<ScrollReveal>
|
||||
<PlaceholderImage
|
||||
<ContentImage
|
||||
src={content["home.originStory.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.originStory.eyebrow}
|
||||
@@ -126,7 +133,9 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
<section className="py-24 bg-forest text-cream">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
<ScrollReveal>
|
||||
<PlaceholderImage
|
||||
<ContentImage
|
||||
src={product.images[0] ?? null}
|
||||
alt={product.name}
|
||||
tone="honey"
|
||||
className="aspect-square w-full"
|
||||
label={product.name}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
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 { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -47,7 +47,11 @@ export async function RegionalSection() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const content = await getContentValues(
|
||||
["home.regionalSection.paragraph1", "home.regionalSection.paragraph2"],
|
||||
[
|
||||
"home.regionalSection.paragraph1",
|
||||
"home.regionalSection.paragraph2",
|
||||
"home.regionalSection.image",
|
||||
],
|
||||
locale
|
||||
);
|
||||
return (
|
||||
@@ -70,7 +74,13 @@ export async function RegionalSection() {
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<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>
|
||||
</Container>
|
||||
</section>
|
||||
@@ -111,12 +121,17 @@ export async function Sustainability() {
|
||||
export async function BusinessTeaser() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const content = await getContentValues(["home.businessTeaser.paragraph"], locale);
|
||||
const content = await getContentValues(
|
||||
["home.businessTeaser.paragraph", "home.businessTeaser.image"],
|
||||
locale
|
||||
);
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<ScrollReveal>
|
||||
<PlaceholderImage
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "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 { StarRating } from "@/components/star-rating";
|
||||
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.
|
||||
* 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();
|
||||
return (
|
||||
<Link
|
||||
href={`/honig/${slug}`}
|
||||
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 className="block text-forest">{name}</span>
|
||||
<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}`}
|
||||
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]}
|
||||
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
label={
|
||||
|
||||
@@ -9,10 +9,10 @@ function isImageSlide(slide: Slide): slide is { src: string } {
|
||||
return "src" in slide;
|
||||
}
|
||||
|
||||
// Solange keine echte Produktfotografie hinterlegt ist (product.images ist
|
||||
// aktuell bei allen Produkten leer, siehe README), zeigt die Galerie diese
|
||||
// vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt werden,
|
||||
// ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
|
||||
// Solange fuer ein Produkt keine echte Produktfotografie hinterlegt ist
|
||||
// (admin/produkte -> Bilder, siehe AdminImageListField), zeigt die Galerie
|
||||
// diese vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt
|
||||
// werden, ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
|
||||
const PLACEHOLDER_SLIDES: Slide[] = [
|
||||
{ tone: "honey", label: "Glas frontal" },
|
||||
{ tone: "cream", label: "Glas seitlich" },
|
||||
|
||||
@@ -914,6 +914,7 @@ export const de: Dictionary = {
|
||||
harvestDateLabel: "Erntedatum",
|
||||
locationLabel: "Standort",
|
||||
descriptionLabel: "Beschreibung",
|
||||
imagesLabel: "Erntebilder",
|
||||
isPublishedLabel: "Öffentlich sichtbar",
|
||||
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.",
|
||||
@@ -952,6 +953,7 @@ export const de: Dictionary = {
|
||||
stockLabel: "Lagerbestand (Gläser)",
|
||||
priceLabel: "Preis (Cent)",
|
||||
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
||||
imagesLabel: "Produktbilder",
|
||||
visibleInShop: "Im Shop sichtbar",
|
||||
save: "Speichern",
|
||||
},
|
||||
@@ -981,6 +983,7 @@ export const de: Dictionary = {
|
||||
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
||||
pricePlaceholder: "990",
|
||||
pricePerKgPlaceholder: "1980",
|
||||
imagesLabel: "Produktbilder",
|
||||
visibleInShop: "Im Shop sichtbar",
|
||||
create: "Anlegen",
|
||||
},
|
||||
@@ -1118,6 +1121,13 @@ export const de: Dictionary = {
|
||||
save: "Speichern",
|
||||
saved: "Gespeichert.",
|
||||
},
|
||||
adminImageField: {
|
||||
none: "Kein Bild",
|
||||
uploading: "Wird hochgeladen…",
|
||||
remove: "Bild entfernen",
|
||||
removeImageAt: (position) => `Bild ${position} entfernen`,
|
||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||
},
|
||||
adminShell: {
|
||||
navDashboard: "Dashboard",
|
||||
navOrders: "Bestellungen",
|
||||
|
||||
@@ -914,6 +914,7 @@ export const en: Dictionary = {
|
||||
harvestDateLabel: "Harvest date",
|
||||
locationLabel: "Location",
|
||||
descriptionLabel: "Description",
|
||||
imagesLabel: "Harvest images",
|
||||
isPublishedLabel: "Publicly visible",
|
||||
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.",
|
||||
@@ -952,6 +953,7 @@ export const en: Dictionary = {
|
||||
stockLabel: "Inventory (jars)",
|
||||
priceLabel: "Price (cents)",
|
||||
pricePerKgLabel: "Base price per kg (cents)",
|
||||
imagesLabel: "Product images",
|
||||
visibleInShop: "Visible in shop",
|
||||
save: "Save",
|
||||
},
|
||||
@@ -981,6 +983,7 @@ export const en: Dictionary = {
|
||||
pricePerKgLabel: "Base price per kg (cents)",
|
||||
pricePlaceholder: "990",
|
||||
pricePerKgPlaceholder: "1980",
|
||||
imagesLabel: "Product images",
|
||||
visibleInShop: "Visible in shop",
|
||||
create: "Create",
|
||||
},
|
||||
@@ -1118,6 +1121,13 @@ export const en: Dictionary = {
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
},
|
||||
adminImageField: {
|
||||
none: "No image",
|
||||
uploading: "Uploading…",
|
||||
remove: "Remove image",
|
||||
removeImageAt: (position) => `Remove image ${position}`,
|
||||
uploadError: "The upload failed.",
|
||||
},
|
||||
adminShell: {
|
||||
navDashboard: "Dashboard",
|
||||
navOrders: "Orders",
|
||||
|
||||
@@ -615,6 +615,7 @@ export interface Dictionary {
|
||||
harvestDateLabel: string;
|
||||
locationLabel: string;
|
||||
descriptionLabel: string;
|
||||
imagesLabel: string;
|
||||
isPublishedLabel: string;
|
||||
isPublishedHint: string;
|
||||
draftNotice: string;
|
||||
@@ -653,6 +654,7 @@ export interface Dictionary {
|
||||
stockLabel: string;
|
||||
priceLabel: string;
|
||||
pricePerKgLabel: string;
|
||||
imagesLabel: string;
|
||||
visibleInShop: string;
|
||||
save: string;
|
||||
};
|
||||
@@ -681,6 +683,7 @@ export interface Dictionary {
|
||||
pricePerKgLabel: string;
|
||||
pricePlaceholder: string;
|
||||
pricePerKgPlaceholder: string;
|
||||
imagesLabel: string;
|
||||
visibleInShop: string;
|
||||
create: string;
|
||||
};
|
||||
@@ -817,6 +820,15 @@ export interface Dictionary {
|
||||
save: 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: {
|
||||
navDashboard: string;
|
||||
navOrders: string;
|
||||
|
||||
@@ -9,7 +9,8 @@ export type ContentFieldType =
|
||||
| "list-simple"
|
||||
| "list-titled"
|
||||
| "list-numbered"
|
||||
| "richtext-sections";
|
||||
| "richtext-sections"
|
||||
| "image";
|
||||
|
||||
export interface ContentFieldDef {
|
||||
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.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
|
||||
// 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: () => "" },
|
||||
|
||||
+32
-6
@@ -21,6 +21,16 @@ function normalizeProduct<T extends { tasteTags: unknown; images: unknown }>(
|
||||
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 interface GetActiveProductsOptions {
|
||||
@@ -106,12 +116,13 @@ export async function getProductsByIds(
|
||||
}
|
||||
|
||||
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
|
||||
export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> {
|
||||
return prisma.harvest.findMany({
|
||||
export async function getHarvests(): Promise<(HarvestWithImages & { batches: Batch[] })[]> {
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
where: { isPublished: true },
|
||||
include: { batches: true },
|
||||
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(
|
||||
year: number,
|
||||
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({
|
||||
where: { year_type: { year, type } },
|
||||
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(
|
||||
code: string
|
||||
): Promise<(Batch & { harvest: Harvest; products: Product[] }) | null> {
|
||||
return prisma.batch.findUnique({
|
||||
): Promise<
|
||||
| (Batch & {
|
||||
harvest: HarvestWithImages;
|
||||
products: (Omit<Product, "images"> & { images: string[] })[];
|
||||
})
|
||||
| null
|
||||
> {
|
||||
const batch = await prisma.batch.findUnique({
|
||||
where: { code },
|
||||
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