mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
0c0ce87947
Room.configuration accepted arbitrary JSON without validation, allowing unsafe or malformed payloads to be stored and creating a security risk. Define a Pydantic schema to enforce structure and constraints, and add validation at the serializer level to reject invalid inputs.
351 lines
11 KiB
Python
351 lines
11 KiB
Python
"""Lobby Service"""
|
|
|
|
import logging
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Dict, List, Optional, Tuple
|
|
from uuid import UUID
|
|
|
|
from django.conf import settings
|
|
from django.core.cache import cache
|
|
|
|
from core import models, utils
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LobbyParticipantStatus(Enum):
|
|
"""Possible states of a participant in the lobby system.
|
|
Values are lowercase strings for consistent serialization and API responses.
|
|
"""
|
|
|
|
UNKNOWN = "unknown"
|
|
WAITING = "waiting"
|
|
ACCEPTED = "accepted"
|
|
DENIED = "denied"
|
|
|
|
|
|
class LobbyError(Exception):
|
|
"""Base exception for lobby-related errors."""
|
|
|
|
|
|
class LobbyParticipantParsingError(LobbyError):
|
|
"""Raised when participant data parsing fails."""
|
|
|
|
|
|
class LobbyParticipantNotFound(LobbyError):
|
|
"""Raised when participant is not found."""
|
|
|
|
|
|
@dataclass
|
|
class LobbyParticipant:
|
|
"""Participant in a lobby system."""
|
|
|
|
status: LobbyParticipantStatus
|
|
username: str
|
|
color: str
|
|
id: str
|
|
|
|
def to_dict(self) -> Dict[str, str]:
|
|
"""Serialize the participant object to a dict representation."""
|
|
return {
|
|
"status": self.status.value,
|
|
"username": self.username,
|
|
"id": self.id,
|
|
"color": self.color,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "LobbyParticipant":
|
|
"""Create a LobbyParticipant instance from a dictionary."""
|
|
try:
|
|
status = LobbyParticipantStatus(
|
|
data.get("status", LobbyParticipantStatus.UNKNOWN.value)
|
|
)
|
|
return cls(
|
|
status=status,
|
|
username=data["username"],
|
|
id=data["id"],
|
|
color=data["color"],
|
|
)
|
|
except (KeyError, ValueError) as e:
|
|
logger.exception("Error creating Participant from dict:")
|
|
raise LobbyParticipantParsingError("Invalid participant data") from e
|
|
|
|
|
|
class LobbyService:
|
|
"""Service for managing participant access through a lobby system.
|
|
|
|
Handles participant entry requests, status management, and notifications
|
|
using cache for state management and LiveKit for real-time updates.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _get_cache_key(room_id: UUID, participant_id: str) -> str:
|
|
"""Generate cache key for participant(s) data."""
|
|
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
|
|
|
@staticmethod
|
|
def _get_or_create_participant_id(request) -> str:
|
|
"""Extract unique participant identifier from the request."""
|
|
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
|
|
|
|
@staticmethod
|
|
def prepare_response(response, participant_id):
|
|
"""Set participant cookie if needed."""
|
|
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
|
response.set_cookie(
|
|
key=settings.LOBBY_COOKIE_NAME,
|
|
value=participant_id,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="Lax",
|
|
)
|
|
|
|
@staticmethod
|
|
def can_bypass_lobby(room, user) -> bool:
|
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
|
|
|
A user can bypass the lobby if:
|
|
1. The room is public (open to everyone)
|
|
2. The room has TRUSTED access level and the user is authenticated
|
|
|
|
Note: Room access levels can change while participants are waiting in the lobby.
|
|
This function only checks the current state and should be called each time
|
|
a participant requests entry to ensure consistent access control, even for
|
|
participants who have already begun waiting.
|
|
"""
|
|
return room.is_public or (
|
|
room.access_level == models.RoomAccessLevel.TRUSTED
|
|
and user.is_authenticated
|
|
)
|
|
|
|
def request_entry(
|
|
self,
|
|
room: models.Room,
|
|
request,
|
|
username: str,
|
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
|
"""Request entry to a room for a participant.
|
|
|
|
The usual status transitions are:
|
|
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
|
|
|
Flow:
|
|
1. Check current status
|
|
2. If waiting, refresh timeout to maintain position
|
|
3. If unknown, add to waiting list
|
|
4. If accepted, generate LiveKit config
|
|
5. If denied, do nothing.
|
|
"""
|
|
|
|
participant_id = self._get_or_create_participant_id(request)
|
|
participant = self._get_participant(room.id, participant_id)
|
|
|
|
room_id = str(room.id)
|
|
|
|
if self.can_bypass_lobby(room=room, user=request.user):
|
|
if participant is None:
|
|
participant = LobbyParticipant(
|
|
status=LobbyParticipantStatus.ACCEPTED,
|
|
username=username,
|
|
id=participant_id,
|
|
color=utils.generate_color(participant_id),
|
|
)
|
|
else:
|
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
|
|
|
livekit_config = utils.generate_livekit_config(
|
|
room_id=room_id,
|
|
user=request.user,
|
|
username=username,
|
|
color=participant.color,
|
|
configuration=room.configuration,
|
|
is_admin_or_owner=False,
|
|
participant_id=participant_id,
|
|
)
|
|
return participant, livekit_config
|
|
|
|
livekit_config = None
|
|
|
|
if participant is None:
|
|
participant = self.enter(room.id, participant_id, username)
|
|
|
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
|
self.refresh_waiting_status(room.id, participant_id)
|
|
|
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
|
# wrongly named, contains access token to join a room
|
|
livekit_config = utils.generate_livekit_config(
|
|
room_id=room_id,
|
|
user=request.user,
|
|
username=username,
|
|
color=participant.color,
|
|
configuration=room.configuration,
|
|
is_admin_or_owner=False,
|
|
participant_id=participant_id,
|
|
)
|
|
|
|
return participant, livekit_config
|
|
|
|
def refresh_waiting_status(self, room_id: UUID, participant_id: str):
|
|
"""Refresh timeout for waiting participant.
|
|
|
|
Extends the waiting period for a participant to maintain their position
|
|
in the lobby queue. Automatic removal if the participant is not
|
|
actively checking their status.
|
|
"""
|
|
cache.touch(
|
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
|
)
|
|
|
|
def enter(
|
|
self, room_id: UUID, participant_id: str, username: str
|
|
) -> LobbyParticipant:
|
|
"""Add participant to waiting lobby.
|
|
|
|
Create a new participant entry in waiting status and notify room
|
|
participants of the new entry request.
|
|
"""
|
|
|
|
color = utils.generate_color(participant_id)
|
|
|
|
participant = LobbyParticipant(
|
|
status=LobbyParticipantStatus.WAITING,
|
|
username=username,
|
|
id=participant_id,
|
|
color=color,
|
|
)
|
|
|
|
try:
|
|
utils.notify_participants(
|
|
room_name=str(room_id),
|
|
notification_data={
|
|
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
|
},
|
|
)
|
|
except utils.NotificationError:
|
|
# If room not created yet, there is no participants to notify
|
|
logger.exception("Failed to notify room participants")
|
|
|
|
cache_key = self._get_cache_key(room_id, participant_id)
|
|
cache.set(
|
|
cache_key,
|
|
participant.to_dict(),
|
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
|
)
|
|
|
|
return participant
|
|
|
|
def _get_participant(
|
|
self, room_id: UUID, participant_id: str
|
|
) -> Optional[LobbyParticipant]:
|
|
"""Check participant's current status in the lobby."""
|
|
|
|
cache_key = self._get_cache_key(room_id, participant_id)
|
|
data = cache.get(cache_key)
|
|
|
|
if not data:
|
|
return None
|
|
|
|
try:
|
|
return LobbyParticipant.from_dict(data)
|
|
except LobbyParticipantParsingError:
|
|
logger.error("Corrupted participant data found and removed: %s", cache_key)
|
|
cache.delete(cache_key)
|
|
return None
|
|
|
|
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
|
"""List all waiting participants for a room."""
|
|
|
|
pattern = self._get_cache_key(room_id, "*")
|
|
keys = cache.keys(pattern)
|
|
|
|
if not keys:
|
|
return []
|
|
|
|
data = cache.get_many(keys)
|
|
|
|
waiting_participants = []
|
|
for cache_key, raw_participant in data.items():
|
|
try:
|
|
participant = LobbyParticipant.from_dict(raw_participant)
|
|
except LobbyParticipantParsingError:
|
|
cache.delete(cache_key)
|
|
continue
|
|
if participant.status == LobbyParticipantStatus.WAITING:
|
|
waiting_participants.append(participant.to_dict())
|
|
|
|
return waiting_participants
|
|
|
|
def handle_participant_entry(
|
|
self,
|
|
room_id: UUID,
|
|
participant_id: str,
|
|
allow_entry: bool,
|
|
) -> None:
|
|
"""Handle decision on participant entry.
|
|
|
|
Updates participant status based on allow_entry:
|
|
- If accepted: ACCEPTED status with extended timeout matching LiveKit token
|
|
- If denied: DENIED status with short timeout allowing status check and retry
|
|
"""
|
|
if allow_entry:
|
|
decision = {
|
|
"status": LobbyParticipantStatus.ACCEPTED,
|
|
"timeout": settings.LOBBY_ACCEPTED_TIMEOUT,
|
|
}
|
|
else:
|
|
decision = {
|
|
"status": LobbyParticipantStatus.DENIED,
|
|
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
|
}
|
|
|
|
self._update_participant_status(room_id, participant_id, **decision)
|
|
|
|
def _update_participant_status(
|
|
self,
|
|
room_id: UUID,
|
|
participant_id: str,
|
|
status: LobbyParticipantStatus,
|
|
timeout: int,
|
|
) -> None:
|
|
"""Update participant status with appropriate timeout."""
|
|
|
|
cache_key = self._get_cache_key(room_id, participant_id)
|
|
|
|
data = cache.get(cache_key)
|
|
if not data:
|
|
logger.error("Participant %s not found", participant_id)
|
|
raise LobbyParticipantNotFound("Participant not found")
|
|
|
|
try:
|
|
participant = LobbyParticipant.from_dict(data)
|
|
except LobbyParticipantParsingError:
|
|
logger.exception(
|
|
"Removed corrupted data for participant %s:", participant_id
|
|
)
|
|
cache.delete(cache_key)
|
|
raise
|
|
|
|
participant.status = status
|
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
|
|
|
def clear_room_cache(self, room_id: UUID) -> None:
|
|
"""Clear all participant entries from the cache for a specific room."""
|
|
|
|
pattern = self._get_cache_key(room_id, "*")
|
|
keys = cache.keys(pattern)
|
|
|
|
if not keys:
|
|
return
|
|
|
|
cache.delete_many(keys)
|
|
|
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
|
"""Clear a given participant entry from the cache for a specific room."""
|
|
|
|
cache_key = self._get_cache_key(room_id, participant_id)
|
|
cache.delete(cache_key)
|