Files
linkra/backend/app/main.py
2026-05-10 09:50:39 +03:00

308 lines
11 KiB
Python

import secrets
import urllib.parse
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
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/config", response_model=AppConfigResponse)
def get_config() -> AppConfigResponse:
return AppConfigResponse(
max_attachment_size_mb=settings.max_attachment_size_mb,
turn_ice_servers=settings.turn_ice_servers,
)
@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
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,
)
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")
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"}