Vier Befunde aus der Pruefung der Upload-Kette, alle am gemeinsamen Datenpfad lib/uploads.ts und app/uploads/[...path]/route.ts: 1. Kein Range-Support. Die Ausliefer-Route beantwortete auch einen Range-Request mit einer vollstaendigen 200-Antwort. Ein <video> laedt sein Material grundsaetzlich per Range; iOS-Safari schickt zuerst die Probe "bytes=0-1" und spielt bei einer 200-Antwort gar nicht ab. Springen im Hero-Video war ebenfalls unmoeglich. Die Route beantwortet Bereichsanfragen jetzt mit 206 samt Content-Range, meldet Accept-Ranges und antwortet auf einen unerfuellbaren Bereich mit 416. 2. Ganze Datei im Speicher. readFile() las jede Datei vollstaendig in den RAM — bei einem Hero-Video bis MAX_UPLOAD_VIDEO_BYTES pro gleichzeitigem Zugriff. Ersetzt durch einen Stream von der Platte, inklusive Content-Length (vorher chunked, ohne Groessenangabe). 3. Keine bedingten Anfragen. Ohne ETag lud der Browser bei jedem Reload den vollen Bildinhalt erneut. ETag aus Groesse + Aenderungszeit, 304 auf If-None-Match. 4. Bilder wurden in Originalaufloesung gespeichert. Ein 24-MP-Foto lag mit 6000x4000 und 9 MB auf dem Volume, und next/image musste dieses Bild fuer JEDE ausgelieferte Breite komplett neu dekodieren. reencodeImage() begrenzt jetzt auf MAX_IMAGE_DIMENSION (2560px lange Kante, Seitenverhaeltnis erhalten, kleinere Bilder werden nie hochskaliert). Zusaetzlich MAX_UPLOAD_BYTES von 8 auf 25 MB angehoben: 8 MB lagen unter dem, was aktuelle Handykameras routinemaessig erzeugen — ein normales Foto brach mit "Die Datei ist zu gross" ab, obwohl es serverseitig ohnehin verkleinert wird. Gegen eine Dekompressionsbombe (kleine Datei, die sich zu Milliarden Pixeln entpackt) bekommt sharp mit MAX_INPUT_PIXELS eine ausdrueckliche Obergrenze von 100 MP, die die Byte-Grenze allein nicht abdeckt. Gemessen an einem 10,17-MB-Foto mit 6000x4000: vorher mit 413 abgelehnt, jetzt als 2560x1707 / 1,88 MB gespeichert; next/image kalt fuer 640px von 180 ms auf 65 ms. Der Kommentar zur proxy-body-size in k8s/06-ingress.yaml nennt jetzt das neue Bild-Limit; die Annotation selbst (110m) deckt es unveraendert ab.
69 lines
3.1 KiB
TypeScript
69 lines
3.1 KiB
TypeScript
import { describe, test, expect } from "vitest";
|
|
import sharp from "sharp";
|
|
import { MAX_IMAGE_DIMENSION, MAX_UPLOAD_BYTES, reencodeImage } from "./uploads";
|
|
|
|
async function makeImage(width: number, height: number): Promise<Buffer> {
|
|
return sharp({ create: { width, height, channels: 3, background: { r: 210, g: 160, b: 40 } } })
|
|
.jpeg({ quality: 90 })
|
|
.toBuffer();
|
|
}
|
|
|
|
describe("reencodeImage", () => {
|
|
test("scales an oversized landscape photo down to the long-edge limit and keeps its aspect ratio", async () => {
|
|
// Ein 24-MP-Handyfoto wurde bisher unveraendert gross gespeichert: das
|
|
// kostete Platz auf dem Upload-Volume UND zwang next/image, fuer jede
|
|
// ausgelieferte Groesse das komplette Bild neu zu dekodieren.
|
|
const source = await makeImage(6000, 4000);
|
|
const meta = await sharp(await reencodeImage(source, "jpg")).metadata();
|
|
expect(meta.width).toBe(MAX_IMAGE_DIMENSION);
|
|
expect(meta.height).toBe(Math.round((MAX_IMAGE_DIMENSION * 4000) / 6000));
|
|
});
|
|
|
|
test("scales an oversized portrait photo by its height instead", async () => {
|
|
const source = await makeImage(3000, 4500);
|
|
const meta = await sharp(await reencodeImage(source, "webp")).metadata();
|
|
expect(meta.height).toBe(MAX_IMAGE_DIMENSION);
|
|
expect(meta.width).toBe(Math.round((MAX_IMAGE_DIMENSION * 3000) / 4500));
|
|
});
|
|
|
|
test("leaves an image that is already small enough at its original size", async () => {
|
|
const source = await makeImage(1200, 800);
|
|
const meta = await sharp(await reencodeImage(source, "jpg")).metadata();
|
|
expect(meta.width).toBe(1200);
|
|
expect(meta.height).toBe(800);
|
|
});
|
|
|
|
test("never enlarges a small image", async () => {
|
|
const source = await makeImage(320, 240);
|
|
const meta = await sharp(await reencodeImage(source, "png")).metadata();
|
|
expect(meta.width).toBe(320);
|
|
expect(meta.height).toBe(240);
|
|
});
|
|
|
|
test("produces a file that is smaller than the oversized original", async () => {
|
|
const source = await makeImage(6000, 4000);
|
|
const output = await reencodeImage(source, "jpg");
|
|
expect(output.length).toBeLessThan(source.length);
|
|
});
|
|
|
|
test("rejects a decompression bomb instead of decoding it", async () => {
|
|
// Ein kleines File, das sich zu absurd vielen Pixeln entpackt, wuerde
|
|
// beim Dekodieren den Arbeitsspeicher fuellen. sharp bekommt deshalb
|
|
// eine ausdrueckliche Pixel-Obergrenze.
|
|
const bomb = await sharp({ create: { width: 11000, height: 11000, channels: 3, background: { r: 0, g: 0, b: 0 } } })
|
|
.png({ compressionLevel: 9 })
|
|
.toBuffer();
|
|
await expect(reencodeImage(bomb, "jpg")).rejects.toThrow();
|
|
}, 120_000);
|
|
});
|
|
|
|
describe("MAX_UPLOAD_BYTES", () => {
|
|
test("accommodates a photo straight out of a modern phone camera", () => {
|
|
// 8 MB lagen unter der Groesse, die aktuelle Handykameras (48 MP,
|
|
// HEIC-Export nach JPEG) routinemaessig erzeugen — der Upload brach mit
|
|
// "Die Datei ist zu gross" ab, obwohl das Bild serverseitig ohnehin
|
|
// verkleinert und neu enkodiert wird.
|
|
expect(MAX_UPLOAD_BYTES).toBeGreaterThanOrEqual(20 * 1024 * 1024);
|
|
});
|
|
});
|