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