From a834caa5112a8e04e17b5360a093a51aa1791d20 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:58:07 +0200 Subject: [PATCH] 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 --- app/api/admin/meta/route.ts | 9 +- app/api/enrollments/sync/route.ts | 152 +++---- app/api/playlists/[id]/route.ts | 22 +- app/api/progress/route.ts | 234 +++++----- app/api/thumbnails/[...path]/route.ts | 15 +- app/api/user/unlocks/route.ts | 50 +-- app/api/videos/[id]/route.ts | 11 + app/api/videos/hls/[...path]/route.ts | 22 +- app/dashboard/dashboard-client.tsx | 4 +- app/uploads/videos/[...path]/route.ts | 52 ++- app/videoplayer/page.tsx | 602 +++++++++++--------------- hooks/useVideo.ts | 13 +- lib/account-status.ts | 55 +++ lib/auth-options.ts | 62 ++- lib/video-access.ts | 62 +++ lib/watch-progress.ts | 224 ++++++++++ 16 files changed, 922 insertions(+), 667 deletions(-) create mode 100644 lib/account-status.ts create mode 100644 lib/video-access.ts create mode 100644 lib/watch-progress.ts diff --git a/app/api/admin/meta/route.ts b/app/api/admin/meta/route.ts index 73ed1aa..ed4ea5a 100644 --- a/app/api/admin/meta/route.ts +++ b/app/api/admin/meta/route.ts @@ -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' } }); diff --git a/app/api/enrollments/sync/route.ts b/app/api/enrollments/sync/route.ts index 767657a..c882f12 100644 --- a/app/api/enrollments/sync/route.ts +++ b/app/api/enrollments/sync/route.ts @@ -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 allowedStudent = await prisma.allowedStudent.findFirst({ + where: { email: { equals: email, mode: 'insensitive' } }, + select: { levels: true, active: true }, + }); - const createdEnrollments = []; + const levels = allowedStudent?.active + ? allowedStudent.levels + .split(',') + .map((level) => level.trim()) + .filter((level) => level.length > 0) + : []; - // 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 }, - }); - - if (!course) { - console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`); - continue; - } - - 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 }, - }); - - if (existing) { - console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id); - } else { - const enrollment = await prisma.enrollment.create({ - data: { - userId: user.id, - courseId: course.id, - }, - }); - console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id); - createdEnrollments.push({ - courseCode: level, - courseId: course.id, - enrollmentId: enrollment.id, - }); - } - } catch (err) { - console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err); - } + if (levels.length === 0) { + return NextResponse.json({ message: 'Enrollments synced', created: [] }); } - console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length); - console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments); + const courses = await prisma.course.findMany({ + where: { code: { in: levels } }, + select: { id: true, code: true }, + }); - return NextResponse.json( - { - message: 'Enrollments synced', - created: createdEnrollments, - }, - { status: 200 } - ); + 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 }, + }); + created.push({ + courseCode: course.code, + courseId: course.id, + enrollmentId: enrollment.id, + }); + } + + 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 }); } } diff --git a/app/api/playlists/[id]/route.ts b/app/api/playlists/[id]/route.ts index 5b7acb8..20b405a 100644 --- a/app/api/playlists/[id]/route.ts +++ b/app/api/playlists/[id]/route.ts @@ -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 }); diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts index 6690037..a6020c3 100644 --- a/app/api/progress/route.ts +++ b/app/api/progress/route.ts @@ -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: { - userId: user.id, - videoId, - startSec: Math.max(0, lastPosition - watched), - endSec: lastPosition, - }, + const existing = await tx.videoWatchSegment.findMany({ + where: { userId: user.id, videoId }, + select: { startSec: true, endSec: true, watchedAt: true }, + orderBy: { startSec: 'asc' }, }); - } - // 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' }, - }); - - // 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 } }, - data: { - watchedSec: totalWatchedSec, - lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition), - percent: percentInt, - durationSec: dur, - completed, - updatedAt: new Date(), - }, - }); - - // 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, + // 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: m.startSec, + endSec: m.endSec, + watchedAt: m.watchedAt ?? now, + })), }); - - 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 }; - } } - } - return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment }); + // 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; + + const updated = await tx.videoProgress.update({ + where, + data: { + watchedSec: Math.max(existingProgress.watchedSec, mergedSec), + lastPos: Math.max(existingProgress.lastPos ?? 0, lastPosition), + percent, + durationSec: Math.round(dur), + completed, + updatedAt: now, + }, + }); + + return { progress: updated, segments: merged }; + }); + + // 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, 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 } diff --git a/app/api/thumbnails/[...path]/route.ts b/app/api/thumbnails/[...path]/route.ts index 0607c94..73543a9 100644 --- a/app/api/thumbnails/[...path]/route.ts +++ b/app/api/thumbnails/[...path]/route.ts @@ -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) { diff --git a/app/api/user/unlocks/route.ts b/app/api/user/unlocks/route.ts index 490b5cf..558b150 100644 --- a/app/api/user/unlocks/route.ts +++ b/app/api/user/unlocks/route.ts @@ -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) { - return NextResponse.json({ error: 'video not found' }, { status: 404 }); + // 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({ + const unlock = await prisma.videoUnlock.upsert({ where: { userId_videoId: { userId: user.id, videoId } }, + update: {}, + create: { 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, - }, - }); - - 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); diff --git a/app/api/videos/[id]/route.ts b/app/api/videos/[id]/route.ts index a807688..4b9db98 100644 --- a/app/api/videos/[id]/route.ts +++ b/app/api/videos/[id]/route.ts @@ -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: { diff --git a/app/api/videos/hls/[...path]/route.ts b/app/api/videos/hls/[...path]/route.ts index 543a134..cf13296 100644 --- a/app/api/videos/hls/[...path]/route.ts +++ b/app/api/videos/hls/[...path]/route.ts @@ -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 diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx index 1965b75..e5d82e2 100644 --- a/app/dashboard/dashboard-client.tsx +++ b/app/dashboard/dashboard-client.tsx @@ -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)) ? (
diff --git a/app/uploads/videos/[...path]/route.ts b/app/uploads/videos/[...path]/route.ts index 45f8cf5..1114661 100644 --- a/app/uploads/videos/[...path]/route.ts +++ b/app/uploads/videos/[...path]/route.ts @@ -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(); + +async function findVideoId(segments: string[]): Promise { + 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 { 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 }); diff --git a/app/videoplayer/page.tsx b/app/videoplayer/page.tsx index aa9e700..ff65a05 100644 --- a/app/videoplayer/page.tsx +++ b/app/videoplayer/page.tsx @@ -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,261 +51,256 @@ 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