Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+11
View File
@@ -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;
}
+32
View File
@@ -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;
}
+189
View File
@@ -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;
},
},
};
+8
View File
@@ -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;
}
+16
View File
@@ -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;
}
+27
View File
@@ -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;
}
}
+52
View File
@@ -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';
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+50
View File
@@ -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;
}