Initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
// lib/admin-check.ts
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from './auth-options';
|
||||
|
||||
export async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
|
||||
const role = (session as any).user.role ?? 'user';
|
||||
if (!(role === 'admin' || role === 'superadmin')) throw { status: 403, message: 'Forbidden' };
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// lib/auth-check.ts
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth-options';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Require a logged-in user. If not logged in, redirect to /login.
|
||||
* Returns the session object when present.
|
||||
*
|
||||
* Use this helper from server components / app-route handlers.
|
||||
*/
|
||||
export async function requireUser(redirectTo = '/login') {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
// server-side redirect (preferred for pages)
|
||||
redirect(redirectTo);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this variant for API routes where you want JSON 401/403 instead of redirect.
|
||||
*/
|
||||
export async function requireUserApi() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
const err: any = new Error('Unauthorized');
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// lib/auth-options.ts
|
||||
import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { prisma } from "./prisma";
|
||||
import { normalizeEmail } from "./normalize-email";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
||||
authorization: {
|
||||
params: {
|
||||
prompt: "select_account",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/login/unauthorized", // Error code passed in query string as ?error=
|
||||
},
|
||||
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `__Secure-next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
callbacks: {
|
||||
/**
|
||||
* SIGN-IN GATE
|
||||
* This callback MUST NOT mutate users.
|
||||
* It only decides: allowed or not.
|
||||
*/
|
||||
async signIn({ user, account }) {
|
||||
if (!account || !user?.email) {
|
||||
return "/login/unauthorized";
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(user.email);
|
||||
|
||||
if (!normalizedEmail) {
|
||||
return "/login/unauthorized";
|
||||
}
|
||||
|
||||
/**
|
||||
* 1️⃣ Enforce: one OAuth account → one user
|
||||
*/
|
||||
const existingAccount = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAccount && existingAccount.userId !== (user as any).id) {
|
||||
// OAuth account already linked to another user → block
|
||||
return "/login/unauthorized";
|
||||
}
|
||||
|
||||
/**
|
||||
* 2️⃣ Check if user already exists
|
||||
*/
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: normalizedEmail,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 3️⃣ Admins / Superadmins are always allowed
|
||||
*/
|
||||
if (
|
||||
existingUser &&
|
||||
(existingUser.role === "admin" || existingUser.role === "superadmin")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4️⃣ Regular users must be explicitly approved
|
||||
*/
|
||||
const allowedStudent = await prisma.allowedStudent.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: normalizedEmail,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!allowedStudent || !allowedStudent.active) {
|
||||
return "/login/unauthorized";
|
||||
}
|
||||
|
||||
/**
|
||||
* IMPORTANT:
|
||||
* - DO NOT create users here
|
||||
* - DO NOT link accounts here
|
||||
* PrismaAdapter will create the user on first successful login
|
||||
*/
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* JWT CALLBACK
|
||||
* Token is the source of truth for session data.
|
||||
* Role must ALWAYS come from the database.
|
||||
*/
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety fallback
|
||||
token.role = token.role ?? "user";
|
||||
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* SESSION CALLBACK
|
||||
* Only exposes data already trusted in the token.
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as any).id = token.id ?? null;
|
||||
(session.user as any).role = token.role ?? "user";
|
||||
(session.user as any).levels = token.levels ?? "";
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export function normalizeEmail(email?: string | null): string | null {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// lib/prisma.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
type GlobalWithPrisma = typeof globalThis & { __prisma?: PrismaClient };
|
||||
|
||||
const globalForPrisma = globalThis as GlobalWithPrisma;
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.__prisma ??
|
||||
new PrismaClient({
|
||||
log: ['error', 'warn'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.__prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// lib/transcoder-auth.ts
|
||||
// Timing-safe Bearer token verification for transcoder API endpoints.
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
|
||||
/**
|
||||
* Verifies the incoming request carries the correct TRANSCODER_SECRET
|
||||
* via an "Authorization: Bearer <token>" header.
|
||||
*
|
||||
* Uses HMAC-SHA256 hashes and timingSafeEqual to prevent timing attacks.
|
||||
*/
|
||||
export function verifyTranscoderToken(request: Request): boolean {
|
||||
const secret = process.env.TRANSCODER_SECRET;
|
||||
if (!secret) return false;
|
||||
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
if (!auth.startsWith("Bearer ")) return false;
|
||||
|
||||
const token = auth.slice(7);
|
||||
|
||||
try {
|
||||
const a = createHash("sha256").update(token).digest();
|
||||
const b = createHash("sha256").update(secret).digest();
|
||||
return timingSafeEqual(a, b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Type definitions for transcoding-related types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Video transcoding status enum
|
||||
* - 'uploaded': Initial state after video upload, awaiting transcoding
|
||||
* - 'processing': Currently being transcoded to HLS format
|
||||
* - 'transcoded': Successfully transcoded, HLS files available
|
||||
* - 'failed': Transcoding failed, MP4 fallback available
|
||||
*/
|
||||
export enum TranscodingStatus {
|
||||
UPLOADED = 'uploaded',
|
||||
PROCESSING = 'processing',
|
||||
TRANSCODED = 'transcoded',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transcoding status is valid
|
||||
*/
|
||||
export function isValidTranscodingStatus(status: string): status is TranscodingStatus {
|
||||
return Object.values(TranscodingStatus).includes(status as TranscodingStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable transcoding status labels
|
||||
*/
|
||||
export const transcodingStatusLabels: Record<TranscodingStatus, string> = {
|
||||
[TranscodingStatus.UPLOADED]: 'Awaiting Transcoding',
|
||||
[TranscodingStatus.PROCESSING]: 'Processing',
|
||||
[TranscodingStatus.TRANSCODED]: 'Ready',
|
||||
[TranscodingStatus.FAILED]: 'Failed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status badge color for UI display
|
||||
*/
|
||||
export function getStatusBadgeColor(status: TranscodingStatus): string {
|
||||
switch (status) {
|
||||
case TranscodingStatus.UPLOADED:
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case TranscodingStatus.PROCESSING:
|
||||
return 'bg-amber-100 text-amber-800';
|
||||
case TranscodingStatus.TRANSCODED:
|
||||
return 'bg-green-100 text-green-800';
|
||||
case TranscodingStatus.FAILED:
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Utility functions for video URL handling with HLS support and fallback to MP4
|
||||
*/
|
||||
|
||||
export type TranscodingStatus = 'uploaded' | 'processing' | 'transcoded' | 'failed';
|
||||
|
||||
export interface VideoUrls {
|
||||
hlsUrl: string | null;
|
||||
mp4Url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate video URLs based on transcoding status
|
||||
* @param videoId - The video ID
|
||||
* @param mp4Url - The original MP4 URL
|
||||
* @param transcodingStatus - The current transcoding status
|
||||
* @returns Object containing HLS and MP4 URLs
|
||||
*/
|
||||
export function getVideoUrls(
|
||||
videoId: string,
|
||||
mp4Url: string,
|
||||
transcodingStatus: TranscodingStatus
|
||||
): VideoUrls {
|
||||
// HLS is only available if transcoding is complete
|
||||
const hlsUrl = transcodingStatus === 'transcoded'
|
||||
? `/api/videos/hls/${videoId}/master.m3u8`
|
||||
: null;
|
||||
|
||||
return {
|
||||
hlsUrl,
|
||||
mp4Url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate video source URL based on availability
|
||||
* Prefers HLS if available, falls back to MP4
|
||||
* @param videoId - The video ID
|
||||
* @param mp4Url - The original MP4 URL
|
||||
* @param transcodingStatus - The current transcoding status
|
||||
* @returns The primary URL to use
|
||||
*/
|
||||
export function getPrimaryVideoUrl(
|
||||
videoId: string,
|
||||
mp4Url: string,
|
||||
transcodingStatus: TranscodingStatus
|
||||
): string {
|
||||
const { hlsUrl } = getVideoUrls(videoId, mp4Url, transcodingStatus);
|
||||
return hlsUrl || mp4Url;
|
||||
}
|
||||
Reference in New Issue
Block a user