+119
-23
@@ -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<string, string> = {
|
||||
".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;
|
||||
|
||||
return new NextResponse(nodeStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
if (rangeIsValid) {
|
||||
// Parse "bytes=<start>-<end>" 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 stream = fs.createReadStream(filePath) as unknown as ReadableStream;
|
||||
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),
|
||||
"Content-Type": contentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,6 +81,16 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
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<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
playsInline
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user