308 lines
11 KiB
Python
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"}
|