Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54919d82ed | ||
|
|
3de9f2906e | ||
|
|
d52753cfef |
No files matched your search
+21
-2
@@ -87,8 +87,10 @@ SMTP_FROM="Wabenhain <[email protected]>"
|
||||
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key-
|
||||
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten,
|
||||
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen
|
||||
# "harvests:read" (und optional "apiaries:read") eintragen, nicht die eines
|
||||
# echten Admin-Zugangs. Details siehe src/lib/apicore-client.ts.
|
||||
# "harvests:read", "apiaries:read" (optional, fuer Standortnamen),
|
||||
# "extractions:read" und "bottling:read" (beide fuer den Chargen-Sync)
|
||||
# eintragen, nicht die eines echten Admin-Zugangs. Details siehe
|
||||
# src/lib/apicore-client.ts.
|
||||
APICORE_API_URL=
|
||||
APICORE_SYNC_EMAIL=
|
||||
APICORE_SYNC_PASSWORD=
|
||||
@@ -157,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);
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import {
|
||||
fetchApiCoreHarvests,
|
||||
fetchApiCoreBottlingBatches,
|
||||
ApiCoreConfigError,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreHarvestWithApiaryName,
|
||||
type ApiCoreBottlingBatchWithHarvestId,
|
||||
} from "@/lib/apicore-client";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
@@ -39,12 +41,27 @@ function buildDescription(source: ApiCoreHarvestWithApiaryName): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zieht Ernten aus ApiCore und legt sie als unveroeffentlichte Entwuerfe an
|
||||
* (isPublished: false) bzw. aktualisiert bereits vorhandene, noch
|
||||
* unveroeffentlichte Entwuerfe. Bereits vom Admin veroeffentlichte,
|
||||
* frueher synchronisierte Ernten werden NICHT angefasst — sonst wuerde ein
|
||||
* erneuter Sync eine bewusste Freigabe- und Bearbeitungsentscheidung des
|
||||
* Admins stillschweigend zuruecksetzen.
|
||||
* Zieht Ernten UND Chargen aus ApiCore.
|
||||
*
|
||||
* Ernten werden als unveroeffentlichte Entwuerfe angelegt (isPublished:
|
||||
* false) bzw. bereits vorhandene, noch unveroeffentlichte Entwuerfe
|
||||
* aktualisiert. Bereits vom Admin veroeffentlichte, frueher synchronisierte
|
||||
* Ernten werden NICHT angefasst — sonst wuerde ein erneuter Sync eine
|
||||
* bewusste Freigabe- und Bearbeitungsentscheidung des Admins stillschweigend
|
||||
* zuruecksetzen.
|
||||
*
|
||||
* Chargen (Wabenhain: Batch, ApiCore: BottlingBatch — siehe Kommentar in
|
||||
* lib/apicore-client.ts fuer die Modul-Zuordnung) werden dagegen NUR neu
|
||||
* angelegt, nie aktualisiert: anders als Harvest hat Batch kein
|
||||
* isPublished-Feld, das eine bereits erfolgte Admin-Bearbeitung markieren
|
||||
* koennte. Ein Update wuerde insbesondere remainingGlasses ueberschreiben —
|
||||
* das ist Wabenhains EIGENER, durch echte Verkaeufe veraenderter
|
||||
* Lagerbestand und darf niemals durch einen ApiCore-Sync zurueckgesetzt
|
||||
* werden. Existiert eine Charge (per code) bereits, wird sie uebersprungen.
|
||||
* Jede Charge braucht eine bereits in Wabenhain vorhandene (synchronisierte
|
||||
* oder manuell angelegte) Ernte als harvestId — Chargen laufen deshalb nach
|
||||
* den Ernten in diesem Lauf, damit gerade frisch angelegte Ernten schon
|
||||
* verfuegbar sind.
|
||||
*
|
||||
* Admin-Berechtigungsfeld "type" bei Wabenhain (Erntesaison, z.B.
|
||||
* "Sommertracht") und ApiCores "honeyType" (Honigsorte, z.B. "Lindenhonig")
|
||||
@@ -122,6 +139,87 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
||||
detail: `${created} neu, ${updated} aktualisiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
// Chargen-Sync laeuft NACH den Ernten (s.o., Doc-Kommentar) und ist
|
||||
// bewusst nicht fatal fuer den gesamten Aufruf: schlaegt nur das Laden der
|
||||
// Chargen fehl, sollen die bereits erfolgreich synchronisierten Ernten
|
||||
// trotzdem sichtbar bleiben statt durch einen Redirect mit Fehlerseite
|
||||
// verloren zu gehen.
|
||||
let batchCreated = 0;
|
||||
let batchSkipped = 0;
|
||||
let batchFailed = 0;
|
||||
let batchLoadError: string | null = null;
|
||||
|
||||
let sourceBatches: ApiCoreBottlingBatchWithHarvestId[] = [];
|
||||
try {
|
||||
sourceBatches = await fetchApiCoreBottlingBatches();
|
||||
} catch (error: unknown) {
|
||||
batchLoadError =
|
||||
error instanceof ApiCoreConfigError || error instanceof ApiCoreRequestError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Unbekannter Fehler";
|
||||
}
|
||||
|
||||
for (const source of sourceBatches) {
|
||||
if (!source.harvestSourceId) {
|
||||
batchFailed++;
|
||||
console.error(`[apicore-sync] Charge ${source.lotNumber} ist keiner Ernte zugeordnet — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
const fillDate = new Date(source.date);
|
||||
const bestBeforeDate = new Date(source.bestBeforeDate);
|
||||
if (Number.isNaN(fillDate.getTime()) || Number.isNaN(bestBeforeDate.getTime())) {
|
||||
batchFailed++;
|
||||
console.error(`[apicore-sync] Charge ${source.lotNumber} hat ein ungültiges Datum — übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.batch.findUnique({ where: { code: source.lotNumber } });
|
||||
if (existing) {
|
||||
batchSkipped++;
|
||||
continue;
|
||||
}
|
||||
const harvest = await prisma.harvest.findUnique({ where: { slug: slugFor(source.harvestSourceId) } });
|
||||
if (!harvest) {
|
||||
batchFailed++;
|
||||
console.error(
|
||||
`[apicore-sync] Charge ${source.lotNumber}: zugehörige Ernte (ApiCore-ID ${source.harvestSourceId}) ist in Wabenhain noch nicht synchronisiert — übersprungen.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.batch.create({
|
||||
data: {
|
||||
code: source.lotNumber,
|
||||
harvestId: harvest.id,
|
||||
fillDate,
|
||||
bestBeforeDate,
|
||||
glassCount: Math.max(Math.round(source.jarCount), 0),
|
||||
remainingGlasses: Math.max(Math.round(source.jarCount), 0),
|
||||
},
|
||||
});
|
||||
batchCreated++;
|
||||
} catch (error: unknown) {
|
||||
batchFailed++;
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
console.error(`[apicore-sync] Charge ${source.lotNumber} konnte nicht importiert werden: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "batch.sync_apicore",
|
||||
entityType: "Batch",
|
||||
detail: batchLoadError
|
||||
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
||||
: `${batchCreated} neu, ${batchSkipped} übersprungen, ${batchFailed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
redirect(`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}`);
|
||||
redirect(
|
||||
`/admin/ernten?synced=${created}&updated=${updated}&skipped=${skipped}&failed=${failed}` +
|
||||
`&batchSynced=${batchCreated}&batchSkipped=${batchSkipped}&batchFailed=${batchFailed}` +
|
||||
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { synced, updated, skipped, failed, syncError } = await props.searchParams;
|
||||
const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError } =
|
||||
await props.searchParams;
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||
include: { _count: { select: { batches: true, products: true } } },
|
||||
@@ -29,6 +30,16 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
||||
{t.adminHarvestsPage.syncError} ({syncError})
|
||||
</p>
|
||||
) : null}
|
||||
{batchSynced !== undefined ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{t.adminHarvestsPage.batchSyncResult(Number(batchSynced), Number(batchSkipped ?? 0), Number(batchFailed ?? 0))}
|
||||
</p>
|
||||
) : null}
|
||||
{batchLoadError !== undefined ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.adminHarvestsPage.batchSyncError} ({batchLoadError})
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function LegalPage({
|
||||
sections,
|
||||
}: {
|
||||
title: string;
|
||||
/** Optional — leer lassen, wenn kein Hinweis (mehr) angezeigt werden soll. */
|
||||
noticeText: string;
|
||||
sections: LegalSection[];
|
||||
}) {
|
||||
@@ -23,9 +24,9 @@ export function LegalPage({
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<h1 className="text-3xl text-forest">{title}</h1>
|
||||
<p className="mt-2 text-xs text-honey-dark tracking-wide-caps">
|
||||
{noticeText}
|
||||
</p>
|
||||
{noticeText ? (
|
||||
<p className="mt-2 text-xs text-honey-dark tracking-wide-caps">{noticeText}</p>
|
||||
) : null}
|
||||
<div className="mt-10 space-y-10">
|
||||
{sections.map((section) => (
|
||||
<section key={section.heading}>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -147,7 +147,7 @@ export const de: Dictionary = {
|
||||
errorMessage: "Da ist etwas schiefgelaufen. Bitte versuche es erneut.",
|
||||
},
|
||||
legalPage: {
|
||||
reviewNotice: "[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]",
|
||||
reviewNotice: "",
|
||||
},
|
||||
notFoundPage: {
|
||||
eyebrow: "404",
|
||||
@@ -231,13 +231,13 @@ export const de: Dictionary = {
|
||||
{
|
||||
heading: "1. Verantwortlicher",
|
||||
paragraphs: [
|
||||
"Sofern gesetzlich erforderlich (z.B. bei umfangreicher Verarbeitung besonderer Datenkategorien oder mehr als 20 Personen, die ständig mit der automatisierten Verarbeitung personenbezogener Daten befasst sind, § 38 BDSG), benennen wir hier eine/n Datenschutzbeauftragte/n: [PLATZHALTER — Name und Kontaktdaten eintragen, falls zutreffend].",
|
||||
"Nach unserer Einschätzung sind wir aufgrund der Größe und Art unseres Betriebs nicht gemäß § 38 BDSG zur Benennung einer/eines Datenschutzbeauftragten verpflichtet. Bei Fragen zum Datenschutz wenden Sie sich bitte direkt an die im Impressum genannte Kontaktadresse.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Hosting",
|
||||
paragraphs: [
|
||||
"Unsere Website wird bei einem externen Hosting-Anbieter gehostet: [PLATZHALTER — Name und Anschrift des Hosting-Anbieters eintragen]. Mit diesem Anbieter besteht ein Auftragsverarbeitungsvertrag gemäß Art. 28 DSGVO. Der Hosting-Anbieter verarbeitet in unserem Auftrag insbesondere die in den Server-Logfiles (siehe Punkt 3) anfallenden Daten sowie sämtliche über diese Website eingegebenen Daten.",
|
||||
"Diese Website wird auf eigener, selbst betriebener Server-Infrastruktur gehostet — nicht bei einem externen Hosting-Anbieter. Es besteht daher kein Auftragsverarbeitungsvertrag mit einem externen Hosting-Dienstleister gemäß Art. 28 DSGVO; die Verarbeitung der Server-Logfiles (siehe Punkt 3) erfolgt ausschließlich durch uns selbst als Verantwortlichen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -897,11 +897,14 @@ export const de: Dictionary = {
|
||||
create: "Anlegen",
|
||||
syncHeading: "ApiCore-Synchronisierung",
|
||||
syncIntro:
|
||||
"Importiert Ernten aus ApiCore als Entwürfe (nicht öffentlich sichtbar). Bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert — nur neue oder noch unveröffentlichte Entwürfe werden aktualisiert.",
|
||||
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar) — bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte.",
|
||||
syncButton: "Jetzt synchronisieren",
|
||||
syncResult: (created, updated, skipped, failed) =>
|
||||
`Synchronisierung abgeschlossen: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||
batchSyncResult: (created, skipped, failed) =>
|
||||
`Chargen: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||
batchSyncError: "Chargen konnten nicht von ApiCore geladen werden",
|
||||
},
|
||||
adminHarvestEditPage: {
|
||||
backToHarvests: "Zurück zu Ernten",
|
||||
@@ -911,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.",
|
||||
@@ -949,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",
|
||||
},
|
||||
@@ -978,6 +983,7 @@ export const de: Dictionary = {
|
||||
pricePerKgLabel: "Grundpreis pro kg (Cent)",
|
||||
pricePlaceholder: "990",
|
||||
pricePerKgPlaceholder: "1980",
|
||||
imagesLabel: "Produktbilder",
|
||||
visibleInShop: "Im Shop sichtbar",
|
||||
create: "Anlegen",
|
||||
},
|
||||
@@ -1115,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",
|
||||
|
||||
@@ -147,7 +147,7 @@ export const en: Dictionary = {
|
||||
errorMessage: "Something went wrong. Please try again.",
|
||||
},
|
||||
legalPage: {
|
||||
reviewNotice: "[PLACEHOLDER — must be reviewed by a lawyer before publication]",
|
||||
reviewNotice: "",
|
||||
},
|
||||
notFoundPage: {
|
||||
eyebrow: "404",
|
||||
@@ -231,13 +231,13 @@ export const en: Dictionary = {
|
||||
{
|
||||
heading: "1. Data Controller",
|
||||
paragraphs: [
|
||||
"If legally required (e.g. for extensive processing of special categories of data, or where more than 20 people are constantly involved in the automated processing of personal data, § 38 BDSG), we name a data protection officer here: [PLACEHOLDER — name and contact details, if applicable].",
|
||||
"Based on the size and nature of our business, we do not believe we are required to appoint a data protection officer under § 38 BDSG. For any data protection questions, please use the contact address given in our legal notice.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Hosting",
|
||||
paragraphs: [
|
||||
"Our website is hosted by an external hosting provider: [PLACEHOLDER — enter the hosting provider's name and address]. We have a data processing agreement with this provider pursuant to Art. 28 GDPR. On our behalf, the hosting provider processes in particular the data collected in the server log files (see item 3) as well as all data entered via this website.",
|
||||
"This website is hosted on our own, self-operated server infrastructure — not by an external hosting provider. There is therefore no data processing agreement with an external hosting provider under Art. 28 GDPR; the server log files described in item 3 are processed exclusively by us as the data controller.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -897,11 +897,14 @@ export const en: Dictionary = {
|
||||
create: "Create",
|
||||
syncHeading: "ApiCore sync",
|
||||
syncIntro:
|
||||
"Imports harvests from ApiCore as drafts (not publicly visible). Previously synced harvests that are already published are left untouched — only new or still-unpublished drafts are updated.",
|
||||
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible) — previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest.",
|
||||
syncButton: "Sync now",
|
||||
syncResult: (created, updated, skipped, failed) =>
|
||||
`Sync complete: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||
syncError: "Sync with ApiCore failed.",
|
||||
batchSyncResult: (created, skipped, failed) =>
|
||||
`Batches: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||
batchSyncError: "Batches could not be loaded from ApiCore",
|
||||
},
|
||||
adminHarvestEditPage: {
|
||||
backToHarvests: "Back to harvests",
|
||||
@@ -911,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.",
|
||||
@@ -949,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",
|
||||
},
|
||||
@@ -978,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",
|
||||
},
|
||||
@@ -1115,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",
|
||||
|
||||
@@ -603,6 +603,9 @@ export interface Dictionary {
|
||||
/** created, updated, skipped, failed */
|
||||
syncResult: (created: number, updated: number, skipped: number, failed: number) => string;
|
||||
syncError: string;
|
||||
/** created, skipped, failed */
|
||||
batchSyncResult: (created: number, skipped: number, failed: number) => string;
|
||||
batchSyncError: string;
|
||||
};
|
||||
adminHarvestEditPage: {
|
||||
backToHarvests: string;
|
||||
@@ -612,6 +615,7 @@ export interface Dictionary {
|
||||
harvestDateLabel: string;
|
||||
locationLabel: string;
|
||||
descriptionLabel: string;
|
||||
imagesLabel: string;
|
||||
isPublishedLabel: string;
|
||||
isPublishedHint: string;
|
||||
draftNotice: string;
|
||||
@@ -650,6 +654,7 @@ export interface Dictionary {
|
||||
stockLabel: string;
|
||||
priceLabel: string;
|
||||
pricePerKgLabel: string;
|
||||
imagesLabel: string;
|
||||
visibleInShop: string;
|
||||
save: string;
|
||||
};
|
||||
@@ -678,6 +683,7 @@ export interface Dictionary {
|
||||
pricePerKgLabel: string;
|
||||
pricePlaceholder: string;
|
||||
pricePerKgPlaceholder: string;
|
||||
imagesLabel: string;
|
||||
visibleInShop: string;
|
||||
create: string;
|
||||
};
|
||||
@@ -814,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;
|
||||
|
||||
@@ -23,9 +23,22 @@ import { z } from "zod";
|
||||
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
|
||||
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
||||
*
|
||||
* Ohne diese drei Variablen wirft fetchApiCoreHarvests() sofort einen
|
||||
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
||||
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
||||
* Ohne diese drei Variablen wirft fetchApiCoreHarvests()/
|
||||
* fetchApiCoreBottlingBatches() sofort einen ApiCoreConfigError — der
|
||||
* Aufrufer (apicore-sync.ts) zeigt dessen Message dem Admin an, statt die
|
||||
* Aktion stillschweigend nichts tun zu lassen.
|
||||
*
|
||||
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
|
||||
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
|
||||
* dem Abfuellen) das fachlich richtige Gegenstueck, sondern "BottlingBatch"
|
||||
* (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch) — dessen
|
||||
* Felder passen fast 1:1: lotNumber/code, date/fillDate,
|
||||
* bestBeforeDate/bestBeforeDate, jarCount/glassCount. Die zugehoerige Ernte
|
||||
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
|
||||
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
|
||||
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
|
||||
* Das erfordert zusaetzlich die Berechtigungen "extractions:read" und
|
||||
* "bottling:read" fuer das Sync-Service-Konto.
|
||||
*/
|
||||
|
||||
export class ApiCoreConfigError extends Error {}
|
||||
@@ -132,3 +145,84 @@ export async function fetchApiCoreHarvests(): Promise<ApiCoreHarvestWithApiaryNa
|
||||
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const extractionSchema = z.object({
|
||||
id: z.string(),
|
||||
// ApiCores GET /extractions liefert je Schleuderung die verknuepften
|
||||
// Ernten als ExtractionHarvest-Zeilen — pro Zeile reicht harvestId, die
|
||||
// uebrigen (verschachtelten Volk-/Standort-)Felder werden nicht gebraucht.
|
||||
harvests: z.array(z.object({ harvestId: z.string() })),
|
||||
});
|
||||
|
||||
const bottlingBatchSchema = z.object({
|
||||
id: z.string(),
|
||||
lotNumber: z.string(),
|
||||
date: z.string(),
|
||||
bestBeforeDate: z.string(),
|
||||
jarCount: z.number(),
|
||||
honeyBatch: z.object({
|
||||
id: z.string(),
|
||||
extractionId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
|
||||
|
||||
export interface ApiCoreBottlingBatchWithHarvestId extends ApiCoreBottlingBatch {
|
||||
/**
|
||||
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
|
||||
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
|
||||
* Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest
|
||||
* ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein
|
||||
* einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste
|
||||
* verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit
|
||||
* auf die tatsaechlich passende Ernte korrigieren.
|
||||
*/
|
||||
harvestSourceId: string | null;
|
||||
}
|
||||
|
||||
export async function fetchApiCoreBottlingBatches(): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
||||
const { baseUrl, email, password } = getConfig();
|
||||
const token = await login(baseUrl, email, password);
|
||||
const authHeader = { Authorization: `Bearer ${token}` };
|
||||
|
||||
let extractionsRes: Response;
|
||||
let batchesRes: Response;
|
||||
try {
|
||||
[extractionsRes, batchesRes] = await Promise.all([
|
||||
fetch(`${baseUrl}/extractions`, { headers: authHeader }),
|
||||
fetch(`${baseUrl}/bottling-batches`, { headers: authHeader }),
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
}
|
||||
if (!extractionsRes.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore: Schleuderungen konnten nicht geladen werden (HTTP ${extractionsRes.status}).`);
|
||||
}
|
||||
if (!batchesRes.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore: Chargen konnten nicht geladen werden (HTTP ${batchesRes.status}).`);
|
||||
}
|
||||
|
||||
const parsedExtractions = z.array(extractionSchema).safeParse(await extractionsRes.json());
|
||||
if (!parsedExtractions.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Schleuderungen.");
|
||||
}
|
||||
const parsedBatches = z.array(bottlingBatchSchema).safeParse(await batchesRes.json());
|
||||
if (!parsedBatches.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Chargen.");
|
||||
}
|
||||
|
||||
const firstHarvestByExtraction = new Map<string, string | null>(
|
||||
parsedExtractions.data.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
|
||||
);
|
||||
|
||||
return parsedBatches.data.map((batch) => ({
|
||||
...batch,
|
||||
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -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