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

+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}` });
}