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:
139
src/app/(dashboard)/dashboard/create/page.tsx
Normal file
139
src/app/(dashboard)/dashboard/create/page.tsx
Normal 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"
|
||||
>
|
||||
← Назад к комнатам
|
||||
</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>
|
||||
);
|
||||
}
|
||||
130
src/app/(dashboard)/dashboard/page.tsx
Normal file
130
src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
50
src/app/api/livekit/token/route.ts
Normal file
50
src/app/api/livekit/token/route.ts
Normal 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 });
|
||||
}
|
||||
91
src/app/api/livekit/webhook/route.ts
Normal file
91
src/app/api/livekit/webhook/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
104
src/app/api/rooms/[roomId]/chat/route.ts
Normal file
104
src/app/api/rooms/[roomId]/chat/route.ts
Normal 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 });
|
||||
}
|
||||
45
src/app/api/rooms/[roomId]/end/route.ts
Normal file
45
src/app/api/rooms/[roomId]/end/route.ts
Normal 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);
|
||||
}
|
||||
94
src/app/api/rooms/[roomId]/files/route.ts
Normal file
94
src/app/api/rooms/[roomId]/files/route.ts
Normal 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 });
|
||||
}
|
||||
64
src/app/api/rooms/[roomId]/hand-raise/route.ts
Normal file
64
src/app/api/rooms/[roomId]/hand-raise/route.ts
Normal 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: [] });
|
||||
}
|
||||
127
src/app/api/rooms/[roomId]/join/route.ts
Normal file
127
src/app/api/rooms/[roomId]/join/route.ts
Normal 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 });
|
||||
}
|
||||
113
src/app/api/rooms/[roomId]/lobby/route.ts
Normal file
113
src/app/api/rooms/[roomId]/lobby/route.ts
Normal 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 });
|
||||
}
|
||||
112
src/app/api/rooms/[roomId]/lobby/stream/route.ts
Normal file
112
src/app/api/rooms/[roomId]/lobby/stream/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
124
src/app/api/rooms/[roomId]/moderate/route.ts
Normal file
124
src/app/api/rooms/[roomId]/moderate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
119
src/app/api/rooms/[roomId]/route.ts
Normal file
119
src/app/api/rooms/[roomId]/route.ts
Normal 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 });
|
||||
}
|
||||
40
src/app/api/rooms/[roomId]/start/route.ts
Normal file
40
src/app/api/rooms/[roomId]/start/route.ts
Normal 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);
|
||||
}
|
||||
38
src/app/api/rooms/by-code/[code]/route.ts
Normal file
38
src/app/api/rooms/by-code/[code]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
65
src/app/api/rooms/route.ts
Normal file
65
src/app/api/rooms/route.ts
Normal 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
1
src/app/globals.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
202
src/app/join/[code]/page.tsx
Normal file
202
src/app/join/[code]/page.tsx
Normal 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
42
src/app/join/page.tsx
Normal 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
25
src/app/layout.tsx
Normal 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
95
src/app/login/page.tsx
Normal 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
28
src/app/page.tsx
Normal 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
112
src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/app/room/[code]/page.tsx
Normal file
196
src/app/room/[code]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/lobby/LobbyManager.tsx
Normal file
95
src/components/lobby/LobbyManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/lobby/WaitingRoom.tsx
Normal file
93
src/components/lobby/WaitingRoom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/room/ChatPanel.tsx
Normal file
118
src/components/room/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/room/ModerationPanel.tsx
Normal file
80
src/components/room/ModerationPanel.tsx
Normal 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
7
src/lib/auth-client.ts
Normal 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
8
src/lib/auth-helpers.ts
Normal 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
16
src/lib/auth.ts
Normal 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
41
src/lib/livekit.ts
Normal 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
12
src/lib/prisma.ts
Normal 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
71
src/middleware.ts
Normal 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
15
src/types/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user