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:
@@ -0,0 +1,55 @@
|
||||
// lib/account-status.ts
|
||||
// Whether an account may currently use the platform, checked against the
|
||||
// database rather than whatever was true when the user signed in.
|
||||
import { prisma } from './prisma';
|
||||
import { normalizeEmail } from './normalize-email';
|
||||
|
||||
export type AccountStatus = {
|
||||
/** False once a student is removed from, or deactivated on, the allow-list. */
|
||||
allowed: boolean;
|
||||
role: string;
|
||||
/** Comma-separated course codes from the allow-list. */
|
||||
levels: string;
|
||||
};
|
||||
|
||||
// Sessions are checked on every request, so results are held briefly. A change
|
||||
// to a student's access therefore takes effect within this window.
|
||||
const TTL_MS = 60_000;
|
||||
const MAX_ENTRIES = 5000;
|
||||
const cache = new Map<string, { expires: number; status: AccountStatus }>();
|
||||
|
||||
export async function getAccountStatus(
|
||||
userId: string,
|
||||
opts: { fresh?: boolean } = {}
|
||||
): Promise<AccountStatus> {
|
||||
const hit = cache.get(userId);
|
||||
if (!opts.fresh && hit && hit.expires > Date.now()) return hit.status;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, role: true },
|
||||
});
|
||||
|
||||
let status: AccountStatus = { allowed: false, role: 'user', levels: '' };
|
||||
if (user) {
|
||||
const role = user.role ?? 'user';
|
||||
const email = normalizeEmail(user.email);
|
||||
const student = email
|
||||
? await prisma.allowedStudent.findFirst({
|
||||
where: { email: { equals: email, mode: 'insensitive' } },
|
||||
select: { levels: true, active: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const isAdmin = role === 'admin' || role === 'superadmin';
|
||||
status = {
|
||||
allowed: isAdmin || !!student?.active,
|
||||
role,
|
||||
levels: student?.levels ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
if (cache.size >= MAX_ENTRIES) cache.clear();
|
||||
cache.set(userId, { expires: Date.now() + TTL_MS, status });
|
||||
return status;
|
||||
}
|
||||
+25
-37
@@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { prisma } from "./prisma";
|
||||
import { normalizeEmail } from "./normalize-email";
|
||||
import { getAccountStatus } from "./account-status";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
@@ -125,48 +126,30 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
/**
|
||||
* JWT CALLBACK
|
||||
* Token is the source of truth for session data.
|
||||
* Role must ALWAYS come from the database.
|
||||
* Runs on sign-in and again every time the session is read. Role, course
|
||||
* levels and whether the account is still allowed always come from the
|
||||
* database, so removing a student takes effect without waiting for their
|
||||
* 30-day session to expire.
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
// On first login
|
||||
if (user?.id) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
if (user?.id) token.id = user.id;
|
||||
|
||||
if (dbUser) {
|
||||
token.id = dbUser.id;
|
||||
token.role = dbUser.role ?? "user";
|
||||
|
||||
// Attach student levels if applicable
|
||||
let levels = "";
|
||||
const normalizedDbEmail = normalizeEmail(dbUser.email);
|
||||
|
||||
if (normalizedDbEmail) {
|
||||
const allowedStudent = await prisma.allowedStudent.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: normalizedDbEmail,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
select: { levels: true },
|
||||
});
|
||||
|
||||
levels = allowedStudent?.levels ?? "";
|
||||
}
|
||||
|
||||
token.levels = levels;
|
||||
const userId = (token.id ?? token.sub) as string | undefined;
|
||||
if (userId) {
|
||||
try {
|
||||
// A fresh sign-in always re-reads the database.
|
||||
const status = await getAccountStatus(userId, { fresh: !!user });
|
||||
token.id = userId;
|
||||
token.role = status.role;
|
||||
token.levels = status.levels;
|
||||
token.disabled = !status.allowed;
|
||||
} catch (err) {
|
||||
// Database hiccup: keep what the token already says rather than
|
||||
// signing everyone out.
|
||||
console.error('[auth] account status check failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Safety fallback
|
||||
token.role = token.role ?? "user";
|
||||
|
||||
return token;
|
||||
@@ -174,9 +157,14 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
/**
|
||||
* SESSION CALLBACK
|
||||
* Only exposes data already trusted in the token.
|
||||
* Only exposes data already trusted in the token. A disabled account gets
|
||||
* a session with no user, which every route treats as signed out.
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (token.disabled) {
|
||||
return { expires: session.expires };
|
||||
}
|
||||
|
||||
if (session.user) {
|
||||
(session.user as any).id = token.id ?? null;
|
||||
(session.user as any).role = token.role ?? "user";
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// lib/video-access.ts
|
||||
// Who is asking, and may they watch this video? Shared by the video API and
|
||||
// the routes that serve the media files themselves.
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from './auth-options';
|
||||
import { prisma } from './prisma';
|
||||
import { checkUnlockEligibility } from './watch-progress';
|
||||
|
||||
export type Viewer = { id: string; isAdmin: boolean };
|
||||
|
||||
/** The signed-in user, or null. Does not touch the database. */
|
||||
export async function getViewer(): Promise<Viewer | null> {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user as { id?: string; email?: string; role?: string } | undefined;
|
||||
if (!user?.email || !user.id) return null;
|
||||
return { id: user.id, isAdmin: user.role === 'admin' || user.role === 'superadmin' };
|
||||
}
|
||||
|
||||
export type AccessResult = { ok: true } | { ok: false; status: number; error: string };
|
||||
|
||||
// A playing video requests a media chunk every few seconds, so a positive
|
||||
// answer is remembered briefly. Refusals are never cached: an unlock applies
|
||||
// straight away, while a newly applied lock can take up to this long to bite.
|
||||
const TTL_MS = 60_000;
|
||||
const MAX_ENTRIES = 20_000;
|
||||
const allowed = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Admins may watch anything. Students need to be enrolled, and the video must
|
||||
* be open to them: first in its playlist, instant access, already unlocked, or
|
||||
* earned by completing the video before it.
|
||||
*/
|
||||
export async function checkVideoAccess(viewer: Viewer, videoId: string): Promise<AccessResult> {
|
||||
if (viewer.isAdmin) return { ok: true };
|
||||
|
||||
const key = `${viewer.id}:${videoId}`;
|
||||
const expires = allowed.get(key);
|
||||
if (expires && expires > Date.now()) return { ok: true };
|
||||
|
||||
const check = await checkUnlockEligibility(viewer.id, videoId);
|
||||
const where = { userId_videoId: { userId: viewer.id, videoId } };
|
||||
|
||||
if (check.ok) {
|
||||
if (!check.free) {
|
||||
// Earned but never recorded (e.g. completed before unlocks were tracked).
|
||||
await prisma.videoUnlock.upsert({
|
||||
where,
|
||||
update: {},
|
||||
create: { userId: viewer.id, videoId },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const unlocked =
|
||||
check.reason === 'not_earned' &&
|
||||
(await prisma.videoUnlock.findUnique({ where, select: { id: true } }));
|
||||
if (!unlocked) return { ok: false, status: check.status, error: check.error };
|
||||
}
|
||||
|
||||
if (allowed.size >= MAX_ENTRIES) allowed.clear();
|
||||
allowed.set(key, Date.now() + TTL_MS);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// lib/watch-progress.ts
|
||||
// Single source of truth for watch-progress maths and the sequential unlock rule.
|
||||
import { prisma } from './prisma';
|
||||
|
||||
/** A video counts as completed (and unlocks the next one) at this percentage. */
|
||||
export const UNLOCK_THRESHOLD_PERCENT = 80;
|
||||
|
||||
export type SecRange = { startSec: number; endSec: number };
|
||||
|
||||
/**
|
||||
* Merges overlapping or touching ranges. Returns a new array sorted by start.
|
||||
* When ranges carry a `watchedAt`, the merged range keeps the most recent one.
|
||||
*/
|
||||
export function mergeRanges<T extends SecRange & { watchedAt?: Date }>(
|
||||
ranges: T[]
|
||||
): Array<SecRange & { watchedAt?: Date }> {
|
||||
const sorted = ranges
|
||||
.filter((r) => r.endSec > r.startSec)
|
||||
.sort((a, b) => a.startSec - b.startSec);
|
||||
|
||||
const merged: Array<SecRange & { watchedAt?: Date }> = [];
|
||||
for (const r of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && r.startSec <= last.endSec) {
|
||||
last.endSec = Math.max(last.endSec, r.endSec);
|
||||
if (r.watchedAt && (!last.watchedAt || r.watchedAt > last.watchedAt)) {
|
||||
last.watchedAt = r.watchedAt;
|
||||
}
|
||||
} else {
|
||||
merged.push({ startSec: r.startSec, endSec: r.endSec, watchedAt: r.watchedAt });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function totalSeconds(ranges: SecRange[]): number {
|
||||
return ranges.reduce((sum, r) => sum + Math.max(0, r.endSec - r.startSec), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates client-reported [start, end] pairs and snaps them to whole seconds
|
||||
* inside the video. Anything malformed is dropped.
|
||||
*/
|
||||
export function sanitizeRanges(input: unknown, durationSec: number): SecRange[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const max = Math.ceil(durationSec);
|
||||
const out: SecRange[] = [];
|
||||
for (const item of input.slice(0, 500)) {
|
||||
if (!Array.isArray(item) || item.length < 2) continue;
|
||||
const s = Number(item[0]);
|
||||
const e = Number(item[1]);
|
||||
if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) continue;
|
||||
const startSec = Math.min(max, Math.max(0, Math.floor(s)));
|
||||
const endSec = Math.min(max, Math.max(0, Math.ceil(e)));
|
||||
if (endSec > startSec) out.push({ startSec, endSec });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type RestrictableVideo = {
|
||||
videoCourses?: Array<{ courseId: string; exclusive: boolean }>;
|
||||
};
|
||||
|
||||
/** Drops videos that are exclusive to courses the user is not enrolled in. */
|
||||
export function filterAccessibleVideos<T extends RestrictableVideo>(
|
||||
videos: T[],
|
||||
userCourseIds: string[]
|
||||
): T[] {
|
||||
return videos.filter((video) => {
|
||||
const restricted = (video.videoCourses ?? [])
|
||||
.filter((a) => a.exclusive)
|
||||
.map((a) => a.courseId);
|
||||
if (restricted.length === 0) return true;
|
||||
return restricted.some((courseId) => userCourseIds.includes(courseId));
|
||||
});
|
||||
}
|
||||
|
||||
type PlaylistVideo = {
|
||||
id: string;
|
||||
title: string;
|
||||
index: number;
|
||||
locked: boolean;
|
||||
instantAccess: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The playlist's videos, in order, as this user sees them. Returns null when
|
||||
* the user is not enrolled in any course the playlist belongs to.
|
||||
*/
|
||||
export async function getPlaylistVideosForUser(
|
||||
userId: string,
|
||||
playlistId: string
|
||||
): Promise<PlaylistVideo[] | null> {
|
||||
const [enrollments, playlist] = await Promise.all([
|
||||
prisma.enrollment.findMany({ where: { userId }, select: { courseId: true } }),
|
||||
prisma.playlist.findUnique({
|
||||
where: { id: playlistId },
|
||||
select: {
|
||||
courseId: true,
|
||||
courses: { select: { courseId: true } },
|
||||
videos: {
|
||||
orderBy: { index: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
index: true,
|
||||
locked: true,
|
||||
instantAccess: true,
|
||||
videoCourses: { select: { courseId: true, exclusive: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (!playlist) return null;
|
||||
|
||||
const userCourseIds = enrollments.map((e) => e.courseId);
|
||||
const playlistCourseIds = [playlist.courseId, ...playlist.courses.map((c) => c.courseId)];
|
||||
if (!playlistCourseIds.some((id) => userCourseIds.includes(id))) return null;
|
||||
|
||||
return filterAccessibleVideos(playlist.videos, userCourseIds).map(
|
||||
({ id, title, index, locked, instantAccess }) => ({ id, title, index, locked, instantAccess })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a user completes `video`: unlocks the next video in the playlist
|
||||
* that needs unlocking (skipping lecturer-locked and instant-access videos).
|
||||
*/
|
||||
export async function unlockNextVideo(
|
||||
userId: string,
|
||||
video: { id: string; playlistId: string }
|
||||
): Promise<{ id: string; title: string } | null> {
|
||||
const videos = await getPlaylistVideosForUser(userId, video.playlistId);
|
||||
if (!videos) return null;
|
||||
const pos = videos.findIndex((v) => v.id === video.id);
|
||||
if (pos === -1) return null;
|
||||
|
||||
const next = videos.slice(pos + 1).find((v) => !v.locked && !v.instantAccess);
|
||||
if (!next) return null;
|
||||
|
||||
await prisma.videoUnlock.upsert({
|
||||
where: { userId_videoId: { userId, videoId: next.id } },
|
||||
update: {},
|
||||
create: { userId, videoId: next.id },
|
||||
});
|
||||
return { id: next.id, title: next.title };
|
||||
}
|
||||
|
||||
export type UnlockCheck =
|
||||
| { ok: true; free: boolean }
|
||||
| {
|
||||
ok: false;
|
||||
status: number;
|
||||
reason: 'not_found' | 'locked' | 'no_access' | 'not_earned';
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides whether a user may unlock a video by hand. Mirrors unlockNextVideo:
|
||||
* a video opens once any watchable video between it and the previous
|
||||
* unlock-gated video (inclusive) has been completed.
|
||||
* `free` means the video never needed an unlock record.
|
||||
*/
|
||||
export async function checkUnlockEligibility(
|
||||
userId: string,
|
||||
videoId: string
|
||||
): Promise<UnlockCheck> {
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { id: videoId },
|
||||
select: { id: true, playlistId: true, locked: true },
|
||||
});
|
||||
if (!video) return { ok: false, status: 404, reason: 'not_found', error: 'Video not found' };
|
||||
if (video.locked) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
reason: 'locked',
|
||||
error: 'This video is locked by your lecturer.',
|
||||
};
|
||||
}
|
||||
|
||||
const videos = await getPlaylistVideosForUser(userId, video.playlistId);
|
||||
const pos = videos ? videos.findIndex((v) => v.id === videoId) : -1;
|
||||
if (!videos || pos === -1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
reason: 'no_access',
|
||||
error: 'You do not have access to this video.',
|
||||
};
|
||||
}
|
||||
|
||||
if (videos[pos].instantAccess || pos === 0) return { ok: true, free: true };
|
||||
|
||||
// Walk back to the previous video that itself needs unlocking.
|
||||
let gateStart = 0;
|
||||
for (let i = pos - 1; i >= 0; i--) {
|
||||
if (!videos[i].locked && !videos[i].instantAccess) {
|
||||
gateStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const gate = videos.slice(gateStart, pos).filter((v) => !v.locked);
|
||||
if (gate.length === 0) return { ok: true, free: true };
|
||||
|
||||
const progress = await prisma.videoProgress.findMany({
|
||||
where: { userId, videoId: { in: gate.map((v) => v.id) } },
|
||||
select: { videoId: true, percent: true, completed: true },
|
||||
});
|
||||
const done = progress.some(
|
||||
(p) => p.completed || p.percent >= UNLOCK_THRESHOLD_PERCENT
|
||||
);
|
||||
if (done) return { ok: true, free: false };
|
||||
|
||||
const previous = gate[gate.length - 1];
|
||||
const watched = progress.find((p) => p.videoId === previous.id)?.percent ?? 0;
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
reason: 'not_earned',
|
||||
error: `Watch at least ${UNLOCK_THRESHOLD_PERCENT}% of "${previous.title}" to unlock this video (you are at ${watched}%).`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user