Files
vidconf/backend/api/conferences.py
Max Ronzhin 4c60e092e5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(room): принудительный мьют участника организатором
Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).

Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.

Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.

Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).

Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
2026-08-01 22:07:06 +03:00

290 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роутер конференций: создание, «Мои конференции», календарь, резолв, вход, правки (ADR-001)."""
import uuid
from datetime import UTC, datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user
from core.db import get_session
from core.rate_limit import enforce_rate_limit
from models.user import User
from schemas.conferences import (
ConferenceCreateIn,
ConferenceOut,
ConferenceUpdateIn,
GuestJoinIn,
JoinIn,
JoinOut,
MuteParticipantIn,
MuteParticipantOut,
OccurrenceOut,
ResolveOut,
)
from services.conference_access import (
ConferenceEndedError,
InvalidPasswordError,
PasswordRequiredError,
)
from services.conferences import (
ConferenceActiveError,
ConferenceNotFoundError,
ConferenceService,
InvalidConferenceStateError,
InviteeUserNotFoundError,
NotConferenceOwnerError,
)
from services.room_control import ParticipantNotInRoomError
router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"])
# Максимальная ширина диапазона `from`/`to` для GET /calendar — защита от
# случайного запроса на годы вперёд (календарь UI показывает недели/месяцы).
MAX_CALENDAR_RANGE = timedelta(days=62)
@router.post("", status_code=status.HTTP_201_CREATED, response_model=ConferenceOut)
async def create_conference(
data: ConferenceCreateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ConferenceOut:
"""Создать конференцию: без `scheduled_at` — мгновенная (создатель входит сразу же)."""
service = ConferenceService(session)
try:
conference, join = await service.create(owner=user, data=data)
except InviteeUserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found"
) from exc
return await service.to_detail_out(conference, viewer=user, join=join)
@router.get("/my", response_model=list[ConferenceOut])
async def list_my_conferences(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> list[ConferenceOut]:
"""Закреплённые конференции + предстоящие разовые владельца."""
service = ConferenceService(session)
return await service.list_my(owner=user)
@router.get("/calendar", response_model=list[OccurrenceOut])
async def get_calendar(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
from_: Annotated[datetime, Query(alias="from")],
to: Annotated[datetime, Query()],
) -> list[OccurrenceOut]:
"""Развёртка вхождений закреплённых (с повторением) и разовых плановых конференций владельца."""
t_from = _require_utc(from_)
t_to = _require_utc(to)
if t_to <= t_from or t_to - t_from > MAX_CALENDAR_RANGE:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invalid_range"
)
service = ConferenceService(session)
return await service.list_calendar(owner=user, t_from=t_from, t_to=t_to)
@router.get("/resolve", response_model=ResolveOut)
async def resolve_conference(
request: Request,
session: Annotated[AsyncSession, Depends(get_session)],
q: Annotated[str, Query(min_length=1)],
) -> ResolveOut:
"""Найти конференцию по номеру или ссылке — без auth; rate limit; единообразный 404.
Для завершённой конференции (`status=ended`) отдаём минимальный ответ —
только `id`/`title`/`status`, без `is_closed`/`requires_password` (ADR-001,
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
join/guest-join), а признак закрытости неактуален для мёртвой конференции.
"""
await enforce_rate_limit(f"resolve:{_client_ip(request)}")
service = ConferenceService(session)
conference = await service.resolve(q)
if conference is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
if conference.status == "ended":
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
return ResolveOut(
id=conference.id,
title=conference.title,
status=conference.status,
is_closed=conference.is_closed,
requires_password=conference.is_closed,
)
@router.post("/{conference_id}/join", response_model=JoinOut)
async def join_conference(
conference_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
data: JoinIn = JoinIn(),
) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем."""
service = ConferenceService(session)
try:
return await service.join_as_user(conference_id, user=user, password=data.password)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except ConferenceEndedError as exc:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc
except PasswordRequiredError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc
except InvalidPasswordError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc
@router.post("/{conference_id}/guest-join", response_model=JoinOut)
async def guest_join_conference(
conference_id: uuid.UUID,
request: Request,
data: GuestJoinIn,
session: Annotated[AsyncSession, Depends(get_session)],
) -> JoinOut:
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}")
service = ConferenceService(session)
try:
return await service.join_as_guest(conference_id, data=data)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except ConferenceEndedError as exc:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="conference_ended") from exc
except PasswordRequiredError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc
except InvalidPasswordError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc
@router.post("/{conference_id}/mute-participant", response_model=MuteParticipantOut)
async def mute_participant(
conference_id: uuid.UUID,
data: MuteParticipantIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> MuteParticipantOut:
"""Принудительно выключить микрофон/камеру участника (задача B2) — владелец/администратор.
Права проверяются ЗАНОВО по владельцу конференции в БД
(`ConferenceService.mute_participant`), а не по метаданным LiveKit-токена
вызывающего — те лишь подсказка для UI и потенциально подделываемы клиентом.
"""
service = ConferenceService(session)
try:
muted = await service.mute_participant(
conference_id, actor=user, target_identity=data.identity, source=data.source
)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except ParticipantNotInRoomError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="participant_not_in_room"
) from exc
return MuteParticipantOut(muted=muted)
@router.get("/{conference_id}", response_model=ConferenceOut)
async def get_conference(
conference_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ConferenceOut:
"""Детальная карточка конференции (с полным составом участников); владелец или администратор."""
service = ConferenceService(session)
try:
conference = await service.get_detail(conference_id, actor=user)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
return await service.to_detail_out(conference, viewer=user)
@router.patch("/{conference_id}", response_model=ConferenceOut)
async def update_conference(
conference_id: uuid.UUID,
data: ConferenceUpdateIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> ConferenceOut:
"""Изменить конференцию: разрешено владельцу или администратору."""
service = ConferenceService(session)
try:
conference = await service.update(conference_id, actor=user, data=data)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except InvalidConferenceStateError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except InviteeUserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="invitee_user_not_found"
) from exc
return await service.to_detail_out(conference, viewer=user)
@router.delete("/{conference_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conference(
conference_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить конференцию: запрещено для активной (409), разрешено владельцу/администратору."""
service = ConferenceService(session)
try:
await service.delete(conference_id, actor=user)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except ConferenceActiveError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="conference_active"
) from exc
def _require_utc(value: datetime) -> datetime:
"""Требовать явную таймзону и привести значение к UTC (в БД и API — только UTC)."""
if value.tzinfo is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="datetime_must_be_timezone_aware",
)
return value.astimezone(UTC)
def _client_ip(request: Request) -> str:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
return request.client.host if request.client else "unknown"