diff --git a/src/app/api/admin/upload-video/route.ts b/src/app/api/admin/upload-video/route.ts index 0152f48..f263f51 100644 --- a/src/app/api/admin/upload-video/route.ts +++ b/src/app/api/admin/upload-video/route.ts @@ -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 { + 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; + 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}` }); } diff --git a/src/lib/video-transcode.ts b/src/lib/video-transcode.ts index 24217a1..e2dc9fa 100644 --- a/src/lib/video-transcode.ts +++ b/src/lib/video-transcode.ts @@ -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 { - 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 { const args = [ "-y", "-i", @@ -56,12 +56,11 @@ export async function transcodeVideoForWeb(buffer: Buffer): Promise { // 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 { ]; 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); } }