feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
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:
1 parent
398db5c5ec
commit
6814e9e2c8
25 files changed
+524
-119
No files matched your search
@@ -5,7 +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 { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -43,6 +43,11 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminImageField } from "@/components/admin-image-field";
|
||||
import { AdminVideoField } from "@/components/admin-video-field";
|
||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||
import { updateSiteContentAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -13,10 +14,11 @@ 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.
|
||||
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
|
||||
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
|
||||
// weiter vollstaendig ist.
|
||||
image: 0,
|
||||
video: 0,
|
||||
};
|
||||
|
||||
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 (
|
||||
<label key={def.key} className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
import { ProductTeaserLink } from "@/components/product-card";
|
||||
@@ -42,33 +43,41 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<div className="flex items-center gap-2 text-honey-dark">
|
||||
<ShieldIcon />
|
||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||
<ScrollReveal>
|
||||
<div className="flex items-center gap-2 text-honey-dark">
|
||||
<ShieldIcon />
|
||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</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">
|
||||
<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}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<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}`}
|
||||
sizes="(min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<ContentImage
|
||||
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 className="mt-14">
|
||||
<ScrollReveal className="mt-14">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="relative">
|
||||
@@ -83,19 +92,21 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
</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-ink/80">{batch.harvest.location}</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" />
|
||||
|
||||
{batch.products.length > 0 ? (
|
||||
<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">
|
||||
{batch.products.map((product) => (
|
||||
<ProductTeaserLink
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ProductTeaserLink } from "@/components/product-card";
|
||||
import { getHarvestByYearType } from "@/lib/data";
|
||||
@@ -45,14 +46,17 @@ export default async function HarvestDetailPage(
|
||||
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||
</Container>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={harvest.location}
|
||||
/>
|
||||
<div>
|
||||
<ScrollReveal>
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={harvest.location}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
{harvest.year} · {formatDate(harvest.harvestDate, locale)}
|
||||
</p>
|
||||
@@ -86,7 +90,7 @@ export default async function HarvestDetailPage(
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
+25
-18
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getHarvests } from "@/lib/data";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -29,29 +30,35 @@ export default async function ErntenPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<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">
|
||||
{years.map((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">
|
||||
{byYear[year].map((harvest) => (
|
||||
<Link
|
||||
key={harvest.id}
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group"
|
||||
>
|
||||
<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, locale)}
|
||||
/>
|
||||
<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>
|
||||
</Link>
|
||||
{byYear[year].map((harvest, i) => (
|
||||
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
||||
<Link
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group block"
|
||||
>
|
||||
<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, 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>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
@@ -26,20 +27,23 @@ export default async function HerkunftPage() {
|
||||
tone="forest"
|
||||
className="h-[50vh] min-h-[360px] w-full mb-20"
|
||||
label={t.herkunftPage.eyebrow}
|
||||
priority
|
||||
/>
|
||||
<Container>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
||||
{t.herkunftPage.aspects.map((a) => (
|
||||
<div key={a.title}>
|
||||
{t.herkunftPage.aspects.map((a, i) => (
|
||||
<ScrollReveal key={a.title} delay={i * 0.08}>
|
||||
<h3 className="text-lg text-forest">{a.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-16 text-xs text-ink/65 max-w-md">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Fragment } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container, Button } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ProductGallery } from "@/components/product-gallery";
|
||||
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
|
||||
import { RecordProductView } from "@/components/record-product-view";
|
||||
@@ -324,7 +325,9 @@ export default async function ProductDetailPage(
|
||||
</Container>
|
||||
|
||||
<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 ? (
|
||||
<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 ? (
|
||||
<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">
|
||||
{relatedProducts.map((related) => (
|
||||
<ProductCard key={related.id} product={related} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
@@ -21,7 +22,7 @@ export default async function ImkereiPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
<div>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.imkereiPage.eyebrow}
|
||||
@@ -32,14 +33,17 @@ export default async function ImkereiPage() {
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.imkereiPage.imageLabel}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.imkereiPage.imageLabel}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -19,9 +20,11 @@ export default async function KontaktPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<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.address.street}</p>
|
||||
<p>
|
||||
@@ -33,14 +36,14 @@ export default async function KontaktPage() {
|
||||
</a>
|
||||
</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>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{t.kontaktPage.pickupText}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-1
@@ -29,7 +29,10 @@ 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", "home.hero.image"], locale),
|
||||
getContentValues(
|
||||
["home.hero.intro", "home.hero.footnote", "home.hero.image", "home.hero.video"],
|
||||
locale
|
||||
),
|
||||
getCompanyInfo(),
|
||||
]);
|
||||
const products = await getActiveProducts();
|
||||
@@ -49,6 +52,7 @@ export default async function HomePage() {
|
||||
footnote={heroContent["home.hero.footnote"]}
|
||||
location={company.location}
|
||||
image={heroContent["home.hero.image"] || null}
|
||||
video={heroContent["home.hero.video"] || null}
|
||||
/>
|
||||
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
||||
<OriginStory />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||
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") }}
|
||||
/>
|
||||
<Container>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.shop.eyebrow}
|
||||
title={t.shop.title}
|
||||
subtitle={t.shop.subtitle}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.shop.eyebrow}
|
||||
title={t.shop.title}
|
||||
subtitle={t.shop.subtitle}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{categoryFilters.map((filter) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { BusinessInquiryForm } from "@/components/business-inquiry-form";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
@@ -27,28 +28,31 @@ export default async function UnternehmenPage() {
|
||||
tone="honey"
|
||||
className="h-[45vh] min-h-[320px] w-full"
|
||||
label={t.unternehmenPage.eyebrow}
|
||||
priority
|
||||
/>
|
||||
<Container className="py-20">
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.unternehmenPage.eyebrow}
|
||||
title={t.unternehmenPage.title}
|
||||
subtitle={t.unternehmenPage.subtitle}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.unternehmenPage.eyebrow}
|
||||
title={t.unternehmenPage.title}
|
||||
subtitle={t.unternehmenPage.subtitle}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid sm:grid-cols-3 gap-8">
|
||||
{t.unternehmenPage.packages.map((pkg) => (
|
||||
<div key={pkg.key} className="border border-ink/10 p-8">
|
||||
{t.unternehmenPage.packages.map((pkg, i) => (
|
||||
<ScrollReveal key={pkg.key} delay={i * 0.08} className="border border-ink/10 p-8">
|
||||
<h3 className="text-lg text-forest">{pkg.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</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>
|
||||
<BusinessInquiryForm />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
+11
-6
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { wissenSlugs } from "@/lib/site-config";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -19,15 +20,19 @@ export default async function WissenPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<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">
|
||||
{wissenSlugs.map((slug) => {
|
||||
{wissenSlugs.map((slug, i) => {
|
||||
const article = t.wissenArticles[slug];
|
||||
return (
|
||||
<Link key={slug} href={`/wissen/${slug}`} className="block group">
|
||||
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
||||
</Link>
|
||||
<ScrollReveal key={slug} delay={(i % 5) * 0.06}>
|
||||
<Link href={`/wissen/${slug}`} className="block group">
|
||||
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -29,11 +29,13 @@ export function Hero({
|
||||
footnote,
|
||||
location,
|
||||
image,
|
||||
video,
|
||||
}: {
|
||||
intro: string;
|
||||
footnote: string;
|
||||
location: string;
|
||||
image: string | null;
|
||||
video: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
@@ -45,14 +47,31 @@ export function Hero({
|
||||
animate="visible"
|
||||
variants={sectionVariants}
|
||||
>
|
||||
<ContentImage
|
||||
src={image}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="h-[78vh] min-h-[520px] w-full"
|
||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||
priority
|
||||
/>
|
||||
{video ? (
|
||||
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
||||
<video
|
||||
src={video}
|
||||
poster={image ?? undefined}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
// 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">
|
||||
<Container>
|
||||
<motion.div
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function OriginStory() {
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
@@ -140,6 +141,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
tone="honey"
|
||||
className="aspect-square w-full"
|
||||
label={product.name}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function RegionalSection() {
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
@@ -135,6 +136,7 @@ export async function BusinessTeaser() {
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
|
||||
@@ -198,6 +198,7 @@ export function ProductCard({
|
||||
? `${product.harvest.year} · ${product.harvest.type}`
|
||||
: t.common.categories[product.category]
|
||||
}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
{stock === 0 ? (
|
||||
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
|
||||
@@ -1129,6 +1129,12 @@ export const de: Dictionary = {
|
||||
removeImageAt: (position) => `Bild ${position} entfernen`,
|
||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||
},
|
||||
adminVideoField: {
|
||||
none: "Kein Video",
|
||||
uploading: "Wird hochgeladen…",
|
||||
remove: "Video entfernen",
|
||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||
},
|
||||
adminShell: {
|
||||
navDashboard: "Dashboard",
|
||||
navOrders: "Bestellungen",
|
||||
|
||||
@@ -1129,6 +1129,12 @@ export const en: Dictionary = {
|
||||
removeImageAt: (position) => `Remove image ${position}`,
|
||||
uploadError: "The upload failed.",
|
||||
},
|
||||
adminVideoField: {
|
||||
none: "No video",
|
||||
uploading: "Uploading…",
|
||||
remove: "Remove video",
|
||||
uploadError: "The upload failed.",
|
||||
},
|
||||
adminShell: {
|
||||
navDashboard: "Dashboard",
|
||||
navOrders: "Orders",
|
||||
|
||||
@@ -830,6 +830,14 @@ export interface Dictionary {
|
||||
removeImageAt: (position: number) => string;
|
||||
uploadError: string;
|
||||
};
|
||||
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
|
||||
* admin/inhalte). */
|
||||
adminVideoField: {
|
||||
none: string;
|
||||
uploading: string;
|
||||
remove: string;
|
||||
uploadError: string;
|
||||
};
|
||||
adminShell: {
|
||||
navDashboard: string;
|
||||
navOrders: string;
|
||||
|
||||
@@ -10,7 +10,8 @@ export type ContentFieldType =
|
||||
| "list-titled"
|
||||
| "list-numbered"
|
||||
| "richtext-sections"
|
||||
| "image";
|
||||
| "image"
|
||||
| "video";
|
||||
|
||||
export interface ContentFieldDef {
|
||||
key: string;
|
||||
@@ -79,6 +80,11 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||
// 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: () => "" },
|
||||
// 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.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: () => "" },
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
generateUploadFilename,
|
||||
isValidUploadPath,
|
||||
resolveUploadPath,
|
||||
detectVideoExtension,
|
||||
isValidVideoUploadPath,
|
||||
} from "./uploads";
|
||||
|
||||
describe("extensionForFormat", () => {
|
||||
@@ -39,6 +41,11 @@ describe("contentTypeForExtension", () => {
|
||||
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", () => {
|
||||
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
|
||||
// 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", () => {
|
||||
test("produces a 32-char lowercase hex name with the given extension", () => {
|
||||
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", () => {
|
||||
test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
|
||||
const resolved = resolveUploadPath(["abc123.png"]);
|
||||
|
||||
+48
-3
@@ -32,7 +32,18 @@ export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPL
|
||||
*/
|
||||
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 AllowedVideoExtension = "mp4" | "webm";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
};
|
||||
|
||||
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)
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -95,6 +133,13 @@ export function isValidUploadPath(value: string): boolean {
|
||||
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
|
||||
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-
|
||||
|
||||
Reference in new issue
Block a user