Files
Vault/app/uploads/videos/[...path]/route.ts
T
twotalesanimation a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Fix watch tracking, video unlocking and access-control gaps
Watch progress and unlocking:
- Player samples the playhead and reports the ranges actually played,
  so skipped sections earn no credit and early plays are not lost
- Server merges ranges into compact segment rows; progress never regresses
- Single 80% unlock rule enforced server-side (lib/watch-progress.ts);
  the Unlock button and automatic unlock now agree
- Lock state and progress bar update live from each progress save
- Playlist is derived from the video, so unlocks work for shared
  playlists and for links from watch history and liked videos
- First/previous video decided by playlist position, not index numbers

Access control:
- Video API and HLS/MP4 routes require a signed-in viewer who is
  enrolled and has the video unlocked; thumbnails require sign-in
- Enrolment sync reads course codes from the allow-list, not the request
- /api/admin/meta is admin-only
- Account status, role and levels are re-checked from the database
  during a session, so deactivated students lose access within a minute

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 16:58:07 +02:00

200 lines
6.6 KiB
TypeScript

// app/uploads/videos/[...path]/route.ts
// Serves uploaded MP4s from UPLOADS_DIR/videos at the /uploads/videos/* URLs
// stored in the database. Streams from disk with HTTP Range support — browsers
// require range requests for <video> seeking, and multi-GB files must never be
// buffered in memory.
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import { prisma } from "@/lib/prisma";
import { checkVideoAccess, getViewer } from "@/lib/video-access";
const VIDEOS_DIR = path.join(process.env.UPLOADS_DIR || "/uploads", "videos");
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
};
function resolveVideoPath(segments: string[]): string | null {
if (segments.length === 0) return null;
const requested = path.resolve(VIDEOS_DIR, ...segments);
// Security: prevent directory traversal attacks
if (!requested.startsWith(path.resolve(VIDEOS_DIR) + path.sep)) return null;
return requested;
}
async function statFile(filePath: string): Promise<fs.Stats | null> {
try {
const stats = await fs.promises.stat(filePath);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
// Parses "bytes=start-end" | "bytes=start-" | "bytes=-suffix".
// Returns null for absent/invalid headers (caller serves the full file) and
// "unsatisfiable" when the range is outside the file (caller returns 416).
function parseRange(
header: string | null,
size: number
): { start: number; end: number } | "unsatisfiable" | null {
if (!header) return null;
const m = header.match(/^bytes=(\d*)-(\d*)$/);
if (!m || (m[1] === "" && m[2] === "")) return null;
let start: number;
let end: number;
if (m[1] === "") {
// suffix range: last N bytes
const suffix = parseInt(m[2], 10);
if (suffix === 0) return "unsatisfiable";
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(m[1], 10);
end = m[2] === "" ? size - 1 : Math.min(parseInt(m[2], 10), size - 1);
}
if (start >= size || start > end) return "unsatisfiable";
return { start, end };
}
// Maps a requested file to the Video row it belongs to, then applies the same
// access rule as the video API. Files with no Video row are admin-only.
const VIDEO_ID_TTL_MS = 5 * 60_000;
const videoIdByPath = new Map<string, { expires: number; videoId: string | null }>();
async function findVideoId(segments: string[]): Promise<string | null> {
const relative = segments.join("/");
const hit = videoIdByPath.get(relative);
if (hit && hit.expires > Date.now()) return hit.videoId;
const byUrl = await prisma.video.findFirst({
where: { url: `/uploads/videos/${relative}` },
select: { id: true },
});
// Uploads are normally stored as {videoId}.mp4
const byName =
byUrl ??
(await prisma.video.findUnique({
where: { id: path.basename(relative, path.extname(relative)) },
select: { id: true },
}));
const videoId = byName?.id ?? null;
if (videoIdByPath.size >= 5000) videoIdByPath.clear();
videoIdByPath.set(relative, { expires: Date.now() + VIDEO_ID_TTL_MS, videoId });
return videoId;
}
async function authorize(segments: string[]): Promise<{ status: number; error: string } | null> {
const viewer = await getViewer();
if (!viewer) return { status: 401, error: "Unauthorized" };
if (viewer.isAdmin) return null;
const videoId = await findVideoId(segments);
if (!videoId) return { status: 404, error: "Not found" };
const access = await checkVideoAccess(viewer, videoId);
return access.ok ? null : { status: access.status, error: access.error };
}
function baseHeaders(filePath: string, stats: fs.Stats): Record<string, string> {
const ext = path.extname(filePath).toLowerCase();
return {
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600",
"Last-Modified": stats.mtime.toUTCString(),
};
}
export async function GET(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const filePath = resolveVideoPath(params?.path || []);
if (!filePath) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const denied = await authorize(params?.path || []);
if (denied) {
return NextResponse.json({ error: denied.error }, { status: denied.status });
}
const stats = await statFile(filePath);
if (!stats) {
console.log(`[Videos] File not found: ${filePath}`);
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const headers = baseHeaders(filePath, stats);
const range = parseRange(request.headers.get("range"), stats.size);
if (range === "unsatisfiable") {
return new NextResponse(null, {
status: 416,
headers: { ...headers, "Content-Range": `bytes */${stats.size}` },
});
}
if (range) {
const stream = fs.createReadStream(filePath, { start: range.start, end: range.end });
return new NextResponse(Readable.toWeb(stream) as unknown as BodyInit, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${range.start}-${range.end}/${stats.size}`,
"Content-Length": String(range.end - range.start + 1),
},
});
}
const stream = fs.createReadStream(filePath);
return new NextResponse(Readable.toWeb(stream) as unknown as BodyInit, {
status: 200,
headers: { ...headers, "Content-Length": String(stats.size) },
});
} catch (error) {
console.error("[Videos] Error serving video file:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function HEAD(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const filePath = resolveVideoPath(params?.path || []);
if (!filePath) return new NextResponse(null, { status: 400 });
const denied = await authorize(params?.path || []);
if (denied) return new NextResponse(null, { status: denied.status });
const stats = await statFile(filePath);
if (!stats) return new NextResponse(null, { status: 404 });
return new NextResponse(null, {
status: 200,
headers: {
...baseHeaders(filePath, stats),
"Content-Length": String(stats.size),
},
});
} catch (error) {
console.error("[Videos] Error in HEAD request:", error);
return new NextResponse(null, { status: 500 });
}
}