Files
vfxreview/app/api/files/[...key]/route.ts
T
twotalesanimation e756eb9015
Deploy / deploy (push) Successful in 2m53s
OBJ then local
2026-07-16 23:04:15 +02:00

170 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 220 s H.264 MP4 clips at 3050 Mbps = ~7125 MB per file,
// 13 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 });
}
// ── Hetzner first, local disk fallback ───────────────────────────────────
// Check object storage first (all new uploads go there directly; migrated
// files live there too). Results are cached in process memory after the
// first check so subsequent range requests pay no extra latency.
// Fall back to local disk only for files that haven't been migrated yet.
let stat: fs.Stats;
try {
if (await hetznerKeyExists(relativePath)) {
const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600);
return NextResponse.redirect(hetznerUrl, 302);
}
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),
},
});
}