import { NextResponse } from "next/server"; 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, Transform } 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"; import { UPLOAD_DIR, MAX_UPLOAD_VIDEO_BYTES, detectVideoExtension, generateUploadFilename } from "@/lib/uploads"; import { transcodeVideoForWeb } from "@/lib/video-transcode"; 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; /** Signalisiert, dass der Datenstrom MAX_UPLOAD_VIDEO_BYTES ueberschritten hat. */ class UploadTooLargeError extends Error { constructor() { super("Upload ueberschreitet MAX_UPLOAD_VIDEO_BYTES."); this.name = "UploadTooLargeError"; } } /** * Zaehlt die durchfliessenden Bytes und bricht ab, sobald die Obergrenze * ueberschritten ist. Noetig, weil Content-Length nur eine Ankuendigung des * Clients ist: ohne diese Pruefung koennte ein Aufrufer beliebig viele Daten * nachschieben und damit das Dateisystem fuellen. */ function limitBytes(maximum: number, onTotal: (total: number) => void): Transform { let total = 0; return new Transform({ transform(chunk: Buffer, _encoding, callback) { total += chunk.length; if (total > maximum) { callback(new UploadTooLargeError()); return; } onTotal(total); callback(null, chunk); }, }); } /** * 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). * * Nimmt die Datei als ROHEN Request-Body entgegen, nicht als * multipart/form-data. Das ist der entscheidende Unterschied zur vorherigen * Fassung: request.formData() liest den gesamten Body in den * Arbeitsspeicher, bevor eine einzige Zeile eigener Code laeuft. Bei einem * Video bis MAX_UPLOAD_VIDEO_BYTES (100 MB) sprengte das zusammen mit dem * Grundverbrauch des Next.js-Servers das Speicherlimit des Pods; der Kernel * beendete den Prozess mitten im Request (OOMKilled), und der vorgeschaltete * Proxy meldete dem Browser daraufhin "502 Bad Gateway" — kein Zeitlimit, * sondern ein weggebrochenes Backend. Genau dieser Status wurde live * beobachtet. * * Als roher Body laesst sich request.body direkt auf die Platte * durchreichen. Der Speicherbedarf ist damit konstant und haengt nicht mehr * von der Dateigroesse ab — ein Chunk zur Zeit statt 100 MB am Stueck. Ein * Formularfeld wird ohnehin nicht gebraucht, es wird genau eine Datei * uebertragen. * * Weiterer Ablauf unveraendert: (1) Magic-Bytes bestaetigen MP4/WebM, (2) * ffmpeg enkodiert web-optimiert neu (siehe lib/video-transcode.ts), (3) nur * das Ergebnis wird nach UPLOAD_DIR verschoben, nie die Originaldatei — ein * erfolgreicher Transcode validiert nebenbei vollstaendig, dass die Datei * als Video dekodierbar ist. * * Zugriff 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)) { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 }); } const auth = await requireAdminForApi(); if ("response" in auth) return auth.response; const { admin } = auth; // Videos sind ein seltener redaktioneller Vorgang (ein Hero-Video pro // Startseite) — deutlich niedrigeres Limit als der Bild-Upload reicht. if (!rateLimit(`admin-upload-video:${admin.id}`, 10, 60 * 60 * 1000)) { return NextResponse.json( { error: "Zu viele Uploads. Bitte versuche es später erneut." }, { status: 429 } ); } if (!request.body) { return NextResponse.json({ error: "Keine Datei übermittelt." }, { status: 400 }); } const maxMegabytes = Math.floor(MAX_UPLOAD_VIDEO_BYTES / (1024 * 1024)); // Content-Length ist nur eine Ankuendigung des Clients — trotzdem frueh // ablehnen, wenn schon sie zu gross ist, damit erst gar nichts uebertragen // wird. Verbindlich ist die mitzaehlende Pruefung unten beim Schreiben. const declaredLength = Number(request.headers.get("content-length")); if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_VIDEO_BYTES) { return NextResponse.json( { error: `Die Datei ist zu groß (max. ${maxMegabytes} MB).` }, { status: 413 } ); } const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-")); const inputPath = path.join(dir, "input"); const outputPath = path.join(dir, "output.mp4"); try { let receivedBytes = 0; try { // Der Cast ueberbrueckt nur eine Typ-Doppelung, keine Laufzeitgrenze: // request.body 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 = request.body as unknown as NodeReadableStream; await pipeline( Readable.fromWeb(webStream), limitBytes(MAX_UPLOAD_VIDEO_BYTES, (total) => { receivedBytes = total; }), createWriteStream(inputPath) ); } catch (error: unknown) { if (error instanceof UploadTooLargeError) { return NextResponse.json( { error: `Die Datei ist zu groß (max. ${maxMegabytes} MB).` }, { status: 413 } ); } throw error; } if (receivedBytes === 0) { return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 }); } // 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(receivedBytes / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`, }); return NextResponse.json({ path: `/uploads/${filename}` }); } catch (error: unknown) { // 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: 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 }); } }