feat: LiveServer-M1 v1 — educational video conferencing platform #1
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
ai-agent
|
||||
*.md
|
||||
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# === Domain & SSL (оставь пустым для локалки) ===
|
||||
# DOMAIN=live.example.com
|
||||
# ACME_EMAIL=admin@example.com
|
||||
|
||||
# === Local dev protection (работает когда DOMAIN не задан) ===
|
||||
# Ключ доступа: открой http://192.168.x.x:3000?key=mySecretKey123
|
||||
DEV_ACCESS_KEY=mySecretKey123
|
||||
# Разрешённые IP (через запятую). localhost всегда разрешён.
|
||||
# ALLOWED_IPS=192.168.1.10,192.168.1.11
|
||||
|
||||
# === Database ===
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/liveserver
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
||||
# === LiveKit ===
|
||||
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
LIVEKIT_API_KEY=your-api-key
|
||||
LIVEKIT_API_SECRET=your-api-secret
|
||||
|
||||
# === AI Agent (Python) ===
|
||||
DEEPGRAM_API_KEY=your-deepgram-key
|
||||
OPENAI_API_KEY=your-openai-key
|
||||
|
||||
# === Storage (MinIO/S3) ===
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=liveserver
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
|
||||
# === Auth ===
|
||||
BETTER_AUTH_SECRET=your-secret-key-change-in-production
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
276
.gitignore
vendored
276
.gitignore
vendored
@@ -1,258 +1,56 @@
|
||||
# ---> VisualStudioCode
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/Release
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.idea/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# ---> Node
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
ai-agent/.env
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
# Misc
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
.eslintcache
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
167
CLAUDE.md
Normal file
167
CLAUDE.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**LiveServer-M1** — образовательная видеоконференц-платформа на базе LiveKit.
|
||||
|
||||
Ключевые фичи:
|
||||
- AI-ассистент: авто-подключение к комнатам, real-time транскрипция (Deepgram), суммаризация лекций (OpenAI GPT)
|
||||
- Защита от Zoom-bombing: lobby/waiting room, PIN-коды, kick/ban по session fingerprint, panic button
|
||||
- Пост-лекционная панель: чат, файлы, транскрипт, AI-саммари, экспорт в PDF/Google Drive
|
||||
|
||||
Полная спецификация: **`PROMPT.md`**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Слой | Технологии |
|
||||
|------|-----------|
|
||||
| Frontend | Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS v4 |
|
||||
| Video | LiveKit Cloud → self-hosted, `@livekit/components-react` |
|
||||
| Backend | Next.js Route Handlers, `livekit-server-sdk` |
|
||||
| Auth | `better-auth` + Prisma adapter |
|
||||
| AI Agent | Python, `livekit-agents`, `livekit-plugins-deepgram` (STT), `livekit-plugins-openai` (LLM) |
|
||||
| DB | PostgreSQL + Prisma 7 ORM |
|
||||
| Storage | MinIO (S3-compatible) |
|
||||
| Proxy | Traefik v3 + Let's Encrypt (prod only) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # 15 API route handlers
|
||||
│ │ │ ├── auth/[...all]/ # better-auth catch-all
|
||||
│ │ │ ├── rooms/ # CRUD, by-code lookup
|
||||
│ │ │ ├── rooms/[roomId]/ # join, lobby, lobby/stream (SSE), chat, files, moderate, hand-raise, start, end
|
||||
│ │ │ └── livekit/ # token generation, webhook receiver
|
||||
│ │ ├── (dashboard)/ # Host/Admin pages
|
||||
│ │ ├── join/[code]/ # Guest entry
|
||||
│ │ ├── room/[code]/ # Video room (LiveKit)
|
||||
│ │ ├── login/ & register/ # Auth pages
|
||||
│ │ └── page.tsx # Landing
|
||||
│ ├── components/
|
||||
│ │ ├── room/ # ChatPanel, ModerationPanel
|
||||
│ │ └── lobby/ # WaitingRoom, LobbyManager
|
||||
│ ├── lib/ # prisma, auth, auth-helpers, livekit
|
||||
│ ├── middleware.ts # Dev protection (DEV_ACCESS_KEY, ALLOWED_IPS)
|
||||
│ └── types/
|
||||
├── ai-agent/ # Python LiveKit Agent
|
||||
├── prisma/schema.prisma # 9 models, 3 enums
|
||||
├── docker-compose.yml # Base: postgres, minio, ai-agent, app
|
||||
├── docker-compose.override.yml # Local dev: direct ports, no SSL
|
||||
├── docker-compose.prod.yml # Traefik + Let's Encrypt
|
||||
└── Dockerfile # Multi-stage Next.js standalone build
|
||||
```
|
||||
|
||||
### Key Data Flows
|
||||
|
||||
1. **Guest join (with lobby):**
|
||||
Guest → POST `/api/rooms/[id]/join` → LobbyEntry(PENDING) → SSE `/api/rooms/[id]/lobby/stream` → Host approves → LiveKit token → connect
|
||||
|
||||
2. **Real-time transcription:**
|
||||
Audio tracks → AI Agent (Deepgram STT) → DataChannel → Live captions
|
||||
|
||||
3. **Post-lecture (`room_finished` webhook):**
|
||||
Transcript → OpenAI GPT → LectureArtifact → `/lectures/[id]`
|
||||
|
||||
### User Roles
|
||||
|
||||
- **ADMIN** — global panel, all rooms monitoring
|
||||
- **HOST** — room creation, moderation, security
|
||||
- **GUEST** — join by link, no registration required
|
||||
|
||||
### Security Layers
|
||||
|
||||
- `sessionFingerprint` (required) for bans — IP is secondary only
|
||||
- PIN hashed with bcrypt, rate-limited (5 attempts/min per IP)
|
||||
- Chat/files auth: verified via ParticipantHistory
|
||||
- SSE lobby: verified against existing LobbyEntry
|
||||
- Token generation: only room owner can get LiveKit token
|
||||
- `DEV_ACCESS_KEY` middleware: protects local dev from network access
|
||||
- LiveKit webhook: signature verification via `WebhookReceiver`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
npm run dev # Next.js dev server (localhost:3000)
|
||||
docker compose up -d postgres minio # DB + Storage only
|
||||
npm run lint # TypeScript type-check (tsc --noEmit)
|
||||
|
||||
# Database
|
||||
npx prisma migrate dev # Create + apply migration
|
||||
npx prisma db push # Quick schema sync (no migration)
|
||||
npx prisma studio # DB GUI
|
||||
npx prisma generate # Regenerate Prisma Client
|
||||
|
||||
# Build
|
||||
npm run build -- --webpack # Production build (Webpack, not Turbopack — WASM limitation on Windows)
|
||||
|
||||
# Docker (full stack)
|
||||
docker compose up -d --build # Local
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # Prod (with Traefik)
|
||||
|
||||
# AI Agent
|
||||
cd ai-agent && python main.py start
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all variables. Key ones:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/liveserver
|
||||
LIVEKIT_URL=wss://...
|
||||
LIVEKIT_API_KEY=...
|
||||
LIVEKIT_API_SECRET=...
|
||||
BETTER_AUTH_SECRET=...
|
||||
|
||||
# Local dev protection (when DOMAIN is not set)
|
||||
DEV_ACCESS_KEY=mySecretKey123
|
||||
# ALLOWED_IPS=192.168.1.10,192.168.1.11
|
||||
|
||||
# Production
|
||||
# DOMAIN=live.example.com
|
||||
# ACME_EMAIL=admin@example.com
|
||||
```
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| **Local dev** | `docker compose up -d` | Direct ports (3000, 5432, 9000). `docker-compose.override.yml` auto-applied |
|
||||
| **Production** | `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` | Traefik reverse proxy, auto SSL via Let's Encrypt |
|
||||
|
||||
## Prisma 7 Notes
|
||||
|
||||
- `prisma.config.ts` in project root (required by Prisma 7)
|
||||
- `datasourceUrl` passed via PrismaClient constructor, not in schema
|
||||
- Schema has no `url` in datasource block — only `provider`
|
||||
- Use `-- --webpack` flag for `next build` on Windows (Turbopack WASM issue)
|
||||
|
||||
## Agent Orchestration
|
||||
|
||||
| Task | Agent |
|
||||
|------|-------|
|
||||
| API routes, backend logic | `backend-architect` |
|
||||
| React components, LiveKit UI | `frontend-developer` |
|
||||
| DB schema, indexes, queries | `database-optimizer` |
|
||||
| Python AI Agent, Deepgram/OpenAI | `ai-engineer` |
|
||||
| System architecture | `software-architect` |
|
||||
| UI design, components | `ui-designer` |
|
||||
| UX flows, accessibility | `ux-architect` |
|
||||
| Code review before commit | `code-reviewer` |
|
||||
|
||||
**Rule:** for tasks spanning 2+ layers — use `agents-orchestrator`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Communication language: Russian
|
||||
- Guests don't register, but `user_id` reserved for future LMS
|
||||
- `sessionId` (UUID) is the cross-cutting key linking LobbyEntry, ParticipantHistory, ChatMessage, SharedFile for guests
|
||||
- `Room.code` is the user-facing invite code (not the DB id)
|
||||
- Files store `fileKey` (S3 object key), not full URLs
|
||||
- `LectureArtifact` is 1:1 with Room
|
||||
- All cascade deletes: removing Room removes all related data
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY prisma ./prisma/
|
||||
COPY prisma.config.ts ./
|
||||
RUN npm ci
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate && npm run build -- --webpack
|
||||
|
||||
# --- Production ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
119
PROMPT.md
Normal file
119
PROMPT.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<role>
|
||||
You are an expert Senior Full-Stack Engineer specializing in WebRTC (LiveKit), Next.js, AI integrations, and secure backend architectures.
|
||||
</role>
|
||||
|
||||
<project_overview>
|
||||
I am building a modern, highly scalable educational video conferencing platform (similar to a specialized Zoom/Google Meet for universities).
|
||||
Key differentiators:
|
||||
1. Deep integration of an AI Assistant that automatically joins rooms, transcribes audio in real-time, and generates post-lecture summaries.
|
||||
2. Advanced security and moderation tools to prevent "Zoom-bombing".
|
||||
3. A centralized post-lecture dashboard containing chat history, shared files, and AI summaries.
|
||||
</project_overview>
|
||||
|
||||
<tech_stack>
|
||||
- **Frontend:** Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS v4.
|
||||
- **Video Core:** LiveKit Cloud (initial phase; migration to self-hosted LiveKit on Proxmox planned for later). Client UI via `@livekit/components-react`.
|
||||
- **Backend:** Node.js (Next.js Route Handlers). LiveKit Server Node SDK (`livekit-server-sdk`) for token generation and room management.
|
||||
- **Authentication:** `better-auth` with Prisma adapter. Email/Password for Hosts and Admins. Guests join without auth (name-only entry), with a `user_id` column reserved for future LMS account linking.
|
||||
- **AI Module:** Python — LiveKit Agents framework (`livekit-agents`). Real-time STT via `livekit-plugins-deepgram` (streaming, low latency). Post-lecture summarization via OpenAI GPT API (`livekit-plugins-openai`).
|
||||
- **Database:** PostgreSQL with Prisma 7 ORM. Config in `prisma.config.ts` (Prisma 7 pattern — no `url` in schema datasource).
|
||||
- **Storage:** S3-compatible storage (MinIO self-hosted on Proxmox, or AWS S3) for recordings, shared files, and chat attachments.
|
||||
- **Reverse Proxy:** Traefik v3 with automatic Let's Encrypt SSL (production). Local dev runs on direct ports without proxy.
|
||||
- **PDF Export:** `@react-pdf/renderer` for generating downloadable lecture summaries.
|
||||
- **Google Drive Export:** Google Drive API with OAuth for optional export of lecture artifacts.
|
||||
</tech_stack>
|
||||
|
||||
<user_roles>
|
||||
1. **Super Admin:** Access to a global dashboard. Can monitor all active rooms, manage global AI settings, view system logs, and manage all users.
|
||||
2. **Host (Professor/Teacher):** Authenticated user (email/password via `better-auth`). Can create rooms, manage participants, share files, and control room security settings.
|
||||
3. **Guest/Student:** Joins via invite link. Does not require registration (enters a display name only). The database stores a `user_id` (nullable) to support future LMS integration where guests can optionally link or create accounts.
|
||||
</user_roles>
|
||||
|
||||
<authentication>
|
||||
- **Library:** `better-auth` with Prisma adapter for database-backed sessions.
|
||||
- **Host/Admin flow:** Standard email/password registration and login. Sessions stored in PostgreSQL.
|
||||
- **Guest flow:** No registration required. Guest provides a display name and a `sessionFingerprint` (browser fingerprint hash, required). Receives a short-lived LiveKit token scoped to a specific room. Guest identity is tracked via a generated `sessionId` (UUID) stored in `ParticipantHistory`.
|
||||
- **Authorization:** Middleware checks role (`ADMIN`, `HOST`, `GUEST`) before granting access to protected API routes and pages.
|
||||
- **Local dev protection:** When `DOMAIN` env is not set, a `DEV_ACCESS_KEY` middleware blocks access without `?key=...` query param or valid cookie. Optional `ALLOWED_IPS` whitelist.
|
||||
</authentication>
|
||||
|
||||
<security_and_moderation>
|
||||
Rooms must support the following security features configured by the Host:
|
||||
|
||||
1. **Waiting Room (Lobby):** Guests cannot enter the LiveKit room until the Host explicitly approves them. Implementation:
|
||||
- Guest submits a join request to the backend API, which creates a `LobbyEntry` record with status `PENDING`.
|
||||
- Guest connects to an SSE (Server-Sent Events) endpoint (`/api/rooms/[roomId]/lobby/stream`) and waits for status change. SSE validates `sessionId` against existing `LobbyEntry` before starting stream.
|
||||
- Host sees pending guests in the room UI (fetched via polling or SSE). Host approves or rejects via API call.
|
||||
- On approval, backend generates a LiveKit token and pushes `APPROVED` status (with token) to the guest's SSE stream. Guest then connects to LiveKit.
|
||||
- On rejection, backend pushes `REJECTED` status. Guest sees a denial message.
|
||||
|
||||
2. **PIN Codes:** Rooms can optionally be protected by a PIN. The PIN is hashed (bcrypt) and stored in `Room.pinHash`. Guests must submit the correct PIN before entering the lobby. **Rate limited:** max 5 PIN attempts per IP per minute (in-memory limiter, returns 429).
|
||||
|
||||
3. **Moderation Actions:**
|
||||
- **Kick & Ban:** Host can remove a participant and add them to the `BannedEntry` table. Ban is based on `sessionFingerprint` (required, combination of browser fingerprint hash + session ID) as the primary identifier. IP address is stored as a secondary signal but is not the sole basis for bans (easily bypassed via VPN). Secondary ban check by `sessionId`.
|
||||
- **Panic Button (Mute All):** Host can instantly revoke `canPublish` permissions for all participants except themselves via a single API call using LiveKit Server SDK's `updateParticipant`.
|
||||
|
||||
4. **Hand-Raising (Webinar Mode):** In rooms with `webinarMode: true`, guests join with `canPublish: false`. They send a "raise hand" signal via LiveKit DataChannels. The Host sees raised hands in the UI and can dynamically grant `canPublish: true` to selected participants via the backend.
|
||||
|
||||
5. **Chat & Files auth:** Both endpoints verify the requester is either an authenticated Host/Admin or a participant with a valid `sessionId` in `ParticipantHistory` for the room.
|
||||
|
||||
6. **LiveKit token security:** Token generation endpoint verifies the requesting user is the actual host of the room (checks `room.hostId === session.user.id`).
|
||||
</security_and_moderation>
|
||||
|
||||
<ai_assistant>
|
||||
The AI Assistant is a Python-based LiveKit Agent that:
|
||||
1. **Auto-joins** every room on `room_started` webhook (or via LiveKit Agents' `AutoSubscribe`).
|
||||
2. **Real-time transcription:** Subscribes to all audio tracks. Uses Deepgram streaming STT (`livekit-plugins-deepgram`) for low-latency, real-time transcription. Transcription segments are published back to the room via LiveKit DataChannels (topic: `transcription`) so participants can see live captions.
|
||||
3. **Post-lecture summarization:** When the room ends (`room_finished` webhook), the agent takes the full transcript and sends it to OpenAI GPT API for structured summarization (key topics, action items, Q&A highlights).
|
||||
4. **Storage:** Transcript and summary are saved to the `LectureArtifact` table in PostgreSQL via psycopg2. Raw transcript is also stored as a file in S3.
|
||||
</ai_assistant>
|
||||
|
||||
<post_lecture_processing>
|
||||
When the Host ends the call (triggering LiveKit `room_finished` webhook):
|
||||
1. The backend sets room status to ENDED and destroys the LiveKit room via `roomService.deleteRoom()`.
|
||||
2. The AI Agent finalizes the transcript and generates a structured summary via LLM.
|
||||
3. The backend aggregates: chat history (from `ChatMessage` table), links to all S3 files shared during the call, and the AI summary.
|
||||
4. A "Lecture Artifacts" page (`/lectures/[id]`) is generated, displaying:
|
||||
- AI summary (structured: key topics, action items, Q&A)
|
||||
- Full transcript (searchable)
|
||||
- Chat history
|
||||
- Shared files (downloadable)
|
||||
- Export options: Download as PDF (`@react-pdf/renderer`), Export to Google Drive (OAuth).
|
||||
5. Access to this page is restricted to the Host, Admin, and optionally participants who were in the room.
|
||||
</post_lecture_processing>
|
||||
|
||||
<deployment>
|
||||
- **Local development:** `docker compose up -d` — auto-applies `docker-compose.override.yml` with direct ports (3000, 5432, 9000). No SSL, protected by `DEV_ACCESS_KEY` middleware.
|
||||
- **Production:** `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` — Traefik v3 reverse proxy with automatic Let's Encrypt SSL. Requires `DOMAIN` and `ACME_EMAIL` env vars. DNS must point `DOMAIN`, `s3.DOMAIN`, `minio.DOMAIN` to server IP.
|
||||
- **Next.js build:** Uses `output: "standalone"` and multi-stage Dockerfile. On Windows, use `--webpack` flag (Turbopack has WASM limitation).
|
||||
- **Future:** Self-hosted LiveKit Server on Proxmox VM with TURN/STUN via Coturn.
|
||||
</deployment>
|
||||
|
||||
<current_state>
|
||||
The following is already implemented:
|
||||
- Prisma schema: 9 models (User, Session, Account, Verification, Room, LobbyEntry, ParticipantHistory, ChatMessage, SharedFile, BannedEntry, LectureArtifact), 3 enums
|
||||
- 15 API route handlers (auth, rooms CRUD, join, lobby, SSE, moderation, chat, files, token, webhook, by-code lookup)
|
||||
- 7 frontend pages (landing, login, register, dashboard, create room, join, video room)
|
||||
- 4 components (ChatPanel, ModerationPanel, WaitingRoom, LobbyManager)
|
||||
- Core libs (prisma, auth, auth-helpers, livekit)
|
||||
- Dev protection middleware (DEV_ACCESS_KEY, ALLOWED_IPS)
|
||||
- Docker Compose (local + prod profiles)
|
||||
- Python AI Agent with Deepgram STT + OpenAI summarization
|
||||
- Multi-stage Dockerfile for Next.js
|
||||
</current_state>
|
||||
|
||||
<next_steps>
|
||||
Remaining work to reach full MVP:
|
||||
1. **Lecture Artifacts page** (`/lectures/[id]`) — display transcript, summary, chat, files with export
|
||||
2. **Admin dashboard** — global room monitoring, user management, role promotion
|
||||
3. **File upload flow** — presigned S3 URL generation, actual upload component
|
||||
4. **Google Drive export** — OAuth flow, Drive API integration
|
||||
5. **PDF export** — `@react-pdf/renderer` for lecture summaries
|
||||
6. **Room invite code** — generate shorter user-friendly codes instead of cuid
|
||||
7. **UI polish** — responsive design, loading states, error boundaries
|
||||
8. **Tests** — API route integration tests, component tests
|
||||
</next_steps>
|
||||
|
||||
<instructions>
|
||||
Continue building iteratively. The core MVP (steps 1-8 in current_state) is done. Focus on the next_steps list above, one feature at a time. Always run `npx tsc --noEmit` after changes to verify no type errors.
|
||||
</instructions>
|
||||
227
README.md
227
README.md
@@ -1,3 +1,228 @@
|
||||
# LiveServer-M1
|
||||
|
||||
Livekit core - Server for VideoHosting
|
||||
Образовательная видеоконференц-платформа на базе LiveKit. AI-транскрипция, модерация, пост-лекционные артефакты.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Frontend:** Next.js 16, React 19, TypeScript, Tailwind CSS v4
|
||||
- **Video:** LiveKit (`@livekit/components-react`)
|
||||
- **Backend:** Next.js Route Handlers, `livekit-server-sdk`
|
||||
- **Auth:** `better-auth` + Prisma adapter
|
||||
- **AI Agent:** Python, `livekit-agents`, Deepgram STT, OpenAI GPT
|
||||
- **DB:** PostgreSQL + Prisma 7
|
||||
- **Storage:** MinIO (S3-compatible)
|
||||
- **Proxy:** Traefik v3 + Let's Encrypt (production)
|
||||
|
||||
## Быстрый старт (автоматический)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> && cd LiveServer-M1
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
Скрипт проведёт через все шаги: проверит зависимости, спросит настройки, сгенерирует `.env`, поднимет Docker, применит миграции, создаст S3 bucket. Работает для локальной разработки и production.
|
||||
|
||||
## Быстрый старт (ручной)
|
||||
|
||||
### 1. Клонировать и установить зависимости
|
||||
|
||||
```bash
|
||||
git clone <repo-url> && cd LiveServer-M1
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Настроить окружение
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Заполни в `.env`:
|
||||
|
||||
| Переменная | Описание |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Для Docker: `postgresql://postgres:postgres@localhost:5432/liveserver` |
|
||||
| `LIVEKIT_URL` | URL LiveKit сервера (`wss://...livekit.cloud` или self-hosted) |
|
||||
| `LIVEKIT_API_KEY` | API Key из LiveKit Dashboard |
|
||||
| `LIVEKIT_API_SECRET` | API Secret из LiveKit Dashboard |
|
||||
| `BETTER_AUTH_SECRET` | Любая случайная строка для подписи сессий |
|
||||
| `DEV_ACCESS_KEY` | Ключ доступа для локалки (защита от посторонних в сети) |
|
||||
|
||||
Остальные переменные опциональны для начала — AI Agent и MinIO нужны позже.
|
||||
|
||||
### 3. Запустить базу данных
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres minio
|
||||
```
|
||||
|
||||
Поднимет PostgreSQL на `localhost:5432` и MinIO на `localhost:9000` (консоль: `localhost:9001`).
|
||||
|
||||
### 4. Применить миграции
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name init
|
||||
```
|
||||
|
||||
Или для быстрого прототипирования без миграций:
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
### 5. Запустить приложение
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Приложение: `http://localhost:3000`
|
||||
|
||||
### 6. (Опционально) AI Agent
|
||||
|
||||
```bash
|
||||
cd ai-agent
|
||||
pip install -r requirements.txt
|
||||
python main.py start
|
||||
```
|
||||
|
||||
Требует `DEEPGRAM_API_KEY` и `OPENAI_API_KEY` в `.env`.
|
||||
|
||||
## Защита на локалке
|
||||
|
||||
Когда `DOMAIN` не задан, middleware блокирует доступ без ключа:
|
||||
|
||||
```
|
||||
http://192.168.x.x:3000?key=mySecretKey123
|
||||
```
|
||||
|
||||
Ключ задаётся в `DEV_ACCESS_KEY`. После первого входа ставится cookie на 7 дней.
|
||||
|
||||
Дополнительно можно ограничить по IP:
|
||||
|
||||
```env
|
||||
ALLOWED_IPS=192.168.1.10,192.168.1.11
|
||||
```
|
||||
|
||||
Localhost (`127.0.0.1`, `::1`) разрешён всегда.
|
||||
|
||||
## Production (с доменом)
|
||||
|
||||
### 1. Настроить DNS
|
||||
|
||||
Направить на IP сервера:
|
||||
- `live.example.com` → приложение
|
||||
- `s3.live.example.com` → MinIO API
|
||||
- `minio.live.example.com` → MinIO Console
|
||||
|
||||
### 2. Заполнить `.env`
|
||||
|
||||
```env
|
||||
DOMAIN=live.example.com
|
||||
ACME_EMAIL=admin@example.com
|
||||
POSTGRES_PASSWORD=<strong-password>
|
||||
MINIO_ROOT_PASSWORD=<strong-password>
|
||||
BETTER_AUTH_SECRET=<random-string>
|
||||
BETTER_AUTH_URL=https://live.example.com
|
||||
NEXT_PUBLIC_APP_URL=https://live.example.com
|
||||
```
|
||||
|
||||
### 3. Запустить
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Traefik автоматически получит SSL-сертификат через Let's Encrypt.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Разработка
|
||||
npm run dev # Next.js dev server
|
||||
npm run lint # TypeScript type-check (tsc --noEmit)
|
||||
npm run build -- --webpack # Production build
|
||||
|
||||
# База данных
|
||||
npx prisma migrate dev # Создать/применить миграцию
|
||||
npx prisma db push # Синхронизировать схему без миграции
|
||||
npx prisma studio # GUI для БД
|
||||
npx prisma generate # Регенерация Prisma Client
|
||||
|
||||
# Docker
|
||||
docker compose up -d # Локалка (postgres + minio)
|
||||
docker compose up -d --build # Локалка с билдом app
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # Прод
|
||||
docker compose logs -f app # Логи приложения
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── auth/[...all]/ # better-auth handler
|
||||
│ │ │ ├── rooms/ # CRUD комнат
|
||||
│ │ │ ├── rooms/[roomId]/ # join, lobby, chat, files, moderate, start, end
|
||||
│ │ │ ├── rooms/by-code/[code]/ # Публичный поиск по инвайт-коду
|
||||
│ │ │ └── livekit/ # token, webhook
|
||||
│ │ ├── (dashboard)/ # Дашборд хоста/админа
|
||||
│ │ ├── join/[code]/ # Страница входа гостя
|
||||
│ │ ├── room/[code]/ # Комната видеоконференции
|
||||
│ │ ├── login/ # Вход
|
||||
│ │ └── register/ # Регистрация
|
||||
│ ├── components/
|
||||
│ │ ├── room/ # ChatPanel, ModerationPanel
|
||||
│ │ └── lobby/ # WaitingRoom, LobbyManager
|
||||
│ ├── lib/ # prisma, auth, livekit, auth-helpers
|
||||
│ ├── middleware.ts # Защита локалки (DEV_ACCESS_KEY, ALLOWED_IPS)
|
||||
│ └── types/
|
||||
├── ai-agent/ # Python LiveKit Agent
|
||||
│ ├── main.py
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── prisma/
|
||||
│ └── schema.prisma
|
||||
├── docker-compose.yml # Базовые сервисы
|
||||
├── docker-compose.override.yml # Локальные порты (автоподхват)
|
||||
├── docker-compose.prod.yml # Traefik + SSL
|
||||
├── Dockerfile # Multi-stage build Next.js
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Публичные (без авторизации)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `*` | `/api/auth/*` | better-auth (login, register, session) |
|
||||
| `GET` | `/api/rooms/by-code/:code` | Инфо о комнате по инвайт-коду |
|
||||
| `POST` | `/api/rooms/:id/join` | Вход гостя (PIN, fingerprint) |
|
||||
| `GET` | `/api/rooms/:id/lobby/stream` | SSE ожидание в лобби |
|
||||
| `POST` | `/api/livekit/webhook` | Вебхуки LiveKit |
|
||||
|
||||
### Требуют авторизации (Host/Admin)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `GET/POST` | `/api/rooms` | Список / создание комнат |
|
||||
| `GET/PATCH/DELETE` | `/api/rooms/:id` | Управление комнатой |
|
||||
| `POST` | `/api/rooms/:id/start` | Старт лекции |
|
||||
| `POST` | `/api/rooms/:id/end` | Завершение лекции |
|
||||
| `GET/POST` | `/api/rooms/:id/lobby` | Управление лобби |
|
||||
| `POST` | `/api/rooms/:id/moderate` | Kick, ban, mute all |
|
||||
| `POST` | `/api/livekit/token` | Генерация токена LiveKit |
|
||||
|
||||
### Требуют участия в комнате (sessionId)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `GET/POST` | `/api/rooms/:id/chat` | Чат комнаты |
|
||||
| `GET/POST` | `/api/rooms/:id/files` | Файлы комнаты |
|
||||
|
||||
## Роли
|
||||
|
||||
| Роль | Возможности |
|
||||
|---|---|
|
||||
| **ADMIN** | Всё + глобальная панель, мониторинг всех комнат |
|
||||
| **HOST** | Создание комнат, модерация, настройки безопасности |
|
||||
| **GUEST** | Вход по ссылке, участие в лекции (без регистрации) |
|
||||
|
||||
3
ai-agent/.dockerignore
Normal file
3
ai-agent/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
15
ai-agent/Dockerfile
Normal file
15
ai-agent/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for psycopg2
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends libpq-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "main.py", "start"]
|
||||
295
ai-agent/main.py
Normal file
295
ai-agent/main.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
LiveServer-M1 AI Agent
|
||||
======================
|
||||
LiveKit agent that provides real-time transcription via Deepgram STT,
|
||||
publishes captions to participants, and on room end generates an
|
||||
AI summary (GPT-4o-mini) stored alongside the full transcript in PostgreSQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
from livekit import rtc
|
||||
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
|
||||
from livekit.plugins import deepgram, openai
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("liveserver-agent")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_db_connection():
|
||||
"""Create a fresh PostgreSQL connection from DATABASE_URL."""
|
||||
dsn = os.environ.get("DATABASE_URL")
|
||||
if not dsn:
|
||||
raise RuntimeError("DATABASE_URL environment variable is not set")
|
||||
return psycopg2.connect(dsn)
|
||||
|
||||
|
||||
def _ensure_table():
|
||||
"""Create the lecture_artifacts table if it doesn't already exist.
|
||||
|
||||
This is a safety net — normally Prisma migrations handle schema creation.
|
||||
The column names match the Prisma model exactly (snake_case mapped via @@map).
|
||||
"""
|
||||
ddl = """
|
||||
CREATE TABLE IF NOT EXISTS lecture_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
"roomId" TEXT UNIQUE NOT NULL,
|
||||
"transcriptText" TEXT,
|
||||
"transcriptUrl" TEXT,
|
||||
summary TEXT,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = _get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(ddl)
|
||||
conn.commit()
|
||||
logger.info("lecture_artifacts table verified")
|
||||
except Exception:
|
||||
logger.exception("Failed to ensure lecture_artifacts table")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_artifact(room_name: str, transcript: str, summary: str | None):
|
||||
"""Upsert a lecture artifact for the given room.
|
||||
|
||||
Uses the LiveKit room name to look up the room row first (rooms.livekitRoomName),
|
||||
then inserts or updates lecture_artifacts.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = _get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
# Resolve internal room id from livekitRoomName
|
||||
cur.execute(
|
||||
'SELECT id FROM rooms WHERE "livekitRoomName" = %s',
|
||||
(room_name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
logger.error("Room not found in DB for livekitRoomName=%s", room_name)
|
||||
return
|
||||
room_id = row[0]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Upsert via ON CONFLICT on roomId (unique)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO lecture_artifacts (id, "roomId", "transcriptText", summary, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT ("roomId")
|
||||
DO UPDATE SET
|
||||
"transcriptText" = EXCLUDED."transcriptText",
|
||||
summary = EXCLUDED.summary,
|
||||
"updatedAt" = EXCLUDED."updatedAt"
|
||||
""",
|
||||
(room_id, transcript, summary, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("Saved artifact for room %s (room_id=%s)", room_name, room_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to save artifact for room %s", room_name)
|
||||
if conn:
|
||||
conn.rollback()
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summarisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def summarise_transcript(transcript: str) -> str | None:
|
||||
"""Send the full transcript to GPT-4o-mini and return a markdown summary."""
|
||||
if not transcript.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
llm = openai.LLM(model="gpt-4o-mini")
|
||||
chat_ctx = openai.ChatContext()
|
||||
chat_ctx.append(
|
||||
role="system",
|
||||
text=(
|
||||
"You are a helpful assistant that summarises educational lectures. "
|
||||
"Produce a concise summary in Markdown with: key topics covered, "
|
||||
"main takeaways, and any action items mentioned. "
|
||||
"Keep it under 500 words. Respond in the same language as the transcript."
|
||||
),
|
||||
)
|
||||
chat_ctx.append(role="user", text=transcript)
|
||||
|
||||
response = await llm.chat(chat_ctx=chat_ctx)
|
||||
return response.choices[0].message.content
|
||||
except Exception:
|
||||
logger.exception("Summarisation failed")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Called by the livekit-agents framework for every room job."""
|
||||
|
||||
logger.info("Agent joining room: %s", ctx.room.name)
|
||||
|
||||
# Connect to the room — subscribe to audio only (for STT)
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
# Accumulated transcript segments: list of {"speaker": str, "text": str, "ts": float}
|
||||
segments: list[dict] = []
|
||||
|
||||
# ----- Deepgram STT setup ----- #
|
||||
stt = deepgram.STT()
|
||||
|
||||
# We'll maintain one recognition stream per audio track
|
||||
active_streams: dict[str, deepgram.SpeechStream] = {}
|
||||
|
||||
async def _process_stt_stream(
|
||||
stream: deepgram.SpeechStream,
|
||||
participant_identity: str,
|
||||
):
|
||||
"""Read STT events from a Deepgram stream, publish captions, accumulate transcript."""
|
||||
async for event in stream:
|
||||
if not event.is_final or not event.alternatives:
|
||||
continue
|
||||
|
||||
text = event.alternatives[0].text.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Accumulate
|
||||
segment = {
|
||||
"speaker": participant_identity,
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
}
|
||||
segments.append(segment)
|
||||
|
||||
# Log to console
|
||||
logger.info("[%s] %s", participant_identity, text)
|
||||
|
||||
# Publish caption to all participants via data channel
|
||||
caption_payload = json.dumps(
|
||||
{
|
||||
"type": "transcription",
|
||||
"speaker": participant_identity,
|
||||
"text": text,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
await ctx.room.local_participant.publish_data(
|
||||
caption_payload,
|
||||
reliable=True,
|
||||
topic="transcription",
|
||||
)
|
||||
|
||||
async def on_track_subscribed(
|
||||
track: rtc.Track,
|
||||
publication: rtc.RemoteTrackPublication,
|
||||
participant: rtc.RemoteParticipant,
|
||||
):
|
||||
"""Start STT for every subscribed audio track."""
|
||||
if track.kind != rtc.TrackKind.KIND_AUDIO:
|
||||
return
|
||||
|
||||
logger.info("Subscribed to audio from %s", participant.identity)
|
||||
|
||||
audio_stream = rtc.AudioStream(track)
|
||||
stt_stream = stt.stream()
|
||||
|
||||
active_streams[participant.identity] = stt_stream
|
||||
|
||||
# Forward audio frames to the STT stream
|
||||
async def _forward():
|
||||
async for frame_event in audio_stream:
|
||||
stt_stream.push_frame(frame_event.frame)
|
||||
|
||||
# Run forwarding and recognition concurrently
|
||||
import asyncio
|
||||
|
||||
asyncio.ensure_future(_forward())
|
||||
asyncio.ensure_future(_process_stt_stream(stt_stream, participant.identity))
|
||||
|
||||
ctx.room.on("track_subscribed", on_track_subscribed)
|
||||
|
||||
# ----- Handle room disconnect / end ----- #
|
||||
|
||||
async def on_disconnected():
|
||||
"""When the agent disconnects from the room, finalise the transcript."""
|
||||
logger.info("Agent disconnected from room %s", ctx.room.name)
|
||||
|
||||
# Close all active STT streams
|
||||
for identity, stream in active_streams.items():
|
||||
try:
|
||||
await stream.aclose()
|
||||
except Exception:
|
||||
logger.warning("Error closing STT stream for %s", identity)
|
||||
|
||||
if not segments:
|
||||
logger.info("No transcript segments collected — nothing to save")
|
||||
return
|
||||
|
||||
# Build full transcript text
|
||||
full_transcript = "\n".join(
|
||||
f"[{seg['speaker']}]: {seg['text']}" for seg in segments
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Full transcript (%d segments, %d chars):\n%s",
|
||||
len(segments),
|
||||
len(full_transcript),
|
||||
full_transcript,
|
||||
)
|
||||
|
||||
# Summarise
|
||||
logger.info("Generating summary via GPT-4o-mini...")
|
||||
summary = await summarise_transcript(full_transcript)
|
||||
if summary:
|
||||
logger.info("Summary:\n%s", summary)
|
||||
else:
|
||||
logger.warning("Summary generation returned empty result")
|
||||
|
||||
# Persist to PostgreSQL
|
||||
save_artifact(ctx.room.name, full_transcript, summary)
|
||||
|
||||
ctx.room.on("disconnected", on_disconnected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
_ensure_table()
|
||||
|
||||
cli.run_app(
|
||||
WorkerOptions(
|
||||
entrypoint_fnc=entrypoint,
|
||||
)
|
||||
)
|
||||
5
ai-agent/requirements.txt
Normal file
5
ai-agent/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
livekit-agents>=1.0
|
||||
livekit-plugins-deepgram>=1.0
|
||||
livekit-plugins-openai>=1.0
|
||||
psycopg2-binary>=2.9
|
||||
python-dotenv>=1.0
|
||||
50
docker-compose.prod.yml
Normal file
50
docker-compose.prod.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
# Продакшен — Traefik + Let's Encrypt
|
||||
# Запуск: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt_data:/letsencrypt
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
environment:
|
||||
NEXT_PUBLIC_APP_URL: https://${DOMAIN}
|
||||
BETTER_AUTH_URL: https://${DOMAIN}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.app.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.app.entrypoints=websecure"
|
||||
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.app.loadbalancer.server.port=3000"
|
||||
|
||||
minio:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.minio-api.rule=Host(`s3.${DOMAIN}`)"
|
||||
- "traefik.http.routers.minio-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.minio-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.minio-api.service=minio-api"
|
||||
- "traefik.http.services.minio-api.loadbalancer.server.port=9000"
|
||||
- "traefik.http.routers.minio-console.rule=Host(`minio.${DOMAIN}`)"
|
||||
- "traefik.http.routers.minio-console.entrypoints=websecure"
|
||||
- "traefik.http.routers.minio-console.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.minio-console.service=minio-console"
|
||||
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
|
||||
|
||||
volumes:
|
||||
letsencrypt_data:
|
||||
51
docker-compose.yml
Normal file
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/liveserver
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: liveserver
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
ai-agent:
|
||||
build: ./ai-agent
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/liveserver
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
5
eslint.config.mjs
Normal file
5
eslint.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default [
|
||||
{
|
||||
ignores: [".next/", "node_modules/", "ai-agent/"],
|
||||
},
|
||||
];
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
8
next.config.ts
Normal file
8
next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8323
package-lock.json
generated
Normal file
8323
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "liveserver-m1",
|
||||
"version": "1.0.0",
|
||||
"description": "Livekit core - Server for VideoHosting",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@prisma/client": "^7.5.0",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.5.5",
|
||||
"livekit-client": "^2.17.3",
|
||||
"livekit-server-sdk": "^2.15.0",
|
||||
"next": "^16.2.1",
|
||||
"postcss": "^8.5.8",
|
||||
"prisma": "^7.5.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.1"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
12
prisma.config.ts
Normal file
12
prisma.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
earlyAccess: true,
|
||||
schema: path.join(__dirname, "prisma", "schema.prisma"),
|
||||
migrate: {
|
||||
async url() {
|
||||
return process.env.DATABASE_URL!;
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
255
prisma/schema.prisma
Normal file
255
prisma/schema.prisma
Normal file
@@ -0,0 +1,255 @@
|
||||
// ============================================================
|
||||
// LiveServer-M1 — Database Schema
|
||||
// Educational video conferencing platform on LiveKit
|
||||
// ============================================================
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ======================== ENUMS =============================
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
HOST
|
||||
GUEST
|
||||
}
|
||||
|
||||
enum RoomStatus {
|
||||
WAITING // Создана, но лекция не началась
|
||||
ACTIVE // Лекция идёт
|
||||
ENDED // Лекция завершена
|
||||
}
|
||||
|
||||
enum LobbyStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// ======================== AUTH ==============================
|
||||
// Таблицы совместимы с better-auth (Prisma adapter)
|
||||
// Docs: https://www.better-auth.com/docs/adapters/prisma
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
role UserRole @default(HOST)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// better-auth relations
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
|
||||
// App relations
|
||||
hostedRooms Room[] @relation("RoomHost")
|
||||
participantHistories ParticipantHistory[]
|
||||
bannedByEntries BannedEntry[] @relation("BannedBy")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
accountId String
|
||||
providerId String
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
idToken String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id @default(cuid())
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("verifications")
|
||||
}
|
||||
|
||||
// ======================== ROOMS =============================
|
||||
|
||||
model Room {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique @default(cuid()) // Короткий код для инвайт-ссылки
|
||||
hostId String
|
||||
status RoomStatus @default(WAITING)
|
||||
|
||||
// Security settings
|
||||
lobbyEnabled Boolean @default(true)
|
||||
pinHash String? // bcrypt hash, null = без PIN
|
||||
webinarMode Boolean @default(false)
|
||||
isLocked Boolean @default(false) // Хост может заблокировать вход полностью
|
||||
|
||||
// LiveKit
|
||||
livekitRoomName String? @unique // Имя комнаты в LiveKit (создаётся при старте)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime? // Когда хост начал лекцию
|
||||
endedAt DateTime? // Когда завершилась
|
||||
|
||||
// Relations
|
||||
host User @relation("RoomHost", fields: [hostId], references: [id])
|
||||
lobbyEntries LobbyEntry[]
|
||||
participantHistories ParticipantHistory[]
|
||||
chatMessages ChatMessage[]
|
||||
sharedFiles SharedFile[]
|
||||
bannedEntries BannedEntry[]
|
||||
lectureArtifact LectureArtifact?
|
||||
|
||||
@@index([hostId])
|
||||
@@index([status])
|
||||
@@index([code])
|
||||
@@map("rooms")
|
||||
}
|
||||
|
||||
// ======================== LOBBY =============================
|
||||
|
||||
model LobbyEntry {
|
||||
id String @id @default(cuid())
|
||||
roomId String
|
||||
displayName String
|
||||
sessionId String // UUID, генерируется на клиенте
|
||||
sessionFingerprint String? // Browser fingerprint hash
|
||||
ipAddress String?
|
||||
status LobbyStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId, status])
|
||||
@@index([sessionId])
|
||||
@@map("lobby_entries")
|
||||
}
|
||||
|
||||
// ======================== PARTICIPANTS ======================
|
||||
|
||||
model ParticipantHistory {
|
||||
id String @id @default(cuid())
|
||||
roomId String
|
||||
userId String? // null для гостей без аккаунта
|
||||
sessionId String // UUID, совпадает с LobbyEntry.sessionId
|
||||
displayName String
|
||||
role UserRole
|
||||
joinedAt DateTime @default(now())
|
||||
leftAt DateTime?
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([roomId])
|
||||
@@index([userId])
|
||||
@@index([sessionId])
|
||||
@@map("participant_histories")
|
||||
}
|
||||
|
||||
// ======================== CHAT ==============================
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
roomId String
|
||||
sessionId String // Кто отправил (связь через sessionId, не userId — гости тоже пишут)
|
||||
senderName String
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId, createdAt])
|
||||
@@map("chat_messages")
|
||||
}
|
||||
|
||||
// ======================== FILES =============================
|
||||
|
||||
model SharedFile {
|
||||
id String @id @default(cuid())
|
||||
roomId String
|
||||
sessionId String // Кто загрузил
|
||||
fileName String
|
||||
fileKey String // S3 object key
|
||||
fileSize Int
|
||||
mimeType String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([roomId])
|
||||
@@map("shared_files")
|
||||
}
|
||||
|
||||
// ======================== BANS ==============================
|
||||
|
||||
model BannedEntry {
|
||||
id String @id @default(cuid())
|
||||
roomId String
|
||||
sessionFingerprint String // Основной идентификатор для бана
|
||||
ipAddress String? // Вторичный сигнал
|
||||
displayName String? // Для UI — кого забанили
|
||||
reason String?
|
||||
bannedById String // Хост, который забанил
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
bannedBy User @relation("BannedBy", fields: [bannedById], references: [id])
|
||||
|
||||
@@unique([roomId, sessionFingerprint])
|
||||
@@index([roomId])
|
||||
@@map("banned_entries")
|
||||
}
|
||||
|
||||
// ======================== LECTURE ARTIFACTS ==================
|
||||
|
||||
model LectureArtifact {
|
||||
id String @id @default(cuid())
|
||||
roomId String @unique // 1:1 с Room
|
||||
transcriptText String? @db.Text // Полный текст транскрипта (для поиска)
|
||||
transcriptUrl String? // S3 key для raw-файла транскрипта
|
||||
summary String? @db.Text // AI-сгенерированная суммаризация (Markdown/JSON)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("lecture_artifacts")
|
||||
}
|
||||
328
setup.sh
Normal file
328
setup.sh
Normal file
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# LiveServer-M1 — Interactive Setup Script
|
||||
# ============================================================
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
BOLD='\033[1m'
|
||||
|
||||
print_banner() {
|
||||
echo ""
|
||||
echo -e "${CYAN}╔══════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║${NC} ${BOLD}LiveServer-M1${NC} — Setup Wizard ${CYAN}║${NC}"
|
||||
echo -e "${CYAN}║${NC} Образовательная видеоконференц-платформа ${CYAN}║${NC}"
|
||||
echo -e "${CYAN}╚══════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
log_step() { echo -e "\n${BLUE}[$(date +%H:%M:%S)]${NC} ${BOLD}$1${NC}"; }
|
||||
log_ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||
log_warn() { echo -e " ${YELLOW}⚠${NC} $1"; }
|
||||
log_err() { echo -e " ${RED}✗${NC} $1"; }
|
||||
|
||||
ask() {
|
||||
local prompt="$1" default="${2:-}" var_name="$3"
|
||||
if [[ -n "$default" ]]; then
|
||||
echo -en " ${CYAN}?${NC} ${prompt} ${YELLOW}[${default}]${NC}: "
|
||||
else
|
||||
echo -en " ${CYAN}?${NC} ${prompt}: "
|
||||
fi
|
||||
read -r input
|
||||
printf -v "$var_name" '%s' "${input:-$default}"
|
||||
}
|
||||
|
||||
ask_secret() {
|
||||
local prompt="$1" default="${2:-}" var_name="$3"
|
||||
if [[ -n "$default" ]]; then
|
||||
echo -en " ${CYAN}?${NC} ${prompt} ${YELLOW}[${default}]${NC}: "
|
||||
else
|
||||
echo -en " ${CYAN}?${NC} ${prompt}: "
|
||||
fi
|
||||
read -rs input
|
||||
echo ""
|
||||
printf -v "$var_name" '%s' "${input:-$default}"
|
||||
}
|
||||
|
||||
ask_yn() {
|
||||
local prompt="$1" default="${2:-y}"
|
||||
local hint="Y/n"
|
||||
[[ "$default" == "n" ]] && hint="y/N"
|
||||
echo -en " ${CYAN}?${NC} ${prompt} ${YELLOW}[${hint}]${NC}: "
|
||||
read -r input
|
||||
input="${input:-$default}"
|
||||
[[ "${input,,}" == "y" || "${input,,}" == "yes" ]]
|
||||
}
|
||||
|
||||
generate_secret() {
|
||||
openssl rand -base64 32 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -d '=/+' | head -c 32
|
||||
}
|
||||
|
||||
check_command() {
|
||||
if command -v "$1" &>/dev/null; then
|
||||
log_ok "$1 найден: $(command -v "$1")"
|
||||
return 0
|
||||
else
|
||||
log_err "$1 не найден"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Main
|
||||
# ============================================================
|
||||
|
||||
print_banner
|
||||
|
||||
# --- Step 1: Check dependencies ---
|
||||
log_step "Шаг 1/6 — Проверка зависимостей"
|
||||
|
||||
MISSING=0
|
||||
check_command docker || MISSING=1
|
||||
check_command "docker compose" 2>/dev/null || check_command docker-compose || MISSING=1
|
||||
check_command node || MISSING=1
|
||||
check_command npm || MISSING=1
|
||||
check_command npx || MISSING=1
|
||||
|
||||
if [[ "$MISSING" -eq 1 ]]; then
|
||||
log_err "Установи недостающие зависимости и запусти скрипт снова."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_VER=$(node -v)
|
||||
log_ok "Node.js: $NODE_VER"
|
||||
|
||||
# --- Step 2: Choose mode ---
|
||||
log_step "Шаг 2/6 — Режим запуска"
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}1)${NC} Локальная разработка (localhost, без SSL)"
|
||||
echo -e " ${BOLD}2)${NC} Production (домен + Traefik + Let's Encrypt SSL)"
|
||||
echo ""
|
||||
ask "Выбери режим" "1" MODE
|
||||
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
|
||||
if [[ "$MODE" == "2" ]]; then
|
||||
echo ""
|
||||
ask "Домен (например live.example.com)" "" DOMAIN
|
||||
ask "Email для Let's Encrypt" "" ACME_EMAIL
|
||||
|
||||
if [[ -z "$DOMAIN" || -z "$ACME_EMAIL" ]]; then
|
||||
log_err "Домен и email обязательны для production."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_ok "Production: ${DOMAIN}"
|
||||
log_warn "Убедись что DNS записи указывают на этот сервер:"
|
||||
echo -e " ${DOMAIN} → $(curl -s ifconfig.me 2>/dev/null || echo '<server-ip>')"
|
||||
echo -e " s3.${DOMAIN} → тот же IP"
|
||||
echo -e " minio.${DOMAIN} → тот же IP"
|
||||
echo ""
|
||||
if ! ask_yn "DNS настроен?" "y"; then
|
||||
log_warn "Настрой DNS и запусти скрипт снова."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
log_ok "Локальная разработка"
|
||||
fi
|
||||
|
||||
# --- Step 3: Configure environment ---
|
||||
log_step "Шаг 3/6 — Настройка окружения"
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}PostgreSQL${NC}"
|
||||
ask "Пароль PostgreSQL" "postgres" PG_PASSWORD
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}MinIO (S3 Storage)${NC}"
|
||||
ask "MinIO root user" "minioadmin" MINIO_USER
|
||||
ask_secret "MinIO root password" "minioadmin" MINIO_PASS
|
||||
ask "Название S3 bucket" "liveserver" S3_BUCKET
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}LiveKit${NC}"
|
||||
ask "LiveKit URL (wss://...)" "" LK_URL
|
||||
ask "LiveKit API Key" "" LK_KEY
|
||||
ask_secret "LiveKit API Secret" "" LK_SECRET
|
||||
ask_secret "LiveKit Webhook Secret (Enter чтобы пропустить)" "" LK_WEBHOOK
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}AI Agent (Enter чтобы пропустить, настроишь позже)${NC}"
|
||||
ask_secret "Deepgram API Key" "" DG_KEY
|
||||
ask_secret "OpenAI API Key" "" OAI_KEY
|
||||
|
||||
echo ""
|
||||
echo -e " ${BOLD}Аутентификация${NC}"
|
||||
AUTH_SECRET=$(generate_secret)
|
||||
log_ok "Сгенерирован BETTER_AUTH_SECRET"
|
||||
|
||||
if [[ "$MODE" == "1" ]]; then
|
||||
DEV_KEY=$(generate_secret | head -c 16)
|
||||
echo ""
|
||||
echo -e " ${BOLD}Защита локалки${NC}"
|
||||
ask "Ключ доступа (DEV_ACCESS_KEY)" "$DEV_KEY" DEV_ACCESS_KEY
|
||||
ask "Разрешённые IP (через запятую, Enter = все)" "" ALLOWED_IPS
|
||||
fi
|
||||
|
||||
# --- Step 4: Generate .env ---
|
||||
log_step "Шаг 4/6 — Генерация .env"
|
||||
|
||||
if [[ "$MODE" == "2" ]]; then
|
||||
APP_URL="https://${DOMAIN}"
|
||||
S3_ENDPOINT="http://minio:9000"
|
||||
else
|
||||
APP_URL="http://localhost:3000"
|
||||
S3_ENDPOINT="http://localhost:9000"
|
||||
fi
|
||||
|
||||
cat > .env << ENVEOF
|
||||
# === Generated by setup.sh at $(date) ===
|
||||
|
||||
# Domain & SSL
|
||||
DOMAIN=${DOMAIN}
|
||||
ACME_EMAIL=${ACME_EMAIL}
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:${PG_PASSWORD}@localhost:5432/liveserver
|
||||
POSTGRES_PASSWORD=${PG_PASSWORD}
|
||||
|
||||
# LiveKit
|
||||
LIVEKIT_URL=${LK_URL}
|
||||
NEXT_PUBLIC_LIVEKIT_URL=${LK_URL}
|
||||
LIVEKIT_API_KEY=${LK_KEY}
|
||||
LIVEKIT_API_SECRET=${LK_SECRET}
|
||||
|
||||
# AI Agent
|
||||
DEEPGRAM_API_KEY=${DG_KEY}
|
||||
OPENAI_API_KEY=${OAI_KEY}
|
||||
|
||||
# Storage (MinIO/S3)
|
||||
S3_ENDPOINT=${S3_ENDPOINT}
|
||||
S3_ACCESS_KEY=${MINIO_USER}
|
||||
S3_SECRET_KEY=${MINIO_PASS}
|
||||
S3_BUCKET=${S3_BUCKET}
|
||||
MINIO_ROOT_USER=${MINIO_USER}
|
||||
MINIO_ROOT_PASSWORD=${MINIO_PASS}
|
||||
|
||||
# Auth
|
||||
BETTER_AUTH_SECRET=${AUTH_SECRET}
|
||||
BETTER_AUTH_URL=${APP_URL}
|
||||
NEXT_PUBLIC_APP_URL=${APP_URL}
|
||||
|
||||
# Local dev protection
|
||||
DEV_ACCESS_KEY=${DEV_ACCESS_KEY:-}
|
||||
ALLOWED_IPS=${ALLOWED_IPS:-}
|
||||
ENVEOF
|
||||
|
||||
log_ok ".env создан"
|
||||
|
||||
# --- Step 5: Install & setup ---
|
||||
log_step "Шаг 5/6 — Установка и настройка"
|
||||
|
||||
# npm install
|
||||
if [[ ! -d "node_modules" ]]; then
|
||||
echo -e " Установка npm зависимостей..."
|
||||
npm install --silent 2>&1 | tail -3
|
||||
log_ok "npm install"
|
||||
else
|
||||
log_ok "node_modules уже существует"
|
||||
fi
|
||||
|
||||
# Prisma generate
|
||||
echo -e " Генерация Prisma Client..."
|
||||
npx prisma generate 2>&1 | tail -1
|
||||
log_ok "Prisma Client сгенерирован"
|
||||
|
||||
# Docker compose up
|
||||
echo -e " Запуск Docker контейнеров..."
|
||||
if [[ "$MODE" == "2" ]]; then
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build 2>&1 | tail -5
|
||||
else
|
||||
docker compose up -d postgres minio 2>&1 | tail -5
|
||||
fi
|
||||
log_ok "Docker контейнеры запущены"
|
||||
|
||||
# Wait for postgres
|
||||
echo -e " Ожидание PostgreSQL..."
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T postgres pg_isready -U postgres &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
log_ok "PostgreSQL готов"
|
||||
|
||||
# Prisma migrate
|
||||
echo -e " Применение миграций..."
|
||||
npx prisma db push --skip-generate 2>&1 | tail -3
|
||||
log_ok "Схема БД синхронизирована"
|
||||
|
||||
# Create MinIO bucket
|
||||
echo -e " Создание S3 bucket..."
|
||||
sleep 2
|
||||
if command -v mc &>/dev/null; then
|
||||
mc alias set local http://localhost:9000 "$MINIO_USER" "$MINIO_PASS" 2>/dev/null || true
|
||||
mc mb "local/${S3_BUCKET}" 2>/dev/null || true
|
||||
log_ok "Bucket '${S3_BUCKET}' создан"
|
||||
else
|
||||
log_warn "MinIO Client (mc) не найден — создай bucket '${S3_BUCKET}' вручную через http://localhost:9001"
|
||||
fi
|
||||
|
||||
# --- Step 6: Done ---
|
||||
log_step "Шаг 6/6 — Готово!"
|
||||
|
||||
echo ""
|
||||
if [[ "$MODE" == "2" ]]; then
|
||||
echo -e " ${GREEN}${BOLD}Production запущен!${NC}"
|
||||
echo ""
|
||||
echo -e " Приложение: ${CYAN}https://${DOMAIN}${NC}"
|
||||
echo -e " MinIO Console: ${CYAN}https://minio.${DOMAIN}${NC}"
|
||||
echo -e " MinIO S3 API: ${CYAN}https://s3.${DOMAIN}${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}${BOLD}Локальная среда готова!${NC}"
|
||||
echo ""
|
||||
echo -e " Запусти приложение: ${CYAN}npm run dev${NC}"
|
||||
echo -e " Приложение: ${CYAN}http://localhost:3000${NC}"
|
||||
echo -e " MinIO Console: ${CYAN}http://localhost:9001${NC}"
|
||||
echo -e " Prisma Studio: ${CYAN}npx prisma studio${NC}"
|
||||
|
||||
if [[ -n "${DEV_ACCESS_KEY:-}" ]]; then
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Для доступа из сети:${NC}"
|
||||
echo -e " ${CYAN}http://<ip>:3000?key=${DEV_ACCESS_KEY}${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$LK_URL" ]]; then
|
||||
echo ""
|
||||
log_warn "LiveKit не настроен — видеозвонки не будут работать."
|
||||
echo -e " Зарегистрируйся на ${CYAN}https://cloud.livekit.io${NC}"
|
||||
echo -e " и заполни LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET в .env"
|
||||
fi
|
||||
|
||||
if [[ -z "$DG_KEY" ]]; then
|
||||
echo ""
|
||||
log_warn "Deepgram не настроен — AI транскрипция отключена."
|
||||
echo -e " Получи ключ: ${CYAN}https://console.deepgram.com${NC}"
|
||||
fi
|
||||
|
||||
if [[ -z "$OAI_KEY" ]]; then
|
||||
echo ""
|
||||
log_warn "OpenAI не настроен — AI суммаризация отключена."
|
||||
echo -e " Получи ключ: ${CYAN}https://platform.openai.com/api-keys${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo -e " Документация: ${CYAN}README.md${NC}"
|
||||
echo -e " Спецификация: ${CYAN}PROMPT.md${NC}"
|
||||
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
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";
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user