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
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user