perf: hochgeladene Bilder und Videos schnell und vollstaendig ausliefern
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.
This commit is contained in:
1 parent
0920d33721
commit
cd8e307faf
5 files changed
+366
-36
No files matched your search
+1
-1
@@ -19,7 +19,7 @@ metadata:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
# ingress-nginx laesst ohne diese Annotation standardmaessig nur 1 MB pro
|
||||
# Request durch — weit unter dem Bild- (8 MB, lib/uploads.ts
|
||||
# Request durch — weit unter dem Bild- (25 MB, lib/uploads.ts
|
||||
# MAX_UPLOAD_BYTES) und erst recht dem Video-Limit (100 MB, lib/uploads.ts
|
||||
# MAX_UPLOAD_VIDEO_BYTES) der Admin-Uploads. Ohne diese Zeile bricht
|
||||
# nginx jeden Upload jenseits von 1 MB vor der App ab (Verbindungsabbruch
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { contentTypeForExtension, resolveUploadPath } from "@/lib/uploads";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { contentTypeForExtension, parseRangeHeader, resolveUploadPath, uploadETag } from "@/lib/uploads";
|
||||
|
||||
// node:fs braucht die Node-Runtime (nicht Edge).
|
||||
export const runtime = "nodejs";
|
||||
@@ -10,28 +12,60 @@ export const runtime = "nodejs";
|
||||
// unter einem einmal ausgelieferten Pfad aendert sich also nie mehr.
|
||||
const CACHE_CONTROL = "public, max-age=31536000, immutable";
|
||||
|
||||
function notFound(): NextResponse {
|
||||
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos) aus, die unter
|
||||
* UPLOAD_DIR liegen. Next.js' statischer public/-Ordner wird zur Build-Zeit
|
||||
* fest ins Docker-Image gebacken und kann keine zur Laufzeit hinzugefuegten
|
||||
* Dateien ausliefern — dieser dynamische Route-Handler streamt sie
|
||||
* stattdessen direkt von der Platte (in Produktion ein SMB-Mount, siehe
|
||||
* UPLOAD_DIR in lib/uploads.ts).
|
||||
* Header, die jede Antwort dieser Route traegt — auch 304 und 416, damit
|
||||
* Caches und Browser in allen Faellen dieselbe Aussage ueber Cachebarkeit,
|
||||
* Typ und Sniffing-Verbot sehen.
|
||||
*/
|
||||
function baseHeaders(contentType: string, etag: string): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": "inline",
|
||||
// Verhindert, dass der Browser den deklarierten Content-Type anhand
|
||||
// des Inhalts "korrigiert" (MIME-Sniffing) — zusaetzliche Absicherung
|
||||
// neben der festen Content-Type-Zuordnung unten.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": CACHE_CONTROL,
|
||||
ETag: etag,
|
||||
// Sagt dem Browser ueberhaupt erst, dass er Teilbereiche anfordern darf
|
||||
// — ohne diesen Header versucht z.B. ein <video> gar keinen Range-
|
||||
// Request und kann folglich auch nicht springen.
|
||||
"Accept-Ranges": "bytes",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert hochgeladene Bilder und Videos (Inhalte-/Produkt-/Erntenfotos,
|
||||
* Hero-Video) aus, die unter UPLOAD_DIR liegen. Next.js' statischer
|
||||
* public/-Ordner wird zur Build-Zeit fest ins Docker-Image gebacken und kann
|
||||
* keine zur Laufzeit hinzugefuegten Dateien ausliefern — dieser dynamische
|
||||
* Route-Handler streamt sie stattdessen direkt von der Platte (in Produktion
|
||||
* ein gemountetes Volume, siehe UPLOAD_DIR in lib/uploads.ts).
|
||||
*
|
||||
* Oeffentlich erreichbar ohne Admin-Pruefung (die Bilder sind fuer die
|
||||
* Die Datei wird als Stream durchgereicht statt vollstaendig in den Speicher
|
||||
* gelesen: ein Hero-Video darf bis zu MAX_UPLOAD_VIDEO_BYTES gross sein, und
|
||||
* dessen kompletter Inhalt pro gleichzeitigem Zugriff im RAM waere weder
|
||||
* noetig noch tragbar. Unterstuetzt werden ausserdem bedingte Anfragen
|
||||
* (If-None-Match -> 304) und Bereichsanfragen (Range -> 206), letzteres ist
|
||||
* Voraussetzung dafuer, dass ein <video> ueberhaupt abspielbar und
|
||||
* springbar ist (siehe parseRangeHeader in lib/uploads.ts).
|
||||
*
|
||||
* Oeffentlich erreichbar ohne Admin-Pruefung (die Dateien sind fuer die
|
||||
* oeffentlichen Shop-/Homepage-Seiten bestimmt) — geschuetzt wird stattdessen
|
||||
* NUR das Schreiben (app/api/admin/upload/route.ts).
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const { path: segments } = await params;
|
||||
|
||||
const resolved = resolveUploadPath(segments);
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (!resolved) return notFound();
|
||||
|
||||
const extension = resolved.slice(resolved.lastIndexOf(".") + 1).toLowerCase();
|
||||
// Content-Type kommt ausschliesslich aus dieser festen Zuordnung nach
|
||||
@@ -42,26 +76,56 @@ export async function GET(
|
||||
// ausgeliefert; alles andere unter UPLOAD_DIR bleibt ueber diese Route
|
||||
// unerreichbar.
|
||||
const contentType = contentTypeForExtension(extension);
|
||||
if (!contentType) {
|
||||
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
if (!contentType) return notFound();
|
||||
|
||||
let data: Buffer;
|
||||
let size: number;
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
data = await readFile(resolved);
|
||||
const stats = await stat(resolved);
|
||||
// Ein Verzeichnis unter einem erlaubten Namen (theoretisch anlegbar auf
|
||||
// dem gemounteten Volume) darf nicht als Datei ausgeliefert werden.
|
||||
if (!stats.isFile()) return notFound();
|
||||
size = stats.size;
|
||||
mtimeMs = stats.mtimeMs;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(data), {
|
||||
const etag = uploadETag(size, mtimeMs);
|
||||
const headers = baseHeaders(contentType, etag);
|
||||
|
||||
// Bedingte Anfrage: der Browser hat die Datei bereits und fragt nur, ob
|
||||
// sie noch aktuell ist. 304 spart die gesamte Nutzlast.
|
||||
if (request.headers.get("if-none-match") === etag) {
|
||||
return new NextResponse(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
const range = parseRangeHeader(request.headers.get("range"), size);
|
||||
|
||||
if (range === "unsatisfiable") {
|
||||
return new NextResponse(null, {
|
||||
status: 416,
|
||||
headers: { ...headers, "Content-Range": `bytes */${size}` },
|
||||
});
|
||||
}
|
||||
|
||||
if (range) {
|
||||
const length = range.end - range.start + 1;
|
||||
const stream = Readable.toWeb(
|
||||
createReadStream(resolved, { start: range.start, end: range.end })
|
||||
) as ReadableStream<Uint8Array>;
|
||||
return new NextResponse(stream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": "inline",
|
||||
// Verhindert, dass der Browser den deklarierten Content-Type anhand
|
||||
// des Inhalts "korrigiert" (MIME-Sniffing) — zusaetzliche Absicherung
|
||||
// neben der festen Content-Type-Zuordnung oben.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": CACHE_CONTROL,
|
||||
...headers,
|
||||
"Content-Range": `bytes ${range.start}-${range.end}/${size}`,
|
||||
"Content-Length": String(length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const stream = Readable.toWeb(createReadStream(resolved)) as ReadableStream<Uint8Array>;
|
||||
return new NextResponse(stream, {
|
||||
headers: { ...headers, "Content-Length": String(size) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { parseRangeHeader, uploadETag } from "./uploads";
|
||||
|
||||
describe("parseRangeHeader", () => {
|
||||
test("returns null when no Range header was sent", () => {
|
||||
expect(parseRangeHeader(null, 5000)).toBeNull();
|
||||
});
|
||||
|
||||
test("parses a normal closed range", () => {
|
||||
expect(parseRangeHeader("bytes=0-1023", 5000)).toEqual({ start: 0, end: 1023 });
|
||||
});
|
||||
|
||||
test("parses an open-ended range up to the last byte", () => {
|
||||
expect(parseRangeHeader("bytes=1024-", 5000)).toEqual({ start: 1024, end: 4999 });
|
||||
});
|
||||
|
||||
test("parses a suffix range (last N bytes)", () => {
|
||||
expect(parseRangeHeader("bytes=-500", 5000)).toEqual({ start: 4500, end: 4999 });
|
||||
});
|
||||
|
||||
test("clamps a suffix range longer than the file to the whole file", () => {
|
||||
expect(parseRangeHeader("bytes=-99999", 5000)).toEqual({ start: 0, end: 4999 });
|
||||
});
|
||||
|
||||
test("clamps an end beyond the last byte", () => {
|
||||
expect(parseRangeHeader("bytes=0-99999", 5000)).toEqual({ start: 0, end: 4999 });
|
||||
});
|
||||
|
||||
test("accepts the single-byte probe iOS Safari sends before playing a video", () => {
|
||||
expect(parseRangeHeader("bytes=0-1", 5000)).toEqual({ start: 0, end: 1 });
|
||||
});
|
||||
|
||||
test("reports a start beyond the end of the file as unsatisfiable", () => {
|
||||
expect(parseRangeHeader("bytes=5000-", 5000)).toBe("unsatisfiable");
|
||||
});
|
||||
|
||||
test("reports a zero-length suffix range as unsatisfiable", () => {
|
||||
expect(parseRangeHeader("bytes=-0", 5000)).toBe("unsatisfiable");
|
||||
});
|
||||
|
||||
test("reports an inverted range as unsatisfiable", () => {
|
||||
expect(parseRangeHeader("bytes=300-100", 5000)).toBe("unsatisfiable");
|
||||
});
|
||||
|
||||
test("reports any range against an empty file as unsatisfiable", () => {
|
||||
expect(parseRangeHeader("bytes=0-", 0)).toBe("unsatisfiable");
|
||||
});
|
||||
|
||||
test("ignores a non-bytes unit and serves the whole file", () => {
|
||||
expect(parseRangeHeader("items=0-100", 5000)).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores a multi-range request and serves the whole file", () => {
|
||||
// Mehrteilige Bereiche waeren eine multipart/byteranges-Antwort — fuer
|
||||
// Bilder/Videos praktisch nie angefragt. Die ganze Datei auszuliefern
|
||||
// ist laut RFC 9110 eine zulaessige Antwort auf jeden Range-Request.
|
||||
expect(parseRangeHeader("bytes=0-10, 20-30", 5000)).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores a syntactically broken range instead of failing the request", () => {
|
||||
expect(parseRangeHeader("bytes=abc-def", 5000)).toBeNull();
|
||||
expect(parseRangeHeader("bytes=-", 5000)).toBeNull();
|
||||
expect(parseRangeHeader("bytes=", 5000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadETag", () => {
|
||||
test("is a quoted, stable token for the same size and mtime", () => {
|
||||
const tag = uploadETag(1234, 1700000000000);
|
||||
expect(tag).toMatch(/^"[^"]+"$/);
|
||||
expect(uploadETag(1234, 1700000000000)).toBe(tag);
|
||||
});
|
||||
|
||||
test("changes when the file size changes", () => {
|
||||
expect(uploadETag(1234, 1700000000000)).not.toBe(uploadETag(1235, 1700000000000));
|
||||
});
|
||||
|
||||
test("changes when the modification time changes", () => {
|
||||
expect(uploadETag(1234, 1700000000000)).not.toBe(uploadETag(1234, 1700000000001));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
+125
-8
@@ -24,13 +24,41 @@ import sharp from "sharp";
|
||||
export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
|
||||
|
||||
/**
|
||||
* 8 MB: grosszuegig genug fuer ein einzelnes unkomprimiertes Foto aus einer
|
||||
* aktuellen Smartphone-Kamera nach Export, aber klein genug, um den Server
|
||||
* nicht mit riesigen Uploads zu belasten — die Datei wird vollstaendig in
|
||||
* den Speicher gelesen (kein Streaming), um sie mit sharp zu validieren und
|
||||
* neu zu enkodieren.
|
||||
* 25 MB fuer die HOCHGELADENE Rohdatei. Die vorherigen 8 MB lagen unter der
|
||||
* Groesse, die aktuelle Handykameras (48 MP, HEIC-Export nach JPEG)
|
||||
* routinemaessig erzeugen — ein voellig normales Foto brach damit mit "Die
|
||||
* Datei ist zu gross" ab. Was tatsaechlich gespeichert wird, ist ohnehin
|
||||
* nicht diese Rohdatei, sondern das von reencodeImage() auf
|
||||
* MAX_IMAGE_DIMENSION verkleinerte und neu enkodierte Ergebnis (in aller
|
||||
* Regel ein Bruchteil davon).
|
||||
*
|
||||
* Die Datei wird zur Validierung vollstaendig in den Speicher gelesen (kein
|
||||
* Streaming); gegen ein kleines File, das sich beim Dekodieren zu absurd
|
||||
* vielen Pixeln aufblaeht, schuetzt zusaetzlich MAX_INPUT_PIXELS.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Laengste Kante, mit der ein Bild gespeichert wird. 2560px deckt jede
|
||||
* Darstellung dieser Seite mit Reserve ab — das groesste Layout ist ein
|
||||
* vollbreiter Hero, und selbst ein Display mit doppelter Pixeldichte
|
||||
* verlangt davon keine hoehere Aufloesung. Alles darueber kostete nur Platz
|
||||
* auf dem Upload-Volume und zwang next/image, fuer JEDE ausgelieferte
|
||||
* Breite das komplette Originalbild neu zu dekodieren (ein 6000x4000-Foto
|
||||
* sind 24 Megapixel pro Variante).
|
||||
*/
|
||||
export const MAX_IMAGE_DIMENSION = 2560;
|
||||
|
||||
/**
|
||||
* Obergrenze fuer die Pixelzahl, die sharp aus einer hochgeladenen Datei
|
||||
* ueberhaupt dekodieren darf (100 MP — mehr als jede Kamera liefert, die
|
||||
* hier realistisch zum Einsatz kommt). Schuetzt vor einer "Dekompressions-
|
||||
* bombe": einer wenige Kilobyte grossen Datei, die sich zu Milliarden
|
||||
* Pixeln entpackt und damit den Arbeitsspeicher des Servers fuellt — die
|
||||
* Byte-Grenze MAX_UPLOAD_BYTES allein faengt das nicht ab, weil sie nur die
|
||||
* komprimierte Groesse kennt.
|
||||
*/
|
||||
export const MAX_INPUT_PIXELS = 100_000_000;
|
||||
|
||||
/**
|
||||
* 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video
|
||||
@@ -176,10 +204,24 @@ export function resolveUploadPath(segments: string[]): string | null {
|
||||
* Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus,
|
||||
* BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst
|
||||
* erschiene ein am Handy "hochkant" aufgenommenes Foto nach dem Entfernen
|
||||
* der Metadaten seitlich verdreht.
|
||||
* der Metadaten seitlich verdreht. Zusaetzlich (3) wird das Bild auf
|
||||
* MAX_IMAGE_DIMENSION begrenzt, damit nie mehr Aufloesung gespeichert wird,
|
||||
* als die Seite je ausliefert.
|
||||
*/
|
||||
export async function reencodeImage(buffer: Buffer, extension: AllowedImageExtension): Promise<Buffer> {
|
||||
const image = sharp(buffer).rotate();
|
||||
const image = sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS })
|
||||
.rotate()
|
||||
// "inside" skaliert so, dass BEIDE Kanten in das Quadrat passen — bei
|
||||
// einem Querformat greift damit die Breite, bei einem Hochformat die
|
||||
// Hoehe, das Seitenverhaeltnis bleibt in beiden Faellen erhalten.
|
||||
// "withoutEnlargement" stellt sicher, dass ein ohnehin kleineres Bild
|
||||
// nie hochskaliert (und damit unscharf) wird.
|
||||
.resize({
|
||||
width: MAX_IMAGE_DIMENSION,
|
||||
height: MAX_IMAGE_DIMENSION,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
switch (extension) {
|
||||
case "jpg":
|
||||
return image.jpeg({ quality: 85 }).toBuffer();
|
||||
@@ -191,3 +233,78 @@ export async function reencodeImage(buffer: Buffer, extension: AllowedImageExten
|
||||
return image.avif({ quality: 60 }).toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein halboffenes Byte-Intervall, beide Grenzen inklusive (wie im HTTP-Header). */
|
||||
export interface ByteRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertet einen HTTP-Range-Header gegen die bekannte Dateigroesse aus.
|
||||
*
|
||||
* Warum das gebraucht wird: ein <video src="/uploads/..."> (siehe
|
||||
* components/hero.tsx) laedt sein Material grundsaetzlich per Range-Request.
|
||||
* iOS-Safari schickt dafuer zuerst eine Ein-Byte-Probe ("bytes=0-1") und
|
||||
* spielt das Video ueberhaupt nicht ab, wenn der Server darauf mit einer
|
||||
* vollstaendigen 200-Antwort statt mit "206 Partial Content" antwortet.
|
||||
* Ohne Range-Unterstuetzung laesst sich ausserdem nirgends im Video
|
||||
* springen, und jeder Abspielversuch laedt die komplette Datei.
|
||||
*
|
||||
* Rueckgabe:
|
||||
* - `null` -> kein (oder kein auswertbarer) Range-Wunsch, ganze Datei
|
||||
* ausliefern. Laut RFC 9110 ist das auf JEDEN Range-Request
|
||||
* eine zulaessige Antwort, deshalb werden auch mehrteilige
|
||||
* und kaputte Bereiche hierher abgebildet statt als Fehler.
|
||||
* - `"unsatisfiable"` -> syntaktisch gueltig, aber ausserhalb der Datei:
|
||||
* muss mit 416 beantwortet werden.
|
||||
* - `ByteRange` -> der auf die Dateigroesse zugeschnittene Bereich.
|
||||
*/
|
||||
export function parseRangeHeader(header: string | null, size: number): ByteRange | "unsatisfiable" | null {
|
||||
if (!header) return null;
|
||||
|
||||
const match = /^bytes=(.+)$/i.exec(header.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const spec = match[1].trim();
|
||||
// Mehrteilige Bereiche braeuchten eine multipart/byteranges-Antwort — fuer
|
||||
// Bilder/Videos praktisch nie angefragt, deshalb bewusst nicht unterstuetzt.
|
||||
if (spec.includes(",")) return null;
|
||||
|
||||
const parts = /^(\d*)-(\d*)$/.exec(spec);
|
||||
if (!parts) return null;
|
||||
const [, rawStart, rawEnd] = parts;
|
||||
if (rawStart === "" && rawEnd === "") return null;
|
||||
|
||||
// Eine leere Datei kann keinen einzigen Bereich erfuellen.
|
||||
if (size <= 0) return "unsatisfiable";
|
||||
const lastByte = size - 1;
|
||||
|
||||
// Suffix-Form "bytes=-N": die LETZTEN N Bytes, nicht "ab Byte N".
|
||||
if (rawStart === "") {
|
||||
const suffixLength = Number(rawEnd);
|
||||
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return "unsatisfiable";
|
||||
return { start: Math.max(0, size - suffixLength), end: lastByte };
|
||||
}
|
||||
|
||||
const start = Number(rawStart);
|
||||
if (!Number.isSafeInteger(start) || start > lastByte) return "unsatisfiable";
|
||||
|
||||
if (rawEnd === "") return { start, end: lastByte };
|
||||
|
||||
const requestedEnd = Number(rawEnd);
|
||||
if (!Number.isSafeInteger(requestedEnd) || requestedEnd < start) return "unsatisfiable";
|
||||
return { start, end: Math.min(requestedEnd, lastByte) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator fuer bedingte Anfragen (If-None-Match). Dateigroesse und
|
||||
* Aenderungszeit genuegen als Kennung, weil eine hochgeladene Datei unter
|
||||
* ihrem zufaelligen Namen nie ueberschrieben wird (siehe
|
||||
* generateUploadFilename) — ein Wechsel beider Werte ist daher ohnehin nur
|
||||
* bei einer echten Neuanlage moeglich. Erspart dem Browser bei jedem Reload
|
||||
* das erneute Herunterladen des Bildinhalts (304 statt 200).
|
||||
*/
|
||||
export function uploadETag(size: number, mtimeMs: number): string {
|
||||
return `"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`;
|
||||
}
|
||||
Reference in new issue
Block a user