init commit

This commit is contained in:
2026-05-10 08:41:16 +03:00
commit c1ef5871c2
32 changed files with 3662 additions and 0 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
APP_BASE_URL=https://linkra.ru
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_PUBLIC_URL=wss://rtc.linkra.ru
LIVEKIT_API_KEY=linkra_key
LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87
CORS_ALLOW_ORIGINS=https://linkra.ru

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

44
backend/app/config.py Normal file
View File

@@ -0,0 +1,44 @@
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_base_url: str = Field(default="http://localhost:8080", alias="APP_BASE_URL")
livekit_url: str = Field(default="ws://livekit:7880", alias="LIVEKIT_URL")
livekit_public_url: str = Field(default="ws://localhost:7880", alias="LIVEKIT_PUBLIC_URL")
livekit_api_key: str = Field(default="devkey", alias="LIVEKIT_API_KEY")
livekit_api_secret: str = Field(default="secret", alias="LIVEKIT_API_SECRET")
call_token_ttl_hours: int = Field(default=2, alias="CALL_TOKEN_TTL_HOURS")
room_empty_timeout_seconds: int = Field(default=600, alias="ROOM_EMPTY_TIMEOUT_SECONDS")
max_attachment_size_mb: int = Field(default=100, alias="MAX_ATTACHMENT_SIZE_MB")
uploads_dir: str = Field(default="uploads", alias="UPLOADS_DIR")
telegram_alerting_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTING_ENABLED")
telegram_bot_token: str = Field(default="", alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str = Field(default="", alias="TELEGRAM_CHAT_ID")
telegram_topic_id: int | None = Field(default=None, alias="TELEGRAM_TOPIC_ID")
block_probe_paths: bool = Field(default=True, alias="BLOCK_PROBE_PATHS")
cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS")
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
@property
def max_attachment_size_bytes(self) -> int:
return self.max_attachment_size_mb * 1024 * 1024
@property
def uploads_path(self) -> Path:
return Path(self.uploads_dir)
@property
def telegram_alerting_available(self) -> bool:
return bool(self.telegram_alerting_enabled and self.telegram_bot_token.strip() and self.telegram_chat_id.strip())
@property
def cors_origins(self) -> list[str]:
raw = getattr(self, 'cors_allow_origins', '')
return [origin.strip() for origin in raw.split(',') if origin.strip()]
settings = Settings()

View File

@@ -0,0 +1,45 @@
from datetime import timedelta
from livekit import api
from .config import settings
async def create_room_if_needed(room_name: str) -> None:
lkapi = api.LiveKitAPI(
url=settings.livekit_url,
api_key=settings.livekit_api_key,
api_secret=settings.livekit_api_secret,
)
try:
rooms = await lkapi.room.list_rooms(api.ListRoomsRequest(names=[room_name]))
if rooms.rooms:
return
await lkapi.room.create_room(
api.CreateRoomRequest(
name=room_name,
empty_timeout=settings.room_empty_timeout_seconds,
max_participants=20,
)
)
finally:
await lkapi.aclose()
async def build_participant_token(*, room_name: str, identity: str, display_name: str) -> str:
token = (
api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret)
.with_identity(identity)
.with_name(display_name)
.with_ttl(timedelta(hours=settings.call_token_ttl_hours))
.with_grants(
api.VideoGrants(
room_join=True,
room=room_name,
can_publish=True,
can_subscribe=True,
can_update_own_metadata=True,
)
)
)
return token.to_jwt()

495
backend/app/main.py Normal file
View File

@@ -0,0 +1,495 @@
import json
import mimetypes
import secrets
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from shutil import rmtree
from uuid import uuid4
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .livekit_service import build_participant_token, create_room_if_needed
from .models import AttachmentRecord, CallRecord, ChatEventRecord
from .schemas import (
AttachmentListResponse,
AttachmentResponse,
AppConfigResponse,
CallInfoResponse,
ChatAuthorSyncRequest,
ChatMessageLogRequest,
CreateCallRequest,
CreateCallResponse,
FinishCallRequest,
JoinCallRequest,
JoinCallResponse,
)
from .probe_blocker import ProbeBlockMiddleware
from .store import store
app = FastAPI(title="Video Call MVP")
app.add_middleware(ProbeBlockMiddleware)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
settings.uploads_path.mkdir(parents=True, exist_ok=True)
def public_app_url() -> str:
return settings.app_base_url.strip().rstrip('/')
def build_attachment_response(room_name: str, attachment: AttachmentRecord) -> AttachmentResponse:
return AttachmentResponse(
attachment_id=attachment.attachment_id,
file_name=attachment.original_name,
content_type=attachment.content_type,
size_bytes=attachment.size_bytes,
download_url=f"/api/calls/{room_name}/attachments/{attachment.attachment_id}",
)
def ensure_call_access(room_name: str, invite_token: str) -> CallRecord:
call = store.get(room_name)
if call is None or not call.is_active:
raise HTTPException(status_code=404, detail="Call not found")
if invite_token != call.invite_token:
raise HTTPException(status_code=403, detail="Invalid invite token")
return call
def room_upload_dir(room_name: str) -> Path:
return settings.uploads_path / room_name
def human_dt(value: datetime) -> str:
return value.astimezone().strftime("%d.%m.%Y %H:%M:%S %Z")
def format_duration(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60)
hours, minutes = divmod(minutes, 60)
if hours:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def split_text_chunks(text: str, limit: int = 3500) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
current = ""
for line in text.splitlines():
candidate = f"{current}\n{line}".strip() if current else line
if len(candidate) > limit and current:
chunks.append(current)
current = line
elif len(line) > limit:
if current:
chunks.append(current)
current = ""
for start in range(0, len(line), limit):
chunks.append(line[start:start + limit])
else:
current = candidate
if current:
chunks.append(current)
return chunks
def telegram_targets() -> list[dict[str, str | int]]:
chat_id = settings.telegram_chat_id.strip()
if settings.telegram_topic_id is not None:
return [
{
"chat_id": chat_id,
"message_thread_id": settings.telegram_topic_id,
}
]
return [{"chat_id": chat_id}]
def telegram_request(
method: str,
fields: dict[str, str | int],
file_path: Path | None = None,
file_name: str | None = None,
content_type: str | None = None,
) -> None:
url = f"https://api.telegram.org/bot{settings.telegram_bot_token.strip()}/{method}"
if file_path is None:
data = urllib.parse.urlencode({k: v for k, v in fields.items() if v is not None}).encode("utf-8")
request = urllib.request.Request(url, data=data, method="POST")
else:
boundary = f"----ChatGPTBoundary{uuid4().hex}"
body = bytearray()
for key, value in fields.items():
if value is None:
continue
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n'.encode("utf-8"))
guessed_type = content_type or mimetypes.guess_type(file_name or file_path.name)[0] or "application/octet-stream"
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="document"; filename="{file_name or file_path.name}"\r\n'.encode(
"utf-8"
)
)
body.extend(f"Content-Type: {guessed_type}\r\n\r\n".encode("utf-8"))
body.extend(file_path.read_bytes())
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode())
request = urllib.request.Request(
url,
data=bytes(body),
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
if not payload.get("ok"):
raise RuntimeError(payload.get("description") or "Telegram API error")
def send_telegram_text(text: str) -> None:
for target in telegram_targets():
for chunk in split_text_chunks(text):
telegram_request(
"sendMessage",
{
**target,
"text": chunk,
"disable_web_page_preview": "true",
},
)
def send_telegram_document(file_path: Path, attachment: AttachmentRecord, caption: str | None = None) -> None:
for target in telegram_targets():
telegram_request(
"sendDocument",
{
**target,
"caption": caption or attachment.original_name,
},
file_path=file_path,
file_name=attachment.original_name,
content_type=attachment.content_type,
)
def send_finish_alert(call: CallRecord) -> None:
if not (settings.telegram_alerting_available and call.telegram_alert_enabled):
return
started_at = call.started_at or call.created_at
finished_at = datetime.now(started_at.tzinfo)
duration_seconds = int((finished_at - started_at).total_seconds()) if started_at else 0
if duration_seconds < 60:
return
if len(call.participant_names) < 2:
return
if call.started_at is None:
return
participants = ", ".join(call.participant_names) if call.participant_names else ""
summary = "\n".join(
[
f"Итоги созвона: {call.room_title}",
f"Комната: {call.room_name}",
f"Создана: {human_dt(call.created_at)}",
f"Таймер созвона: {format_duration(duration_seconds)}",
f"Участники: {participants}",
]
)
send_telegram_text(summary)
transcript_lines: list[str] = []
for event in call.chat_events:
timestamp = event.created_at.astimezone().strftime("%H:%M:%S")
if event.event_type == "text" and event.text:
transcript_lines.append(f"[{timestamp}] {event.author}: {event.text}")
elif event.event_type == "file":
transcript_lines.append(f"[{timestamp}] {event.author}: файл {event.file_name or 'без названия'}")
if transcript_lines:
send_telegram_text("Сообщения и файлы из чата:\n" + "\n".join(transcript_lines))
else:
send_telegram_text("Сообщения и файлы из чата: нет пользовательских сообщений.")
attachment_authors = {
event.attachment_id: event.author
for event in call.chat_events
if event.event_type == "file" and event.attachment_id
}
upload_dir = room_upload_dir(call.room_name)
for attachment in call.attachments:
file_path = upload_dir / attachment.stored_name
if not file_path.exists():
continue
author = attachment_authors.get(attachment.attachment_id)
caption = f"Файл из чата: {attachment.original_name}"
if author:
caption = f"{caption}{author}"
send_telegram_document(file_path, attachment, caption=caption[:1000])
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/config", response_model=AppConfigResponse)
def get_config() -> AppConfigResponse:
return AppConfigResponse(
telegram_alerting_available=settings.telegram_alerting_available,
max_attachment_size_mb=settings.max_attachment_size_mb,
)
@app.get("/i/{short_code}")
def invite_short_redirect(request: Request, short_code: str):
room_name = store.resolve_short_invite(short_code)
if room_name is None:
raise HTTPException(status_code=404, detail="Not found")
call = store.get(room_name)
if call is None or not call.is_active or call.invite_short_code != short_code:
raise HTTPException(status_code=404, detail="Not found")
query = urllib.parse.urlencode({"invite": call.invite_token})
base = str(request.base_url).rstrip("/")
return RedirectResponse(url=f"{base}/call/{room_name}?{query}", status_code=302)
@app.post("/api/calls", response_model=CreateCallResponse)
async def create_call(payload: CreateCallRequest | None = None) -> CreateCallResponse:
room_name = f"call-{uuid4().hex[:10]}"
invite_token = uuid4().hex
room_title = (payload.room_title if payload and payload.room_title else room_name)
password = payload.password.strip() if payload and payload.password else None
telegram_alert_enabled = bool(payload.telegram_alert_enabled) if payload else False
quick_join_default = bool(payload.quick_join) if payload else False
short_code = secrets.token_hex(4)
while not store.register_short_invite(short_code, room_name):
short_code = secrets.token_hex(4)
call = CallRecord(
room_name=room_name,
room_title=room_title,
invite_token=invite_token,
invite_short_code=short_code,
quick_join_default=quick_join_default,
password=password or None,
telegram_alert_enabled=settings.telegram_alerting_available and telegram_alert_enabled,
)
store.create(call)
await create_room_if_needed(room_name)
base = public_app_url()
invite_link = f"{base}/call/{room_name}?invite={invite_token}"
short_invite_link = f"{base}/i/{short_code}"
return CreateCallResponse(
room_name=room_name,
room_title=room_title,
invite_token=invite_token,
invite_link=invite_link,
short_invite_link=short_invite_link,
has_password=bool(password),
)
@app.get("/api/calls/{room_name}", response_model=CallInfoResponse)
def get_call(room_name: str) -> CallInfoResponse:
call = store.get(room_name)
if call is None:
raise HTTPException(status_code=404, detail="Call not found")
return CallInfoResponse(
room_name=call.room_name,
room_title=call.room_title,
is_active=call.is_active,
created_at=call.created_at.isoformat(),
has_password=bool(call.password),
)
@app.post("/api/calls/{room_name}/join", response_model=JoinCallResponse)
async def join_call(room_name: str, payload: JoinCallRequest) -> JoinCallResponse:
call = store.get(room_name)
if call is None or not call.is_active:
raise HTTPException(status_code=404, detail="Call not found")
if payload.invite_token != call.invite_token:
raise HTTPException(status_code=403, detail="Invalid invite token")
if call.password and payload.password.strip() != call.password:
raise HTTPException(status_code=403, detail="Неверный пароль комнаты")
identity = payload.identity or f"user-{uuid4().hex[:12]}"
participant_token = await build_participant_token(
room_name=room_name,
identity=identity,
display_name=payload.display_name,
)
store.mark_started(room_name)
store.add_participant(room_name, payload.display_name)
return JoinCallResponse(
server_url=settings.livekit_public_url,
participant_token=participant_token,
room_name=room_name,
room_title=call.room_title,
identity=identity,
display_name=payload.display_name,
)
@app.post("/api/calls/{room_name}/chat-events")
def log_chat_message(room_name: str, payload: ChatMessageLogRequest) -> dict[str, str]:
ensure_call_access(room_name, payload.invite_token)
pid = (payload.participant_identity or "").strip() or None
store.add_chat_event(
room_name,
ChatEventRecord(
event_type="text",
author=payload.display_name.strip(),
author_identity=pid,
text=payload.text.strip(),
),
)
return {"status": "ok"}
@app.post("/api/calls/{room_name}/chat-author-sync")
def sync_chat_author_display(room_name: str, payload: ChatAuthorSyncRequest) -> dict[str, str]:
ensure_call_access(room_name, payload.invite_token)
name = payload.display_name.strip()
if not name:
raise HTTPException(status_code=400, detail="Пустое имя")
store.update_chat_events_author_by_identity(
room_name,
payload.participant_identity.strip(),
name,
)
return {"status": "ok"}
@app.get("/api/calls/{room_name}/attachments", response_model=AttachmentListResponse)
def list_attachments(room_name: str, invite_token: str) -> AttachmentListResponse:
ensure_call_access(room_name, invite_token)
items = [build_attachment_response(room_name, item) for item in store.list_attachments(room_name)]
return AttachmentListResponse(items=items)
@app.post("/api/calls/{room_name}/attachments", response_model=AttachmentResponse)
async def upload_attachment(
room_name: str,
invite_token: str,
file: UploadFile = File(...),
display_name: str = Form(default=""),
participant_identity: str = Form(default=""),
) -> AttachmentResponse:
ensure_call_access(room_name, invite_token)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="Файл не выбран")
upload_dir = room_upload_dir(room_name)
upload_dir.mkdir(parents=True, exist_ok=True)
attachment_id = uuid4().hex
ext = Path(filename).suffix
stored_name = f"{attachment_id}{ext}"
destination = upload_dir / stored_name
size_bytes = 0
try:
with destination.open("wb") as target:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size_bytes += len(chunk)
if size_bytes > settings.max_attachment_size_bytes:
raise HTTPException(
status_code=413,
detail=f"Максимальный размер файла — {settings.max_attachment_size_mb} МБ",
)
target.write(chunk)
except Exception:
if destination.exists():
destination.unlink(missing_ok=True)
raise
finally:
await file.close()
attachment = AttachmentRecord(
attachment_id=attachment_id,
original_name=filename,
stored_name=stored_name,
content_type=file.content_type or "application/octet-stream",
size_bytes=size_bytes,
)
store.add_attachment(room_name, attachment)
author = display_name.strip() or "Участник"
aid = participant_identity.strip() or None
store.add_chat_event(
room_name,
ChatEventRecord(
event_type="file",
author=author,
author_identity=aid,
attachment_id=attachment_id,
file_name=filename,
),
)
return build_attachment_response(room_name, attachment)
@app.get("/api/calls/{room_name}/attachments/{attachment_id}")
def download_attachment(room_name: str, attachment_id: str, invite_token: str):
ensure_call_access(room_name, invite_token)
attachment = store.get_attachment(room_name, attachment_id)
if attachment is None:
raise HTTPException(status_code=404, detail="Файл не найден")
file_path = room_upload_dir(room_name) / attachment.stored_name
if not file_path.exists():
raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(
path=file_path,
filename=attachment.original_name,
media_type=attachment.content_type or "application/octet-stream",
)
@app.post("/api/calls/{room_name}/finish")
def finish_call(room_name: str, payload: FinishCallRequest) -> dict[str, str]:
call = store.get(room_name)
if call is None or not call.is_active:
raise HTTPException(status_code=404, detail="Call not found")
if payload.invite_token != call.invite_token:
raise HTTPException(status_code=403, detail="Invalid invite token")
alert_status = "disabled"
if settings.telegram_alerting_available and call.telegram_alert_enabled:
try:
send_finish_alert(call)
alert_status = "sent"
except Exception:
alert_status = "failed"
store.clear_attachments(room_name)
upload_dir = room_upload_dir(room_name)
if upload_dir.exists():
rmtree(upload_dir, ignore_errors=True)
store.deactivate(room_name)
return {"status": "finished", "telegram_alert_status": alert_status}

42
backend/app/models.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class AttachmentRecord(BaseModel):
attachment_id: str
original_name: str
stored_name: str
content_type: str
size_bytes: int
created_at: datetime = Field(default_factory=utcnow)
class ChatEventRecord(BaseModel):
event_type: str
author: str
author_identity: str | None = None
text: str | None = None
attachment_id: str | None = None
file_name: str | None = None
created_at: datetime = Field(default_factory=utcnow)
class CallRecord(BaseModel):
room_name: str
room_title: str
invite_token: str
invite_short_code: str | None = None
quick_join_default: bool = False
password: str | None = None
telegram_alert_enabled: bool = False
created_at: datetime = Field(default_factory=utcnow)
started_at: datetime | None = None
is_active: bool = True
participant_names: list[str] = Field(default_factory=list)
chat_events: list[ChatEventRecord] = Field(default_factory=list)
attachments: list[AttachmentRecord] = Field(default_factory=list)

View File

@@ -0,0 +1,37 @@
import re
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from .config import settings
_PROBE_PATH_RE = re.compile(
r"""(?ix)
\.(?:php|phtml|phar|sh|sql|bak|old|orig|swp|zip|tar|gz)(?:\?|$|/)|
/\.(?:env|git|svn|hg|bzr|htpasswd|htaccess)(?:/|$)|
(?:^|/)\.env(?:\.|\?|$|/)|
[^/]+\.env(?:\?|$|/)|
(?:^|/)(?:wp-admin|wp-includes|wp-content|wp-login\.php|xmlrpc\.php)(?:/|$)|
(?:^|/)(?:phpmyadmin|pma|adminer|vendor/phpunit)(?:/|$)|
(?:^|/)cgi-bin(?:/|$)|
(?:^|/)(?:wlwmanifest\.xml|readme\.html|license\.txt)(?:/|$)
"""
)
def suspicious_request_path(path: str) -> bool:
return bool(_PROBE_PATH_RE.search(path))
class ProbeBlockMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not settings.block_probe_paths:
return await call_next(request)
p = request.url.path
if p.startswith('/static/'):
return await call_next(request)
if suspicious_request_path(p):
return Response(status_code=404, content=b'Not Found', media_type='text/plain')
return await call_next(request)

79
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,79 @@
from typing import Optional
from pydantic import BaseModel, Field
class CreateCallRequest(BaseModel):
room_title: str = Field(default="", max_length=100)
password: str = Field(default="", max_length=100)
telegram_alert_enabled: bool = False
quick_join: bool = False
class CreateCallResponse(BaseModel):
room_name: str
room_title: str
invite_token: str
invite_link: str
short_invite_link: str
has_password: bool = False
class JoinCallRequest(BaseModel):
invite_token: str = Field(min_length=8)
display_name: str = Field(min_length=1, max_length=50)
password: str = Field(default="", max_length=100)
with_video: bool = True
with_audio: bool = True
identity: Optional[str] = Field(default=None, max_length=64)
class JoinCallResponse(BaseModel):
server_url: str
participant_token: str
room_name: str
room_title: str
identity: str
display_name: str
class CallInfoResponse(BaseModel):
room_name: str
room_title: str
is_active: bool
created_at: str
has_password: bool = False
class AppConfigResponse(BaseModel):
telegram_alerting_available: bool
max_attachment_size_mb: int
class FinishCallRequest(BaseModel):
invite_token: str = Field(min_length=8)
class AttachmentResponse(BaseModel):
attachment_id: str
file_name: str
content_type: str
size_bytes: int
download_url: str
class AttachmentListResponse(BaseModel):
items: list[AttachmentResponse] = Field(default_factory=list)
class ChatMessageLogRequest(BaseModel):
invite_token: str = Field(min_length=8)
display_name: str = Field(min_length=1, max_length=50)
text: str = Field(min_length=1, max_length=1000)
participant_identity: Optional[str] = Field(default=None, max_length=64)
class ChatAuthorSyncRequest(BaseModel):
invite_token: str = Field(min_length=8)
participant_identity: str = Field(min_length=1, max_length=64)
display_name: str = Field(min_length=1, max_length=50)

127
backend/app/store.py Normal file
View File

@@ -0,0 +1,127 @@
from threading import Lock
from typing import Optional
from .models import AttachmentRecord, CallRecord, ChatEventRecord, utcnow
class InMemoryCallStore:
def __init__(self) -> None:
self._lock = Lock()
self._calls: dict[str, CallRecord] = {}
self._short_to_room: dict[str, str] = {}
def create(self, call: CallRecord) -> CallRecord:
with self._lock:
self._calls[call.room_name] = call
return call
def register_short_invite(self, short_code: str, room_name: str) -> bool:
with self._lock:
if short_code in self._short_to_room:
return False
self._short_to_room[short_code] = room_name
return True
def resolve_short_invite(self, short_code: str) -> Optional[str]:
with self._lock:
return self._short_to_room.get(short_code)
def get(self, room_name: str) -> Optional[CallRecord]:
with self._lock:
return self._calls.get(room_name)
def deactivate(self, room_name: str) -> Optional[CallRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
if call.invite_short_code:
self._short_to_room.pop(call.invite_short_code, None)
updated = call.model_copy(update={"is_active": False})
self._calls[room_name] = updated
return updated
def mark_started(self, room_name: str):
with self._lock:
call = self._calls.get(room_name)
if call is None or call.started_at is not None:
return call
updated = call.model_copy(update={"started_at": utcnow()})
self._calls[room_name] = updated
return updated
def add_participant(self, room_name: str, display_name: str):
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
name = display_name.strip()
if not name or name in call.participant_names:
return call
updated = call.model_copy(update={"participant_names": [*call.participant_names, name]})
self._calls[room_name] = updated
return updated
def add_chat_event(self, room_name: str, event: ChatEventRecord) -> Optional[CallRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
updated = call.model_copy(update={"chat_events": [*call.chat_events, event]})
self._calls[room_name] = updated
return updated
def update_chat_events_author_by_identity(
self, room_name: str, participant_identity: str, display_name: str
) -> Optional[CallRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
new_events: list[ChatEventRecord] = []
for ev in call.chat_events:
if ev.author_identity and ev.author_identity == participant_identity:
new_events.append(ev.model_copy(update={"author": display_name}))
else:
new_events.append(ev)
updated = call.model_copy(update={"chat_events": new_events})
self._calls[room_name] = updated
return updated
def add_attachment(self, room_name: str, attachment: AttachmentRecord) -> Optional[CallRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
updated = call.model_copy(update={"attachments": [*call.attachments, attachment]})
self._calls[room_name] = updated
return updated
def list_attachments(self, room_name: str) -> list[AttachmentRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return []
return list(call.attachments)
def get_attachment(self, room_name: str, attachment_id: str) -> Optional[AttachmentRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
for attachment in call.attachments:
if attachment.attachment_id == attachment_id:
return attachment
return None
def clear_attachments(self, room_name: str) -> Optional[CallRecord]:
with self._lock:
call = self._calls.get(room_name)
if call is None:
return None
updated = call.model_copy(update={"attachments": []})
self._calls[room_name] = updated
return updated
store = InMemoryCallStore()

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
livekit-api
pydantic-settings
python-multipart

BIN
backend/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
livekit:
image: livekit/livekit-server:latest
container_name: linkra-livekit
command: --config /etc/livekit.yaml
restart: unless-stopped
ports:
- "7881:7881/tcp"
- "7882:7882/udp"
- "127.0.0.1:7880:7880"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
networks:
- linkra_net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7880"]
interval: 5s
timeout: 3s
retries: 20
backend:
build:
context: ./backend
container_name: linkra-backend
restart: unless-stopped
env_file:
- path: .env
required: false
ports:
- "127.0.0.1:8000:8000"
depends_on:
livekit:
condition: service_healthy
networks:
- linkra_net
frontend:
build:
context: ./frontend
container_name: linkra-frontend
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
depends_on:
- backend
networks:
- linkra_net
networks:
linkra_net:
driver: bridge

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
npm-debug.log

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang='ru'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />
<meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' />
<title>Linkra</title>
</head>
<body>
<div id='root'></div>
<script type='module' src='/src/main.jsx'></script>
</body>
</html>

37
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 120m;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /i/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

1336
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"typescript": "latest",
"react": "latest",
"react-dom": "latest",
"livekit-client": "latest",
"qrcode": "latest"
},
"devDependencies": {}
}

730
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,730 @@
import React,{useCallback,useEffect,useMemo,useRef,useState}from'react';
import{createRoot}from'react-dom/client';
import{Room,RoomEvent,Track}from'livekit-client';
import QRCode from'qrcode';
import'./styles.css';
const TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert';
function apiUrl(path) {
return path;
}
async function readJson(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Ошибка запроса');
return data;
}
function routeFromLocation() {
const path = window.location.pathname;
if (path === '/') return { name: 'home' };
if (path === '/call/overloaded') return { name: 'error', code: '503' };
const match = path.match(/^\/call\/([^/]+)$/);
if (match) return { name: 'call', roomName: decodeURIComponent(match[1]) };
return { name: 'error', code: '404' };
}
function getInviteToken() {
return new URL(window.location.href).searchParams.get('invite') || '';
}
function randomGuestName() {
const a = Date.now().toString(36).slice(-4);
const b = Math.random().toString(36).slice(2, 6);
return `Гость-${a}${b}`.slice(0, 24);
}
function instaRoomTitle() {
return `Инста-${Math.random().toString(36).slice(2, 8)}`;
}
function Toasts({ items }) {
return (
<div className='toast-stack'>
{items.map((toast) => (
<div className={`toast ${toast.type}`} key={toast.id}>{toast.message}</div>
))}
</div>
);
}
function useToasts() {
const [items, setItems] = useState([]);
const showToast = useCallback((message, type = 'info') => {
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
setItems((current) => current.concat({ id, message, type }));
window.setTimeout(() => {
setItems((current) => current.filter((item) => item.id !== id));
}, 2600);
}, []);
return [items, showToast];
}
function ErrorPage({ code }) {
const copy = {
'403': ['Приглашение недействительно', 'Ссылка приглашения недействительна или была заменена. Запросите новую ссылку у организатора.'],
'410': ['Звонок завершён', 'Эта встреча уже закончена. Попросите организатора прислать новую ссылку.'],
'503': ['Сервис временно недоступен', 'Сервер перегружен. Подождите немного и обновите страницу или зайдите позже.'],
'404': ['Комната не найдена', 'Такой страницы нет или ссылка устарела. Проверьте адрес или создайте новый звонок на главной странице.']
};
const [title, message] = copy[code] || copy['404'];
useEffect(() => {
document.title = `${title} · Linkra`;
}, [title]);
return (
<main className='container error-page'>
<section className='card error-card'>
<p className='error-code'>{code}</p>
<h1>{title}</h1>
<p className='muted'>{message}</p>
<div className='row centered'>
<a className='btn primary' href='/'>На главную</a>
{code === '503' ? <button className='btn secondary' type='button' onClick={() => window.location.reload()}>Обновить страницу</button> : null}
</div>
</section>
</main>
);
}
function HomePage({ showToast }) {
const [config, setConfig] = useState({ telegram_alerting_available: false, max_attachment_size_mb: 100 });
const [settingsOpen, setSettingsOpen] = useState(false);
const [telegramAlert, setTelegramAlert] = useState(() => localStorage.getItem(TELEGRAM_PREF_KEY) === '1');
const [roomTitle, setRoomTitle] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [instaLoading, setInstaLoading] = useState(false);
const [result, setResult] = useState(null);
const qrRef = useRef(null);
useEffect(() => {
document.title = 'Созвон по ссылке · Linkra';
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
}, []);
useEffect(() => {
localStorage.setItem(TELEGRAM_PREF_KEY, telegramAlert ? '1' : '0');
}, [telegramAlert]);
useEffect(() => {
if (!result || !qrRef.current) return;
const target = result.short_invite_link || result.invite_link;
QRCode.toCanvas(qrRef.current, target, { width: 200, margin: 1 }).catch(() => {});
}, [result]);
const createCall = async ({ quickJoin = false } = {}) => {
const title = quickJoin ? instaRoomTitle() : roomTitle.trim();
const roomPassword = quickJoin ? '' : password.trim();
if (!quickJoin && title.length < 4) {
showToast('Название комнаты должно содержать минимум 4 символа', 'error');
return null;
}
if (!quickJoin && roomPassword.length > 0 && roomPassword.length < 4) {
showToast('Пароль должен содержать минимум 4 символа', 'error');
return null;
}
const setBusy = quickJoin ? setInstaLoading : setLoading;
setBusy(true);
try {
const response = await fetch(apiUrl('/api/calls'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
room_title: title,
password: roomPassword,
telegram_alert_enabled: config.telegram_alerting_available && telegramAlert,
quick_join: quickJoin
})
});
return await readJson(response);
} catch (error) {
showToast(error.message || 'Не удалось создать звонок', 'error');
return null;
} finally {
setBusy(false);
}
};
const handleCreate = async () => {
const data = await createCall();
if (!data) return;
setResult(data);
showToast('Ссылка на звонок готова', 'success');
};
const handleInsta = async () => {
const data = await createCall({ quickJoin: true });
if (!data) return;
const target = data.short_invite_link || data.invite_link;
sessionStorage.setItem(`linkra-insta:${data.room_name}`, JSON.stringify({
invite_token: data.invite_token,
display_name: randomGuestName(),
mic_enabled: false,
camera_enabled: false
}));
await navigator.clipboard.writeText(target).catch(() => {});
window.location.href = target;
};
const copyText = async (value) => {
await navigator.clipboard.writeText(value);
showToast('Ссылка скопирована', 'success');
};
return (
<main className='container home-shell'>
<div className='home-grid'>
<header className='home-hero'>
<h1>Созвон по ссылке</h1>
<p className='muted home-hero-lead'>Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.</p>
<ul className='home-features'>
<li>Камера и микрофон без установки</li>
<li>Чат и файлы в комнате</li>
<li>Короткая ссылка и QR для телефона</li>
<li>Кнопка «Инста» для быстрого входа</li>
</ul>
</header>
<section className='card home-card'>
<div className='home-card-header'>
<h2>Новая комната</h2>
<div className='settings-wrap'>
<button className='icon-btn small' type='button' onClick={() => setSettingsOpen((value) => !value)}></button>
{settingsOpen ? (
<div className='settings-menu'>
{config.telegram_alerting_available ? (
<label className='checkbox-line'>
<input type='checkbox' checked={telegramAlert} onChange={(event) => setTelegramAlert(event.target.checked)} />
<span>Итоги созвона в Telegram</span>
</label>
) : <p className='muted'>Итоги в Telegram недоступны на сервере</p>}
</div>
) : null}
</div>
</div>
{!result ? (
<div className='stack'>
<label className='field'>
<span>Название комнаты</span>
<input value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} type='text' placeholder='Например, Планёрка команды' maxLength='100' />
</label>
<label className='field'>
<span>Пароль комнаты <small className='muted'>(необязательно)</small></span>
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Например, team2026' maxLength='100' />
</label>
<div className='home-actions'>
<button className='btn primary' type='button' disabled={loading} onClick={handleCreate}>{loading ? 'Создаю...' : 'Создать звонок'}</button>
<button className='btn secondary' type='button' disabled={instaLoading} onClick={handleInsta}>{instaLoading ? 'Создаём...' : 'Инста'}</button>
</div>
</div>
) : (
<div className='stack'>
<label className='field'>
<span>Ссылка приглашения</span>
<input value={result.short_invite_link || result.invite_link} readOnly onFocus={(event) => event.target.select()} />
</label>
<button className='btn primary' type='button' onClick={() => copyText(result.short_invite_link || result.invite_link)}>Копировать</button>
<details>
<summary>Показать полную ссылку и QR-код</summary>
<label className='field'>
<span>Полная ссылка</span>
<input value={result.invite_link} readOnly onFocus={(event) => event.target.select()} />
</label>
<button className='btn secondary' type='button' onClick={() => copyText(result.invite_link)}>Копировать полную</button>
<div className='qr-box'><canvas ref={qrRef}></canvas></div>
</details>
<p className='muted'>Название комнаты: {result.room_title}</p>
<a className='btn primary link-btn' href={result.short_invite_link || result.invite_link}>Перейти в звонок</a>
</div>
)}
</section>
</div>
</main>
);
}
function MediaTrack({ track, kind, muted = false, className = '' }) {
const ref = useRef(null);
useEffect(() => {
if (!track || !ref.current) return undefined;
track.attach(ref.current);
return () => {
track.detach(ref.current);
};
}, [track]);
if (kind === 'audio') return <audio ref={ref} autoPlay playsInline muted={muted} className={className} />;
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
}
function VideoTile({ item, active }) {
const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare);
const screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare);
const audio = item.tracks.filter((entry) => entry.kind === 'audio');
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
return (
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
<div className='video-label-row'>
<span>{item.name}</span>
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
</div>
{screen ? (
<div className='screen-slot'>
<MediaTrack track={screen.track} kind='video' className='screen-share-video' muted={item.isLocal} />
</div>
) : null}
<div className='media-slot'>
{camera ? <MediaTrack track={camera.track} kind='video' className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>}
{audio.map((entry) => item.isLocal ? null : <MediaTrack key={entry.id} track={entry.track} kind='audio' className='hidden-audio' />)}
</div>
</div>
);
}
function CallPage({ roomName, showToast }) {
const [call, setCall] = useState(null);
const [loadError, setLoadError] = useState(null);
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [micEnabled, setMicEnabled] = useState(true);
const [cameraEnabled, setCameraEnabled] = useState(true);
const [joining, setJoining] = useState(false);
const [room, setRoom] = useState(null);
const [participants, setParticipants] = useState([]);
const [activeSpeakers, setActiveSpeakers] = useState([]);
const [messages, setMessages] = useState([]);
const [chatText, setChatText] = useState('');
const [config, setConfig] = useState({ max_attachment_size_mb: 100 });
const [elapsed, setElapsed] = useState(0);
const previewRef = useRef(null);
const previewStreamRef = useRef(null);
const fileRef = useRef(null);
const inviteToken = useMemo(getInviteToken, []);
const joined = !!room;
const refreshParticipants = useCallback((nextRoom) => {
if (!nextRoom) return;
const mapParticipant = (participant, isLocal) => {
const tracks = Array.from(participant.trackPublications.values())
.filter((publication) => publication.track)
.map((publication) => ({
id: publication.trackSid || publication.track.sid || `${participant.identity}-${publication.source}`,
track: publication.track,
source: publication.source,
kind: publication.kind || publication.track.kind
}));
return {
identity: participant.identity,
name: participant.name || participant.identity,
isLocal,
tracks
};
};
setParticipants([
mapParticipant(nextRoom.localParticipant, true),
...Array.from(nextRoom.remoteParticipants.values()).map((participant) => mapParticipant(participant, false))
]);
}, []);
useEffect(() => {
fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}`))
.then(readJson)
.then((data) => {
if (!data.is_active) {
setLoadError('410');
return;
}
setCall(data);
document.title = `${data.room_title} · Linkra`;
})
.catch(() => setLoadError('404'));
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
}, [roomName]);
useEffect(() => {
const raw = sessionStorage.getItem(`linkra-insta:${roomName}`);
if (!raw) return;
try {
const data = JSON.parse(raw);
if (data.invite_token && data.invite_token !== inviteToken) return;
setDisplayName(data.display_name || randomGuestName());
setMicEnabled(Boolean(data.mic_enabled));
setCameraEnabled(Boolean(data.camera_enabled));
} catch {
return;
}
}, [inviteToken, roomName]);
useEffect(() => {
if (joined || !navigator.mediaDevices) return undefined;
let cancelled = false;
const run = async () => {
if (previewStreamRef.current) {
previewStreamRef.current.getTracks().forEach((track) => track.stop());
previewStreamRef.current = null;
}
if (!cameraEnabled && !micEnabled) {
if (previewRef.current) previewRef.current.srcObject = null;
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: cameraEnabled });
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
previewStreamRef.current = stream;
if (previewRef.current) previewRef.current.srcObject = cameraEnabled ? new MediaStream(stream.getVideoTracks()) : null;
} catch {
showToast('Не удалось получить доступ к камере или микрофону', 'error');
}
};
run();
return () => {
cancelled = true;
};
}, [cameraEnabled, joined, micEnabled, showToast]);
useEffect(() => {
if (!joined) return undefined;
const started = Date.now();
const interval = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - started) / 1000));
}, 1000);
return () => window.clearInterval(interval);
}, [joined]);
useEffect(() => () => {
if (previewStreamRef.current) previewStreamRef.current.getTracks().forEach((track) => track.stop());
if (room) room.disconnect();
}, [room]);
const publishData = async (payload) => {
if (!room) return;
const encoded = new TextEncoder().encode(JSON.stringify(payload));
await room.localParticipant.publishData(encoded, { reliable: true });
};
const joinCall = async () => {
const name = displayName.trim();
if (!name) {
showToast('Введи имя', 'error');
return;
}
if (!inviteToken) {
setLoadError('403');
return;
}
setJoining(true);
try {
const response = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/join`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
display_name: name,
password,
with_video: cameraEnabled,
with_audio: micEnabled
})
});
const data = await readJson(response);
if (previewStreamRef.current) {
previewStreamRef.current.getTracks().forEach((track) => track.stop());
previewStreamRef.current = null;
}
const nextRoom = new Room({ adaptiveStream: true, dynacast: true });
const refresh = () => refreshParticipants(nextRoom);
nextRoom
.on(RoomEvent.TrackSubscribed, refresh)
.on(RoomEvent.TrackUnsubscribed, refresh)
.on(RoomEvent.LocalTrackPublished, refresh)
.on(RoomEvent.LocalTrackUnpublished, refresh)
.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => setActiveSpeakers(speakers.map((speaker) => speaker.identity)))
.on(RoomEvent.DataReceived, (payload, participant) => {
const text = new TextDecoder().decode(payload);
const message = JSON.parse(text);
if (message.type === 'chat') {
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: participant?.name || participant?.identity || 'Участник',
text: message.text,
own: false
}));
}
if (message.type === 'file') {
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: participant?.name || participant?.identity || 'Участник',
attachment: message.attachment,
own: false
}));
}
})
.on(RoomEvent.Disconnected, () => {
setRoom(null);
setParticipants([]);
});
await nextRoom.connect(data.server_url, data.participant_token);
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
setRoom(nextRoom);
refreshParticipants(nextRoom);
sessionStorage.removeItem(`linkra-insta:${roomName}`);
showToast('Ты в комнате', 'success');
} catch (error) {
showToast(error.message || 'Не удалось войти в звонок', 'error');
} finally {
setJoining(false);
}
};
const leaveCall = async () => {
if (!room) return;
await room.disconnect();
setRoom(null);
setParticipants([]);
};
const toggleMic = async () => {
if (!room) {
setMicEnabled((value) => !value);
return;
}
const next = !micEnabled;
await room.localParticipant.setMicrophoneEnabled(next);
setMicEnabled(next);
refreshParticipants(room);
};
const toggleCamera = async () => {
if (!room) {
setCameraEnabled((value) => !value);
return;
}
const next = !cameraEnabled;
await room.localParticipant.setCameraEnabled(next);
setCameraEnabled(next);
refreshParticipants(room);
};
const toggleScreen = async () => {
if (!room) return;
const enabled = Array.from(room.localParticipant.trackPublications.values()).some((publication) => publication.source === Track.Source.ScreenShare && publication.track);
await room.localParticipant.setScreenShareEnabled(!enabled);
refreshParticipants(room);
};
const finishCall = async () => {
if (!window.confirm('Завершить звонок для всех участников?')) return;
try {
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/finish`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_token: inviteToken })
}).then(readJson);
await leaveCall();
setLoadError('410');
} catch (error) {
showToast(error.message || 'Не удалось завершить звонок', 'error');
}
};
const sendChat = async () => {
const text = chatText.trim();
if (!text || !room) return;
try {
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/chat-events`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
display_name: displayName.trim(),
participant_identity: room.localParticipant.identity,
text
})
}).catch(() => {});
await publishData({ type: 'chat', text });
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: `${displayName.trim()} (ты)`,
text,
own: true
}));
setChatText('');
} catch (error) {
showToast(error.message || 'Не удалось отправить сообщение', 'error');
}
};
const sendFile = async (file) => {
if (!file || !room) return;
if (file.size > config.max_attachment_size_mb * 1024 * 1024) {
showToast(`Максимальный размер файла — ${config.max_attachment_size_mb} МБ`, 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('display_name', displayName.trim() || 'Участник');
formData.append('participant_identity', room.localParticipant.identity);
try {
const attachment = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/attachments?invite_token=${encodeURIComponent(inviteToken)}`), {
method: 'POST',
body: formData
}).then(readJson);
await publishData({ type: 'file', attachment });
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: `${displayName.trim()} (ты)`,
attachment,
own: true
}));
showToast('Файл отправлен', 'success');
} catch (error) {
showToast(error.message || 'Не удалось загрузить файл', 'error');
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
const copyInvite = async () => {
await navigator.clipboard.writeText(window.location.href);
showToast('Ссылка скопирована', 'success');
};
const time = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`;
if (loadError) return <ErrorPage code={loadError} />;
if (!call) return <main className='container'><section className='card'>Загрузка...</section></main>;
return (
<main className='container wide'>
{!joined ? (
<section className='card join-card'>
<div className='prejoin-layout'>
<div className='preview-stage'>
{cameraEnabled ? <video ref={previewRef} className='prejoin-preview-video' autoPlay playsInline muted></video> : <div className='preview-placeholder'><div className='avatar-placeholder'>?</div><p>Камера выключена</p></div>}
</div>
<div className='stack'>
<h1>{call.room_title}</h1>
<p className='muted'>Проверь камеру и микрофон перед входом в комнату.</p>
<label className='field'>
<span>Твоё имя</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} type='text' placeholder='Например, Артём' maxLength='50' />
</label>
{call.has_password ? (
<label className='field'>
<span>Пароль комнаты</span>
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Введите пароль комнаты' maxLength='100' />
</label>
) : null}
<div className='row'>
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</button>
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</button>
</div>
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
</div>
</div>
</section>
) : (
<>
<section className='card meeting-card'>
<div className='meeting-topbar'>
<div>
<div className='meeting-title'>Созвон</div>
<h1 className='meeting-room-name'>{call.room_title}</h1>
<p className='muted'>В комнате: {participants.length}</p>
</div>
<div className='meeting-controls'>
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic}>Mic</button>
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera}>Cam</button>
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
<div className='meeting-timer'>{time}</div>
</div>
</div>
<div className='videos'>
{participants.map((item) => <VideoTile key={item.identity} item={item} active={activeSpeakers.includes(item.identity)} />)}
</div>
</section>
<aside className='card chat-card'>
<h2>Чат комнаты</h2>
<div className='chat-messages'>
{messages.map((message) => (
<div className={`chat-message${message.own ? ' own' : ''}`} key={message.id}>
<div className='chat-author'>{message.author}</div>
{message.attachment ? (
<a className='file-link' href={`${message.attachment.download_url}?invite_token=${encodeURIComponent(inviteToken)}`} target='_blank' rel='noreferrer'>{message.attachment.file_name}</a>
) : <div className='chat-text'>{message.text}</div>}
</div>
))}
</div>
<div className='chat-form'>
<input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' />
<input ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
</div>
</aside>
<div className='call-bottom-actions is-visible'>
<button className='btn secondary' type='button' onClick={leaveCall}>Выйти</button>
<button className='btn danger' type='button' onClick={finishCall}>Завершить</button>
</div>
</>
)}
</main>
);
}
function App() {
const [route, setRoute] = useState(routeFromLocation);
const [toasts, showToast] = useToasts();
useEffect(() => {
const onPop = () => setRoute(routeFromLocation());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
return (
<>
<Toasts items={toasts} />
{route.name === 'home' ? <HomePage showToast={showToast} /> : null}
{route.name === 'call' ? <CallPage roomName={route.roomName} showToast={showToast} /> : null}
{route.name === 'error' ? <ErrorPage code={route.code} /> : null}
</>
);
}
createRoot(document.getElementById('root')).render(<App />);

535
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,535 @@
:root {
--bg: #0c1222;
--bg-deep: #060910;
--card: #111827;
--line: #243041;
--text: #f8fafc;
--muted: #94a3b8;
--primary: #2563eb;
--danger: #dc2626;
--success: #22c55e;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Inter, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(ellipse 110% 75% at 50% -12%, rgba(37, 99, 235, 0.2), transparent 52%),
linear-gradient(168deg, var(--bg-deep) 0%, var(--bg) 44%, #0f172a 100%);
}
a {
color: inherit;
}
.container {
max-width: 1120px;
margin: 0 auto;
padding: 32px 16px;
}
.container.wide {
max-width: 1680px;
}
.home-shell {
min-height: 100vh;
display: flex;
align-items: center;
}
.home-grid {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 440px);
gap: 48px;
align-items: center;
}
.card {
background: rgba(17, 24, 39, 0.96);
border: 1px solid var(--line);
border-radius: 20px;
padding: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
}
h1,
h2 {
margin-top: 0;
}
.muted {
color: var(--muted);
line-height: 1.55;
}
.home-hero-lead {
max-width: 44ch;
}
.home-features {
margin: 24px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
color: var(--muted);
}
.home-features li {
position: relative;
padding-left: 22px;
}
.home-features li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary);
}
.home-card-header,
.meeting-topbar,
.row {
display: flex;
gap: 12px;
align-items: center;
}
.home-card-header,
.meeting-topbar {
justify-content: space-between;
}
.centered {
justify-content: center;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
input {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: #0b1220;
color: white;
font: inherit;
}
.btn,
.icon-btn,
.toggle-btn {
border: 0;
border-radius: 12px;
padding: 12px 16px;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: var(--primary);
color: white;
}
.btn.secondary,
.toggle-btn {
background: #f8fafc;
color: #111827;
}
.btn.danger {
background: var(--danger);
color: white;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.link-btn {
display: inline-flex;
justify-content: center;
text-decoration: none;
}
.icon-btn {
min-width: 50px;
min-height: 50px;
background: #1f2937;
color: white;
}
.icon-btn.small {
min-width: 44px;
min-height: 44px;
padding: 0;
}
.icon-btn.is-off {
background: #3a1111;
color: #fecaca;
}
.toggle-btn.active {
background: #13274f;
color: white;
border: 1px solid #355cae;
}
.toggle-btn.inactive {
background: #1f2937;
color: #cbd5e1;
border: 1px solid #4b5563;
}
.home-actions {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
}
.settings-wrap {
position: relative;
}
.settings-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 260px;
padding: 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(17, 24, 39, 0.98);
}
.checkbox-line {
display: flex;
gap: 10px;
align-items: center;
}
.checkbox-line input {
width: 18px;
height: 18px;
}
.qr-box {
display: inline-flex;
padding: 12px;
border-radius: 14px;
background: white;
}
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
display: grid;
gap: 10px;
max-width: min(420px, calc(100vw - 32px));
}
.toast {
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(17, 24, 39, 0.96);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.toast.success {
border-color: rgba(34, 197, 94, 0.5);
}
.toast.error {
border-color: rgba(239, 68, 68, 0.55);
}
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-card {
max-width: 500px;
text-align: center;
}
.error-code {
margin: 0 0 12px;
color: var(--muted);
letter-spacing: 0.12em;
}
.join-card {
margin-bottom: 20px;
}
.prejoin-layout {
display: grid;
grid-template-columns: minmax(320px, 1.2fr) minmax(300px, 0.9fr);
gap: 20px;
}
.preview-stage {
min-height: 420px;
border-radius: 18px;
overflow: hidden;
background: #020617;
border: 1px solid rgba(148, 163, 184, 0.16);
display: flex;
align-items: center;
justify-content: center;
}
.prejoin-preview-video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.meeting-card {
margin-bottom: 20px;
}
.meeting-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.meeting-room-name {
margin-bottom: 0;
}
.meeting-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
}
.meeting-timer {
min-width: 74px;
text-align: center;
font-size: 18px;
font-weight: 700;
color: #93c5fd;
}
.videos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.video-tile {
background: #0b1220;
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
min-height: 360px;
}
.video-tile.speaking {
border-color: var(--success);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.35);
}
.video-label-row {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
}
.screen-slot {
height: 260px;
background: #020617;
}
.media-slot {
min-height: 320px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #020617;
}
.media-slot video,
.screen-slot video {
width: 100%;
height: 100%;
min-height: inherit;
object-fit: cover;
}
.screen-share-video {
object-fit: contain !important;
}
.local-video {
transform: scaleX(-1);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 44px;
font-weight: 800;
}
.hidden-audio {
width: 0;
height: 0;
opacity: 0;
}
.chat-card {
margin-bottom: 96px;
}
.chat-messages {
min-height: 260px;
max-height: 420px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 16px;
background: #0b1220;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-message {
background: #111827;
border: 1px solid #223046;
border-radius: 12px;
padding: 10px 12px;
}
.chat-message.own {
background: #102143;
border-color: #274b8f;
}
.chat-author {
font-size: 12px;
color: #93c5fd;
margin-bottom: 6px;
font-weight: 700;
}
.chat-text {
word-break: break-word;
}
.chat-form {
margin-top: 14px;
display: grid;
gap: 10px;
}
.file-link {
color: #93c5fd;
}
.call-bottom-actions {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 50;
display: flex;
gap: 12px;
padding: 14px 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(11, 18, 32, 0.96);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22);
}
@media (max-width: 900px) {
.home-grid,
.prejoin-layout {
grid-template-columns: 1fr;
}
.meeting-topbar {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 640px) {
.container {
padding: 20px 12px;
}
.home-actions,
.row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.card {
padding: 18px 14px;
}
.preview-stage {
min-height: 280px;
}
.call-bottom-actions {
width: calc(100% - 24px);
flex-direction: column;
}
}

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import{defineConfig}from'vite';
import react from'@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/health': 'http://localhost:8000',
'/i': 'http://localhost:8000'
}
}
});

11
livekit.yaml Normal file
View File

@@ -0,0 +1,11 @@
port: 7880
bind_addresses:
- "0.0.0.0"
keys:
linkra_key: "8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87"
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
logging:
level: info