Files
vfxreview/app/api/files/[...key]/route.ts
T
twotalesanimation f0dcf17e93
Deploy / deploy (push) Successful in 2m50s
Image url update
2026-07-29 07:24:57 +02:00

211 lines
9.0 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 });
}
// ── Storage routing ───────────────────────────────────────────────────────
// Videos: check Hetzner first — bypasses ISP throttling for large streams.
// Fallback to local disk if the file hasn't been migrated yet.
// Images / other assets: local disk only — avoids presigned-URL 400 errors
// seen on Hetzner for non-video content.
const videoExts = new Set([".mp4", ".mov", ".avi", ".mxf", ".webm"]);
const isVideo = videoExts.has(path.extname(relativePath).toLowerCase());
if (isVideo) {
try {
if (await hetznerKeyExists(relativePath)) {
const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600);
return NextResponse.redirect(hetznerUrl, 302);
}
} catch {
// Hetzner unavailable — fall through to local disk
}
}
// Local disk (all non-video files, and video files not yet on Hetzner)
let stat: fs.Stats;
try {
stat = fs.statSync(filePath);
} catch {
// Not on local disk and not a video served above — try Hetzner as last resort
try {
const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600);
if (isVideo) {
// Videos aren't rendered through next/image — a redirect lets the
// browser/<video> stream directly from Hetzner (range requests,
// no extra hop through this server).
return NextResponse.redirect(hetznerUrl, 302);
}
// Images/other assets may be requested by Next's built-in image
// optimizer (/_next/image) for thumbnails. That optimizer does NOT
// follow redirects on this Next.js version (`images.maximumRedirects`
// only exists in much newer Next releases) and returns a bare 400 for
// any 3xx response. Proxy the bytes through instead so the optimizer
// (and any other consumer) sees a normal 200 response.
const upstream = await fetch(hetznerUrl);
if (!upstream.ok || !upstream.body) {
return new NextResponse("Not found", { status: 404 });
}
const proxyHeaders = new Headers();
const upstreamType = upstream.headers.get("content-type");
const upstreamLength = upstream.headers.get("content-length");
if (upstreamType) proxyHeaders.set("Content-Type", upstreamType);
if (upstreamLength) proxyHeaders.set("Content-Length", upstreamLength);
proxyHeaders.set("Cache-Control", "public, max-age=3600, immutable");
return new NextResponse(upstream.body, { status: 200, headers: proxyHeaders });
} 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),
},
});
}