Fix watch tracking, video unlocking and access-control gaps
Deploy Vault CMS / deploy (push) Failing after 38s
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:
@@ -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' } });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+103
-131
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user