diff --git a/app/api/files/[...key]/route.ts b/app/api/files/[...key]/route.ts index 8dcf820..8e4d4f3 100644 --- a/app/api/files/[...key]/route.ts +++ b/app/api/files/[...key]/route.ts @@ -2,6 +2,32 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +// ── 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 8 MB each chunk: +// • A 7 MB clip (2 s @ 30 Mbps) arrives in a single request. +// • A 75 MB clip (20 s @ 30 Mbps) needs ~10 requests total. +// • A 125 MB clip (20 s @ 50 Mbps) needs ~16 requests total. +// +// The previous 2 MB cap produced 4× as many HTTP round-trips and 4× as many +// file-open/stat operations for the same content, with no benefit for this +// workload. Larger values (16 MB+) offer diminishing returns and increase +// per-request heap pressure. +const CHUNK_SIZE = 8 * 1024 * 1024; // 8 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[] }> } @@ -10,19 +36,31 @@ export async function GET( // key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"] const relativePath = key.join("/"); - // Sanitize: prevent path traversal + // ── Path traversal sanitisation ─────────────────────────────────────────── const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads"); const filePath = path.resolve(path.join(uploadDir, relativePath)); - if (!filePath.startsWith(uploadDir)) { + // 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 }); } - if (!fs.existsSync(filePath)) { + // Single stat() replaces the previous existsSync() + statSync() (2 → 1 + // syscall). The try/catch also catches ENOENT, EACCES, ENAMETOOLONG, etc. + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { return new NextResponse("Not found", { status: 404 }); } - const stat = fs.statSync(filePath); + // 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", @@ -33,35 +71,93 @@ export async function GET( }; const contentType = mimeMap[ext] ?? "application/octet-stream"; - // Support range requests so the HTML5 video player can seek + // ── 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) { - const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-"); - const start = parseInt(startStr, 10); - const end = endStr ? parseInt(endStr, 10) : stat.size - 1; - const chunkSize = end - start + 1; + // 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; - const stream = fs.createReadStream(filePath, { start, end }); - const nodeStream = stream as unknown as ReadableStream; + 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}` }, + }); + } - return new NextResponse(nodeStream, { - status: 206, - headers: { - "Content-Range": `bytes ${start}-${end}/${stat.size}`, - "Accept-Ranges": "bytes", - "Content-Length": String(chunkSize), - "Content-Type": contentType, - }, - }); + 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 } - const stream = fs.createReadStream(filePath) as unknown as ReadableStream; + // ── 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), - "Content-Type": contentType, - "Accept-Ranges": "bytes", }, }); } diff --git a/components/player/ReviewPlayer.tsx b/components/player/ReviewPlayer.tsx index 2780829..34cb38a 100644 --- a/components/player/ReviewPlayer.tsx +++ b/components/player/ReviewPlayer.tsx @@ -81,6 +81,16 @@ export const ReviewPlayer = forwardRef( setFps(fps); }, [fps, setFps]); + // Reset playback state when the source changes so the timeline and + // timecode don't briefly show the previous clip's values while the new + // video is loading its metadata. + useEffect(() => { + setCurrentFrame(0); + setCurrentTime(0); + setDuration(0); + setTotalFrames(0); + }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames]); + // ── Playback state sync ────────────────────────────────────────────────── const handleTimeUpdate = useCallback(() => { const video = videoRef.current; @@ -269,7 +279,7 @@ export const ReviewPlayer = forwardRef( onLoadedMetadata={handleLoadedMetadata} onPlay={handlePlay} onPause={handlePause} - preload="metadata" + preload="auto" playsInline />