import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { generateHetznerStreamUrl, hetznerKeyExists } from "@/lib/storage"; // ── Tuning constants ────────────────────────────────────────────────────────── // // CHUNK_SIZE — maximum bytes returned per Range response. // // Target workload: 2–20 s H.264 MP4 clips at 30–50 Mbps = ~7–125 MB per file, // 1–3 concurrent reviewers on a LAN/office network. // // At 2 MB each chunk: // • A 7 MB clip (2 s @ 30 Mbps) arrives in ~4 requests. // • A 75 MB clip (20 s @ 30 Mbps) needs ~38 requests total. // • A 125 MB clip (20 s @ 50 Mbps) needs ~63 requests total. // // Keeps time-to-first-frame low: the player can start after the first // 2 MB chunk rather than waiting for a full 8 MB download. const CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB // READ_HIGH_WATER_MARK — libuv/Node.js internal read-buffer size per stream. // // Default is 64 KB, meaning an 8 MB chunk requires ~128 kernel→userspace copy // iterations. 512 KB reduces that to ~16 iterations, lowering V8 GC pressure // and improving throughput for large sequential reads. Still small enough that // 3 concurrent streams only hold ~1.5 MB of read buffers in total. const READ_HIGH_WATER_MARK = 512 * 1024; // 512 KB export async function GET( req: NextRequest, { params }: { params: Promise<{ key: string[] }> } ) { const { key } = await params; // key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"] const relativePath = key.join("/"); // ── Path traversal sanitisation ─────────────────────────────────────────── const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads"); const filePath = path.resolve(path.join(uploadDir, relativePath)); // Append path.sep so "/uploads2/evil.mp4".startsWith("/uploads") cannot // pass — the resolved path must be strictly inside the upload directory. if (!filePath.startsWith(uploadDir + path.sep)) { return new NextResponse("Forbidden", { status: 403 }); } // ── Storage routing ─────────────────────────────────────────────────────── // Videos: check Hetzner first — bypasses ISP throttling for large streams. // Fallback to local disk if the file hasn't been migrated yet. // Images / other assets: local disk only — avoids presigned-URL 400 errors // seen on Hetzner for non-video content. const videoExts = new Set([".mp4", ".mov", ".avi", ".mxf", ".webm"]); const isVideo = videoExts.has(path.extname(relativePath).toLowerCase()); if (isVideo) { try { if (await hetznerKeyExists(relativePath)) { const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600); return NextResponse.redirect(hetznerUrl, 302); } } catch { // Hetzner unavailable — fall through to local disk } } // Local disk (all non-video files, and video files not yet on Hetzner) let stat: fs.Stats; try { stat = fs.statSync(filePath); } catch { // Not on local disk and not a video served above — try Hetzner as last resort try { const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600); if (isVideo) { // Videos aren't rendered through next/image — a redirect lets the // browser/