Files
Wabenhain/src/app/api/admin/upload-video/route.ts
T
Nick Szaunig 96bb3d9c50
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m10s
fix: Video-Upload als roher Body statt multipart — beseitigt die 502-Ursache
Live beobachtet: der Upload scheitert mit HTTP 502, nicht mit 504. Das ist
ein wesentlicher Unterschied. 502 bedeutet, dass der vorgeschaltete Proxy
vom Backend gar keine Antwort bekam — der Prozess hat die Verbindung mitten
im Request fallen gelassen. Eine Zeitueberschreitung waere 504. Die bisher
verfolgte Timeout-Spur war damit falsch.

Uebrig blieb als Erklaerung der Arbeitsspeicher, und dort lag noch die
eigentliche Ursache: request.formData() liest den GESAMTEN Body in den
Speicher, bevor eine einzige Zeile eigener Code laeuft. Der vorherige
Streaming-Umbau hat nur die zusaetzlichen Kopien danach beseitigt
(arrayBuffer, Buffer, readFile) — diese erste nicht. Bei einem Video bis
100 MB genuegt das, um zusammen mit dem Grundverbrauch des Servers das
Speicherlimit des Pods zu sprengen; der Kernel beendet den Prozess
(OOMKilled), und der Proxy meldet 502.

Die Datei kommt jetzt als roher Request-Body statt als multipart/form-data.
Damit laesst sich request.body direkt auf die Platte durchreichen, und der
Speicherbedarf haengt nicht mehr von der Dateigroesse ab. Ein Formularfeld
wurde ohnehin nicht gebraucht, es wird genau eine Datei uebertragen. Die
Groessengrenze prueft jetzt ein mitzaehlender Transform-Stream, weil
Content-Length nur eine Ankuendigung des Clients ist.

Gemessen im Produktions-Build mit einem 95-MB-Video: Status 200 nach 5,9
Sekunden, Arbeitsspeicher des Servers von 67 MB auf in der Spitze 149 MB —
also rund ein Drittel des Limits von 512Mi, mit dem der Pod bisher lief.

Ausserdem berichtigt: der eigene Meldungstext behauptete bei 502 "fast
immer eine Zeitueberschreitung". Das ist falsch und hat die Fehlersuche in
die falsche Richtung gelenkt. 502 und 504 werden jetzt getrennt erklaert —
502 als weggebrochenes Backend (meist Speichermangel), 504 als echtes
Zeitlimit.
2026-09-15 02:27:11 +02:00

248 lines
9.9 KiB
TypeScript

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<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).
*
* 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<Uint8Array>;
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 });
}
}