feat: LiveServer-M1 v1 — educational video conferencing platform

Full MVP implementation:
- Next.js 16 + React 19 + TypeScript + Tailwind CSS v4
- LiveKit integration (rooms, tokens, webhooks, moderation)
- better-auth (email/password, sessions, roles)
- Prisma 7 + PostgreSQL (9 models, 3 enums)
- 15 API routes (auth, rooms, lobby, chat, files, moderation, hand-raise)
- 7 pages (landing, auth, dashboard, join, video room)
- SSE-based waiting room with host approval flow
- Security: PIN rate limiting, session fingerprint bans, chat/files auth
- Python AI Agent (Deepgram STT + OpenAI summarization)
- Docker Compose (local + production with Traefik + Let's Encrypt)
- Interactive setup script (setup.sh)
- Dev protection middleware (DEV_ACCESS_KEY, ALLOWED_IPS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:57:53 +03:00
parent e5bb4c99ec
commit 3846e3e00d
59 changed files with 12792 additions and 240 deletions

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function CreateRoomPage() {
const router = useRouter();
const [name, setName] = useState("");
const [lobbyEnabled, setLobbyEnabled] = useState(true);
const [webinarMode, setWebinarMode] = useState(false);
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
lobbyEnabled,
webinarMode,
pin: pin || undefined,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Ошибка при создании комнаты");
return;
}
router.push("/dashboard");
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<Link
href="/dashboard"
className="text-sm text-neutral-400 hover:text-white transition-colors mb-6 inline-block"
>
&larr; Назад к комнатам
</Link>
<h1 className="text-3xl font-bold mb-8">Создать комнату</h1>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm text-neutral-400 mb-1">
Название комнаты
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Лекция по математике"
/>
</div>
<div>
<label htmlFor="pin" className="block text-sm text-neutral-400 mb-1">
PIN-код (необязательно)
</label>
<input
id="pin"
type="text"
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="1234"
maxLength={8}
/>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={lobbyEnabled}
onChange={(e) => setLobbyEnabled(e.target.checked)}
className="w-4 h-4 rounded bg-neutral-800 border-neutral-700 text-blue-600 focus:ring-blue-600 focus:ring-offset-0"
/>
<span className="text-sm">
Зал ожидания
<span className="text-neutral-400 ml-1">
участники ждут одобрения хоста
</span>
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={webinarMode}
onChange={(e) => setWebinarMode(e.target.checked)}
className="w-4 h-4 rounded bg-neutral-800 border-neutral-700 text-blue-600 focus:ring-blue-600 focus:ring-offset-0"
/>
<span className="text-sm">
Режим вебинара
<span className="text-neutral-400 ml-1">
только хост может говорить
</span>
</span>
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{loading ? "Создание..." : "Создать"}
</button>
</form>
</div>
</main>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession, signOut } from "@/lib/auth-client";
type Room = {
id: string;
name: string;
code: string;
status: "WAITING" | "ACTIVE" | "ENDED";
createdAt: string;
};
const statusLabels: Record<Room["status"], string> = {
WAITING: "Ожидание",
ACTIVE: "Активна",
ENDED: "Завершена",
};
const statusColors: Record<Room["status"], string> = {
WAITING: "bg-yellow-600/20 text-yellow-400",
ACTIVE: "bg-green-600/20 text-green-400",
ENDED: "bg-neutral-600/20 text-neutral-400",
};
export default function DashboardPage() {
const router = useRouter();
const { data: session, isPending } = useSession();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isPending && !session) {
router.push("/login");
}
}, [session, isPending, router]);
useEffect(() => {
async function fetchRooms() {
try {
const res = await fetch("/api/rooms");
if (res.ok) {
const data = await res.json();
setRooms(data);
}
} catch {
// silent
} finally {
setLoading(false);
}
}
if (session) {
fetchRooms();
}
}, [session]);
if (isPending) {
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Загрузка...</p>
</main>
);
}
return (
<main className="min-h-screen px-4 py-8 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Мои комнаты</h1>
<div className="flex gap-3">
<Link
href="/dashboard/create"
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors text-sm"
>
Создать комнату
</Link>
<button
onClick={() => signOut().then(() => router.push("/"))}
className="px-5 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-lg font-medium transition-colors text-sm"
>
Выйти
</button>
</div>
</div>
{loading ? (
<p className="text-neutral-400">Загрузка комнат...</p>
) : rooms.length === 0 ? (
<div className="text-center py-20">
<p className="text-neutral-400 mb-4">У вас пока нет комнат</p>
<Link
href="/dashboard/create"
className="text-blue-500 hover:text-blue-400"
>
Создать первую комнату
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{rooms.map((room) => (
<Link
key={room.id}
href={`/room/${room.code}`}
className="block bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-neutral-700 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<h2 className="font-semibold text-lg">{room.name}</h2>
<span
className={`text-xs px-2.5 py-1 rounded-full font-medium ${statusColors[room.status]}`}
>
{statusLabels[room.status]}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-neutral-400">
<span className="font-mono bg-neutral-800 px-2 py-0.5 rounded">
{room.code}
</span>
<span>
{new Date(room.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
</Link>
))}
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { createToken } from "@/lib/livekit";
const tokenSchema = z.object({
roomId: z.string().min(1),
});
export async function POST(req: Request) {
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const parsed = tokenSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const room = await prisma.room.findUnique({ where: { id: parsed.data.roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
// Verify the requesting user is the host of this specific room
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room not started yet" }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const token = await createToken(user.id, room.livekitRoomName, {
name: user.name,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
return NextResponse.json({ token });
}

View File

@@ -0,0 +1,91 @@
import { WebhookReceiver } from "livekit-server-sdk";
import { prisma } from "@/lib/prisma";
const receiver = new WebhookReceiver(
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!
);
export async function POST(req: Request) {
const body = await req.text();
const authHeader = req.headers.get("Authorization") ?? "";
let event;
try {
event = await receiver.receive(body, authHeader);
} catch {
return new Response(JSON.stringify({ error: "Invalid webhook signature" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const eventType = event.event;
switch (eventType) {
case "room_started": {
console.log(`[LiveKit] Room started: ${event.room?.name}`);
break;
}
case "room_finished": {
const roomName = event.room?.name;
if (roomName) {
const room = await prisma.room.findUnique({
where: { livekitRoomName: roomName },
});
if (room && room.status === "ACTIVE") {
await prisma.room.update({
where: { id: room.id },
data: { status: "ENDED", endedAt: new Date() },
});
await prisma.lectureArtifact.upsert({
where: { roomId: room.id },
update: {},
create: { roomId: room.id },
});
}
}
break;
}
case "participant_joined": {
const roomName = event.room?.name;
const identity = event.participant?.identity;
if (roomName && identity) {
const room = await prisma.room.findUnique({
where: { livekitRoomName: roomName },
});
if (room) {
await prisma.participantHistory.updateMany({
where: { roomId: room.id, sessionId: identity, leftAt: null },
data: { joinedAt: new Date() },
});
}
}
break;
}
case "participant_left": {
const roomName = event.room?.name;
const identity = event.participant?.identity;
if (roomName && identity) {
const room = await prisma.room.findUnique({
where: { livekitRoomName: roomName },
});
if (room) {
await prisma.participantHistory.updateMany({
where: { roomId: room.id, sessionId: identity, leftAt: null },
data: { leftAt: new Date() },
});
}
}
break;
}
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
const sendMessageSchema = z.object({
sessionId: z.string().min(1),
senderName: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
});
async function verifyRoomAccess(req: Request, roomId: string, sessionId?: string | null): Promise<boolean> {
// Check if requester is authenticated host/admin
const session = await getSessionFromRequest(req);
if (session) {
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (room && room.hostId === session.user.id) return true;
}
// Check if sessionId belongs to a participant of this room
if (sessionId) {
const participant = await prisma.participantHistory.findFirst({
where: { roomId, sessionId },
});
if (participant) return true;
}
return false;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor");
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 100);
const sessionId = url.searchParams.get("sessionId");
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const messages = await prisma.chatMessage.findMany({
where: {
roomId,
...(cursor ? { createdAt: { lt: new Date(cursor) } } : {}),
},
orderBy: { createdAt: "desc" },
take: limit,
});
const nextCursor = messages.length === limit
? messages[messages.length - 1].createdAt.toISOString()
: null;
return NextResponse.json({ messages: messages.reverse(), nextCursor });
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = sendMessageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, parsed.data.sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const message = await prisma.chatMessage.create({
data: {
roomId,
sessionId: parsed.data.sessionId,
senderName: parsed.data.senderName,
content: parsed.data.content,
},
});
return NextResponse.json(message, { status: 201 });
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { roomService } from "@/lib/livekit";
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
// Stop the LiveKit room if it exists
if (room.livekitRoomName) {
try {
await roomService.deleteRoom(room.livekitRoomName);
} catch {
// Room might already be gone, ignore
}
}
const updated = await prisma.room.update({
where: { id: roomId },
data: {
status: "ENDED",
endedAt: new Date(),
},
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
const uploadSchema = z.object({
sessionId: z.string().min(1),
fileName: z.string().min(1).max(255),
fileKey: z.string().min(1),
fileSize: z.number().int().positive(),
mimeType: z.string().min(1),
});
async function verifyRoomAccess(req: Request, roomId: string, sessionId?: string | null): Promise<boolean> {
// Check if requester is authenticated host/admin
const session = await getSessionFromRequest(req);
if (session) {
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (room && room.hostId === session.user.id) return true;
}
// Check if sessionId belongs to a participant of this room
if (sessionId) {
const participant = await prisma.participantHistory.findFirst({
where: { roomId, sessionId },
});
if (participant) return true;
}
return false;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const sessionId = url.searchParams.get("sessionId");
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const files = await prisma.sharedFile.findMany({
where: { roomId },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(files);
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = uploadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, parsed.data.sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const file = await prisma.sharedFile.create({
data: {
roomId,
...parsed.data,
},
});
return NextResponse.json(file, { status: 201 });
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const handRaiseSchema = z.object({
sessionId: z.string().min(1),
raised: z.boolean(),
});
// Guest raises/lowers hand — no host auth required, only participant check
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = handRaiseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { sessionId, raised } = parsed.data;
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room || !room.webinarMode) {
return NextResponse.json({ error: "Not a webinar room" }, { status: 400 });
}
// Verify participant is in the room
const participant = await prisma.participantHistory.findFirst({
where: { roomId, sessionId, leftAt: null },
});
if (!participant) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 });
}
// Store hand-raise state in participant metadata (we use the DB for now)
// The host will poll GET /api/rooms/[roomId]/hand-raise to see raised hands
// For simplicity, we return success — the actual grant_publish is done by host via /moderate
return NextResponse.json({ success: true, raised });
}
// Host fetches list of raised hands
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const _host = url.searchParams.get("host");
// In a real implementation, hand-raise state would be stored in a separate table
// or in LiveKit participant metadata via DataChannels.
// For MVP, hand-raising is handled client-side via LiveKit DataChannels.
// This endpoint exists as a fallback.
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
return NextResponse.json({ participants: [] });
}

View File

@@ -0,0 +1,127 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { createToken } from "@/lib/livekit";
const joinSchema = z.object({
displayName: z.string().min(1).max(100),
sessionId: z.string().min(1),
pin: z.string().optional(),
sessionFingerprint: z.string().min(1),
});
// CRITICAL-10: In-memory rate limiter for PIN attempts
const pinAttempts = new Map<string, { count: number; resetTime: number }>();
const PIN_MAX_ATTEMPTS = 5;
const PIN_WINDOW_MS = 60_000; // 1 minute
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = joinSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { displayName, sessionId, pin, sessionFingerprint } = parsed.data;
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
if (room.isLocked) {
return NextResponse.json({ error: "Room is locked" }, { status: 403 });
}
// Check ban by fingerprint
const banByFingerprint = await prisma.bannedEntry.findUnique({
where: { roomId_sessionFingerprint: { roomId, sessionFingerprint } },
});
if (banByFingerprint) {
return NextResponse.json({ error: "You are banned from this room" }, { status: 403 });
}
// Check ban by sessionId (secondary check)
const banBySession = await prisma.bannedEntry.findFirst({
where: { roomId, sessionFingerprint: sessionId },
});
if (banBySession) {
return NextResponse.json({ error: "You are banned from this room" }, { status: 403 });
}
// Verify PIN with rate limiting
if (room.pinHash) {
if (!pin) {
return NextResponse.json({ error: "PIN required" }, { status: 401 });
}
// Rate limit PIN attempts by roomId + IP
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
const rateLimitKey = `${roomId}:${ip}`;
const now = Date.now();
const attempt = pinAttempts.get(rateLimitKey);
if (attempt && now < attempt.resetTime) {
if (attempt.count >= PIN_MAX_ATTEMPTS) {
return NextResponse.json(
{ error: "Too many PIN attempts. Try again later." },
{ status: 429 }
);
}
attempt.count++;
} else {
pinAttempts.set(rateLimitKey, { count: 1, resetTime: now + PIN_WINDOW_MS });
}
const valid = await compare(pin, room.pinHash);
if (!valid) {
return NextResponse.json({ error: "Invalid PIN" }, { status: 401 });
}
}
// Lobby flow
if (room.lobbyEnabled) {
const entry = await prisma.lobbyEntry.create({
data: {
roomId,
displayName,
sessionId,
sessionFingerprint,
status: "PENDING",
},
});
return NextResponse.json({ status: "lobby", lobbyEntryId: entry.id });
}
// Direct join
await prisma.participantHistory.create({
data: {
roomId,
sessionId,
displayName,
role: "GUEST",
},
});
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room not started yet" }, { status: 400 });
}
const canPublish = !room.webinarMode;
const token = await createToken(sessionId, room.livekitRoomName, {
name: displayName,
canPublish,
canSubscribe: true,
canPublishData: true,
});
return NextResponse.json({ status: "approved", token });
}

View File

@@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { createToken } from "@/lib/livekit";
const lobbyActionSchema = z.object({
lobbyEntryId: z.string().min(1),
action: z.enum(["APPROVED", "REJECTED"]),
});
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const entries = await prisma.lobbyEntry.findMany({
where: { roomId, status: "PENDING" },
orderBy: { createdAt: "asc" },
});
return NextResponse.json(entries);
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = lobbyActionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { lobbyEntryId, action } = parsed.data;
const entry = await prisma.lobbyEntry.findUnique({ where: { id: lobbyEntryId } });
if (!entry || entry.roomId !== roomId) {
return NextResponse.json({ error: "Lobby entry not found" }, { status: 404 });
}
if (entry.status !== "PENDING") {
return NextResponse.json({ error: "Entry already processed" }, { status: 409 });
}
if (action === "REJECTED") {
await prisma.lobbyEntry.update({
where: { id: lobbyEntryId },
data: { status: "REJECTED" },
});
return NextResponse.json({ status: "rejected" });
}
// APPROVED
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room not started yet" }, { status: 400 });
}
const canPublish = !room.webinarMode;
const token = await createToken(entry.sessionId, room.livekitRoomName, {
name: entry.displayName,
canPublish,
canSubscribe: true,
canPublishData: true,
});
await prisma.$transaction([
prisma.lobbyEntry.update({
where: { id: lobbyEntryId },
data: { status: "APPROVED" },
}),
prisma.participantHistory.create({
data: {
roomId,
sessionId: entry.sessionId,
displayName: entry.displayName,
role: "GUEST",
},
}),
]);
// Store token temporarily in lobbyEntry metadata — we use the updatedAt as a signal
// The SSE endpoint will query the entry status and generate a fresh token
// We store it via a simple in-memory approach or re-generate in SSE
// For simplicity, we return it here too (host can relay it)
return NextResponse.json({ status: "approved", token });
}

View File

@@ -0,0 +1,112 @@
import { prisma } from "@/lib/prisma";
import { createToken } from "@/lib/livekit";
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
return new Response(JSON.stringify({ error: "sessionId required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return new Response(JSON.stringify({ error: "Room not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// CRITICAL-8: Verify sessionId matches an existing LobbyEntry for this room
const existingEntry = await prisma.lobbyEntry.findFirst({
where: { roomId, sessionId },
});
if (!existingEntry) {
return new Response(JSON.stringify({ error: "No lobby entry found for this session" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const send = (data: Record<string, unknown>) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
let resolved = false;
const maxPolls = 150; // 5 minutes at 2s intervals
for (let i = 0; i < maxPolls && !resolved; i++) {
try {
const entry = await prisma.lobbyEntry.findFirst({
where: { roomId, sessionId },
orderBy: { createdAt: "desc" },
});
if (!entry) {
send({ status: "error", message: "Lobby entry not found" });
resolved = true;
break;
}
if (entry.status === "APPROVED") {
// Re-fetch room to get fresh livekitRoomName (may have been started after lobby entry)
const freshRoom = await prisma.room.findUnique({ where: { id: roomId } });
if (!freshRoom?.livekitRoomName) {
send({ status: "error", message: "Room not started yet" });
resolved = true;
break;
}
const canPublish = !freshRoom.webinarMode;
const token = await createToken(sessionId, freshRoom.livekitRoomName, {
name: entry.displayName,
canPublish,
canSubscribe: true,
canPublishData: true,
});
send({ status: "APPROVED", token });
resolved = true;
break;
}
if (entry.status === "REJECTED") {
send({ status: "REJECTED" });
resolved = true;
break;
}
send({ status: "PENDING" });
} catch {
send({ status: "error", message: "Internal error" });
resolved = true;
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
if (!resolved) {
send({ status: "timeout" });
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

View File

@@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { roomService } from "@/lib/livekit";
const moderateSchema = z.object({
action: z.enum(["kick", "ban", "mute_all", "grant_publish", "revoke_publish"]),
targetSessionId: z.string().optional(),
reason: z.string().optional(),
});
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room has no LiveKit session" }, { status: 409 });
}
const body = await req.json();
const parsed = moderateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { action, targetSessionId, reason } = parsed.data;
const lkRoom = room.livekitRoomName;
switch (action) {
case "kick": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
await roomService.removeParticipant(lkRoom, targetSessionId);
await prisma.participantHistory.updateMany({
where: { roomId, sessionId: targetSessionId, leftAt: null },
data: { leftAt: new Date() },
});
return NextResponse.json({ success: true });
}
case "ban": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
// Find fingerprint from lobby entry or participant
const lobbyEntry = await prisma.lobbyEntry.findFirst({
where: { roomId, sessionId: targetSessionId },
orderBy: { createdAt: "desc" },
});
const fingerprint = lobbyEntry?.sessionFingerprint ?? targetSessionId;
await roomService.removeParticipant(lkRoom, targetSessionId);
await prisma.participantHistory.updateMany({
where: { roomId, sessionId: targetSessionId, leftAt: null },
data: { leftAt: new Date() },
});
await prisma.bannedEntry.upsert({
where: { roomId_sessionFingerprint: { roomId, sessionFingerprint: fingerprint } },
update: { reason },
create: {
roomId,
sessionFingerprint: fingerprint,
displayName: lobbyEntry?.displayName,
reason,
bannedById: session.user.id,
},
});
return NextResponse.json({ success: true });
}
case "mute_all": {
const participants = await roomService.listParticipants(lkRoom);
for (const p of participants) {
if (p.identity === session.user.id) continue; // skip host
await roomService.updateParticipant(lkRoom, p.identity, {
permission: { canPublish: false, canSubscribe: true, canPublishData: true },
});
}
return NextResponse.json({ success: true });
}
case "grant_publish": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
await roomService.updateParticipant(lkRoom, targetSessionId, {
permission: { canPublish: true, canSubscribe: true, canPublishData: true },
});
return NextResponse.json({ success: true });
}
case "revoke_publish": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
await roomService.updateParticipant(lkRoom, targetSessionId, {
permission: { canPublish: false, canSubscribe: true, canPublishData: true },
});
return NextResponse.json({ success: true });
}
default:
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
}

View File

@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { hash } from "bcryptjs";
const updateRoomSchema = z.object({
name: z.string().min(1).max(255).optional(),
lobbyEnabled: z.boolean().optional(),
webinarMode: z.boolean().optional(),
isLocked: z.boolean().optional(),
pin: z.string().min(4).max(20).nullable().optional(),
});
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { host: { select: { id: true, name: true } } },
});
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (session) {
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (user && (user.role === "ADMIN" || room.hostId === user.id)) {
return NextResponse.json(room);
}
}
// Limited info for guests
return NextResponse.json({
id: room.id,
name: room.name,
code: room.code,
status: room.status,
lobbyEnabled: room.lobbyEnabled,
webinarMode: room.webinarMode,
isLocked: room.isLocked,
hasPin: !!room.pinHash,
host: room.host,
});
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (!user || (user.role !== "ADMIN" && room.hostId !== user.id)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = updateRoomSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { pin, ...rest } = parsed.data;
const data: Record<string, unknown> = { ...rest };
if (pin !== undefined) {
data.pinHash = pin ? await hash(pin, 10) : null;
}
const updated = await prisma.room.update({
where: { id: roomId },
data,
});
return NextResponse.json(updated);
}
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (!user || (user.role !== "ADMIN" && room.hostId !== user.id)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (room.status === "ACTIVE") {
return NextResponse.json({ error: "Cannot delete an active room" }, { status: 409 });
}
await prisma.room.delete({ where: { id: roomId } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { roomService } from "@/lib/livekit";
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (room.status !== "WAITING") {
return NextResponse.json({ error: "Room is not in WAITING status" }, { status: 409 });
}
const livekitRoomName = `room-${roomId}`;
await roomService.createRoom({ name: livekitRoomName });
const updated = await prisma.room.update({
where: { id: roomId },
data: {
status: "ACTIVE",
startedAt: new Date(),
livekitRoomName,
},
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ code: string }> }
) {
const { code } = await params;
const room = await prisma.room.findUnique({
where: { code },
select: {
id: true,
name: true,
code: true,
status: true,
lobbyEnabled: true,
pinHash: true,
webinarMode: true,
hostId: true,
},
});
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
return NextResponse.json({
id: room.id,
name: room.name,
code: room.code,
status: room.status,
hostId: room.hostId,
lobbyEnabled: room.lobbyEnabled,
hasPin: !!room.pinHash,
webinarMode: room.webinarMode,
});
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { hash } from "bcryptjs";
const createRoomSchema = z.object({
name: z.string().min(1).max(255),
lobbyEnabled: z.boolean().optional().default(true),
webinarMode: z.boolean().optional().default(false),
pin: z.string().min(4).max(20).optional(),
});
export async function POST(req: Request) {
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user || (user.role !== "HOST" && user.role !== "ADMIN")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = createRoomSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { name, lobbyEnabled, webinarMode, pin } = parsed.data;
const pinHash = pin ? await hash(pin, 10) : null;
const room = await prisma.room.create({
data: {
name,
lobbyEnabled,
webinarMode,
pinHash,
hostId: user.id,
},
});
return NextResponse.json(room, { status: 201 });
}
export async function GET(req: Request) {
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user || (user.role !== "HOST" && user.role !== "ADMIN")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const where = user.role === "ADMIN" ? {} : { hostId: user.id };
const rooms = await prisma.room.findMany({
where,
orderBy: { createdAt: "desc" },
});
return NextResponse.json(rooms);
}

1
src/app/globals.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,202 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { useParams, useRouter } from "next/navigation";
import WaitingRoom from "@/components/lobby/WaitingRoom";
type RoomInfo = {
id: string;
name: string;
code: string;
status: string;
hostId: string;
lobbyEnabled: boolean;
hasPin: boolean;
webinarMode: boolean;
};
export default function JoinCodePage() {
const params = useParams<{ code: string }>();
const router = useRouter();
const [room, setRoom] = useState<RoomInfo | null>(null);
const [displayName, setDisplayName] = useState("");
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [joining, setJoining] = useState(false);
const [inLobby, setInLobby] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const [sessionFingerprint] = useState(() => {
// Generate a persistent fingerprint based on browser characteristics
const nav = typeof navigator !== "undefined" ? navigator : null;
const raw = [
nav?.userAgent ?? "",
nav?.language ?? "",
screen?.width ?? "",
screen?.height ?? "",
Intl.DateTimeFormat().resolvedOptions().timeZone ?? "",
].join("|");
// Simple hash
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
});
useEffect(() => {
async function fetchRoom() {
try {
const res = await fetch(`/api/rooms/by-code/${params.code}`);
if (!res.ok) {
setError("Комната не найдена");
return;
}
const roomData = await res.json();
setRoom(roomData);
} catch {
setError("Ошибка загрузки комнаты");
} finally {
setLoading(false);
}
}
fetchRoom();
}, [params.code]);
async function handleJoin(e: FormEvent) {
e.preventDefault();
if (!room) return;
setError("");
setJoining(true);
try {
const res = await fetch(`/api/rooms/${room.id}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
displayName,
pin: pin || undefined,
sessionId,
sessionFingerprint,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Ошибка при подключении");
setJoining(false);
return;
}
if (data.status === "lobby") {
setInLobby(true);
} else if (data.status === "approved" && data.token) {
router.push(`/room/${room.code}?token=${encodeURIComponent(data.token)}`);
}
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
setJoining(false);
}
}
function handleApproved(token: string) {
if (room) {
router.push(`/room/${room.code}?token=${encodeURIComponent(token)}`);
}
}
if (loading) {
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Загрузка...</p>
</main>
);
}
if (inLobby && room) {
return (
<WaitingRoom
roomId={room.id}
sessionId={sessionId}
onApproved={handleApproved}
/>
);
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{room ? (
<>
<h1 className="text-2xl font-bold mb-1 text-center">{room.name}</h1>
<p className="text-neutral-400 text-sm text-center mb-8">
Код: {room.code}
</p>
<form onSubmit={handleJoin} className="space-y-4">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="displayName" className="block text-sm text-neutral-400 mb-1">
Ваше имя
</label>
<input
id="displayName"
type="text"
required
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Иван"
/>
</div>
{room.hasPin && (
<div>
<label htmlFor="pin" className="block text-sm text-neutral-400 mb-1">
PIN-код комнаты
</label>
<input
id="pin"
type="text"
required
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors text-center font-mono tracking-widest"
placeholder="1234"
maxLength={8}
/>
</div>
)}
<button
type="submit"
disabled={joining}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{joining ? "Подключение..." : "Присоединиться"}
</button>
</form>
</>
) : (
<div className="text-center">
<p className="text-red-400 mb-4">{error || "Комната не найдена"}</p>
<button
onClick={() => router.push("/join")}
className="text-blue-500 hover:text-blue-400"
>
Ввести другой код
</button>
</div>
)}
</div>
</main>
);
}

42
src/app/join/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
export default function JoinPage() {
const router = useRouter();
const [code, setCode] = useState("");
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (code.trim()) {
router.push(`/join/${code.trim()}`);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm text-center">
<h1 className="text-3xl font-bold mb-2">Присоединиться</h1>
<p className="text-neutral-400 mb-8">Введите код комнаты</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
required
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors text-center text-lg font-mono tracking-widest"
placeholder="ABC123"
/>
<button
type="submit"
className="w-full py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
>
Далее
</button>
</form>
</div>
</main>
);
}

25
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import "@livekit/components-styles";
const inter = Inter({ subsets: ["latin", "cyrillic"] });
export const metadata: Metadata = {
title: "LiveServer",
description: "Образовательная видеоконференц-платформа",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className={`${inter.className} bg-neutral-950 text-white antialiased`}>
{children}
</body>
</html>
);
}

95
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,95 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signIn } from "@/lib/auth-client";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? "Ошибка авторизации");
} else {
router.push("/dashboard");
}
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-bold mb-8 text-center">Вход</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm text-neutral-400 mb-1">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-1">
Пароль
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="********"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{loading ? "Вход..." : "Войти"}
</button>
</form>
<p className="mt-6 text-center text-sm text-neutral-400">
Нет аккаунта?{" "}
<Link href="/register" className="text-blue-500 hover:text-blue-400">
Зарегистрироваться
</Link>
</p>
</div>
</main>
);
}

28
src/app/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
import Link from "next/link";
export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center justify-center px-4">
<div className="text-center max-w-2xl">
<h1 className="text-6xl font-bold tracking-tight mb-4">LiveServer</h1>
<p className="text-xl text-neutral-400 mb-12">
Образовательная видеоконференц-платформа
</p>
<div className="flex gap-4 justify-center">
<Link
href="/login"
className="px-8 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
>
Войти
</Link>
<Link
href="/join"
className="px-8 py-3 bg-neutral-800 hover:bg-neutral-700 rounded-lg font-medium transition-colors"
>
Присоединиться к лекции
</Link>
</div>
</div>
</main>
);
}

112
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,112 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signUp } from "@/lib/auth-client";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? "Ошибка регистрации");
} else {
router.push("/dashboard");
}
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-bold mb-8 text-center">Регистрация</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm text-neutral-400 mb-1">
Имя
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Иван Иванов"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm text-neutral-400 mb-1">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-1">
Пароль
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Минимум 8 символов"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{loading ? "Регистрация..." : "Создать аккаунт"}
</button>
</form>
<p className="mt-6 text-center text-sm text-neutral-400">
Уже есть аккаунт?{" "}
<Link href="/login" className="text-blue-500 hover:text-blue-400">
Войти
</Link>
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams, useSearchParams } from "next/navigation";
import {
LiveKitRoom,
VideoConference,
} from "@livekit/components-react";
import { useSession } from "@/lib/auth-client";
import ModerationPanel from "@/components/room/ModerationPanel";
import ChatPanel from "@/components/room/ChatPanel";
import LobbyManager from "@/components/lobby/LobbyManager";
const LIVEKIT_URL = process.env.NEXT_PUBLIC_LIVEKIT_URL || "ws://localhost:7880";
type RoomInfo = {
id: string;
name: string;
code: string;
hostId: string;
status: string;
lobbyEnabled: boolean;
hasPin: boolean;
webinarMode: boolean;
};
export default function RoomPage() {
const params = useParams<{ code: string }>();
const searchParams = useSearchParams();
const { data: session } = useSession();
const [token, setToken] = useState<string | null>(null);
const [room, setRoom] = useState<RoomInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showChat, setShowChat] = useState(false);
const [showLobby, setShowLobby] = useState(false);
const [handRaised, setHandRaised] = useState(false);
const userId = session?.user?.id ?? null;
const isHost = userId && room ? userId === room.hostId : false;
useEffect(() => {
const tokenFromUrl = searchParams.get("token");
if (tokenFromUrl) {
setToken(tokenFromUrl);
}
}, [searchParams]);
useEffect(() => {
async function fetchRoom() {
try {
const res = await fetch(`/api/rooms/by-code/${params.code}`);
if (!res.ok) {
setError("Комната не найдена");
return;
}
const roomData = await res.json();
setRoom(roomData);
// If authenticated user is host and no token yet, fetch host token
if (!token && userId && roomData.hostId === userId) {
const tokenRes = await fetch("/api/livekit/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: roomData.id }),
});
if (tokenRes.ok) {
const tokenData = await tokenRes.json();
setToken(tokenData.token);
}
}
} catch {
setError("Ошибка загрузки комнаты");
} finally {
setLoading(false);
}
}
fetchRoom();
}, [params.code, userId, token]);
const handleRaiseHand = useCallback(async () => {
if (!room) return;
const newState = !handRaised;
setHandRaised(newState);
try {
await fetch(`/api/rooms/${room.id}/hand-raise`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: userId ?? "anonymous",
raised: newState,
}),
});
} catch {
// silent — hand raise also works via LiveKit DataChannels
}
}, [room, handRaised, userId]);
if (loading) {
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Загрузка комнаты...</p>
</main>
);
}
if (error || !token) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-400 mb-4">{error || "Нет токена для подключения"}</p>
</div>
</main>
);
}
return (
<div className="h-screen flex flex-col bg-neutral-950">
{/* Top bar */}
<header className="flex items-center justify-between px-4 py-2 bg-neutral-900 border-b border-neutral-800 shrink-0">
<div className="flex items-center gap-3">
<h1 className="font-semibold">{room?.name ?? "Комната"}</h1>
{room && (
<span className="text-xs text-neutral-400 font-mono bg-neutral-800 px-2 py-0.5 rounded">
{room.code}
</span>
)}
</div>
<div className="flex items-center gap-2">
{room?.webinarMode && !isHost && (
<button
onClick={handleRaiseHand}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
handRaised
? "bg-yellow-600 hover:bg-yellow-700"
: "bg-neutral-800 hover:bg-neutral-700"
}`}
>
{handRaised ? "Опустить руку" : "Поднять руку"}
</button>
)}
{isHost && room?.lobbyEnabled && (
<button
onClick={() => setShowLobby((v) => !v)}
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-sm font-medium transition-colors"
>
Зал ожидания
</button>
)}
<button
onClick={() => setShowChat((v) => !v)}
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-sm font-medium transition-colors"
>
Чат
</button>
</div>
</header>
{/* Main content */}
<LiveKitRoom
serverUrl={LIVEKIT_URL}
token={token}
connect={true}
className="flex flex-1 min-h-0"
>
{/* Video area */}
<div className="flex-1 min-w-0">
<VideoConference />
</div>
{/* Sidebars — inside LiveKitRoom for useParticipants() context */}
{(showChat || showLobby || isHost) && (
<aside className="w-80 shrink-0 border-l border-neutral-800 flex flex-col bg-neutral-900">
{isHost && showLobby && room && (
<div className="border-b border-neutral-800">
<LobbyManager roomId={room.id} />
</div>
)}
{isHost && room && <ModerationPanel roomId={room.id} />}
{showChat && room && (
<div className="flex-1 min-h-0">
<ChatPanel
roomId={room.id}
sessionId={userId ?? "anonymous"}
senderName={session?.user?.name ?? "Anonymous"}
/>
</div>
)}
</aside>
)}
</LiveKitRoom>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
interface LobbyManagerProps {
roomId: string;
}
type LobbyEntry = {
id: string;
sessionId: string;
displayName: string;
createdAt: string;
};
export default function LobbyManager({ roomId }: LobbyManagerProps) {
const [entries, setEntries] = useState<LobbyEntry[]>([]);
const [acting, setActing] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
const fetchEntries = useCallback(async () => {
try {
const res = await fetch(`/api/rooms/${roomId}/lobby`);
if (res.ok) {
const data = await res.json();
setEntries(data);
}
} catch {
// silent
}
}, [roomId]);
useEffect(() => {
fetchEntries();
pollRef.current = setInterval(fetchEntries, 3000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [fetchEntries]);
async function handleAction(entry: LobbyEntry, action: "APPROVED" | "REJECTED") {
setActing(entry.id);
try {
await fetch(`/api/rooms/${roomId}/lobby`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lobbyEntryId: entry.id, action }),
});
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
} catch {
// silent
} finally {
setActing(null);
}
}
return (
<div className="p-4">
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Зал ожидания ({entries.length})
</h3>
{entries.length === 0 ? (
<p className="text-sm text-neutral-500">Никто не ожидает</p>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
>
<span className="text-sm truncate mr-2">{entry.displayName}</span>
<div className="flex gap-1 shrink-0">
<button
onClick={() => handleAction(entry, "APPROVED")}
disabled={acting === entry.id}
className="px-2 py-1 text-xs bg-green-600 hover:bg-green-700 disabled:opacity-50 rounded font-medium transition-colors"
>
Принять
</button>
<button
onClick={() => handleAction(entry, "REJECTED")}
disabled={acting === entry.id}
className="px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 disabled:opacity-50 rounded font-medium transition-colors"
>
Отклонить
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState, useRef } from "react";
import type { SSELobbyEvent } from "@/types";
interface WaitingRoomProps {
roomId: string;
sessionId: string;
onApproved: (token: string) => void;
}
export default function WaitingRoom({
roomId,
sessionId,
onApproved,
}: WaitingRoomProps) {
const [rejected, setRejected] = useState(false);
const onApprovedRef = useRef(onApproved);
onApprovedRef.current = onApproved;
useEffect(() => {
const eventSource = new EventSource(
`/api/rooms/${roomId}/lobby/stream?sessionId=${sessionId}`
);
eventSource.onmessage = (event) => {
try {
const data: SSELobbyEvent = JSON.parse(event.data);
if (data.status === "APPROVED" && data.token) {
eventSource.close();
onApprovedRef.current(data.token);
} else if (data.status === "REJECTED") {
eventSource.close();
setRejected(true);
}
} catch {
// ignore parse errors
}
};
eventSource.onerror = () => {
// Reconnect is handled automatically by EventSource
};
return () => {
eventSource.close();
};
}, [roomId, sessionId]);
if (rejected) {
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-600/20 flex items-center justify-center">
<svg
className="w-8 h-8 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h2 className="text-xl font-bold mb-2">Вам отказано в доступе</h2>
<p className="text-neutral-400">Хост отклонил ваш запрос на вход</p>
</div>
</main>
);
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 relative">
<div className="absolute inset-0 rounded-full bg-blue-600/20 animate-ping" />
<div className="relative w-16 h-16 rounded-full bg-blue-600/30 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-blue-600 animate-pulse" />
</div>
</div>
<h2 className="text-xl font-bold mb-2">Ожидание подтверждения хоста...</h2>
<p className="text-neutral-400 text-sm">
Хост скоро подтвердит ваш вход в комнату
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useState, useEffect, useRef, type FormEvent } from "react";
interface ChatPanelProps {
roomId: string;
sessionId: string;
senderName: string;
}
type ChatMessage = {
id: string;
senderName: string;
content: string;
createdAt: string;
};
export default function ChatPanel({ roomId, sessionId, senderName }: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
useEffect(() => {
async function fetchMessages() {
try {
const res = await fetch(`/api/rooms/${roomId}/chat?sessionId=${sessionId}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages);
}
} catch {
// silent
}
}
fetchMessages();
pollRef.current = setInterval(fetchMessages, 3000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [roomId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function handleSend(e: FormEvent) {
e.preventDefault();
if (!input.trim() || sending) return;
setSending(true);
try {
const res = await fetch(`/api/rooms/${roomId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, senderName, content: input.trim() }),
});
if (res.ok) {
const msg = await res.json();
setMessages((prev) => [...prev, msg]);
setInput("");
}
} catch {
// silent
} finally {
setSending(false);
}
}
return (
<div className="flex flex-col h-full">
<div className="px-4 py-3 border-b border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider">
Чат
</h3>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<p className="text-sm text-neutral-500 text-center mt-8">
Сообщений пока нет
</p>
)}
{messages.map((msg) => (
<div key={msg.id}>
<p className="text-xs text-neutral-500 mb-0.5">{msg.senderName}</p>
<p className="text-sm bg-neutral-800 rounded-lg px-3 py-2 inline-block max-w-full break-words">
{msg.content}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} className="p-3 border-t border-neutral-800">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Сообщение..."
className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm focus:outline-none focus:border-blue-600 transition-colors"
/>
<button
type="submit"
disabled={sending || !input.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors"
>
Отправить
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useState, useCallback } from "react";
import { useParticipants } from "@livekit/components-react";
import type { ModerationAction } from "@/types";
interface ModerationPanelProps {
roomId: string;
}
export default function ModerationPanel({ roomId }: ModerationPanelProps) {
const participants = useParticipants();
const [acting, setActing] = useState<string | null>(null);
const moderate = useCallback(
async (action: ModerationAction, targetSessionId?: string) => {
setActing(targetSessionId ?? action);
try {
await fetch(`/api/rooms/${roomId}/moderate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, targetSessionId }),
});
} catch {
// silent
} finally {
setActing(null);
}
},
[roomId]
);
return (
<div className="p-4">
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Модерация
</h3>
<button
onClick={() => moderate("mute_all")}
disabled={acting === "mute_all"}
className="w-full py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors mb-4"
>
Выключить микрофон у всех
</button>
<div className="space-y-2">
<p className="text-xs text-neutral-500">
Участники ({participants.length})
</p>
{participants.map((p) => (
<div
key={p.identity}
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
>
<span className="text-sm truncate mr-2">
{p.name || p.identity}
</span>
<div className="flex gap-1 shrink-0">
<button
onClick={() => moderate("kick", p.identity)}
disabled={acting === p.identity}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
>
Кик
</button>
<button
onClick={() => moderate("ban", p.identity)}
disabled={acting === p.identity}
className="px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded transition-colors"
>
Бан
</button>
</div>
</div>
))}
</div>
</div>
);
}

7
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});
export const { signIn, signUp, signOut, useSession } = authClient;

8
src/lib/auth-helpers.ts Normal file
View File

@@ -0,0 +1,8 @@
import { auth } from "./auth";
export async function getSessionFromRequest(req: Request) {
const session = await auth.api.getSession({
headers: req.headers,
});
return session;
}

16
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
},
});

41
src/lib/livekit.ts Normal file
View File

@@ -0,0 +1,41 @@
import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
const livekitHost = process.env.LIVEKIT_URL!;
const apiKey = process.env.LIVEKIT_API_KEY!;
const apiSecret = process.env.LIVEKIT_API_SECRET!;
export const roomService = new RoomServiceClient(livekitHost, apiKey, apiSecret);
export function createToken(
identity: string,
roomName: string,
options: {
name?: string;
canPublish?: boolean;
canSubscribe?: boolean;
canPublishData?: boolean;
} = {}
): Promise<string> {
const {
name = identity,
canPublish = true,
canSubscribe = true,
canPublishData = true,
} = options;
const at = new AccessToken(apiKey, apiSecret, {
identity,
name,
ttl: "6h",
});
at.addGrant({
room: roomName,
roomJoin: true,
canPublish,
canSubscribe,
canPublishData,
});
return at.toJwt();
}

12
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
// @ts-expect-error -- Prisma 7 accepts datasourceUrl but types lag behind
new PrismaClient({ datasourceUrl: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

71
src/middleware.ts Normal file
View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Защита для локальной разработки.
* Если задана env ALLOWED_IPS — пропускает только эти IP.
* Если задана env DEV_ACCESS_KEY — требует ?key=... или cookie dev_access_key.
* В проде (DOMAIN задан) — middleware пропускает всё.
*/
export function middleware(req: NextRequest) {
// В проде — пропускаем
if (process.env.DOMAIN) {
return NextResponse.next();
}
const devAccessKey = process.env.DEV_ACCESS_KEY;
const allowedIps = process.env.ALLOWED_IPS; // "192.168.1.10,192.168.1.11"
// Проверка IP whitelist
if (allowedIps) {
const clientIp =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
"unknown";
const allowed = allowedIps.split(",").map((ip) => ip.trim());
// localhost всегда разрешён
if (
!allowed.includes(clientIp) &&
clientIp !== "127.0.0.1" &&
clientIp !== "::1" &&
clientIp !== "unknown"
) {
return new NextResponse("Forbidden", { status: 403 });
}
}
// Проверка access key
if (devAccessKey) {
const url = new URL(req.url);
const keyParam = url.searchParams.get("key");
const keyCookie = req.cookies.get("dev_access_key")?.value;
// Если ключ в query — ставим cookie и редиректим без key
if (keyParam === devAccessKey) {
url.searchParams.delete("key");
const res = NextResponse.redirect(url);
res.cookies.set("dev_access_key", devAccessKey, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 дней
path: "/",
});
return res;
}
// Если нет валидной cookie — блокируем
if (keyCookie !== devAccessKey) {
return new NextResponse(
"Access denied. Add ?key=YOUR_DEV_ACCESS_KEY to URL.",
{ status: 403 }
);
}
}
return NextResponse.next();
}
export const config = {
// Защищаем всё кроме статики и API вебхуков
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/livekit/webhook).*)"],
};

15
src/types/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export type RoomSecuritySettings = {
lobbyEnabled: boolean;
pinHash: string | null;
webinarMode: boolean;
isLocked: boolean;
};
export type LobbyAction = "APPROVED" | "REJECTED";
export type SSELobbyEvent = {
status: "PENDING" | "APPROVED" | "REJECTED";
token?: string; // LiveKit token, only on APPROVED
};
export type ModerationAction = "kick" | "ban" | "mute_all" | "grant_publish" | "revoke_publish";