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
+55
View File
@@ -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
View File
@@ -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";
+62
View File
@@ -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 };
}
+224
View File
@@ -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}%).`,
};
}