Fix watch tracking, video unlocking and access-control gaps
Deploy Vault CMS / deploy (push) Failing after 38s

Watch progress and unlocking:
- Player samples the playhead and reports the ranges actually played,
  so skipped sections earn no credit and early plays are not lost
- Server merges ranges into compact segment rows; progress never regresses
- Single 80% unlock rule enforced server-side (lib/watch-progress.ts);
  the Unlock button and automatic unlock now agree
- Lock state and progress bar update live from each progress save
- Playlist is derived from the video, so unlocks work for shared
  playlists and for links from watch history and liked videos
- First/previous video decided by playlist position, not index numbers

Access control:
- Video API and HLS/MP4 routes require a signed-in viewer who is
  enrolled and has the video unlocked; thumbnails require sign-in
- Enrolment sync reads course codes from the allow-list, not the request
- /api/admin/meta is admin-only
- Account status, role and levels are re-checked from the database
  during a session, so deactivated students lose access within a minute

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-11-11 16:58:07 +02:00
parent 08b6e58b8e
commit a834caa511
16 changed files with 922 additions and 667 deletions
+4 -5
View File
@@ -1,13 +1,12 @@
// app/api/admin/meta/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { adminGuard } from '@/lib/admin-check';
export async function GET() {
// Basic protection: ensure signed-in user is admin. Modify as needed.
// If you want stricter, get session from context or check roles.
// Here we skip session check to let dev access; but production should check.
const denied = await adminGuard();
if (denied) return denied;
try {
const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
+54 -98
View File
@@ -1,122 +1,78 @@
// app/api/enrollments/sync/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
export async function POST(req: NextRequest) {
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
/**
* Enrols the signed-in user in the courses listed for them on the allow-list.
* The course codes are read from the database; anything the browser sends is
* ignored, so a user cannot enrol themselves in a course they were not given.
*/
export async function POST() {
try {
const session = await getServerSession(authOptions);
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
if (!session?.user?.email) {
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
const email = normalizeEmail(session?.user?.email);
if (!email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userEmail = session.user.email;
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
const body = await req.json();
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
const levels: string[] = body?.levels || [];
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
if (!Array.isArray(levels) || levels.length === 0) {
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
return NextResponse.json(
{ error: 'Levels array required' },
{ status: 400 }
);
}
// Get user
const user = await prisma.user.findUnique({
where: { email: userEmail },
const user = await prisma.user.findFirst({
where: { email: { equals: email, mode: 'insensitive' } },
select: { id: true },
});
if (!user) {
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
const 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 });
}
}
+17 -5
View File
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth-options";
import { filterAccessibleVideos } from "../../../../lib/watch-progress";
export async function GET(req: Request, context: any) {
try {
@@ -20,6 +21,7 @@ export async function GET(req: Request, context: any) {
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { enrollments: { select: { courseId: true } } },
});
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -37,21 +39,31 @@ export async function GET(req: Request, context: any) {
image: true,
},
},
videoCourses: { select: { courseId: true, exclusive: true } },
},
orderBy: { index: "asc" },
},
course: true,
courses: { select: { courseId: true } },
},
});
if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
const enrolled = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
});
if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Enrolment in the primary course or any course the playlist is shared with.
const userCourseIds = user.enrollments.map((e) => e.courseId);
const playlistCourseIds = [playlist.courseId, ...playlist.courses.map((c) => c.courseId)];
if (!playlistCourseIds.some((courseId) => userCourseIds.includes(courseId))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json({ playlist });
// Hide videos reserved for courses this user is not in, as /api/playlists does.
const visible = {
...playlist,
videos: filterAccessibleVideos(playlist.videos, userCourseIds),
};
return NextResponse.json({ playlist: visible });
} catch (err: any) {
console.error("GET /api/playlists/[id] error:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
+103 -131
View File
@@ -3,6 +3,14 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import {
UNLOCK_THRESHOLD_PERCENT,
mergeRanges,
sanitizeRanges,
totalSeconds,
unlockNextVideo,
type SecRange,
} from '@/lib/watch-progress';
export async function GET(req: Request) {
try {
@@ -47,36 +55,6 @@ export async function GET(req: Request) {
}
}
// Helper function to calculate unique watched seconds from segments
function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
if (segments.length === 0) return 0;
// Sort and merge overlapping ranges
const sorted = segments.sort((a, b) => a.startSec - b.startSec);
const merged: Array<[number, number]> = [];
for (const seg of sorted) {
if (merged.length === 0) {
merged.push([seg.startSec, seg.endSec]);
} else {
const last = merged[merged.length - 1];
if (seg.startSec <= last[1] + 0.5) {
// Overlapping or adjacent, merge
last[1] = Math.max(last[1], seg.endSec);
} else {
// Gap, new range
merged.push([seg.startSec, seg.endSec]);
}
}
}
let total = 0;
for (const [start, end] of merged) {
total += Math.max(0, end - start);
}
return Math.round(total);
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
@@ -86,137 +64,131 @@ export async function POST(req: Request) {
const userEmail = session.user.email;
const body = await req.json().catch(() => ({}));
const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
const { videoId, watchedSec, lastPos, duration } = body ?? {};
if (!videoId)
if (!videoId || typeof videoId !== 'string')
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
if (!duration || Number(duration) <= 0)
return NextResponse.json({ error: 'duration required' }, { status: 400 });
const watched = Number(watchedSec ?? 0);
const lastPosition = Number(lastPos ?? 0);
const dur = Number(duration);
// look up user id
const user = await prisma.user.findUnique({ where: { email: userEmail } });
const user = await prisma.user.findUnique({
where: { email: userEmail },
select: { id: true },
});
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Validate that the video exists
const video = await prisma.video.findUnique({ where: { id: videoId } });
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, playlistId: true, durationSec: true },
});
if (!video) {
// Log at debug level since this is expected when users have old cached references
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Ensure VideoProgress exists or create it
let videoProgress = await prisma.videoProgress.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
// The stored duration wins; the player's value is only a fallback for
// videos uploaded without one.
const dur =
video.durationSec && video.durationSec > 0 ? video.durationSec : Number(duration);
if (!Number.isFinite(dur) || dur <= 0)
return NextResponse.json({ error: 'duration required' }, { status: 400 });
if (!videoProgress) {
videoProgress = await prisma.videoProgress.create({
data: {
const lastPosition = Math.max(0, Math.floor(Number(lastPos ?? 0)) || 0);
// Current players send the ranges actually played. Tabs still running the
// previous player only send a total, from which one range is inferred.
let incoming: SecRange[];
if (Array.isArray(body?.ranges)) {
incoming = sanitizeRanges(body.ranges, dur);
} else {
const watched = Number(watchedSec ?? 0);
incoming = sanitizeRanges(
watched > 0 ? [[lastPosition - watched, lastPosition]] : [],
dur
);
}
const where = { userId_videoId: { userId: user.id, videoId } };
const { progress, segments } = await prisma.$transaction(async (tx) => {
const existingProgress = await tx.videoProgress.upsert({
where,
update: {},
create: {
userId: user.id,
videoId,
watchedSec: 0,
lastPos: 0,
percent: 0,
durationSec: dur,
durationSec: Math.round(dur),
},
});
}
// Record the watch segment if watched seconds > 0
let newSegment = null;
if (watched > 0) {
newSegment = await prisma.videoWatchSegment.create({
data: {
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 }
+11 -4
View File
@@ -4,6 +4,7 @@
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
import { getViewer } from '@/lib/video-access';
// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
@@ -23,6 +24,11 @@ export async function GET(
);
}
// Thumbnails show course content, so they need a signed-in user.
if (!(await getViewer())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Construct the file path and validate it
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
@@ -44,7 +50,7 @@ export async function GET(
}
// Read the file
const fileContent = fs.readFileSync(requestedPath);
const fileContent = await fs.promises.readFile(requestedPath);
// Determine content type based on file extension
let contentType = 'application/octet-stream';
@@ -64,8 +70,7 @@ export async function GET(
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
'Cache-Control': 'private, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
},
});
} catch (error) {
@@ -90,6 +95,8 @@ export async function HEAD(
return new NextResponse(null, { status: 400 });
}
if (!(await getViewer())) return new NextResponse(null, { status: 401 });
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
@@ -107,7 +114,7 @@ export async function HEAD(
status: 200,
headers: {
'Content-Type': 'image/jpeg', // Default for HEAD requests
'Cache-Control': 'public, max-age=31536000, immutable',
'Cache-Control': 'private, max-age=31536000, immutable',
},
});
} catch (error) {
+21 -29
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { checkUnlockEligibility } from '@/lib/watch-progress';
export async function GET(req: Request) {
try {
@@ -59,40 +60,31 @@ export async function POST(req: Request) {
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Verify video exists
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, locked: true, instantAccess: true },
});
if (!video) {
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);
+11
View File
@@ -4,6 +4,7 @@ import { prisma } from "../../../../lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth-options";
import { getVideoUrls } from "../../../../lib/video-urls";
import { checkVideoAccess } from "../../../../lib/video-access";
export async function GET(req: Request, context: any) {
try {
@@ -74,6 +75,16 @@ export async function GET(req: Request, context: any) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Sequential locks apply to students even when they follow a direct link.
const role = (session.user as any).role;
const isAdmin = role === "admin" || role === "superadmin";
if (!isAdmin) {
const access = await checkVideoAccess({ id: user.id, isAdmin: false }, id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
}
// Find the next video (index = current.index + 1)
const next = await prisma.video.findFirst({
where: {
+19 -3
View File
@@ -4,6 +4,7 @@
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
import { checkVideoAccess, getViewer } from '@/lib/video-access';
// Path to HLS directory (should match UPLOADS_DIR/hls)
const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
@@ -23,6 +24,17 @@ export async function GET(
);
}
// Signed-in viewers only, and only for videos they may watch.
// The first path segment is the video id.
const viewer = await getViewer();
if (!viewer) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const access = await checkVideoAccess(viewer, pathSegments[0]);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
// Construct the file path and validate it
const requestedPath = path.join(HLS_DIR, ...pathSegments);
@@ -44,7 +56,7 @@ export async function GET(
}
// Read the file
const fileContent = fs.readFileSync(requestedPath);
const fileContent = await fs.promises.readFile(requestedPath);
// Determine content type based on file extension
let contentType = 'application/octet-stream';
@@ -62,8 +74,7 @@ export async function GET(
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
'Cache-Control': 'private, max-age=3600', // Viewer's browser only, 1 hour
'Accept-Ranges': 'bytes',
},
});
@@ -89,6 +100,11 @@ export async function HEAD(
return new NextResponse(null, { status: 400 });
}
const viewer = await getViewer();
if (!viewer) return new NextResponse(null, { status: 401 });
const access = await checkVideoAccess(viewer, pathSegments[0]);
if (!access.ok) return new NextResponse(null, { status: access.status });
const requestedPath = path.join(HLS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks