diff --git a/src/app/api/admin/upload-video/route.ts b/src/app/api/admin/upload-video/route.ts index f263f51..b112a86 100644 --- a/src/app/api/admin/upload-video/route.ts +++ b/src/app/api/admin/upload-video/route.ts @@ -3,7 +3,7 @@ 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 { 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"; @@ -23,6 +23,35 @@ export const runtime = "nodejs"; */ 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 @@ -46,33 +75,33 @@ async function moveIntoUploadDir(from: string, to: string): Promise { /** * 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) 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), - * (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). + * 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. * - * 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. + * 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. * - * 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. + * 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)) { @@ -92,17 +121,19 @@ export async function POST(request: Request) { ); } - const formData = await request.formData().catch(() => null); - const file = formData?.get("file"); - if (!(file instanceof File)) { + if (!request.body) { return NextResponse.json({ error: "Keine Datei übermittelt." }, { status: 400 }); } - if (file.size === 0) { - return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 }); - } - if (file.size > MAX_UPLOAD_VIDEO_BYTES) { + + 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. ${Math.floor(MAX_UPLOAD_VIDEO_BYTES / (1024 * 1024))} MB).` }, + { error: `Die Datei ist zu groß (max. ${maxMegabytes} MB).` }, { status: 413 } ); } @@ -112,12 +143,33 @@ export async function POST(request: Request) { const outputPath = path.join(dir, "output.mp4"); try { - // 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)); + 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 @@ -173,7 +225,7 @@ export async function POST(request: Request) { action: "media.upload", entityType: "Upload", entityId: filename, - detail: `mp4, ${Math.ceil(file.size / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`, + detail: `mp4, ${Math.ceil(receivedBytes / 1024)} KB -> ${Math.ceil(outputBytes / 1024)} KB`, }); return NextResponse.json({ path: `/uploads/${filename}` }); diff --git a/src/components/admin-video-field.tsx b/src/components/admin-video-field.tsx index 30e797c..897814f 100644 --- a/src/components/admin-video-field.tsx +++ b/src/components/admin-video-field.tsx @@ -33,11 +33,20 @@ export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldPr setStatus("uploading"); setErrorMessage(""); try { - const body = new FormData(); - body.append("file", file); let response: Response; try { - response = await fetch("/api/admin/upload-video", { method: "POST", body }); + // Die Datei wird als ROHER Body gesendet, nicht als FormData: auf der + // Serverseite liest request.formData() den gesamten Body erst + // vollstaendig in den Arbeitsspeicher, was bei einem Video bis 100 MB + // das Speicherlimit des Pods sprengte (ausfuehrliche Begruendung in + // app/api/admin/upload-video/route.ts). Als roher Body laesst er sich + // dort direkt auf die Platte durchreichen. fetch() streamt ein + // File-Objekt, es landet also auch hier nie am Stueck im Speicher. + response = await fetch("/api/admin/upload-video", { + method: "POST", + headers: { "content-type": file.type || "application/octet-stream" }, + body: file, + }); } catch { // fetch() wirft nur, wenn der Request den Server gar nicht erreicht // oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index d0e5dea..642b27c 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1142,8 +1142,10 @@ export const de: Dictionary = { uploadErrorStatus: (status) => status === 413 ? `Die Datei wurde abgewiesen, weil sie zu groß ist (HTTP 413). Sie hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN.` - : status === 502 || status === 503 || status === 504 || status === 522 || status === 524 - ? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist fast immer eine Zeitüberschreitung: die Verarbeitung dauert länger, als der vorgeschaltete Proxy auf eine Antwort wartet.` + : status === 502 + ? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten — häufig, weil ihm der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.` + : status === 503 || status === 504 || status === 522 || status === 524 + ? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist eine Zeitüberschreitung: die Verarbeitung dauerte länger, als der vorgeschaltete Proxy zulässt.` : `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`, uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.", }, @@ -1156,8 +1158,10 @@ export const de: Dictionary = { uploadErrorStatus: (status) => status === 413 ? `Das Video wurde abgewiesen, weil es zu groß ist (HTTP 413). Es hat den Shop gar nicht erreicht — diese Grenze setzt der vorgeschaltete Proxy bzw. das CDN, nicht der Shop selbst.` - : status === 502 || status === 503 || status === 504 || status === 522 || status === 524 - ? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Bei Videos ist das fast immer eine Zeitüberschreitung: das Umrechnen fürs Web dauert länger, als der vorgeschaltete Proxy auf eine Antwort wartet. Ein kürzeres oder kleineres Video kommt in der Regel durch.` + : status === 502 + ? `Der Server hat die Verarbeitung abgebrochen (HTTP 502). Das Backend hat die Verbindung fallen gelassen, ohne zu antworten — bei Videos meist, weil ihm beim Verarbeiten der Arbeitsspeicher ausging. Bitte informiere die Technik und nenne diese Nummer.` + : status === 503 || status === 504 || status === 522 || status === 524 + ? `Der Server hat die Verarbeitung abgebrochen (HTTP ${status}). Das ist eine Zeitüberschreitung: der Upload dauerte länger, als der vorgeschaltete Proxy zulässt. Ein kürzeres oder kleineres Video kommt in der Regel durch.` : `Der Server hat unerwartet geantwortet (HTTP ${status}) — keine verwertbare Rückmeldung. Bitte informiere die Technik und nenne diese Nummer.`, uploadErrorNetwork: "Die Verbindung zum Server wurde während des Uploads unterbrochen.", }, diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 0de2a03..a968ce0 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1142,8 +1142,10 @@ export const en: Dictionary = { uploadErrorStatus: (status) => status === 413 ? `The file was rejected because it is too large (HTTP 413). It never reached the shop — that limit comes from the reverse proxy or CDN in front of it.` - : status === 502 || status === 503 || status === 504 || status === 522 || status === 524 - ? `The server aborted processing (HTTP ${status}). This is almost always a timeout: processing takes longer than the proxy in front of it waits for a response.` + : status === 502 + ? `The server aborted processing (HTTP 502). The backend dropped the connection without answering — often because it ran out of memory. Please report this number to your technical contact.` + : status === 503 || status === 504 || status === 522 || status === 524 + ? `The server aborted processing (HTTP ${status}). This is a timeout: processing took longer than the proxy in front of it allows.` : `The server responded unexpectedly (HTTP ${status}) — no usable reply. Please report this number to your technical contact.`, uploadErrorNetwork: "The connection to the server was interrupted during the upload.", }, @@ -1156,8 +1158,10 @@ export const en: Dictionary = { uploadErrorStatus: (status) => status === 413 ? `The video was rejected because it is too large (HTTP 413). It never reached the shop — that limit comes from the reverse proxy or CDN in front of it, not from the shop itself.` - : status === 502 || status === 503 || status === 504 || status === 522 || status === 524 - ? `The server aborted processing (HTTP ${status}). For videos this is almost always a timeout: re-encoding for the web takes longer than the proxy in front of it waits for a response. A shorter or smaller video usually gets through.` + : status === 502 + ? `The server aborted processing (HTTP 502). The backend dropped the connection without answering — for videos usually because it ran out of memory while processing. Please report this number to your technical contact.` + : status === 503 || status === 504 || status === 522 || status === 524 + ? `The server aborted processing (HTTP ${status}). This is a timeout: the upload took longer than the proxy in front of it allows. A shorter or smaller video usually gets through.` : `The server responded unexpectedly (HTTP ${status}) — no usable reply. Please report this number to your technical contact.`, uploadErrorNetwork: "The connection to the server was interrupted during the upload.", },