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}` });
|
||||
}
|
||||
Reference in new issue
Block a user