feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s

Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero
(home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem
Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden
Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der
Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt,
Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher
nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder
(Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder
(Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt
statt voller Viewport-Breite.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-11 14:53:14 +02:00
1 parent 398db5c5ec
commit 6814e9e2c8
25 files changed
+524 -119

No files matched your search

+6 -1
View File
@@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry"; import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
/** /**
@@ -43,6 +43,11 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) { if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
continue; continue;
} }
// Analog zum Bild-Feld: ein Video-Feld darf nur einen Pfad enthalten,
// den app/api/admin/upload-video/route.ts tatsaechlich erzeugt hat.
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
continue;
}
updates.push({ key: def.key, valueDe, valueEn: null }); updates.push({ key: def.key, valueDe, valueEn: null });
} }
} }
+15 -3
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { AdminImageField } from "@/components/admin-image-field"; import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry"; import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions"; import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -13,10 +14,11 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
"list-titled": 5, "list-titled": 5,
"list-numbered": 6, "list-numbered": 6,
"richtext-sections": 14, "richtext-sections": 14,
// Bild-Feld hat kein <textarea>, nutzt ROWS_BY_TYPE also nicht — Wert nur // Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// vorhanden, damit dieses Record<ContentFieldDef["type"], number> weiter // — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// vollstaendig ist. // weiter vollstaendig ist.
image: 0, image: 0,
video: 0,
}; };
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> { async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
@@ -77,6 +79,16 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
/> />
); );
} }
if (def.type === "video") {
return (
<AdminVideoField
key={def.key}
name={def.key}
label={label}
initialValue={row?.valueDe ?? ""}
/>
);
}
return ( return (
<label key={def.key} className="block text-sm"> <label key={def.key} className="block text-sm">
<span className="text-ink/70">{label}</span> <span className="text-ink/70">{label}</span>
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log";
import {
UPLOAD_DIR,
MAX_UPLOAD_VIDEO_BYTES,
detectVideoExtension,
generateUploadFilename,
} from "@/lib/uploads";
import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge).
export const runtime = "nodejs";
/**
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, ein
* Video wird deshalb NICHT neu enkodiert (kein Pixel-fuer-Pixel-Rebuild wie
* bei Bildern moeglich), sondern nur anhand seiner Magic-Bytes als MP4/WebM
* verifiziert und unveraendert gespeichert. Das ist vertretbar, weil (1) nur
* eingeloggte 2FA-Admins diesen Endpunkt erreichen (requireAdminForApi),
* (2) ausgelieferte Dateien einen festen, aus der Endung abgeleiteten
* Content-Type + "X-Content-Type-Options: nosniff" bekommen (siehe
* app/uploads/[...path]/route.ts) und daher nie als HTML/Skript interpretiert
* werden koennen, und (3) der zufaellige Dateiname + die feste Endungs-
* Allowlist jede andere Nutzung des Pfads verhindern.
*/
export async function POST(request: Request) {
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;
// Videos sind ein seltener redaktioneller Vorgang (ein Hero-Video pro
// Startseite) — deutlich niedrigeres Limit als der Bild-Upload reicht.
if (!rateLimit(`admin-upload-video:${admin.id}`, 10, 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_VIDEO_BYTES) {
return NextResponse.json(
{ error: `Die Datei ist zu groß (max. ${Math.floor(MAX_UPLOAD_VIDEO_BYTES / (1024 * 1024))} MB).` },
{ status: 413 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const extension = detectVideoExtension(buffer);
if (!extension) {
return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 }
);
}
await mkdir(UPLOAD_DIR, { recursive: true });
const filename = generateUploadFilename(extension);
await writeFile(path.join(UPLOAD_DIR, filename), buffer);
await logAdminAction(admin, {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `${extension}, ${Math.ceil(buffer.length / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
}
+38 -27
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { TrustBadges } from "@/components/trust-badges"; import { TrustBadges } from "@/components/trust-badges";
import { ShieldIcon } from "@/components/account-ui"; import { ShieldIcon } from "@/components/account-ui";
import { ProductTeaserLink } from "@/components/product-card"; import { ProductTeaserLink } from "@/components/product-card";
@@ -42,33 +43,41 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
return ( return (
<div className="py-20"> <div className="py-20">
<Container className="max-w-3xl"> <Container className="max-w-3xl">
<div className="flex items-center gap-2 text-honey-dark"> <ScrollReveal>
<ShieldIcon /> <div className="flex items-center gap-2 text-honey-dark">
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span> <ShieldIcon />
</div> <span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
<h1 className="mt-3 text-4xl md:text-5xl text-forest"> </div>
{batch.harvest.type} {batch.harvest.year} <h1 className="mt-3 text-4xl md:text-5xl text-forest">
</h1> {batch.harvest.type} {batch.harvest.year}
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p> </h1>
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
</ScrollReveal>
<div className="mt-10 grid sm:grid-cols-2 gap-6"> <div className="mt-10 grid sm:grid-cols-2 gap-6">
<ContentImage <ScrollReveal>
src={batch.harvest.images[0] ?? null} <ContentImage
alt={`${batch.harvest.type} ${batch.harvest.year}`} src={batch.harvest.images[0] ?? null}
tone="honey" alt={`${batch.harvest.type} ${batch.harvest.year}`}
className="aspect-square" tone="honey"
label={`${batch.harvest.type} ${batch.harvest.year}`} className="aspect-square"
/> label={`${batch.harvest.type} ${batch.harvest.year}`}
<ContentImage sizes="(min-width: 640px) 50vw, 100vw"
src={batch.harvest.images[1] ?? null} />
alt={batch.harvest.location} </ScrollReveal>
tone="forest" <ScrollReveal delay={0.1}>
className="aspect-square" <ContentImage
label={batch.harvest.location} src={batch.harvest.images[1] ?? null}
/> alt={batch.harvest.location}
tone="forest"
className="aspect-square"
label={batch.harvest.location}
sizes="(min-width: 640px) 50vw, 100vw"
/>
</ScrollReveal>
</div> </div>
<div className="mt-14"> <ScrollReveal className="mt-14">
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{steps.map((step, i) => ( {steps.map((step, i) => (
<div key={step.label} className="relative"> <div key={step.label} className="relative">
@@ -83,19 +92,21 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
</div> </div>
))} ))}
</div> </div>
</div> </ScrollReveal>
<div className="mt-10 border-t border-ink/10 pt-8"> <ScrollReveal className="mt-10 border-t border-ink/10 pt-8">
<p className="text-sm tracking-wide-caps text-ink/70 mb-2">{t.chargePage.regionLabel}</p> <p className="text-sm tracking-wide-caps text-ink/70 mb-2">{t.chargePage.regionLabel}</p>
<p className="text-ink/80">{batch.harvest.location}</p> <p className="text-ink/80">{batch.harvest.location}</p>
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p> <p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p>
</div> </ScrollReveal>
<TrustBadges className="mt-10 pt-8 border-t border-ink/10" /> <TrustBadges className="mt-10 pt-8 border-t border-ink/10" />
{batch.products.length > 0 ? ( {batch.products.length > 0 ? (
<div className="mt-14 border-t border-ink/10 pt-10"> <div className="mt-14 border-t border-ink/10 pt-10">
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.chargePage.fromThisBatch}</p> <ScrollReveal>
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.chargePage.fromThisBatch}</p>
</ScrollReveal>
<div className="grid sm:grid-cols-2 gap-6"> <div className="grid sm:grid-cols-2 gap-6">
{batch.products.map((product) => ( {batch.products.map((product) => (
<ProductTeaserLink <ProductTeaserLink
+13 -9
View File
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ProductTeaserLink } from "@/components/product-card"; import { ProductTeaserLink } from "@/components/product-card";
import { getHarvestByYearType } from "@/lib/data"; import { getHarvestByYearType } from "@/lib/data";
@@ -45,14 +46,17 @@ export default async function HarvestDetailPage(
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} /> <BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
</Container> </Container>
<Container className="grid md:grid-cols-2 gap-14"> <Container className="grid md:grid-cols-2 gap-14">
<ContentImage <ScrollReveal>
src={harvest.images[0] ?? null} <ContentImage
alt={harvest.type} src={harvest.images[0] ?? null}
tone="forest" alt={harvest.type}
className="aspect-[4/5] w-full" tone="forest"
label={harvest.location} className="aspect-[4/5] w-full"
/> label={harvest.location}
<div> sizes="(min-width: 768px) 50vw, 100vw"
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<p className="tracking-wide-caps text-xs text-honey-dark mb-3"> <p className="tracking-wide-caps text-xs text-honey-dark mb-3">
{harvest.year} · {formatDate(harvest.harvestDate, locale)} {harvest.year} · {formatDate(harvest.harvestDate, locale)}
</p> </p>
@@ -86,7 +90,7 @@ export default async function HarvestDetailPage(
/> />
))} ))}
</div> </div>
</div> </ScrollReveal>
</Container> </Container>
</div> </div>
); );
+25 -18
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getHarvests } from "@/lib/data"; import { getHarvests } from "@/lib/data";
import { formatMonthYear } from "@/lib/format"; import { formatMonthYear } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -29,29 +30,35 @@ export default async function ErntenPage() {
return ( return (
<div className="py-20"> <div className="py-20">
<Container> <Container>
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} /> <ScrollReveal>
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
</ScrollReveal>
<div className="mt-16 space-y-16"> <div className="mt-16 space-y-16">
{years.map((year) => ( {years.map((year) => (
<div key={year}> <div key={year}>
<h2 className="text-2xl text-forest mb-6">{year}</h2> <ScrollReveal>
<h2 className="text-2xl text-forest mb-6">{year}</h2>
</ScrollReveal>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
{byYear[year].map((harvest) => ( {byYear[year].map((harvest, i) => (
<Link <ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
key={harvest.id} <Link
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`} href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
className="group" className="group block"
> >
<ContentImage <ContentImage
src={harvest.images[0] ?? null} src={harvest.images[0] ?? null}
alt={harvest.type} alt={harvest.type}
tone="forest" tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]" className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
label={formatMonthYear(harvest.harvestDate, locale)} label={formatMonthYear(harvest.harvestDate, locale)}
/> sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3> />
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p> <h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
</Link> <p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
</Link>
</ScrollReveal>
))} ))}
</div> </div>
</div> </div>
+13 -9
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValue } from "@/lib/content"; import { getContentValue } from "@/lib/content";
@@ -26,20 +27,23 @@ export default async function HerkunftPage() {
tone="forest" tone="forest"
className="h-[50vh] min-h-[360px] w-full mb-20" className="h-[50vh] min-h-[360px] w-full mb-20"
label={t.herkunftPage.eyebrow} label={t.herkunftPage.eyebrow}
priority
/> />
<Container> <Container>
<SectionHeading <ScrollReveal>
level="h1" <SectionHeading
eyebrow={t.herkunftPage.eyebrow} level="h1"
title={t.herkunftPage.title} eyebrow={t.herkunftPage.eyebrow}
subtitle={t.herkunftPage.subtitle} title={t.herkunftPage.title}
/> subtitle={t.herkunftPage.subtitle}
/>
</ScrollReveal>
<div className="mt-16 grid sm:grid-cols-2 gap-10"> <div className="mt-16 grid sm:grid-cols-2 gap-10">
{t.herkunftPage.aspects.map((a) => ( {t.herkunftPage.aspects.map((a, i) => (
<div key={a.title}> <ScrollReveal key={a.title} delay={i * 0.08}>
<h3 className="text-lg text-forest">{a.title}</h3> <h3 className="text-lg text-forest">{a.title}</h3>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p> <p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
</div> </ScrollReveal>
))} ))}
</div> </div>
<p className="mt-16 text-xs text-ink/65 max-w-md"> <p className="mt-16 text-xs text-ink/65 max-w-md">
+7 -2
View File
@@ -3,6 +3,7 @@ import { Fragment } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Container, Button } from "@/components/ui"; import { Container, Button } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { ProductGallery } from "@/components/product-gallery"; import { ProductGallery } from "@/components/product-gallery";
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card"; import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
import { RecordProductView } from "@/components/record-product-view"; import { RecordProductView } from "@/components/record-product-view";
@@ -324,7 +325,9 @@ export default async function ProductDetailPage(
</Container> </Container>
<Container id="bewertungen" className="mt-20 max-w-3xl"> <Container id="bewertungen" className="mt-20 max-w-3xl">
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2> <ScrollReveal>
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
</ScrollReveal>
{error ? ( {error ? (
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p> <p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
@@ -404,7 +407,9 @@ export default async function ProductDetailPage(
{relatedProducts.length > 0 ? ( {relatedProducts.length > 0 ? (
<Container className="mt-20"> <Container className="mt-20">
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2> <ScrollReveal>
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{relatedProducts.map((related) => ( {relatedProducts.map((related) => (
<ProductCard key={related.id} product={related} /> <ProductCard key={related.id} product={related} />
+13 -9
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValue } from "@/lib/content"; import { getContentValue } from "@/lib/content";
@@ -21,7 +22,7 @@ export default async function ImkereiPage() {
return ( return (
<div className="py-20"> <div className="py-20">
<Container className="grid md:grid-cols-2 gap-14 items-center"> <Container className="grid md:grid-cols-2 gap-14 items-center">
<div> <ScrollReveal>
<SectionHeading <SectionHeading
level="h1" level="h1"
eyebrow={t.imkereiPage.eyebrow} eyebrow={t.imkereiPage.eyebrow}
@@ -32,14 +33,17 @@ export default async function ImkereiPage() {
<p key={paragraph}>{paragraph}</p> <p key={paragraph}>{paragraph}</p>
))} ))}
</div> </div>
</div> </ScrollReveal>
<ContentImage <ScrollReveal delay={0.1}>
src={image || null} <ContentImage
alt={t.imkereiPage.imageLabel} src={image || null}
tone="honey" alt={t.imkereiPage.imageLabel}
className="aspect-[4/5] w-full" tone="honey"
label={t.imkereiPage.imageLabel} className="aspect-[4/5] w-full"
/> label={t.imkereiPage.imageLabel}
sizes="(min-width: 768px) 50vw, 100vw"
/>
</ScrollReveal>
</Container> </Container>
</div> </div>
); );
+8 -5
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -19,9 +20,11 @@ export default async function KontaktPage() {
return ( return (
<div className="py-20"> <div className="py-20">
<Container className="max-w-2xl"> <Container className="max-w-2xl">
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} /> <ScrollReveal>
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
</ScrollReveal>
<div className="mt-10 space-y-2 text-ink/75"> <ScrollReveal delay={0.1} className="mt-10 space-y-2 text-ink/75">
<p>{company.name}</p> <p>{company.name}</p>
<p>{company.address.street}</p> <p>{company.address.street}</p>
<p> <p>
@@ -33,14 +36,14 @@ export default async function KontaktPage() {
</a> </a>
</p> </p>
<p>{company.phone}</p> <p>{company.phone}</p>
</div> </ScrollReveal>
<div className="mt-12 border-t border-ink/10 pt-8"> <ScrollReveal delay={0.18} className="mt-12 border-t border-ink/10 pt-8">
<h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2> <h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2>
<p className="mt-2 text-sm text-ink/65 leading-relaxed"> <p className="mt-2 text-sm text-ink/65 leading-relaxed">
{t.kontaktPage.pickupText} {t.kontaktPage.pickupText}
</p> </p>
</div> </ScrollReveal>
</Container> </Container>
</div> </div>
); );
+5 -1
View File
@@ -29,7 +29,10 @@ export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
const locale = await getLocale(); const locale = await getLocale();
const [heroContent, company] = await Promise.all([ const [heroContent, company] = await Promise.all([
getContentValues(["home.hero.intro", "home.hero.footnote", "home.hero.image"], locale), getContentValues(
["home.hero.intro", "home.hero.footnote", "home.hero.image", "home.hero.video"],
locale
),
getCompanyInfo(), getCompanyInfo(),
]); ]);
const products = await getActiveProducts(); const products = await getActiveProducts();
@@ -49,6 +52,7 @@ export default async function HomePage() {
footnote={heroContent["home.hero.footnote"]} footnote={heroContent["home.hero.footnote"]}
location={company.location} location={company.location}
image={heroContent["home.hero.image"] || null} image={heroContent["home.hero.image"] || null}
video={heroContent["home.hero.video"] || null}
/> />
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} /> <CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
<OriginStory /> <OriginStory />
+9 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { ProductCard } from "@/components/product-card"; import { ProductCard } from "@/components/product-card";
import { RecentlyViewedSection } from "@/components/recently-viewed-section"; import { RecentlyViewedSection } from "@/components/recently-viewed-section";
import { getActiveProducts, type ProductSort } from "@/lib/data"; import { getActiveProducts, type ProductSort } from "@/lib/data";
@@ -82,12 +83,14 @@ export default async function ShopPage(props: PageProps<"/shop">) {
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }} dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
/> />
<Container> <Container>
<SectionHeading <ScrollReveal>
level="h1" <SectionHeading
eyebrow={t.shop.eyebrow} level="h1"
title={t.shop.title} eyebrow={t.shop.eyebrow}
subtitle={t.shop.subtitle} title={t.shop.title}
/> subtitle={t.shop.subtitle}
/>
</ScrollReveal>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
{categoryFilters.map((filter) => { {categoryFilters.map((filter) => {
+15 -11
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { BusinessInquiryForm } from "@/components/business-inquiry-form"; import { BusinessInquiryForm } from "@/components/business-inquiry-form";
import { ScrollReveal } from "@/components/scroll-reveal";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValue } from "@/lib/content"; import { getContentValue } from "@/lib/content";
@@ -27,28 +28,31 @@ export default async function UnternehmenPage() {
tone="honey" tone="honey"
className="h-[45vh] min-h-[320px] w-full" className="h-[45vh] min-h-[320px] w-full"
label={t.unternehmenPage.eyebrow} label={t.unternehmenPage.eyebrow}
priority
/> />
<Container className="py-20"> <Container className="py-20">
<SectionHeading <ScrollReveal>
level="h1" <SectionHeading
eyebrow={t.unternehmenPage.eyebrow} level="h1"
title={t.unternehmenPage.title} eyebrow={t.unternehmenPage.eyebrow}
subtitle={t.unternehmenPage.subtitle} title={t.unternehmenPage.title}
/> subtitle={t.unternehmenPage.subtitle}
/>
</ScrollReveal>
<div className="mt-14 grid sm:grid-cols-3 gap-8"> <div className="mt-14 grid sm:grid-cols-3 gap-8">
{t.unternehmenPage.packages.map((pkg) => ( {t.unternehmenPage.packages.map((pkg, i) => (
<div key={pkg.key} className="border border-ink/10 p-8"> <ScrollReveal key={pkg.key} delay={i * 0.08} className="border border-ink/10 p-8">
<h3 className="text-lg text-forest">{pkg.title}</h3> <h3 className="text-lg text-forest">{pkg.title}</h3>
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p> <p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
</div> </ScrollReveal>
))} ))}
</div> </div>
<div className="mt-20 max-w-2xl"> <ScrollReveal className="mt-20 max-w-2xl">
<h2 className="text-2xl text-forest mb-8">{t.unternehmenPage.inquiryHeading}</h2> <h2 className="text-2xl text-forest mb-8">{t.unternehmenPage.inquiryHeading}</h2>
<BusinessInquiryForm /> <BusinessInquiryForm />
</div> </ScrollReveal>
</Container> </Container>
</div> </div>
); );
+11 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Container, SectionHeading } from "@/components/ui"; import { Container, SectionHeading } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { wissenSlugs } from "@/lib/site-config"; import { wissenSlugs } from "@/lib/site-config";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -19,15 +20,19 @@ export default async function WissenPage() {
return ( return (
<div className="py-20"> <div className="py-20">
<Container className="max-w-3xl"> <Container className="max-w-3xl">
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} /> <ScrollReveal>
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} />
</ScrollReveal>
<div className="mt-14 space-y-10"> <div className="mt-14 space-y-10">
{wissenSlugs.map((slug) => { {wissenSlugs.map((slug, i) => {
const article = t.wissenArticles[slug]; const article = t.wissenArticles[slug];
return ( return (
<Link key={slug} href={`/wissen/${slug}`} className="block group"> <ScrollReveal key={slug} delay={(i % 5) * 0.06}>
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2> <Link href={`/wissen/${slug}`} className="block group">
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p> <h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
</Link> <p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
</Link>
</ScrollReveal>
); );
})} })}
</div> </div>
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
interface AdminVideoFieldProps {
/** Formularfeld-Name, unter dem der aktuelle Videopfad beim Speichern des
* umgebenden <form> mitgeschickt wird (siehe admin/actions/content.ts). */
name: string;
label: string;
initialValue: string;
}
/**
* Video-Upload-Feld fuer admin/inhalte (Content-Registry-Feldtyp "video",
* aktuell nur home.hero.video). Analog zu AdminImageField: laedt die Datei
* sofort per fetch() an app/api/admin/upload-video hoch und haelt den
* zurueckgegebenen Pfad in einem versteckten Formularfeld — das eigentliche
* Speichern passiert weiterhin ueber das umgebende
* <form action={updateSiteContentAction}>.
*/
export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldProps) {
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-video", { method: "POST", body });
const data: { path?: string; error?: string } = await response.json();
if (!response.ok || !data.path) {
throw new Error(data.error ?? t.adminVideoField.uploadError);
}
setValue(data.path);
setStatus("idle");
} catch (error: unknown) {
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : t.adminVideoField.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 ? (
<video src={value} className="h-20 w-32 object-cover border border-ink/10" muted loop autoPlay playsInline />
) : (
<div className="h-20 w-32 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1">
{t.adminVideoField.none}
</div>
)}
<div>
<input
ref={inputRef}
type="file"
accept="video/mp4,video/webm"
onChange={handleFileChange}
disabled={status === "uploading"}
className="block text-xs"
/>
{status === "uploading" ? (
<p className="mt-1 text-xs text-ink/70">{t.adminVideoField.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/70 underline"
>
{t.adminVideoField.remove}
</button>
) : null}
</div>
</div>
</div>
);
}
+27 -8
View File
@@ -29,11 +29,13 @@ export function Hero({
footnote, footnote,
location, location,
image, image,
video,
}: { }: {
intro: string; intro: string;
footnote: string; footnote: string;
location: string; location: string;
image: string | null; image: string | null;
video: string | null;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
@@ -45,14 +47,31 @@ export function Hero({
animate="visible" animate="visible"
variants={sectionVariants} variants={sectionVariants}
> >
<ContentImage {video ? (
src={image} <div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
alt="" <video
tone="forest" src={video}
className="h-[78vh] min-h-[520px] w-full" poster={image ?? undefined}
label="Bienenstand im Morgenlicht · Altenmünster" className="absolute inset-0 h-full w-full object-cover"
priority // Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
/> // (poster) statt der automatisch laufenden Videoschleife (WCAG
// 2.2.2 "Pause, Stop, Hide" fuer automatisch startende Bewegung).
autoPlay={!shouldReduceMotion}
muted
loop
playsInline
/>
</div>
) : (
<ContentImage
src={image}
alt=""
tone="forest"
className="h-[78vh] min-h-[520px] w-full"
label="Bienenstand im Morgenlicht · Altenmünster"
priority
/>
)}
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent"> <div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent">
<Container> <Container>
<motion.div <motion.div
+2
View File
@@ -63,6 +63,7 @@ export async function OriginStory() {
tone="forest" tone="forest"
className="aspect-[4/5] w-full" className="aspect-[4/5] w-full"
label={t.home.originStory.eyebrow} label={t.home.originStory.eyebrow}
sizes="(min-width: 768px) 50vw, 100vw"
/> />
</ScrollReveal> </ScrollReveal>
<ScrollReveal delay={0.1}> <ScrollReveal delay={0.1}>
@@ -140,6 +141,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
tone="honey" tone="honey"
className="aspect-square w-full" className="aspect-square w-full"
label={product.name} label={product.name}
sizes="(min-width: 768px) 50vw, 100vw"
/> />
</ScrollReveal> </ScrollReveal>
<ScrollReveal delay={0.1}> <ScrollReveal delay={0.1}>
+2
View File
@@ -80,6 +80,7 @@ export async function RegionalSection() {
tone="forest" tone="forest"
className="aspect-[4/5] w-full" className="aspect-[4/5] w-full"
label={t.home.regionalSection.eyebrow} label={t.home.regionalSection.eyebrow}
sizes="(min-width: 768px) 50vw, 100vw"
/> />
</ScrollReveal> </ScrollReveal>
</Container> </Container>
@@ -135,6 +136,7 @@ export async function BusinessTeaser() {
tone="honey" tone="honey"
className="aspect-[4/5] w-full" className="aspect-[4/5] w-full"
label={t.home.businessTeaser.eyebrow} label={t.home.businessTeaser.eyebrow}
sizes="(min-width: 768px) 50vw, 100vw"
/> />
</ScrollReveal> </ScrollReveal>
<ScrollReveal delay={0.1}> <ScrollReveal delay={0.1}>
+1
View File
@@ -198,6 +198,7 @@ export function ProductCard({
? `${product.harvest.year} · ${product.harvest.type}` ? `${product.harvest.year} · ${product.harvest.type}`
: t.common.categories[product.category] : t.common.categories[product.category]
} }
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/> />
{stock === 0 ? ( {stock === 0 ? (
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1"> <span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
+6
View File
@@ -1129,6 +1129,12 @@ export const de: Dictionary = {
removeImageAt: (position) => `Bild ${position} entfernen`, removeImageAt: (position) => `Bild ${position} entfernen`,
uploadError: "Der Upload ist fehlgeschlagen.", uploadError: "Der Upload ist fehlgeschlagen.",
}, },
adminVideoField: {
none: "Kein Video",
uploading: "Wird hochgeladen…",
remove: "Video entfernen",
uploadError: "Der Upload ist fehlgeschlagen.",
},
adminShell: { adminShell: {
navDashboard: "Dashboard", navDashboard: "Dashboard",
navOrders: "Bestellungen", navOrders: "Bestellungen",
+6
View File
@@ -1129,6 +1129,12 @@ export const en: Dictionary = {
removeImageAt: (position) => `Remove image ${position}`, removeImageAt: (position) => `Remove image ${position}`,
uploadError: "The upload failed.", uploadError: "The upload failed.",
}, },
adminVideoField: {
none: "No video",
uploading: "Uploading…",
remove: "Remove video",
uploadError: "The upload failed.",
},
adminShell: { adminShell: {
navDashboard: "Dashboard", navDashboard: "Dashboard",
navOrders: "Orders", navOrders: "Orders",
+8
View File
@@ -830,6 +830,14 @@ export interface Dictionary {
removeImageAt: (position: number) => string; removeImageAt: (position: number) => string;
uploadError: string; uploadError: string;
}; };
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
* admin/inhalte). */
adminVideoField: {
none: string;
uploading: string;
remove: string;
uploadError: string;
};
adminShell: { adminShell: {
navDashboard: string; navDashboard: string;
navOrders: string; navOrders: string;
+7 -1
View File
@@ -10,7 +10,8 @@ export type ContentFieldType =
| "list-titled" | "list-titled"
| "list-numbered" | "list-numbered"
| "richtext-sections" | "richtext-sections"
| "image"; | "image"
| "video";
export interface ContentFieldDef { export interface ContentFieldDef {
key: string; key: string;
@@ -79,6 +80,11 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer // sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
// dessen eigenes Product.images (siehe admin/produkte). // 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.hero.image", group: "home", labelDe: "Hero: Bild", labelEn: "Hero: Image", type: "image", localized: false, getDefault: () => "" },
// Optionales Hintergrundvideo fuer den Hero (MP4/WebM, siehe
// app/api/admin/upload-video/route.ts). Wenn gepflegt, zeigt hero.tsx das
// Video statt des Hero-Bilds/Platzhalters an derselben Stelle — home.hero.image
// bleibt als Fallback/Poster-Bild erhalten, falls das Video wieder entfernt wird.
{ key: "home.hero.video", group: "home", labelDe: "Hero: Hintergrundvideo (MP4/WebM, optional — ersetzt das Hero-Bild)", labelEn: "Hero: Background video (MP4/WebM, optional — replaces the hero image)", type: "video", localized: false, getDefault: () => "" },
{ key: "home.originStory.image", group: "home", labelDe: "Herkunft: Bild", labelEn: "Origin: 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.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: () => "" }, { key: "home.businessTeaser.image", group: "home", labelDe: "Geschäftskunden-Hinweis: Bild", labelEn: "Business teaser: Image", type: "image", localized: false, getDefault: () => "" },
+61
View File
@@ -7,6 +7,8 @@ import {
generateUploadFilename, generateUploadFilename,
isValidUploadPath, isValidUploadPath,
resolveUploadPath, resolveUploadPath,
detectVideoExtension,
isValidVideoUploadPath,
} from "./uploads"; } from "./uploads";
describe("extensionForFormat", () => { describe("extensionForFormat", () => {
@@ -39,6 +41,11 @@ describe("contentTypeForExtension", () => {
expect(contentTypeForExtension("avif")).toBe("image/avif"); expect(contentTypeForExtension("avif")).toBe("image/avif");
}); });
test("returns the correct MIME type for the video extensions", () => {
expect(contentTypeForExtension("mp4")).toBe("video/mp4");
expect(contentTypeForExtension("webm")).toBe("video/webm");
});
test("returns null for an extension outside the fixed allow-list", () => { test("returns null for an extension outside the fixed allow-list", () => {
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird — // Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
// siehe app/uploads/[...path]/route.ts. // siehe app/uploads/[...path]/route.ts.
@@ -48,6 +55,33 @@ describe("contentTypeForExtension", () => {
}); });
}); });
describe("detectVideoExtension", () => {
function bufferWithFtypAt4(): Buffer {
// Reale MP4-Dateien haben eine variable Box-Groesse in den ersten 4
// Bytes, gefolgt von der literalen ASCII-Kennung "ftyp" — die konkreten
// ersten 4 Bytes sind fuer die Erkennung irrelevant.
return Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypisom", "ascii")]);
}
test("recognizes an MP4 file via its ftyp box", () => {
expect(detectVideoExtension(bufferWithFtypAt4())).toBe("mp4");
});
test("recognizes a WebM file via its EBML header", () => {
expect(detectVideoExtension(Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x02]))).toBe("webm");
});
test("rejects a buffer that is neither MP4 nor WebM (e.g. a JPEG or arbitrary bytes)", () => {
expect(detectVideoExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBeNull();
expect(detectVideoExtension(Buffer.from("not a video", "ascii"))).toBeNull();
});
test("rejects a buffer too short to contain either signature", () => {
expect(detectVideoExtension(Buffer.from([0x1a, 0x45]))).toBeNull();
expect(detectVideoExtension(Buffer.alloc(0))).toBeNull();
});
});
describe("generateUploadFilename", () => { describe("generateUploadFilename", () => {
test("produces a 32-char lowercase hex name with the given extension", () => { test("produces a 32-char lowercase hex name with the given extension", () => {
const name = generateUploadFilename("webp"); const name = generateUploadFilename("webp");
@@ -90,6 +124,33 @@ describe("isValidUploadPath", () => {
}); });
}); });
describe("isValidVideoUploadPath", () => {
test("accepts a path in exactly the shape the video upload route produces", () => {
expect(isValidVideoUploadPath(`/uploads/${generateUploadFilename("mp4")}`)).toBe(true);
expect(isValidVideoUploadPath(`/uploads/${generateUploadFilename("webm")}`)).toBe(true);
});
test("rejects an image path (different endpoint's output)", () => {
expect(isValidVideoUploadPath(`/uploads/${generateUploadFilename("png")}`)).toBe(false);
});
test("rejects a path traversal attempt", () => {
expect(isValidVideoUploadPath("/uploads/../../etc/passwd")).toBe(false);
});
test("rejects an absolute external URL (not an upload-endpoint path)", () => {
expect(isValidVideoUploadPath("https://attacker.example/x.mp4")).toBe(false);
});
test("rejects a client-chosen filename instead of the generated random one", () => {
expect(isValidVideoUploadPath("/uploads/my-video.mp4")).toBe(false);
});
test("rejects an empty string", () => {
expect(isValidVideoUploadPath("")).toBe(false);
});
});
describe("resolveUploadPath", () => { describe("resolveUploadPath", () => {
test("resolves a plain filename to a path inside UPLOAD_DIR", () => { test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
const resolved = resolveUploadPath(["abc123.png"]); const resolved = resolveUploadPath(["abc123.png"]);
+48 -3
View File
@@ -32,7 +32,18 @@ export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPL
*/ */
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
/**
* 40 MB: ein kurzes (wenige Sekunden), fuer Web komprimiertes Hero-Video
* passt bequem darunter. Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil
* Video-Dateien inhaerent groesser sind — Videos werden aber (anders als
* Bilder) NICHT neu enkodiert (dafuer gibt es hier keine sharp-Entsprechung
* ohne eine ffmpeg-Abhaengigkeit), die Groessengrenze ist daher die einzige
* Bremse gegen uebermaessig grosse Uploads.
*/
export const MAX_UPLOAD_VIDEO_BYTES = 40 * 1024 * 1024;
export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif"; export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif";
export type AllowedVideoExtension = "mp4" | "webm";
/** /**
* Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes, * Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes,
@@ -52,6 +63,30 @@ export function extensionForFormat(format: string | undefined): AllowedImageExte
return EXTENSION_BY_FORMAT[format] ?? null; return EXTENSION_BY_FORMAT[format] ?? null;
} }
/**
* Erkennt MP4/WebM ausschliesslich anhand der tatsaechlichen Magic-Bytes am
* Dateianfang (analog zu extensionForFormat fuer Bilder via sharp) — nie
* ueber den Client-Dateinamen oder den vom Client gesendeten Content-Type,
* beide sind frei faelschbar. MP4 traegt seine "ftyp"-Box-Kennung ab Byte 4
* (die ersten 4 Bytes sind eine variable Box-Groesse), WebM beginnt mit dem
* fixen EBML-Header 0x1A45DFA3.
*/
export function detectVideoExtension(buffer: Buffer): AllowedVideoExtension | null {
if (buffer.length >= 12 && buffer.toString("ascii", 4, 8) === "ftyp") {
return "mp4";
}
if (
buffer.length >= 4 &&
buffer[0] === 0x1a &&
buffer[1] === 0x45 &&
buffer[2] === 0xdf &&
buffer[3] === 0xa3
) {
return "webm";
}
return null;
}
/** /**
* Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits * Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits
* validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client- * validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client-
@@ -59,15 +94,17 @@ export function extensionForFormat(format: string | undefined): AllowedImageExte
* interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe * interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe
* app/uploads/[...path]/route.ts. * app/uploads/[...path]/route.ts.
*/ */
const CONTENT_TYPE_BY_EXTENSION: Record<AllowedImageExtension, string> = { const CONTENT_TYPE_BY_EXTENSION: Record<AllowedImageExtension | AllowedVideoExtension, string> = {
jpg: "image/jpeg", jpg: "image/jpeg",
png: "image/png", png: "image/png",
webp: "image/webp", webp: "image/webp",
avif: "image/avif", avif: "image/avif",
mp4: "video/mp4",
webm: "video/webm",
}; };
export function contentTypeForExtension(extension: string): string | null { export function contentTypeForExtension(extension: string): string | null {
return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension] ?? null; return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension | AllowedVideoExtension] ?? null;
} }
/** /**
@@ -76,11 +113,12 @@ export function contentTypeForExtension(extension: string): string | null {
* (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen) * (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen)
* sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite. * sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite.
*/ */
export function generateUploadFilename(extension: AllowedImageExtension): string { export function generateUploadFilename(extension: AllowedImageExtension | AllowedVideoExtension): string {
return `${randomBytes(16).toString("hex")}.${extension}`; return `${randomBytes(16).toString("hex")}.${extension}`;
} }
const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/; const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/;
const VIDEO_UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(mp4|webm)$/;
/** /**
* Prueft, ob ein gespeicherter Bildpfad exakt der Form entspricht, die * Prueft, ob ein gespeicherter Bildpfad exakt der Form entspricht, die
@@ -95,6 +133,13 @@ export function isValidUploadPath(value: string): boolean {
return UPLOAD_PATH_PATTERN.test(value); return UPLOAD_PATH_PATTERN.test(value);
} }
/** Wie isValidUploadPath, aber fuer Video-Feldwerte (siehe home.hero.video
* in lib/content-registry.ts) — eigenes Endungs-Set, damit die Bild-
* Validierung oben nicht mit-gelockert wird. */
export function isValidVideoUploadPath(value: string): boolean {
return VIDEO_UPLOAD_PATH_PATTERN.test(value);
}
/** /**
* Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen * Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg- * UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-