fix: Video-Upload haelt das Video nicht mehr mehrfach im Arbeitsspeicher

Die Route las ein bis zu 100 MB grosses Video gleich mehrfach vollstaendig
in den Node-Heap: einmal aus file.arrayBuffer(), einmal als Buffer daraus,
und das Transcoding-Ergebnis noch einmal aus readFile(). Zusammen mit dem
Grundverbrauch des Next.js-Servers sprengt das ein Speicherlimit von
512Mi, wie es k8s/05-app-deployment.yaml bisher setzte — der Kernel
beendet den Prozess dann mitten im Upload (OOMKilled). Im Browser kommt
davon nur die HTML-Fehlerseite des Reverse-Proxy an, die vorher als
"Unexpected token '<', "<!DOCTYPE "... is not valid JSON" erschien.

Die Datei wandert jetzt streamend auf die Platte (file.stream() ->
createWriteStream), ffmpeg liest und schreibt ohnehin dort, und das
Ergebnis wird per rename() an seinen Platz verschoben — mit copyFile()
als Rueckfallebene, weil rename() ueber Dateisystemgrenzen hinweg mit
EXDEV scheitert und UPLOAD_DIR in Produktion auf einem eigenen Volume
liegt. Die Formaterkennung liest nur noch die ersten 12 Bytes statt der
ganzen Datei. transcodeVideoForWeb() arbeitet entsprechend auf
Dateipfaden statt auf Buffern.

Ausserdem faengt die Route jetzt jeden verbleibenden unerwarteten Fehler
ab und antwortet auch dann mit JSON: eine durchschlagende Exception
liesse Next.js eine HTML-Fehlerseite ausliefern, aus der der Client keine
Meldung gewinnen kann.

Verifiziert mit einem 83,8-MB-Video: 200 mit JSON-Body, Ergebnis 10,3 MB,
Auslieferung inkl. Range-Anfrage (206) korrekt.
This commit is contained in:
Nick Szaunig committed 2026-09-14 16:15:17 +02:00
1 parent b11edd7751
commit 713bf48157
2 files changed
+166 -90

No files matched your search

+134 -50
View File
@@ -1,6 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { mkdir, writeFile } from "node:fs/promises"; import { createWriteStream } from "node:fs";
import { copyFile, mkdir, mkdtemp, open, rename, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { isTrustedOrigin } from "@/lib/csrf"; import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
@@ -11,25 +16,63 @@ import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge). // node:fs braucht die Node-Runtime (nicht Edge).
export const runtime = "nodejs"; export const runtime = "nodejs";
/**
* Anzahl Bytes vom Dateianfang, die fuer die Formaterkennung genuegen:
* detectVideoExtension() braucht hoechstens die ersten 12 (MP4 traegt seine
* "ftyp"-Kennung ab Byte 4, WebM den EBML-Header ab Byte 0).
*/
const MAGIC_BYTES_LENGTH = 12;
/**
* Verschiebt die fertige Datei aus dem Temp-Verzeichnis an ihren
* endgueltigen Platz. rename() ist ein reiner Verzeichniseintrag-Wechsel und
* damit sofort fertig, funktioniert aber NUR innerhalb desselben
* Dateisystems — in Produktion liegt UPLOAD_DIR auf einem gemounteten
* Volume, das Temp-Verzeichnis dagegen im Container-Dateisystem, und
* rename() scheitert dann mit EXDEV. copyFile() ueberbrueckt das; es kopiert
* im Betriebssystem, nicht ueber den Node-Heap, haelt die Datei also
* ebenfalls nie vollstaendig im Arbeitsspeicher.
*/
async function moveIntoUploadDir(from: string, to: string): Promise<void> {
try {
await rename(from, to);
} catch (error: unknown) {
const isCrossDevice = error instanceof Error && "code" in error && error.code === "EXDEV";
if (!isCrossDevice) throw error;
await copyFile(from, to);
}
}
/** /**
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe * Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video). * lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil * Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier * Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
* uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die * uebernimmt ffmpeg (siehe lib/video-transcode.ts) die Neuenkodierung.
* Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur *
* Ablauf: (1) die hochgeladene Datei wird STREAMEND in eine temporaere Datei
* geschrieben, (2) ihre Magic-Bytes bestaetigen MP4/WebM (nur
* Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit), * Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit),
* (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton, * (3) ffmpeg enkodiert sie zu einem web-optimierten MP4 neu, (4) NUR das
* begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart * transkodierte Ergebnis wird nach UPLOAD_DIR verschoben, nie die
* ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange * Originaldatei — ein erfolgreicher Transcode validiert nebenbei
* Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3) * vollstaendig, dass die Datei tatsaechlich als Video dekodierbar ist
* NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei — * (analog zu reencodeImage() fuer Bilder).
* ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die *
* Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte- * Warum streamend statt ueber Buffer: ein Video darf bis
* Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). Zugriff * MAX_UPLOAD_VIDEO_BYTES (100 MB) gross sein. Die vorherige Fassung hielt es
* ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi), Auslieferung * gleich mehrfach vollstaendig im Node-Heap — einmal aus file.arrayBuffer(),
* mit festem Content-Type + "X-Content-Type-Options: nosniff" (siehe * einmal als Buffer daraus, und das Transcoding-Ergebnis noch einmal aus
* app/uploads/[...path]/route.ts) und zufaelligem Dateinamen. * readFile(). Bei dem in k8s/05-app-deployment.yaml gesetzten Speicherlimit
* reicht das aus, um den Prozess mitten im Upload abzuschiessen; im Browser
* kommt davon nur die HTML-Fehlerseite des Reverse-Proxy an, die der Client
* frueher als "Unexpected token '<'" meldete. Jetzt wandert die Datei ueber
* einen Stream auf die Platte und von dort per rename/copyFile an ihr Ziel,
* ohne je vollstaendig im Speicher zu liegen.
*
* Zugriff ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi),
* Auslieferung mit festem Content-Type + "X-Content-Type-Options: nosniff"
* (siehe app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isTrustedOrigin(request)) { if (!isTrustedOrigin(request)) {
@@ -64,48 +107,89 @@ export async function POST(request: Request) {
); );
} }
const buffer = Buffer.from(await file.arrayBuffer()); const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
const inputPath = path.join(dir, "input");
const outputPath = path.join(dir, "output.mp4");
if (!detectVideoExtension(buffer)) {
return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 }
);
}
let transcoded: Buffer;
try { try {
transcoded = await transcodeVideoForWeb(buffer); // Der Cast ueberbrueckt nur eine Typ-Doppelung, keine Laufzeitgrenze:
// File.stream() ist in den DOM-Typen als ReadableStream deklariert,
// Readable.fromWeb() erwartet die Deklaration aus node:stream/web. Unter
// der Node-Runtime ist beides dieselbe globale Klasse.
const webStream = file.stream() as unknown as NodeReadableStream<Uint8Array>;
await pipeline(Readable.fromWeb(webStream), createWriteStream(inputPath));
// Formaterkennung ausschliesslich anhand der tatsaechlichen Magic-Bytes
// am Dateianfang — nie ueber den Client-Dateinamen oder den vom Client
// gesendeten Content-Type, beide sind frei faelschbar. Gelesen werden nur
// die ersten Bytes, nicht die ganze Datei.
const head = Buffer.alloc(MAGIC_BYTES_LENGTH);
const handle = await open(inputPath, "r");
let bytesRead: number;
try {
({ bytesRead } = await handle.read(head, 0, MAGIC_BYTES_LENGTH, 0));
} finally {
await handle.close();
}
if (!detectVideoExtension(head.subarray(0, bytesRead))) {
return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 }
);
}
try {
await transcodeVideoForWeb(inputPath, outputPath);
} catch (error: unknown) {
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
return NextResponse.json(
{ error: "Das Video konnte nicht verarbeitet werden." },
{ status: 400 }
);
}
// Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
// urspruenglich erkannten Eingabe-Endung.
const filename = generateUploadFilename("mp4");
let outputBytes: number;
try {
outputBytes = (await stat(outputPath)).size;
await mkdir(UPLOAD_DIR, { recursive: true });
await moveIntoUploadDir(outputPath, path.join(UPLOAD_DIR, filename));
} catch (error: unknown) {
// Ohne dieses Catch wuerde ein Dateisystemfehler (z.B. fehlende
// Schreibrechte auf UPLOAD_DIR) als unbehandelte Exception
// durchschlagen -> Next.js liefert dafuer eine HTML-Fehlerseite statt
// JSON zurueck, aus der der Client keine Meldung gewinnen kann.
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
return NextResponse.json(
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
{ status: 500 }
);
}
await logAdminAction(admin, {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `mp4, ${Math.ceil(file.size / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
} catch (error: unknown) { } catch (error: unknown) {
console.error("Video-Upload: Transcoding fehlgeschlagen.", error); // Letzte Auffanglinie: alles, was oben nicht schon gezielt behandelt ist
// (z.B. ein Abbruch beim Schreiben der temporaeren Datei), muss trotzdem
// als JSON herauskommen — eine HTML-Fehlerseite waere fuer den Client
// nicht auswertbar.
console.error("Video-Upload: unerwarteter Fehler.", error);
return NextResponse.json( return NextResponse.json(
{ error: "Das Video konnte nicht verarbeitet werden." }, { error: "Das Video konnte nicht verarbeitet werden." },
{ status: 400 }
);
}
// Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
// urspruenglich erkannten Eingabe-Endung.
const filename = generateUploadFilename("mp4");
try {
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(path.join(UPLOAD_DIR, filename), transcoded);
} catch (error: unknown) {
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
return NextResponse.json(
{ error: "Die Datei konnte nicht gespeichert werden. Bitte informiere die Technik." },
{ status: 500 } { status: 500 }
); );
} finally {
// Temp-Verzeichnis in JEDEM Fall raeumen — auch bei einem Abbruch
// mittendrin, sonst sammeln sich Rohvideos im Container an.
await rm(dir, { recursive: true, force: true });
} }
await logAdminAction(admin, {
action: "media.upload",
entityType: "Upload",
entityId: filename,
detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`,
});
return NextResponse.json({ path: `/uploads/${filename}` });
} }
+32 -40
View File
@@ -1,7 +1,4 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import ffmpegStaticPath from "ffmpeg-static"; import ffmpegStaticPath from "ffmpeg-static";
/** /**
@@ -9,7 +6,7 @@ import ffmpegStaticPath from "ffmpeg-static";
* (versehentlich oder absichtlich) pathologischer Upload einen * (versehentlich oder absichtlich) pathologischer Upload einen
* Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten * Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten
* (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze * (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze
* Hintergrundvideo bei "medium"-Preset deutlich. * Hintergrundvideo bei "veryfast"-Preset deutlich.
*/ */
const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000; const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -29,20 +26,23 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
* statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy. * statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy.
* 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei) * 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei)
* an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die * an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann — * GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann.
* das allein erklaerte einen grossen Teil der langsamen ersten
* Wiedergabe, unabhaengig von der Dateigroesse.
* *
* Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei * Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei
* tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die * tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die
* gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon * gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon
* abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der * abdeckt (volle Neuvalidierung der Frame-Daten, nicht nur der Magic-Bytes).
* Magic-Bytes am Dateianfang). *
* Arbeitet bewusst auf DATEIPFADEN, nicht auf Buffern: ffmpeg liest und
* schreibt ohnehin von der Platte, und ein Hero-Video darf bis zu
* MAX_UPLOAD_VIDEO_BYTES gross sein. Eingabe und Ergebnis zusaetzlich
* vollstaendig im Node-Heap zu halten, kostete pro Upload ein Vielfaches
* der Dateigroesse an Arbeitsspeicher — bei einem Container mit knappem
* Speicherlimit reicht das aus, um den Prozess abzuschiessen (der Browser
* sieht davon nur den Verbindungsabbruch bzw. die HTML-Fehlerseite des
* Reverse-Proxy).
*/ */
export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> { export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
const inputPath = path.join(dir, "input");
const outputPath = path.join(dir, "output.mp4");
const args = [ const args = [
"-y", "-y",
"-i", "-i",
@@ -56,12 +56,11 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das // gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit // steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
// "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig. // "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig.
// Noetig, weil ein Reverse-Proxy/CDN (hier: Cloudflare) vor einem zu // Noetig, weil ein Reverse-Proxy/CDN vor einem zu lange antwortenden
// lange antwortenden Origin-Request selbst abbricht und eine eigene // Origin-Request selbst abbricht und eine eigene HTML-Fehlerseite
// HTML-Fehlerseite ausliefert -> im Client "Unexpected token '<' ... // ausliefert -> im Client "Unexpected token '<' ... is not valid JSON",
// is not valid JSON", weil response.json() diese HTML-Seite nicht // weil die Antwort dann kein JSON mehr ist (siehe lib/upload-client.ts,
// parsen kann. Mit "medium" konnte ein 57-MB-Rohvideo laenger brauchen, // das diesen Fall inzwischen als Status meldet statt zu parsen).
// als das CDN auf eine Antwort wartet.
"-preset", "-preset",
"veryfast", "veryfast",
"-crf", "-crf",
@@ -78,27 +77,20 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
]; ];
try { try {
await writeFile(inputPath, buffer); // Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
try { // ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add // /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter await runFfmpeg("ffmpeg", args);
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur. } catch (error: unknown) {
await runFfmpeg("ffmpeg", args); // ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
} catch (error: unknown) { // lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein // npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev"
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per // ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "npm run // tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert
// dev" ohne manuelle Einrichtung funktioniert. Jeder andere Fehler // durchschlagen, nicht stillschweigend erneut versucht werden.
// (z.B. eine tatsaechlich nicht dekodierbare Datei) soll dagegen const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
// unveraendert durchschlagen, nicht stillschweigend erneut versucht if (!isMissingBinary || !ffmpegStaticPath) throw error;
// werden. await runFfmpeg(ffmpegStaticPath, args);
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
if (!isMissingBinary || !ffmpegStaticPath) throw error;
await runFfmpeg(ffmpegStaticPath, args);
}
return await readFile(outputPath);
} finally {
await rm(dir, { recursive: true, force: true });
} }
} }