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