a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
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>
200 lines
6.6 KiB
TypeScript
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 });
|
|
}
|
|
}
|