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:
1 parent
b11edd7751
commit
713bf48157
2 files changed
+166
-90
No files matched your search
@@ -1,6 +1,11 @@
|
||||
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 { 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 { rateLimit } from "@/lib/rate-limit";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
@@ -11,25 +16,63 @@ import { requireAdminForApi } from "../_shared";
|
||||
// node:fs braucht die Node-Runtime (nicht Edge).
|
||||
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
|
||||
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
|
||||
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
|
||||
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
|
||||
* uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die
|
||||
* Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur
|
||||
* uebernimmt ffmpeg (siehe lib/video-transcode.ts) die Neuenkodierung.
|
||||
*
|
||||
* 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),
|
||||
* (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton,
|
||||
* begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart
|
||||
* ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange
|
||||
* Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3)
|
||||
* NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei —
|
||||
* ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die
|
||||
* Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte-
|
||||
* Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). 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.
|
||||
* (3) ffmpeg enkodiert sie zu einem web-optimierten MP4 neu, (4) NUR das
|
||||
* transkodierte Ergebnis wird nach UPLOAD_DIR verschoben, nie die
|
||||
* Originaldatei — ein erfolgreicher Transcode validiert nebenbei
|
||||
* vollstaendig, dass die Datei tatsaechlich als Video dekodierbar ist
|
||||
* (analog zu reencodeImage() fuer Bilder).
|
||||
*
|
||||
* Warum streamend statt ueber Buffer: ein Video darf bis
|
||||
* MAX_UPLOAD_VIDEO_BYTES (100 MB) gross sein. Die vorherige Fassung hielt es
|
||||
* gleich mehrfach vollstaendig im Node-Heap — einmal aus file.arrayBuffer(),
|
||||
* einmal als Buffer daraus, und das Transcoding-Ergebnis noch einmal aus
|
||||
* 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) {
|
||||
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 {
|
||||
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) {
|
||||
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(
|
||||
{ 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 }
|
||||
);
|
||||
} 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
@@ -1,7 +1,4 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -9,7 +6,7 @@ import ffmpegStaticPath from "ffmpeg-static";
|
||||
* (versehentlich oder absichtlich) pathologischer Upload einen
|
||||
* Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten
|
||||
* (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;
|
||||
|
||||
@@ -29,20 +26,23 @@ const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
* statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy.
|
||||
* 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei)
|
||||
* an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die
|
||||
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann —
|
||||
* das allein erklaerte einen grossen Teil der langsamen ersten
|
||||
* Wiedergabe, unabhaengig von der Dateigroesse.
|
||||
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann.
|
||||
*
|
||||
* Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei
|
||||
* tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die
|
||||
* gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon
|
||||
* abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der
|
||||
* Magic-Bytes am Dateianfang).
|
||||
* abdeckt (volle Neuvalidierung der Frame-Daten, nicht nur der Magic-Bytes).
|
||||
*
|
||||
* 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> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
|
||||
const inputPath = path.join(dir, "input");
|
||||
const outputPath = path.join(dir, "output.mp4");
|
||||
export async function transcodeVideoForWeb(inputPath: string, outputPath: string): Promise<void> {
|
||||
const args = [
|
||||
"-y",
|
||||
"-i",
|
||||
@@ -56,12 +56,11 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
||||
// gegen Kompressionseffizienz, nicht gegen das Qualitaetsziel selbst (das
|
||||
// steuert weiterhin allein -crf) — bei identischem CRF wird die Datei mit
|
||||
// "veryfast" minimal groesser, aber typischerweise 3-5x schneller fertig.
|
||||
// Noetig, weil ein Reverse-Proxy/CDN (hier: Cloudflare) vor einem zu
|
||||
// lange antwortenden Origin-Request selbst abbricht und eine eigene
|
||||
// HTML-Fehlerseite ausliefert -> im Client "Unexpected token '<' ...
|
||||
// is not valid JSON", weil response.json() diese HTML-Seite nicht
|
||||
// parsen kann. Mit "medium" konnte ein 57-MB-Rohvideo laenger brauchen,
|
||||
// als das CDN auf eine Antwort wartet.
|
||||
// Noetig, weil ein Reverse-Proxy/CDN vor einem zu lange antwortenden
|
||||
// Origin-Request selbst abbricht und eine eigene HTML-Fehlerseite
|
||||
// ausliefert -> im Client "Unexpected token '<' ... is not valid JSON",
|
||||
// weil die Antwort dann kein JSON mehr ist (siehe lib/upload-client.ts,
|
||||
// das diesen Fall inzwischen als Status meldet statt zu parsen).
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
@@ -78,27 +77,20 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
|
||||
];
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
try {
|
||||
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
|
||||
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
|
||||
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
|
||||
await runFfmpeg("ffmpeg", args);
|
||||
} catch (error: unknown) {
|
||||
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
|
||||
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
|
||||
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "npm run
|
||||
// dev" ohne manuelle Einrichtung funktioniert. Jeder andere Fehler
|
||||
// (z.B. eine tatsaechlich nicht dekodierbare Datei) soll dagegen
|
||||
// unveraendert durchschlagen, nicht stillschweigend erneut versucht
|
||||
// werden.
|
||||
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 });
|
||||
// Bevorzugt "ffmpeg" ueber PATH — im Produktions-Image per "apk add
|
||||
// ffmpeg" installiert (siehe Dockerfile "runner"-Stage), fest unter
|
||||
// /usr/bin und unabhaengig von jeder Node/pnpm-Verzeichnisstruktur.
|
||||
await runFfmpeg("ffmpeg", args);
|
||||
} catch (error: unknown) {
|
||||
// ENOENT = Binary ueber PATH nicht gefunden (typischerweise ein
|
||||
// lokaler Windows-/Mac-Rechner ohne System-ffmpeg) — dann auf die per
|
||||
// npm mitgelieferte ffmpeg-static-Binary ausweichen, damit "pnpm dev"
|
||||
// ohne manuelle Einrichtung funktioniert. Jeder andere Fehler (z.B. eine
|
||||
// tatsaechlich nicht dekodierbare Datei) soll dagegen unveraendert
|
||||
// durchschlagen, nicht stillschweigend erneut versucht werden.
|
||||
const isMissingBinary = error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
if (!isMissingBinary || !ffmpegStaticPath) throw error;
|
||||
await runFfmpeg(ffmpegStaticPath, args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user