162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
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 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 });
|
||
}
|
||
|
||
// 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 });
|
||
}
|
||
|
||
// 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",
|
||
".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=<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 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),
|
||
},
|
||
});
|
||
}
|