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
@@ -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