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]>
184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
import { describe, test, expect } from "vitest";
|
|
import path from "node:path";
|
|
import {
|
|
UPLOAD_DIR,
|
|
extensionForFormat,
|
|
contentTypeForExtension,
|
|
generateUploadFilename,
|
|
isValidUploadPath,
|
|
resolveUploadPath,
|
|
detectVideoExtension,
|
|
isValidVideoUploadPath,
|
|
} from "./uploads";
|
|
|
|
describe("extensionForFormat", () => {
|
|
test("maps sharp's jpeg format to the jpg extension", () => {
|
|
expect(extensionForFormat("jpeg")).toBe("jpg");
|
|
});
|
|
|
|
test("passes png/webp/avif through unchanged", () => {
|
|
expect(extensionForFormat("png")).toBe("png");
|
|
expect(extensionForFormat("webp")).toBe("webp");
|
|
expect(extensionForFormat("avif")).toBe("avif");
|
|
});
|
|
|
|
test("rejects an unsupported format (e.g. gif/svg/tiff)", () => {
|
|
expect(extensionForFormat("gif")).toBeNull();
|
|
expect(extensionForFormat("svg")).toBeNull();
|
|
expect(extensionForFormat("tiff")).toBeNull();
|
|
});
|
|
|
|
test("rejects an undefined format", () => {
|
|
expect(extensionForFormat(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("contentTypeForExtension", () => {
|
|
test("returns the correct MIME type for each allowed extension", () => {
|
|
expect(contentTypeForExtension("jpg")).toBe("image/jpeg");
|
|
expect(contentTypeForExtension("png")).toBe("image/png");
|
|
expect(contentTypeForExtension("webp")).toBe("image/webp");
|
|
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.
|
|
expect(contentTypeForExtension("html")).toBeNull();
|
|
expect(contentTypeForExtension("svg")).toBeNull();
|
|
expect(contentTypeForExtension("")).toBeNull();
|
|
});
|
|
});
|
|
|
|
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");
|
|
expect(name).toMatch(/^[a-f0-9]{32}\.webp$/);
|
|
});
|
|
|
|
test("never reuses the same name across calls (collision-resistant)", () => {
|
|
const names = new Set(Array.from({ length: 50 }, () => generateUploadFilename("jpg")));
|
|
expect(names.size).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe("isValidUploadPath", () => {
|
|
test("accepts a path in exactly the shape the upload route produces", () => {
|
|
expect(isValidUploadPath(`/uploads/${generateUploadFilename("png")}`)).toBe(true);
|
|
});
|
|
|
|
test("rejects a path traversal attempt", () => {
|
|
expect(isValidUploadPath("/uploads/../../etc/passwd")).toBe(false);
|
|
});
|
|
|
|
test("rejects an absolute external URL (not an upload-endpoint path)", () => {
|
|
expect(isValidUploadPath("https://attacker.example/x.png")).toBe(false);
|
|
});
|
|
|
|
test("rejects a javascript: pseudo-protocol value", () => {
|
|
expect(isValidUploadPath("javascript:alert(1)")).toBe(false);
|
|
});
|
|
|
|
test("rejects a client-chosen filename instead of the generated random one", () => {
|
|
expect(isValidUploadPath("/uploads/my-photo.png")).toBe(false);
|
|
});
|
|
|
|
test("rejects an extension outside the allow-list", () => {
|
|
expect(isValidUploadPath("/uploads/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.svg")).toBe(false);
|
|
});
|
|
|
|
test("rejects an empty string", () => {
|
|
expect(isValidUploadPath("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
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"]);
|
|
expect(resolved).toBe(path.join(UPLOAD_DIR, "abc123.png"));
|
|
});
|
|
|
|
test("resolves nested-looking but safe segments inside UPLOAD_DIR", () => {
|
|
const resolved = resolveUploadPath(["sub", "abc123.png"]);
|
|
expect(resolved).toBe(path.join(UPLOAD_DIR, "sub", "abc123.png"));
|
|
});
|
|
|
|
test("rejects a '..' segment (path traversal attempt)", () => {
|
|
expect(resolveUploadPath(["..", "..", "etc", "passwd"])).toBeNull();
|
|
});
|
|
|
|
test("rejects a segment that is a single '.'", () => {
|
|
expect(resolveUploadPath([".", "abc123.png"])).toBeNull();
|
|
});
|
|
|
|
test("rejects an empty segment", () => {
|
|
expect(resolveUploadPath([""])).toBeNull();
|
|
});
|
|
|
|
test("rejects an encoded traversal baked into one segment", () => {
|
|
// Next.js liefert Catch-all-Segmente bereits prozent-dekodiert an — ein
|
|
// Segment kann trotzdem woertlich ".." enthalten, wenn jemand die Route
|
|
// direkt (nicht ueber generierte Links) aufruft.
|
|
expect(resolveUploadPath(["a", "..", "..", "b"])).toBeNull();
|
|
});
|
|
});
|