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); return NextResponse.redirect(hetznerUrl, 302); } catch { return new NextResponse("Not found", { status: 404 }); } } // Guard against directory traversal that resolves to a directory if (!stat.isFile()) { return new NextResponse("Not found", { status: 404 }); } // ── Content-Type ────────────────────────────────────────────────────────── const ext = path.extname(filePath).toLowerCase(); const mimeMap: Record = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mxf": "application/mxf", ".webm": "video/webm", }; const contentType = mimeMap[ext] ?? "application/octet-stream"; // ── Caching headers ─────────────────────────────────────────────────────── // ETag: stable because uploaded files are immutable — re-uploads produce // a new UUID filename, so mtime and size never change for a given path. const etag = `"${stat.size.toString(36)}-${stat.mtimeMs.toString(36)}"`; const lastModified = stat.mtime.toUTCString(); const sharedHeaders = { "Content-Type": contentType, "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600, immutable", "ETag": etag, "Last-Modified": lastModified, }; const rangeHeader = req.headers.get("range"); // ── Range request path (RFC 7233) ───────────────────────────────────────── if (rangeHeader) { // RFC 7233 §3.2 — If-Range: only honour Range if the representation is // unchanged. If the validator doesn't match, fall through to a full 200. const ifRange = req.headers.get("if-range"); const rangeIsValid = !ifRange || ifRange === etag || ifRange === lastModified; if (rangeIsValid) { // Parse "bytes=-" with regex to handle surrounding whitespace const rangeMatch = /bytes\s*=\s*(\d*)-(\d*)/i.exec(rangeHeader); if (!rangeMatch) { // Malformed Range header → 416 Range Not Satisfiable return new NextResponse(null, { status: 416, headers: { ...sharedHeaders, "Content-Range": `bytes */${stat.size}` }, }); } const start = rangeMatch[1] ? parseInt(rangeMatch[1], 10) : 0; // RFC 7233 §2.1: start byte past EOF → 416 if (start >= stat.size) { return new NextResponse(null, { status: 416, headers: { ...sharedHeaders, "Content-Range": `bytes */${stat.size}` }, }); } // Cap end: honour explicit client requests but never exceed CHUNK_SIZE // beyond start, and never past EOF. const requestedEnd = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : stat.size - 1; const end = Math.min(requestedEnd, start + CHUNK_SIZE - 1, stat.size - 1); const chunkSize = end - start + 1; const stream = fs.createReadStream(filePath, { start, end, highWaterMark: READ_HIGH_WATER_MARK, }) as unknown as ReadableStream; return new NextResponse(stream, { status: 206, headers: { ...sharedHeaders, "Content-Range": `bytes ${start}-${end}/${stat.size}`, "Content-Length": String(chunkSize), }, }); } // If-Range mismatch: fall through to full-file response below } // ── Full-file response (no Range header, or If-Range mismatch) ──────────── // RFC 7232: 304 Not Modified applies only to full-file (non-range) requests const ifNoneMatch = req.headers.get("if-none-match"); const ifModifiedSince = req.headers.get("if-modified-since"); if ( (ifNoneMatch && ifNoneMatch === etag) || (!ifNoneMatch && ifModifiedSince && new Date(ifModifiedSince) >= stat.mtime) ) { return new NextResponse(null, { status: 304 }); } const stream = fs.createReadStream(filePath, { highWaterMark: READ_HIGH_WATER_MARK, }) as unknown as ReadableStream; return new NextResponse(stream, { headers: { ...sharedHeaders, "Content-Length": String(stat.size), }, }); }