Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026. 1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx, поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей. Проверено на проде: в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту» действовал на весь инстанс разом, а не на клиента. 2. Считались все запросы подряд, включая успешные. Одиннадцатый человек, открывший ссылку на конференцию в течение минуты, получал 429 — и видел «Не удалось найти конференцию» для существующей и активной конференции. Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно. Что изменилось: - адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному клиентом, и лимит обходился бы одним заголовком; - жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ: конференция не найдена или пароль неверен. Именно так выглядит перебор номера, от которого лимит и защищает по ADR-001, п.4; - на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше правдоподобного числа участников одной конференции. Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30 подряд); перебор номера, несуществующий идентификатор и подбор пароля по-прежнему упираются; лимит одного клиента не задевает другого.
315 lines
14 KiB
Python
315 lines
14 KiB
Python
"""Роутер конференций: создание, «Мои конференции», календарь, резолв, вход, правки (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 (
|
||
RATE_LIMIT_MISS_MAX_REQUESTS,
|
||
RATE_LIMIT_SOFT_MAX_REQUESTS,
|
||
client_ip,
|
||
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), а признак закрытости неактуален для мёртвой конференции.
|
||
"""
|
||
ip = client_ip(request)
|
||
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
|
||
# вся конференция открывает ссылку в одну минуту.
|
||
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||
service = ConferenceService(session)
|
||
conference = await service.resolve(q)
|
||
if conference is None:
|
||
# Жёсткий счётчик — только на промахи: перебор номера конференции
|
||
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
|
||
await enforce_rate_limit(
|
||
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
|
||
)
|
||
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."""
|
||
ip = client_ip(request)
|
||
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
|
||
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
|
||
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||
service = ConferenceService(session)
|
||
try:
|
||
return await service.join_as_guest(conference_id, data=data)
|
||
except ConferenceNotFoundError as exc:
|
||
await _count_guest_join_miss(ip)
|
||
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:
|
||
# Подбор пароля закрытой конференции — тот же класс атаки, что и
|
||
# перебор номера, поэтому считается жёстким счётчиком.
|
||
await _count_guest_join_miss(ip)
|
||
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)
|
||
|
||
|
||
async def _count_guest_join_miss(ip: str) -> None:
|
||
"""Учесть неудачную попытку гостевого входа в жёстком счётчике.
|
||
|
||
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
|
||
(несуществующая конференция и неверный пароль) и обязано бросать 429
|
||
ровно так же, как обычный `enforce_rate_limit`.
|
||
"""
|
||
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)
|