Fix watch tracking, video unlocking and access-control gaps
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>
This commit is contained in:
twotalesanimation
2026-11-11 16:58:07 +02:00
parent 08b6e58b8e
commit a834caa511
16 changed files with 922 additions and 667 deletions
+4 -5
View File
@@ -1,13 +1,12 @@
// app/api/admin/meta/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { adminGuard } from '@/lib/admin-check';
export async function GET() {
// Basic protection: ensure signed-in user is admin. Modify as needed.
// If you want stricter, get session from context or check roles.
// Here we skip session check to let dev access; but production should check.
const denied = await adminGuard();
if (denied) return denied;
try {
const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
+46 -90
View File
@@ -1,122 +1,78 @@
// app/api/enrollments/sync/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
export async function POST(req: NextRequest) {
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
/**
* Enrols the signed-in user in the courses listed for them on the allow-list.
* The course codes are read from the database; anything the browser sends is
* ignored, so a user cannot enrol themselves in a course they were not given.
*/
export async function POST() {
try {
const session = await getServerSession(authOptions);
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
if (!session?.user?.email) {
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
const email = normalizeEmail(session?.user?.email);
if (!email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userEmail = session.user.email;
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
const body = await req.json();
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
const levels: string[] = body?.levels || [];
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
if (!Array.isArray(levels) || levels.length === 0) {
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
return NextResponse.json(
{ error: 'Levels array required' },
{ status: 400 }
);
}
// Get user
const user = await prisma.user.findUnique({
where: { email: userEmail },
const user = await prisma.user.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { id: true },
});
if (!user) {
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
const createdEnrollments = [];
// For each course level, find the course and create enrollment
for (const level of levels) {
try {
console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
const course = await prisma.course.findUnique({
where: { code: level },
const allowedStudent = await prisma.allowedStudent.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { levels: true, active: true },
});
if (!course) {
console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
continue;
const levels = allowedStudent?.active
? allowedStudent.levels
.split(',')
.map((level) => level.trim())
.filter((level) => level.length > 0)
: [];
if (levels.length === 0) {
return NextResponse.json({ message: 'Enrollments synced', created: [] });
}
console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
// Check if enrollment already exists
const existing = await prisma.enrollment.findFirst({
where: { userId: user.id, courseId: course.id },
const courses = await prisma.course.findMany({
where: { code: { in: levels } },
select: { id: true, code: true },
});
if (existing) {
console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
} else {
const missing = levels.filter((level) => !courses.some((c) => c.code === level));
if (missing.length > 0) {
console.warn(`[SYNC-ENROLLMENTS] Course codes not found: ${missing.join(', ')}`);
}
const created = [];
for (const course of courses) {
const existing = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: user.id, courseId: course.id } },
select: { id: true },
});
if (existing) continue;
const enrollment = await prisma.enrollment.create({
data: {
userId: user.id,
courseId: course.id,
},
data: { userId: user.id, courseId: course.id },
});
console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
createdEnrollments.push({
courseCode: level,
created.push({
courseCode: course.code,
courseId: course.id,
enrollmentId: enrollment.id,
});
}
} catch (err) {
console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
}
}
console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
return NextResponse.json(
{
message: 'Enrollments synced',
created: createdEnrollments,
},
{ status: 200 }
);
return NextResponse.json({ message: 'Enrollments synced', created });
} catch (err) {
console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
return NextResponse.json(
{ error: 'Failed to sync enrollments' },
{ status: 500 }
);
console.error('[SYNC-ENROLLMENTS] error:', err);
return NextResponse.json({ error: 'Failed to sync enrollments' }, { status: 500 });
}
}
+17 -5
View File
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth-options";
import { filterAccessibleVideos } from "../../../../lib/watch-progress";
export async function GET(req: Request, context: any) {
try {
@@ -20,6 +21,7 @@ export async function GET(req: Request, context: any) {
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { enrollments: { select: { courseId: true } } },
});
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -37,21 +39,31 @@ export async function GET(req: Request, context: any) {
image: true,
},
},
videoCourses: { select: { courseId: true, exclusive: true } },
},
orderBy: { index: "asc" },
},
course: true,
courses: { select: { courseId: true } },
},
});
if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
const enrolled = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
});
if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Enrolment in the primary course or any course the playlist is shared with.
const userCourseIds = user.enrollments.map((e) => e.courseId);
const playlistCourseIds = [playlist.courseId, ...playlist.courses.map((c) => c.courseId)];
if (!playlistCourseIds.some((courseId) => userCourseIds.includes(courseId))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json({ playlist });
// Hide videos reserved for courses this user is not in, as /api/playlists does.
const visible = {
...playlist,
videos: filterAccessibleVideos(playlist.videos, userCourseIds),
};
return NextResponse.json({ playlist: visible });
} catch (err: any) {
console.error("GET /api/playlists/[id] error:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
+93 -121
View File
@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import {
UNLOCK_THRESHOLD_PERCENT,
mergeRanges,
sanitizeRanges,
totalSeconds,
unlockNextVideo,
type SecRange,
} from '@/lib/watch-progress';
export async function GET(req: Request) {
try {
@@ -47,36 +55,6 @@ export async function GET(req: Request) {
}
}
// Helper function to calculate unique watched seconds from segments
function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
if (segments.length === 0) return 0;
// Sort and merge overlapping ranges
const sorted = segments.sort((a, b) => a.startSec - b.startSec);
const merged: Array<[number, number]> = [];
for (const seg of sorted) {
if (merged.length === 0) {
merged.push([seg.startSec, seg.endSec]);
} else {
const last = merged[merged.length - 1];
if (seg.startSec <= last[1] + 0.5) {
// Overlapping or adjacent, merge
last[1] = Math.max(last[1], seg.endSec);
} else {
// Gap, new range
merged.push([seg.startSec, seg.endSec]);
}
}
}
let total = 0;
for (const [start, end] of merged) {
total += Math.max(0, end - start);
}
return Math.round(total);
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
@@ -86,137 +64,131 @@ export async function POST(req: Request) {
const userEmail = session.user.email;
const body = await req.json().catch(() => ({}));
const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
const { videoId, watchedSec, lastPos, duration } = body ?? {};
if (!videoId)
if (!videoId || typeof videoId !== 'string')
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
if (!duration || Number(duration) <= 0)
return NextResponse.json({ error: 'duration required' }, { status: 400 });
const watched = Number(watchedSec ?? 0);
const lastPosition = Number(lastPos ?? 0);
const dur = Number(duration);
// look up user id
const user = await prisma.user.findUnique({ where: { email: userEmail } });
const user = await prisma.user.findUnique({
where: { email: userEmail },
select: { id: true },
});
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Validate that the video exists
const video = await prisma.video.findUnique({ where: { id: videoId } });
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, playlistId: true, durationSec: true },
});
if (!video) {
// Log at debug level since this is expected when users have old cached references
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Ensure VideoProgress exists or create it
let videoProgress = await prisma.videoProgress.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
// The stored duration wins; the player's value is only a fallback for
// videos uploaded without one.
const dur =
video.durationSec && video.durationSec > 0 ? video.durationSec : Number(duration);
if (!Number.isFinite(dur) || dur <= 0)
return NextResponse.json({ error: 'duration required' }, { status: 400 });
if (!videoProgress) {
videoProgress = await prisma.videoProgress.create({
data: {
const lastPosition = Math.max(0, Math.floor(Number(lastPos ?? 0)) || 0);
// Current players send the ranges actually played. Tabs still running the
// previous player only send a total, from which one range is inferred.
let incoming: SecRange[];
if (Array.isArray(body?.ranges)) {
incoming = sanitizeRanges(body.ranges, dur);
} else {
const watched = Number(watchedSec ?? 0);
incoming = sanitizeRanges(
watched > 0 ? [[lastPosition - watched, lastPosition]] : [],
dur
);
}
const where = { userId_videoId: { userId: user.id, videoId } };
const { progress, segments } = await prisma.$transaction(async (tx) => {
const existingProgress = await tx.videoProgress.upsert({
where,
update: {},
create: {
userId: user.id,
videoId,
watchedSec: 0,
lastPos: 0,
percent: 0,
durationSec: dur,
durationSec: Math.round(dur),
},
});
}
// Record the watch segment if watched seconds > 0
let newSegment = null;
if (watched > 0) {
newSegment = await prisma.videoWatchSegment.create({
data: {
const existing = await tx.videoWatchSegment.findMany({
where: { userId: user.id, videoId },
select: { startSec: true, endSec: true, watchedAt: true },
orderBy: { startSec: 'asc' },
});
// Keep one row per distinct watched stretch instead of one per update.
const now = new Date();
const merged = mergeRanges([
...existing,
...incoming.map((r) => ({ ...r, watchedAt: now })),
]);
const unchanged =
merged.length === existing.length &&
merged.every(
(m, i) => m.startSec === existing[i].startSec && m.endSec === existing[i].endSec
);
if (!unchanged) {
await tx.videoWatchSegment.deleteMany({ where: { userId: user.id, videoId } });
await tx.videoWatchSegment.createMany({
data: merged.map((m) => ({
userId: user.id,
videoId,
startSec: Math.max(0, lastPosition - watched),
endSec: lastPosition,
},
startSec: m.startSec,
endSec: m.endSec,
watchedAt: m.watchedAt ?? now,
})),
});
}
// Fetch all segments for this user+video to recalculate totals
const allSegments = await prisma.videoWatchSegment.findMany({
where: { userId: user.id, videoId },
select: { startSec: true, endSec: true },
orderBy: { createdAt: 'asc' },
});
// Progress never goes backwards, even if segment rows were pruned.
const mergedSec = totalSeconds(merged);
const percent = Math.max(
existingProgress.percent,
Math.min(100, Math.round((mergedSec / dur) * 100))
);
const completed =
existingProgress.completed || percent >= UNLOCK_THRESHOLD_PERCENT;
// Calculate total unique watched seconds
const totalWatchedSec = calculateWatchedSeconds(allSegments);
// Calculate completion percentage
const ratio = dur > 0 ? totalWatchedSec / dur : 0;
const percentInt = Math.min(100, Math.round(ratio * 100));
const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
// Update VideoProgress with recalculated values
const upserted = await prisma.videoProgress.update({
where: { userId_videoId: { userId: user.id, videoId } },
const updated = await tx.videoProgress.update({
where,
data: {
watchedSec: totalWatchedSec,
lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
percent: percentInt,
durationSec: dur,
watchedSec: Math.max(existingProgress.watchedSec, mergedSec),
lastPos: Math.max(existingProgress.lastPos ?? 0, lastPosition),
percent,
durationSec: Math.round(dur),
completed,
updatedAt: new Date(),
updatedAt: now,
},
});
// If 80% watched, unlock next video in playlist for this user
let unlockedNext = null;
if (completed && playlistId) {
// find current video
const current = await prisma.video.findUnique({ where: { id: videoId } });
if (current && current.playlistId === playlistId) {
// Find next video that is NOT instant access and NOT globally locked
// Skip any instant access videos in the sequence
const nextVideos = await prisma.video.findMany({
where: {
playlistId,
index: { gt: current.index },
locked: false,
instantAccess: false,
},
orderBy: { index: 'asc' },
take: 1,
return { progress: updated, segments: merged };
});
if (nextVideos.length > 0) {
const next = nextVideos[0];
// Create a VideoUnlock record for this user (per-user unlock tracking)
const unlock = await prisma.videoUnlock.upsert({
where: { userId_videoId: { userId: user.id, videoId: next.id } },
update: {}, // if already exists, do nothing
create: { userId: user.id, videoId: next.id },
});
unlockedNext = { id: next.id, title: next.title };
}
}
}
// Completing a video unlocks the next one in its playlist for this user.
const unlockedNext = progress.completed
? await unlockNextVideo(user.id, video)
: null;
return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
return NextResponse.json({ ok: true, progress, unlockedNext, segments });
} catch (err: any) {
console.error('progress error', err);
// Handle foreign key constraint violations
if (err.code === 'P2003') {
const constraint = err.meta?.constraint_name;
if (constraint?.includes('videoId')) {
console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
return NextResponse.json(
{ error: 'Invalid video reference' },
{ status: 400 }
);
}
}
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
+11 -4
View File
@@ -4,6 +4,7 @@
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
import { getViewer } from '@/lib/video-access';
// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
@@ -23,6 +24,11 @@ export async function GET(
);
}
// Thumbnails show course content, so they need a signed-in user.
if (!(await getViewer())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Construct the file path and validate it
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
@@ -44,7 +50,7 @@ export async function GET(
}
// Read the file
const fileContent = fs.readFileSync(requestedPath);
const fileContent = await fs.promises.readFile(requestedPath);
// Determine content type based on file extension
let contentType = 'application/octet-stream';
@@ -64,8 +70,7 @@ export async function GET(
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
'Cache-Control': 'private, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
},
});
} catch (error) {
@@ -90,6 +95,8 @@ export async function HEAD(
return new NextResponse(null, { status: 400 });
}
if (!(await getViewer())) return new NextResponse(null, { status: 401 });
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
@@ -107,7 +114,7 @@ export async function HEAD(
status: 200,
headers: {
'Content-Type': 'image/jpeg', // Default for HEAD requests
'Cache-Control': 'public, max-age=31536000, immutable',
'Cache-Control': 'private, max-age=31536000, immutable',
},
});
} catch (error) {
+20 -28
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { checkUnlockEligibility } from '@/lib/watch-progress';
export async function GET(req: Request) {
try {
@@ -59,40 +60,31 @@ export async function POST(req: Request) {
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Verify video exists
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, locked: true, instantAccess: true },
});
if (!video) {
// Admins can open anything; everyone else must have earned the unlock.
const role = (session.user as any).role;
const isAdmin = role === 'admin' || role === 'superadmin';
if (!isAdmin) {
const check = await checkUnlockEligibility(user.id, videoId);
if (!check.ok) {
return NextResponse.json({ error: check.error }, { status: check.status });
}
if (check.free) {
return NextResponse.json({ message: 'Video does not need unlocking', unlock: null });
}
} else {
const exists = await prisma.video.findUnique({ where: { id: videoId }, select: { id: true } });
if (!exists) {
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Check if already unlocked
const existingUnlock = await prisma.videoUnlock.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
if (existingUnlock) {
return NextResponse.json({
message: 'Video already unlocked',
unlock: existingUnlock
});
}
// Create the unlock record
const unlock = await prisma.videoUnlock.create({
data: {
userId: user.id,
videoId: videoId,
},
const unlock = await prisma.videoUnlock.upsert({
where: { userId_videoId: { userId: user.id, videoId } },
update: {},
create: { userId: user.id, videoId },
});
return NextResponse.json({
message: 'Video unlocked successfully',
unlock
});
return NextResponse.json({ message: 'Video unlocked successfully', unlock });
} catch (err: any) {
console.error('user unlocks POST error', err);
+11
View File
@@ -4,6 +4,7 @@ import { prisma } from "../../../../lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth-options";
import { getVideoUrls } from "../../../../lib/video-urls";
import { checkVideoAccess } from "../../../../lib/video-access";
export async function GET(req: Request, context: any) {
try {
@@ -74,6 +75,16 @@ export async function GET(req: Request, context: any) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Sequential locks apply to students even when they follow a direct link.
const role = (session.user as any).role;
const isAdmin = role === "admin" || role === "superadmin";
if (!isAdmin) {
const access = await checkVideoAccess({ id: user.id, isAdmin: false }, id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
}
// Find the next video (index = current.index + 1)
const next = await prisma.video.findFirst({
where: {
+19 -3
View File
@@ -4,6 +4,7 @@
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
import { checkVideoAccess, getViewer } from '@/lib/video-access';
// Path to HLS directory (should match UPLOADS_DIR/hls)
const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
@@ -23,6 +24,17 @@ export async function GET(
);
}
// Signed-in viewers only, and only for videos they may watch.
// The first path segment is the video id.
const viewer = await getViewer();
if (!viewer) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const access = await checkVideoAccess(viewer, pathSegments[0]);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
// Construct the file path and validate it
const requestedPath = path.join(HLS_DIR, ...pathSegments);
@@ -44,7 +56,7 @@ export async function GET(
}
// Read the file
const fileContent = fs.readFileSync(requestedPath);
const fileContent = await fs.promises.readFile(requestedPath);
// Determine content type based on file extension
let contentType = 'application/octet-stream';
@@ -62,8 +74,7 @@ export async function GET(
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
'Cache-Control': 'private, max-age=3600', // Viewer's browser only, 1 hour
'Accept-Ranges': 'bytes',
},
});
@@ -89,6 +100,11 @@ export async function HEAD(
return new NextResponse(null, { status: 400 });
}
const viewer = await getViewer();
if (!viewer) return new NextResponse(null, { status: 401 });
const access = await checkVideoAccess(viewer, pathSegments[0]);
if (!access.ok) return new NextResponse(null, { status: access.status });
const requestedPath = path.join(HLS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
+2 -2
View File
@@ -400,7 +400,7 @@ export default function DashboardClient() {
handleOpenVideo(pl.id, v.id);
return;
}
if ((v as any).index === 0) {
if (v.id === pl.videos?.[0]?.id) {
handleOpenVideo(pl.id, v.id);
return;
}
@@ -417,7 +417,7 @@ export default function DashboardClient() {
className="w-full h-full object-cover"
loading="lazy"
/>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
{(v as any).locked || (!(v as any).instantAccess && v.id !== pl.videos?.[0]?.id && !userUnlocks.has(v.id)) ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
+51 -1
View File
@@ -8,6 +8,8 @@ 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");
@@ -63,12 +65,52 @@ function parseRange(
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": "public, max-age=3600",
"Cache-Control": "private, max-age=3600",
"Last-Modified": stats.mtime.toUTCString(),
};
}
@@ -84,6 +126,11 @@ export async function GET(
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}`);
@@ -132,6 +179,9 @@ export async function HEAD(
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 });
+246 -344
View File
@@ -27,6 +27,7 @@ import { Button } from '@/components/ui/button';
import { HlsPlayer } from '@/components/hls';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
import { toast } from 'sonner';
type VideoFromApi = {
id: string;
@@ -50,221 +51,186 @@ type VideoFromApi = {
} | null;
};
type ProgressResult = {
progress?: { percent?: number };
segments?: WatchSegment[];
unlockedNext?: { id: string; title: string } | null;
};
type TrackedVideo = { videoId: string; durationSec: number | null };
/**
* Records which parts of a video were actually played and reports them to
* /api/progress.
*
* It samples the <video> element on a timer rather than relying on media
* events, so it keeps working when the element is swapped (one per video) or
* when playback starts before anything else on the page has settled. A sample
* only counts as watched when the playhead moved forward by no more than the
* wall-clock time that passed, which excludes seeks and scrubbing.
*/
export function useWatchTracker(
videoRef: React.RefObject<HTMLVideoElement | null>,
opts: { sendIntervalMs?: number; commitIntervalSec?: number; debug?: boolean } = {}
opts: {
sendIntervalMs?: number;
sampleIntervalMs?: number;
onResult?: (videoId: string, result: ProgressResult) => void;
} = {}
) {
const sendIntervalMs = opts.sendIntervalMs ?? 5000;
const commitIntervalSec = opts.commitIntervalSec ?? 3;
const debug = !!opts.debug;
const sampleIntervalMs = opts.sampleIntervalMs ?? 500;
const rangesRef = React.useRef<Array<[number, number]>>([]);
const lastTimeRef = React.useRef<number | null>(null);
const currentRangeStartRef = React.useRef<number | null>(null);
const sendTimerRef = React.useRef<number | null>(null);
const commitTimerRef = React.useRef<number | null>(null);
// merging helper
function mergeRanges(ranges: Array<[number, number]>) {
if (!ranges.length) return [];
ranges.sort((a, b) => a[0] - b[0]);
const merged: Array<[number, number]> = [];
for (const [s, e] of ranges) {
if (!merged.length) merged.push([s, e]);
else {
const last = merged[merged.length - 1];
if (s <= last[1] + 0.5) last[1] = Math.max(last[1], e);
else merged.push([s, e]);
}
}
return merged;
}
function addRange(s: number, e: number) {
if (e <= s) return;
if (debug) console.debug("[tracker] addRange", s, e);
rangesRef.current.push([s, e]);
rangesRef.current = mergeRanges(rangesRef.current);
}
function getUniqueWatchedSec() {
let sum = 0;
for (const [a, b] of rangesRef.current) sum += Math.max(0, b - a);
return Math.round(sum);
}
// commit the "current" playing segment to ranges (useful while playing)
function commitCurrentRange() {
const el = videoRef.current;
if (!el) return;
const start = currentRangeStartRef.current;
const now = el.currentTime;
if (start === null) return;
// only commit if we've moved forward at least 0.5s to avoid noise
if (now - start >= 0.5) {
addRange(start, now);
// keep currentRangeStart open at 'now' so we continue accumulating
currentRangeStartRef.current = now;
}
}
// send progress to server (same signature you had)
async function sendProgress(args: { videoId: string; playlistId?: string; duration: number; extra?: Record<string, any> }) {
const { videoId, playlistId, duration, extra } = args;
const watchedSec = getUniqueWatchedSec();
const el = videoRef.current;
const lastPos = Math.floor(el?.currentTime ?? 0);
if (!duration || duration <= 0) return null;
if (watchedSec === 0 && (el?.paused ?? true)) return null;
const payload = { videoId, playlistId, watchedSec, lastPos, duration, ...extra };
try {
if (debug) console.debug("[tracker] sendProgress", payload);
const res = await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
const onResultRef = React.useRef(opts.onResult);
React.useEffect(() => {
onResultRef.current = opts.onResult;
});
// If video not found, stop trying to track progress
if (!res.ok) {
if (res.status === 404) {
console.warn(`[tracker] Video ${videoId} not found, stopping progress tracking`);
stopAutoSend(); // Stop automatic progress tracking for deleted video
return null;
}
return null;
}
const activeRef = React.useRef<TrackedVideo | null>(null);
const rangesRef = React.useRef<Array<[number, number]>>([]);
const lastSampleRef = React.useRef<{ el: HTMLVideoElement; time: number; wall: number } | null>(null);
const lastPosRef = React.useRef(0);
const elementDurationRef = React.useRef(0);
const dirtyRef = React.useRef(false);
return res.ok ? await res.json().catch(() => null) : null;
} catch (err) {
if (debug) console.warn("[tracker] sendProgress error", err);
return null;
}
}
const reset = React.useCallback((next: TrackedVideo | null) => {
activeRef.current = next;
rangesRef.current = [];
lastSampleRef.current = null;
lastPosRef.current = 0;
elementDurationRef.current = 0;
dirtyRef.current = false;
}, []);
// start/stop auto-send
function startAutoSend(args: { videoId: string; playlistId?: string; duration: number }) {
stopAutoSend(); // clear existing
// immediate send (best-effort)
sendProgress(args).catch(() => {});
// periodic send
sendTimerRef.current = window.setInterval(() => {
const watched = getUniqueWatchedSec();
const sample = React.useCallback(() => {
const el = videoRef.current;
const isPlaying = !!(el && !el.paused && !el.ended);
if (watched > 0 || isPlaying) {
sendProgress(args).catch(() => {});
}
}, sendIntervalMs) as unknown as number;
// commit currently playing ranges periodically (so continuous play is captured)
commitTimerRef.current = window.setInterval(() => {
commitCurrentRange();
}, commitIntervalSec * 1000) as unknown as number;
if (debug) console.debug("[tracker] startAutoSend", { sendIntervalMs, commitIntervalSec });
}
function stopAutoSend(opts?: { sendFinal?: boolean; videoId?: string; playlistId?: string; duration?: number }) {
if (sendTimerRef.current) {
window.clearInterval(sendTimerRef.current);
sendTimerRef.current = null;
}
if (commitTimerRef.current) {
window.clearInterval(commitTimerRef.current);
commitTimerRef.current = null;
}
if (opts?.sendFinal && opts.videoId && opts.duration) {
sendProgress({ videoId: opts.videoId, playlistId: opts.playlistId, duration: opts.duration }).catch(() => {});
}
if (debug) console.debug("[tracker] stopAutoSend");
}
// attach listeners to populate rangesRef
React.useEffect(() => {
const el = videoRef.current;
if (!el) {
if (debug) console.debug("[tracker] no video element to attach");
if (!activeRef.current || !el || typeof el.currentTime !== 'number') {
lastSampleRef.current = null;
return;
}
currentRangeStartRef.current = null;
lastTimeRef.current = el.currentTime ?? 0;
const onPlay = () => {
currentRangeStartRef.current = el.currentTime;
lastTimeRef.current = el.currentTime;
if (debug) console.debug("[tracker] play", currentRangeStartRef.current);
};
const onPause = () => {
if (currentRangeStartRef.current !== null) {
addRange(currentRangeStartRef.current, el.currentTime);
currentRangeStartRef.current = null;
}
if (debug) console.debug("[tracker] pause, ranges:", rangesRef.current);
};
const onTimeUpdate = () => {
const prev = lastSampleRef.current;
const now = el.currentTime;
const last = lastTimeRef.current ?? now;
// detect seek (big jump)
if (Math.abs(now - last) > 2.5) {
if (currentRangeStartRef.current !== null) {
addRange(currentRangeStartRef.current, last);
}
currentRangeStartRef.current = now;
if (debug) console.debug("[tracker] seek detected, start:", now);
}
lastTimeRef.current = now;
// we do not addRange here to avoid flooding; commit timer handles mid-play commits
};
const wall = performance.now();
lastSampleRef.current = { el, time: now, wall };
const onEnded = () => {
if (currentRangeStartRef.current !== null) {
addRange(currentRangeStartRef.current, el.duration ?? lastTimeRef.current ?? 0);
currentRangeStartRef.current = null;
}
if (debug) console.debug("[tracker] ended, ranges:", rangesRef.current);
};
// First sample of a new element: nothing to compare against yet.
if (!prev || prev.el !== el) return;
el.addEventListener("play", onPlay);
el.addEventListener("pause", onPause);
el.addEventListener("timeupdate", onTimeUpdate);
el.addEventListener("ended", onEnded);
lastPosRef.current = now;
if (Number.isFinite(el.duration) && el.duration > 0) {
elementDurationRef.current = el.duration;
}
if (el.seeking) return;
const delta = now - prev.time;
if (delta <= 0) return; // paused, or jumped backwards
// Real playback cannot outrun the clock (timers may fire late in
// background tabs, hence wall-clock time rather than the interval).
const elapsedSec = (wall - prev.wall) / 1000;
const maxAdvance = elapsedSec * Math.max(1, el.playbackRate || 1) + 0.75;
if (delta > maxAdvance) return; // jumped forwards
const ranges = rangesRef.current;
const last = ranges[ranges.length - 1];
if (last && Math.abs(last[1] - prev.time) < 0.01) {
last[1] = now; // still in the same stretch of playback
} else {
ranges.push([prev.time, now]);
}
dirtyRef.current = true;
}, [videoRef]);
const flush = React.useCallback(async (keepalive = false) => {
const active = activeRef.current;
if (!active || !dirtyRef.current || rangesRef.current.length === 0) return;
const duration = active.durationSec || Math.floor(elementDurationRef.current);
if (!duration || duration <= 0) return; // metadata not loaded yet; try again next tick
// Everything played this session is resent each time; the server merges,
// so a dropped request loses nothing.
const ranges = rangesRef.current.map(([s, e]) => [s, e]);
dirtyRef.current = false;
try {
const res = await fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive,
body: JSON.stringify({
videoId: active.videoId,
ranges,
lastPos: Math.floor(lastPosRef.current),
duration,
}),
});
const stillActive = activeRef.current?.videoId === active.videoId;
if (!res.ok) {
if (res.status === 404) {
// Video was deleted: stop tracking it.
if (stillActive) reset(null);
} else if (stillActive) {
dirtyRef.current = true;
}
return;
}
const json = await res.json().catch(() => null);
if (json) onResultRef.current?.(active.videoId, json);
} catch {
if (activeRef.current?.videoId === active.videoId) dirtyRef.current = true;
}
}, [reset]);
/** Begin tracking a video. Anything recorded for a previous video is dropped. */
const start = React.useCallback((video: TrackedVideo) => reset(video), [reset]);
/** Send what is left for the current video, then stop tracking it. */
const stop = React.useCallback(() => {
void flush(true);
reset(null);
}, [flush, reset]);
React.useEffect(() => {
const sampleTimer = window.setInterval(sample, sampleIntervalMs);
const sendTimer = window.setInterval(() => void flush(), sendIntervalMs);
// Leaving or hiding the page: get the latest progress out while we can.
const flushNow = () => {
sample();
void flush(true);
};
const onVisibility = () => {
if (document.visibilityState === 'hidden') flushNow();
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', flushNow);
return () => {
el.removeEventListener("play", onPlay);
el.removeEventListener("pause", onPause);
el.removeEventListener("timeupdate", onTimeUpdate);
el.removeEventListener("ended", onEnded);
stopAutoSend();
window.clearInterval(sampleTimer);
window.clearInterval(sendTimer);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pagehide', flushNow);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef.current]);
}, [sample, flush, sampleIntervalMs, sendIntervalMs]);
return {
getUniqueWatchedSec,
addRange,
sendProgress,
startAutoSend,
stopAutoSend,
};
return { start, stop };
}
export default function Page() {
const search = useSearchParams();
const router = useRouter();
const playlistId = search.get('playlistId') ?? undefined;
const playlistIdParam = search.get('playlistId') ?? undefined;
const videoId = search.get('videoId') ?? undefined;
const { playlist, isLoading: playlistLoading } = usePlaylist(playlistId);
const { video, next, isLoading: videoLoading } = useVideo(videoId);
const { video, isLoading: videoLoading, error: videoError } = useVideo(videoId);
// Links from watch history and liked videos carry no playlist; use the video's own.
const { playlist, isLoading: playlistLoading } = usePlaylist(
playlistIdParam ?? (video as any)?.playlistId ?? undefined
);
// Segments state for current video and playlist videos
const [currentSegments, setCurrentSegments] = React.useState<WatchSegment[]>([]);
@@ -273,38 +239,68 @@ export default function Page() {
// Per-user unlock state
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set()); // Set of unlocked videoIds
const [videoInstantAccess, setVideoInstantAccess] = React.useState<Record<string, boolean>>({}); // videoId -> instantAccess
const [unlockingVideo, setUnlockingVideo] = React.useState<string | null>(null); // videoId being unlocked
// inside your videoplayer component
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const tracker = useWatchTracker(videoRef, { sendIntervalMs: 5000 });
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const trackedVideoIdRef = React.useRef<string | null>(null);
const userUnlocksRef = React.useRef(userUnlocks);
React.useEffect(() => {
userUnlocksRef.current = userUnlocks;
}, [userUnlocks]);
// Fetch segments for current video
// Every progress update returns the server's view: percentage, merged
// segments, and the next video if this one just unlocked it.
const handleProgressResult = React.useCallback((id: string, result: ProgressResult) => {
const segments = result.segments;
if (segments) {
setPlaylistSegments((prev) => ({ ...prev, [id]: segments }));
}
if (id === trackedVideoIdRef.current) {
if (segments) setCurrentSegments(segments);
if (typeof result.progress?.percent === 'number') setCurrentProgress(result.progress.percent);
}
const unlocked = result.unlockedNext;
if (unlocked && !userUnlocksRef.current.has(unlocked.id)) {
setUserUnlocks((prev) => new Set(prev).add(unlocked.id));
toast.success(`Unlocked: ${unlocked.title}`);
}
}, []);
const tracker = useWatchTracker(videoRef, { onResult: handleProgressResult });
const { start: startTracking, stop: stopTracking } = tracker;
// Load saved progress for the current video. Live updates then arrive with
// each progress save, so there is no need to poll.
React.useEffect(() => {
if (!videoId) return;
let cancelled = false;
const fetchSegments = async () => {
try {
const res = await fetch(`/api/progress/segments?videoId=${videoId}`);
const [res, progressRes] = await Promise.all([
fetch(`/api/progress/segments?videoId=${videoId}`),
fetch(`/api/progress?videoId=${videoId}`),
]);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
setCurrentSegments(data.segments ?? []);
if (!cancelled) setCurrentSegments(data.segments ?? []);
}
const progressRes = await fetch(`/api/progress?videoId=${videoId}`);
if (progressRes.ok) {
const data = await progressRes.json();
setCurrentProgress(data.percent ?? 0);
if (!cancelled) setCurrentProgress(data.percent ?? 0);
}
} catch (err) {
console.error('Failed to fetch segments:', err);
}
};
setCurrentSegments([]);
setCurrentProgress(0);
fetchSegments();
// Refresh segments every 5 seconds
const interval = setInterval(fetchSegments, 5000);
return () => clearInterval(interval);
return () => {
cancelled = true;
};
}, [videoId]);
// Fetch segments for all playlist videos
@@ -313,10 +309,8 @@ React.useEffect(() => {
const fetchPlaylistSegments = async () => {
const segments: Record<string, WatchSegment[]> = {};
const instantAccess: Record<string, boolean> = {};
for (const video of playlist.videos) {
// Fetch segments
try {
const res = await fetch(`/api/progress/segments?videoId=${video.id}`);
if (res.ok) {
@@ -326,15 +320,13 @@ React.useEffect(() => {
} catch (err) {
console.error(`Failed to fetch segments for video ${video.id}:`, err);
}
// Track instantAccess status from video object
instantAccess[video.id] = (video as any).instantAccess ?? false;
}
setPlaylistSegments(segments);
setVideoInstantAccess(instantAccess);
// Keep anything newer that arrived from a progress save in the meantime.
setPlaylistSegments((prev) => ({ ...segments, ...prev }));
};
setPlaylistSegments({});
fetchPlaylistSegments();
}, [playlist?.videos]);
@@ -345,8 +337,9 @@ React.useEffect(() => {
const res = await fetch('/api/user/unlocks');
if (res.ok) {
const data = await res.json();
const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
setUserUnlocks(unlockedVideoIds);
const unlockedVideoIds = (data.unlocks?.map((u: any) => u.videoId) ?? []) as string[];
// Merge, in case a progress save unlocked something while this loaded.
setUserUnlocks((prev) => new Set([...prev, ...unlockedVideoIds]));
}
} catch (err) {
console.error('Failed to fetch user unlocks:', err);
@@ -356,55 +349,20 @@ React.useEffect(() => {
fetchUserUnlocks();
}, []);
// Track whichever video is loaded; switching videos flushes the old one first.
const loadedVideoId = video?.id ? String(video.id) : null;
const loadedVideoDuration: number | null = video?.durationSec ?? null;
React.useEffect(() => {
if (!video) return;
if (!loadedVideoId) return;
let didCancel = false;
let poll: number | null = null;
const startWhenReady = () => {
if (didCancel) return;
const el = videoRef.current;
const duration = (video.durationSec ?? Math.floor(el?.duration ?? 0)) || 0;
if (el) {
tracker.startAutoSend({
videoId: String(video.id),
playlistId: playlist?.id,
duration,
});
} else {
// poll until the video element mounts (should be quick)
poll = window.setInterval(() => {
if (videoRef.current) {
if (poll) {
window.clearInterval(poll);
poll = null;
}
tracker.startAutoSend({
videoId: String(video.id),
playlistId: playlist?.id,
duration: (video.durationSec ?? Math.floor(videoRef.current?.duration ?? 0)) || 0,
});
}
}, 150) as unknown as number;
}
};
startWhenReady();
trackedVideoIdRef.current = loadedVideoId;
startTracking({ videoId: loadedVideoId, durationSec: loadedVideoDuration });
return () => {
didCancel = true;
if (poll) {
window.clearInterval(poll);
poll = null;
}
const duration = video?.durationSec ?? Math.floor(videoRef.current?.duration ?? 0);
// send final snapshot
tracker.stopAutoSend({ sendFinal: true, videoId: String(video?.id ?? ''), playlistId: playlist?.id, duration });
stopTracking();
trackedVideoIdRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.id, playlist?.id]);
}, [loadedVideoId, loadedVideoDuration, startTracking, stopTracking]);
const current: VideoFromApi = (video as VideoFromApi) ?? {
@@ -425,84 +383,40 @@ React.useEffect(() => {
return `${mins}:${secs}`;
};
const handleUnlock = async (v: VideoFromApi) => {
console.log('unlock request for', v.id);
// A video is open when it is the first in the playlist, marked instant
// access, or unlocked for this user. The lecturer's lock overrides all three.
const firstVideoId: string | undefined = playlist?.videos?.[0]?.id;
const isAccessible = (v: VideoFromApi) =>
!v.locked &&
(!!(v as any).instantAccess || v.id === firstVideoId || userUnlocks.has(v.id));
// Prevent multiple unlock attempts
if (unlockingVideo === v.id) return;
const openVideo = (v: VideoFromApi) => {
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
};
// The server decides whether the unlock is earned and explains when it is not.
const handleUnlock = async (v: VideoFromApi) => {
if (unlockingVideo === v.id || v.locked) return;
setUnlockingVideo(v.id);
try {
// Can't unlock if globally locked
if ((v as any).locked) {
console.log('Video is globally locked, cannot unlock');
return;
}
// Can't unlock if already unlocked or has instant access
if ((v as any).instantAccess || userUnlocks.has(v.id)) {
console.log('Video already accessible');
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
return;
}
// Check if previous video in sequence is completed
if (!playlist?.videos) return;
const videoIndex = (v as any).index;
if (videoIndex <= 0) {
// First video should always be accessible
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
return;
}
// Find previous video
const previousVideo = playlist.videos.find((pv: any) => pv.index === videoIndex - 1);
if (!previousVideo) {
console.log('Previous video not found');
return;
}
// Check if previous video is completed
const progressRes = await fetch(`/api/progress?videoId=${previousVideo.id}`);
if (!progressRes.ok) {
console.error('Failed to fetch previous video progress');
alert('Error checking unlock requirements. Please try again.');
return;
}
const progressData = await progressRes.json();
const isCompleted = progressData.percent >= 90; // Consider 90% as completed
if (!isCompleted) {
console.log('Previous video not completed yet - need', Math.ceil(90 - progressData.percent), '% more');
// TODO: Show toast notification explaining unlock requirements
alert(`You need to complete ${Math.ceil(90 - progressData.percent)}% more of the previous video to unlock this one.`);
return;
}
// Previous video is completed, unlock this video
const unlockRes = await fetch('/api/user/unlocks', {
const res = await fetch('/api/user/unlocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: v.id }),
});
const data = await res.json().catch(() => ({}));
if (unlockRes.ok) {
// Update local state
setUserUnlocks(prev => new Set(prev).add(v.id));
console.log('Video unlocked successfully');
// Redirect to the unlocked video
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
} else {
const errorData = await unlockRes.json().catch(() => ({ error: 'Unknown error' }));
console.error('Failed to unlock video:', errorData.error);
alert(`Failed to unlock video: ${errorData.error}`);
if (!res.ok) {
toast.error(data?.error ?? 'Could not unlock this video. Please try again.');
return;
}
setUserUnlocks((prev) => new Set(prev).add(v.id));
openVideo(v);
} catch (err) {
console.error('Error checking unlock conditions:', err);
alert('Error checking if video can be unlocked. Please try again.');
console.error('Error unlocking video:', err);
toast.error('Could not unlock this video. Please try again.');
} finally {
setUnlockingVideo(null);
}
@@ -585,7 +499,11 @@ React.useEffect(() => {
ref={videoRef}
/>
) : (
<div className="flex items-center justify-center h-80">Video not available</div>
<div className="flex items-center justify-center h-80 px-6 text-center">
{videoError?.message && !/^HTTP \d+$/.test(videoError.message)
? videoError.message
: 'Video not available'}
</div>
)}
</div>
</CardContent>
@@ -698,7 +616,7 @@ React.useEffect(() => {
) : (
playlist?.videos?.map((v: VideoFromApi) => {
const active = String(v.id) === String(current.id);
const watchedPercent = 0; // Replace with actual watched % from your state/API
const accessible = isAccessible(v);
return (
<div
key={v.id}
@@ -707,29 +625,13 @@ React.useEffect(() => {
active ? 'border-primary bg-muted' : 'border-transparent hover:border-border'
}`}
onClick={() => {
// Check if video is accessible based on the logic:
// 1. If locked at schema level, always deny
if ((v as any).locked) return;
// 2. If instantAccess, always allow
if ((v as any).instantAccess) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 3. If first video (index 0), always allow
if ((v as any).index === 0) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 4. Otherwise, check if user has unlocked it
if (userUnlocks.has(v.id)) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
}
if (accessible) openVideo(v);
}}
>
<div className="flex items-center gap-3">
<div className="relative w-20 h-12 shrink-0 overflow-hidden rounded">
<img src={v.thumbnail ?? '/01.jpg'} alt={v.title} className="w-full h-full object-cover" loading="lazy" />
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
{!accessible ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
@@ -742,23 +644,23 @@ React.useEffect(() => {
<Badge>{fmt(v)}</Badge>
</div>
<div className="text-xs text-muted-foreground truncate mt-1">
{(v as any).locked ? 'Locked' : (v as any).instantAccess || (v as any).index === 0 ? 'Available' : userUnlocks.has(v.id) ? 'Available' : active ? 'Now playing' : 'Locked'}
{active ? 'Now playing' : accessible ? 'Available' : 'Locked'}
</div>
</div>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
{!accessible ? (
<div className="ml-2">
<Button
size="sm"
variant={(v as any).locked ? "secondary" : "default"}
disabled={(v as any).locked || unlockingVideo === v.id}
variant={v.locked ? "secondary" : "default"}
disabled={v.locked || unlockingVideo === v.id}
onClick={(e) => {
e.stopPropagation();
handleUnlock(v);
}}
>
{unlockingVideo === v.id ? 'Unlocking...' : (v as any).locked ? 'Locked' : 'Unlock'}
{unlockingVideo === v.id ? 'Unlocking...' : v.locked ? 'Locked' : 'Unlock'}
</Button>
</div>
) : null}
+11 -2
View File
@@ -24,7 +24,11 @@ export function useVideo(id?: string) {
fetch(`/api/videos/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.ok) {
// The server explains refusals (e.g. which video to finish first).
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
const json = await res.json();
if (!cancelled) {
setVideo(json.video ?? null);
@@ -32,7 +36,12 @@ export function useVideo(id?: string) {
}
})
.catch((err) => {
if (!cancelled) setError(err);
if (!cancelled) {
// Never leave the previous video on screen under a new address.
setVideo(null);
setNext(null);
setError(err);
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
+55
View File
@@ -0,0 +1,55 @@
// lib/account-status.ts
// Whether an account may currently use the platform, checked against the
// database rather than whatever was true when the user signed in.
import { prisma } from './prisma';
import { normalizeEmail } from './normalize-email';
export type AccountStatus = {
/** False once a student is removed from, or deactivated on, the allow-list. */
allowed: boolean;
role: string;
/** Comma-separated course codes from the allow-list. */
levels: string;
};
// Sessions are checked on every request, so results are held briefly. A change
// to a student's access therefore takes effect within this window.
const TTL_MS = 60_000;
const MAX_ENTRIES = 5000;
const cache = new Map<string, { expires: number; status: AccountStatus }>();
export async function getAccountStatus(
userId: string,
opts: { fresh?: boolean } = {}
): Promise<AccountStatus> {
const hit = cache.get(userId);
if (!opts.fresh && hit && hit.expires > Date.now()) return hit.status;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, role: true },
});
let status: AccountStatus = { allowed: false, role: 'user', levels: '' };
if (user) {
const role = user.role ?? 'user';
const email = normalizeEmail(user.email);
const student = email
? await prisma.allowedStudent.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { levels: true, active: true },
})
: null;
const isAdmin = role === 'admin' || role === 'superadmin';
status = {
allowed: isAdmin || !!student?.active,
role,
levels: student?.levels ?? '',
};
}
if (cache.size >= MAX_ENTRIES) cache.clear();
cache.set(userId, { expires: Date.now() + TTL_MS, status });
return status;
}
+25 -37
View File
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "./prisma";
import { normalizeEmail } from "./normalize-email";
import { getAccountStatus } from "./account-status";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
@@ -125,48 +126,30 @@ export const authOptions: NextAuthOptions = {
/**
* JWT CALLBACK
* Token is the source of truth for session data.
* Role must ALWAYS come from the database.
* Runs on sign-in and again every time the session is read. Role, course
* levels and whether the account is still allowed always come from the
* database, so removing a student takes effect without waiting for their
* 30-day session to expire.
*/
async jwt({ token, user }) {
// On first login
if (user?.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
role: true,
email: true,
},
});
if (user?.id) token.id = user.id;
if (dbUser) {
token.id = dbUser.id;
token.role = dbUser.role ?? "user";
// Attach student levels if applicable
let levels = "";
const normalizedDbEmail = normalizeEmail(dbUser.email);
if (normalizedDbEmail) {
const allowedStudent = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedDbEmail,
mode: "insensitive",
},
},
select: { levels: true },
});
levels = allowedStudent?.levels ?? "";
}
token.levels = levels;
const userId = (token.id ?? token.sub) as string | undefined;
if (userId) {
try {
// A fresh sign-in always re-reads the database.
const status = await getAccountStatus(userId, { fresh: !!user });
token.id = userId;
token.role = status.role;
token.levels = status.levels;
token.disabled = !status.allowed;
} catch (err) {
// Database hiccup: keep what the token already says rather than
// signing everyone out.
console.error('[auth] account status check failed', err);
}
}
// Safety fallback
token.role = token.role ?? "user";
return token;
@@ -174,9 +157,14 @@ export const authOptions: NextAuthOptions = {
/**
* SESSION CALLBACK
* Only exposes data already trusted in the token.
* Only exposes data already trusted in the token. A disabled account gets
* a session with no user, which every route treats as signed out.
*/
async session({ session, token }) {
if (token.disabled) {
return { expires: session.expires };
}
if (session.user) {
(session.user as any).id = token.id ?? null;
(session.user as any).role = token.role ?? "user";
+62
View File
@@ -0,0 +1,62 @@
// lib/video-access.ts
// Who is asking, and may they watch this video? Shared by the video API and
// the routes that serve the media files themselves.
import { getServerSession } from 'next-auth';
import { authOptions } from './auth-options';
import { prisma } from './prisma';
import { checkUnlockEligibility } from './watch-progress';
export type Viewer = { id: string; isAdmin: boolean };
/** The signed-in user, or null. Does not touch the database. */
export async function getViewer(): Promise<Viewer | null> {
const session = await getServerSession(authOptions);
const user = session?.user as { id?: string; email?: string; role?: string } | undefined;
if (!user?.email || !user.id) return null;
return { id: user.id, isAdmin: user.role === 'admin' || user.role === 'superadmin' };
}
export type AccessResult = { ok: true } | { ok: false; status: number; error: string };
// A playing video requests a media chunk every few seconds, so a positive
// answer is remembered briefly. Refusals are never cached: an unlock applies
// straight away, while a newly applied lock can take up to this long to bite.
const TTL_MS = 60_000;
const MAX_ENTRIES = 20_000;
const allowed = new Map<string, number>();
/**
* Admins may watch anything. Students need to be enrolled, and the video must
* be open to them: first in its playlist, instant access, already unlocked, or
* earned by completing the video before it.
*/
export async function checkVideoAccess(viewer: Viewer, videoId: string): Promise<AccessResult> {
if (viewer.isAdmin) return { ok: true };
const key = `${viewer.id}:${videoId}`;
const expires = allowed.get(key);
if (expires && expires > Date.now()) return { ok: true };
const check = await checkUnlockEligibility(viewer.id, videoId);
const where = { userId_videoId: { userId: viewer.id, videoId } };
if (check.ok) {
if (!check.free) {
// Earned but never recorded (e.g. completed before unlocks were tracked).
await prisma.videoUnlock.upsert({
where,
update: {},
create: { userId: viewer.id, videoId },
});
}
} else {
const unlocked =
check.reason === 'not_earned' &&
(await prisma.videoUnlock.findUnique({ where, select: { id: true } }));
if (!unlocked) return { ok: false, status: check.status, error: check.error };
}
if (allowed.size >= MAX_ENTRIES) allowed.clear();
allowed.set(key, Date.now() + TTL_MS);
return { ok: true };
}
+224
View File
@@ -0,0 +1,224 @@
// lib/watch-progress.ts
// Single source of truth for watch-progress maths and the sequential unlock rule.
import { prisma } from './prisma';
/** A video counts as completed (and unlocks the next one) at this percentage. */
export const UNLOCK_THRESHOLD_PERCENT = 80;
export type SecRange = { startSec: number; endSec: number };
/**
* Merges overlapping or touching ranges. Returns a new array sorted by start.
* When ranges carry a `watchedAt`, the merged range keeps the most recent one.
*/
export function mergeRanges<T extends SecRange & { watchedAt?: Date }>(
ranges: T[]
): Array<SecRange & { watchedAt?: Date }> {
const sorted = ranges
.filter((r) => r.endSec > r.startSec)
.sort((a, b) => a.startSec - b.startSec);
const merged: Array<SecRange & { watchedAt?: Date }> = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && r.startSec <= last.endSec) {
last.endSec = Math.max(last.endSec, r.endSec);
if (r.watchedAt && (!last.watchedAt || r.watchedAt > last.watchedAt)) {
last.watchedAt = r.watchedAt;
}
} else {
merged.push({ startSec: r.startSec, endSec: r.endSec, watchedAt: r.watchedAt });
}
}
return merged;
}
export function totalSeconds(ranges: SecRange[]): number {
return ranges.reduce((sum, r) => sum + Math.max(0, r.endSec - r.startSec), 0);
}
/**
* Validates client-reported [start, end] pairs and snaps them to whole seconds
* inside the video. Anything malformed is dropped.
*/
export function sanitizeRanges(input: unknown, durationSec: number): SecRange[] {
if (!Array.isArray(input)) return [];
const max = Math.ceil(durationSec);
const out: SecRange[] = [];
for (const item of input.slice(0, 500)) {
if (!Array.isArray(item) || item.length < 2) continue;
const s = Number(item[0]);
const e = Number(item[1]);
if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) continue;
const startSec = Math.min(max, Math.max(0, Math.floor(s)));
const endSec = Math.min(max, Math.max(0, Math.ceil(e)));
if (endSec > startSec) out.push({ startSec, endSec });
}
return out;
}
type RestrictableVideo = {
videoCourses?: Array<{ courseId: string; exclusive: boolean }>;
};
/** Drops videos that are exclusive to courses the user is not enrolled in. */
export function filterAccessibleVideos<T extends RestrictableVideo>(
videos: T[],
userCourseIds: string[]
): T[] {
return videos.filter((video) => {
const restricted = (video.videoCourses ?? [])
.filter((a) => a.exclusive)
.map((a) => a.courseId);
if (restricted.length === 0) return true;
return restricted.some((courseId) => userCourseIds.includes(courseId));
});
}
type PlaylistVideo = {
id: string;
title: string;
index: number;
locked: boolean;
instantAccess: boolean;
};
/**
* The playlist's videos, in order, as this user sees them. Returns null when
* the user is not enrolled in any course the playlist belongs to.
*/
export async function getPlaylistVideosForUser(
userId: string,
playlistId: string
): Promise<PlaylistVideo[] | null> {
const [enrollments, playlist] = await Promise.all([
prisma.enrollment.findMany({ where: { userId }, select: { courseId: true } }),
prisma.playlist.findUnique({
where: { id: playlistId },
select: {
courseId: true,
courses: { select: { courseId: true } },
videos: {
orderBy: { index: 'asc' },
select: {
id: true,
title: true,
index: true,
locked: true,
instantAccess: true,
videoCourses: { select: { courseId: true, exclusive: true } },
},
},
},
}),
]);
if (!playlist) return null;
const userCourseIds = enrollments.map((e) => e.courseId);
const playlistCourseIds = [playlist.courseId, ...playlist.courses.map((c) => c.courseId)];
if (!playlistCourseIds.some((id) => userCourseIds.includes(id))) return null;
return filterAccessibleVideos(playlist.videos, userCourseIds).map(
({ id, title, index, locked, instantAccess }) => ({ id, title, index, locked, instantAccess })
);
}
/**
* Called when a user completes `video`: unlocks the next video in the playlist
* that needs unlocking (skipping lecturer-locked and instant-access videos).
*/
export async function unlockNextVideo(
userId: string,
video: { id: string; playlistId: string }
): Promise<{ id: string; title: string } | null> {
const videos = await getPlaylistVideosForUser(userId, video.playlistId);
if (!videos) return null;
const pos = videos.findIndex((v) => v.id === video.id);
if (pos === -1) return null;
const next = videos.slice(pos + 1).find((v) => !v.locked && !v.instantAccess);
if (!next) return null;
await prisma.videoUnlock.upsert({
where: { userId_videoId: { userId, videoId: next.id } },
update: {},
create: { userId, videoId: next.id },
});
return { id: next.id, title: next.title };
}
export type UnlockCheck =
| { ok: true; free: boolean }
| {
ok: false;
status: number;
reason: 'not_found' | 'locked' | 'no_access' | 'not_earned';
error: string;
};
/**
* Decides whether a user may unlock a video by hand. Mirrors unlockNextVideo:
* a video opens once any watchable video between it and the previous
* unlock-gated video (inclusive) has been completed.
* `free` means the video never needed an unlock record.
*/
export async function checkUnlockEligibility(
userId: string,
videoId: string
): Promise<UnlockCheck> {
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, playlistId: true, locked: true },
});
if (!video) return { ok: false, status: 404, reason: 'not_found', error: 'Video not found' };
if (video.locked) {
return {
ok: false,
status: 403,
reason: 'locked',
error: 'This video is locked by your lecturer.',
};
}
const videos = await getPlaylistVideosForUser(userId, video.playlistId);
const pos = videos ? videos.findIndex((v) => v.id === videoId) : -1;
if (!videos || pos === -1) {
return {
ok: false,
status: 403,
reason: 'no_access',
error: 'You do not have access to this video.',
};
}
if (videos[pos].instantAccess || pos === 0) return { ok: true, free: true };
// Walk back to the previous video that itself needs unlocking.
let gateStart = 0;
for (let i = pos - 1; i >= 0; i--) {
if (!videos[i].locked && !videos[i].instantAccess) {
gateStart = i;
break;
}
}
const gate = videos.slice(gateStart, pos).filter((v) => !v.locked);
if (gate.length === 0) return { ok: true, free: true };
const progress = await prisma.videoProgress.findMany({
where: { userId, videoId: { in: gate.map((v) => v.id) } },
select: { videoId: true, percent: true, completed: true },
});
const done = progress.some(
(p) => p.completed || p.percent >= UNLOCK_THRESHOLD_PERCENT
);
if (done) return { ok: true, free: false };
const previous = gate[gate.length - 1];
const watched = progress.find((p) => p.videoId === previous.id)?.percent ?? 0;
return {
ok: false,
status: 403,
reason: 'not_earned',
error: `Watch at least ${UNLOCK_THRESHOLD_PERCENT}% of "${previous.title}" to unlock this video (you are at ${watched}%).`,
};
}