Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5c9dc1fa8 |
+1
-3
@@ -12,7 +12,6 @@ and this project adheres to
|
||||
|
||||
- ✨(helm) support celery with our Django backend #1124
|
||||
- ✨(helm) support ingress for custom background image #1124
|
||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -22,12 +21,11 @@ and this project adheres to
|
||||
- ♿(frontend) improve chat toast a11y for screen readers #1109
|
||||
- ♿(frontend) improve ui and aria labels for help article links #1108
|
||||
- 🌐(frontend) improve German translation #1125
|
||||
- 🦺(backend) optionally validate room names using a regex #1132
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
|
||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
||||
|
||||
## [1.10.0] - 2026-03-05
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
import logging
|
||||
import re
|
||||
from os.path import splitext
|
||||
from typing import Literal
|
||||
from urllib.parse import quote
|
||||
@@ -13,7 +14,7 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from pydantic import BaseModel, Field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
@@ -123,16 +124,6 @@ class ListRoomSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
"""Wip"""
|
||||
|
||||
can_publish_sources: list[Literal[
|
||||
"microphone", "screen_share", "screen_share_audio", "camera"
|
||||
]] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class RoomSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Room model for the API."""
|
||||
|
||||
@@ -141,19 +132,20 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, configuration):
|
||||
"""Wip."""
|
||||
def validate_name(self, value):
|
||||
"""Validate the name matches the optionally configured ROOM_NAME_REGEX"""
|
||||
|
||||
if configuration is None:
|
||||
return configuration
|
||||
if not value:
|
||||
raise serializers.ValidationError("Name cannot be empty.")
|
||||
|
||||
try:
|
||||
RoomConfiguration.model_validate(configuration)
|
||||
except ValidationError as e:
|
||||
raise SuspiciousOperation("Wip, invalid room configuration")
|
||||
room_name_regex = settings.ROOM_NAME_REGEX
|
||||
if room_name_regex is None:
|
||||
return value
|
||||
|
||||
return configuration
|
||||
if not re.fullmatch(room_name_regex, value):
|
||||
raise SuspiciousOperation("Name does not match the expected format.")
|
||||
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
|
||||
@@ -16,58 +14,11 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the authenticated user ID as the throttle cache key."""
|
||||
|
||||
if request.user and not request.user.is_authenticated:
|
||||
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
|
||||
|
||||
return super().get_cache_key(request, view)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
|
||||
Keying on the cookie rather than the IP address prevents penalising
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
security measure against brute-force or DoS attacks. This throttle exists
|
||||
solely to guard against accidental hammering from buggy clients.
|
||||
"""
|
||||
|
||||
if request.user and request.user.is_authenticated:
|
||||
return None # Only throttle unauthenticated requests.
|
||||
|
||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||
|
||||
if participant_id is None:
|
||||
return None # No throttling for cookieless requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
"ident": participant_id,
|
||||
}
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
@@ -224,7 +224,6 @@ class RoomViewSet(
|
||||
API endpoints to access and perform actions on rooms.
|
||||
"""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.RoomPermissions]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
@@ -393,10 +392,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[
|
||||
throttling.RequestEntryAuthenticatedUserRateThrottle,
|
||||
throttling.RequestEntryAnonRateThrottle,
|
||||
],
|
||||
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
||||
)
|
||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Request entry to a room"""
|
||||
|
||||
@@ -3,7 +3,10 @@ Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
@@ -109,3 +112,47 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||
def test_api_rooms_create_invalid_regex(mock_suspicious, settings):
|
||||
"""A room can not be created when its name doesn't match the configured pattern."""
|
||||
|
||||
settings.ROOM_NAME_REGEX = r"[a-z]{3}-[a-z]{4}-[a-z]{3}"
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert Room.objects.exists() is False
|
||||
|
||||
mock_suspicious.assert_called_once_with("Name does not match the expected format.")
|
||||
|
||||
|
||||
def test_api_rooms_create_valid_regex(settings):
|
||||
"""A room can be created when its name matches the configured pattern."""
|
||||
|
||||
settings.ROOM_NAME_REGEX = r"[a-z]{3}-[a-z]{4}-[a-z]{3}"
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "foo-barn-baz",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert Room.objects.count() == 1
|
||||
assert Room.objects.filter(name="foo-barn-baz").exists()
|
||||
|
||||
@@ -120,39 +120,3 @@ def test_api_rooms_list_authenticated_distinct():
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_pagination_page_size():
|
||||
"""Users should be able to customize the number of results per page via page_size param."""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
RoomFactory.create_batch(11, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/rooms/?page_size=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 11
|
||||
assert len(content["results"]) == 2
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
response = client.get("/api/v1.0/rooms/?page=6&page_size=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 11
|
||||
assert len(content["results"]) == 1
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/rooms/?page=5&page_size=2"
|
||||
|
||||
response = client.get("/api/v1.0/rooms/?page_size=3")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 11
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
assert content["previous"] is None
|
||||
|
||||
@@ -631,109 +631,3 @@ def test_list_waiting_participants_empty(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"participants": []}
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_without_cookie(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Anonymous users without a cookie should not be throttled."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("mocked-cookie") is not None
|
||||
|
||||
client.cookies.clear() # Simulate a new cookieless request
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_with_cookie(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
client.cookies.load({"mocked-cookie": participant_id})
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_authenticated_user(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Authenticated users should be throttled."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"""
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory(
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"configuration": {"the_key": "the_value"},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -104,7 +104,7 @@ def test_api_rooms_update_administrators():
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
assert room.configuration == {"the_key": "the_value"}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
|
||||
@@ -752,6 +752,13 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Room validation
|
||||
ROOM_NAME_REGEX = values.RegexValue(
|
||||
None,
|
||||
environ_name="ROOM_NAME_REGEX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in with your account",
|
||||
"body": "Instead of waiting, log in with your account.",
|
||||
"body": "Instead of waiting, log in with your ProConnect account.",
|
||||
"button": {
|
||||
"ariaLabel": "Close the suggestion",
|
||||
"label": "OK"
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Connectez-vous avec votre compte",
|
||||
"body": "Au lieu de patienter, connectez-vous avec votre compte.",
|
||||
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
|
||||
"button": {
|
||||
"ariaLabel": "Fermer la suggestion",
|
||||
"label": "OK"
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in met je account",
|
||||
"body": "In plaats van te wachten, log in met je account.",
|
||||
"body": "In plaats van te wachten, log in met je ProConnect-account.",
|
||||
"button": {
|
||||
"ariaLabel": "Sluit de suggestie",
|
||||
"label": "OK"
|
||||
|
||||
Reference in New Issue
Block a user