+112
-16
@@ -2,6 +2,32 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
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(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ key: string[] }> }
|
{ 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"]
|
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
|
||||||
const relativePath = key.join("/");
|
const relativePath = key.join("/");
|
||||||
|
|
||||||
// Sanitize: prevent path traversal
|
// ── Path traversal sanitisation ───────────────────────────────────────────
|
||||||
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
||||||
const filePath = path.resolve(path.join(uploadDir, relativePath));
|
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 });
|
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 });
|
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 ext = path.extname(filePath).toLowerCase();
|
||||||
const mimeMap: Record<string, string> = {
|
const mimeMap: Record<string, string> = {
|
||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
@@ -33,35 +71,93 @@ export async function GET(
|
|||||||
};
|
};
|
||||||
const contentType = mimeMap[ext] ?? "application/octet-stream";
|
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");
|
const rangeHeader = req.headers.get("range");
|
||||||
|
|
||||||
|
// ── Range request path (RFC 7233) ─────────────────────────────────────────
|
||||||
if (rangeHeader) {
|
if (rangeHeader) {
|
||||||
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
|
// RFC 7233 §3.2 — If-Range: only honour Range if the representation is
|
||||||
const start = parseInt(startStr, 10);
|
// unchanged. If the validator doesn't match, fall through to a full 200.
|
||||||
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
const ifRange = req.headers.get("if-range");
|
||||||
|
const rangeIsValid = !ifRange || ifRange === etag || ifRange === lastModified;
|
||||||
|
|
||||||
|
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 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 chunkSize = end - start + 1;
|
||||||
|
|
||||||
const stream = fs.createReadStream(filePath, { start, end });
|
const stream = fs.createReadStream(filePath, {
|
||||||
const nodeStream = stream as unknown as ReadableStream;
|
start,
|
||||||
|
end,
|
||||||
|
highWaterMark: READ_HIGH_WATER_MARK,
|
||||||
|
}) as unknown as ReadableStream;
|
||||||
|
|
||||||
return new NextResponse(nodeStream, {
|
return new NextResponse(stream, {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: {
|
headers: {
|
||||||
|
...sharedHeaders,
|
||||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
"Content-Length": String(chunkSize),
|
"Content-Length": String(chunkSize),
|
||||||
"Content-Type": contentType,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 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;
|
||||||
|
|
||||||
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
|
|
||||||
return new NextResponse(stream, {
|
return new NextResponse(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
|
...sharedHeaders,
|
||||||
"Content-Length": String(stat.size),
|
"Content-Length": String(stat.size),
|
||||||
"Content-Type": contentType,
|
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
|||||||
setFps(fps);
|
setFps(fps);
|
||||||
}, [fps, setFps]);
|
}, [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 ──────────────────────────────────────────────────
|
// ── Playback state sync ──────────────────────────────────────────────────
|
||||||
const handleTimeUpdate = useCallback(() => {
|
const handleTimeUpdate = useCallback(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -269,7 +279,7 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
|||||||
onLoadedMetadata={handleLoadedMetadata}
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
onPlay={handlePlay}
|
onPlay={handlePlay}
|
||||||
onPause={handlePause}
|
onPause={handlePause}
|
||||||
preload="metadata"
|
preload="auto"
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user