mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-14 00:50:48 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb422309b9 |
+1
-1
@@ -11,7 +11,6 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
- 🔒️(backend) throttle the start recording endpoint
|
||||
|
||||
## [1.35.0] - 2026-10-13
|
||||
|
||||
@@ -20,6 +19,7 @@ and this project adheres to
|
||||
- ✨(frontend) let users pick the recording encoding
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
- ⚡️(backend) send recording notification emails from a Celery task
|
||||
- ✨(backend) allow static feature flags without an analytics backend #1809
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -120,6 +120,26 @@ DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Nu
|
||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||
```
|
||||
|
||||
### Feature flags without an analytics backend
|
||||
|
||||
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
|
||||
|
||||
```env
|
||||
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
|
||||
```
|
||||
|
||||
You can also set it directly in `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
|
||||
|
||||
## Step 3: Configure your firewall
|
||||
|
||||
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
||||
|
||||
@@ -335,8 +335,6 @@ These are the environmental options available on meet backend.
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
|
||||
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
|
||||
| START_RECORDING_THROTTLE_RATES | Recording start throttle rate per authenticated user | 10/minute |
|
||||
| START_RECORDING_DAILY_THROTTLE_RATES | Daily recording start cap per authenticated user | 100/day |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
@@ -418,6 +416,8 @@ These are the environmental options available on meet backend.
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
|
||||
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
|
||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||
|
||||
if not dotted_path:
|
||||
return NoOpAnalytics()
|
||||
return NoOpAnalytics(**options)
|
||||
|
||||
backend_class = import_string(dotted_path)
|
||||
return backend_class(**options)
|
||||
|
||||
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
|
||||
|
||||
|
||||
class NoOpAnalytics(AnalyticsBackend):
|
||||
"""Default backend: silently discards everything."""
|
||||
"""
|
||||
Default backend: discards events and serves static feature flags.
|
||||
|
||||
Feature flags can be declared without any analytics provider, e.g.:
|
||||
|
||||
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
feature_flags: Mapping[str, bool | str | None] | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> None:
|
||||
# Other settings are ignored, so that settings meant for another
|
||||
# backend never break an instance running without one.
|
||||
self._feature_flags = dict(feature_flags or {})
|
||||
|
||||
def identify(self, user: User, properties=None) -> None:
|
||||
"""No-op: discards identify calls."""
|
||||
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""No-op: nothing to flush."""
|
||||
|
||||
def get_user_feature_flags(
|
||||
self,
|
||||
user: User,
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Return the feature flags declared in settings, for any user."""
|
||||
return self._feature_flags
|
||||
|
||||
@@ -47,18 +47,6 @@ class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
|
||||
scope = "room_creation_daily"
|
||||
|
||||
|
||||
class StartRecordingUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle recording starts per authenticated user."""
|
||||
|
||||
scope = "start_recording"
|
||||
|
||||
|
||||
class StartRecordingDailyUserRateThrottle(StartRecordingUserRateThrottle):
|
||||
"""Cap recording starts per authenticated user over a day."""
|
||||
|
||||
scope = "start_recording_daily"
|
||||
|
||||
|
||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated user requesting room entry"""
|
||||
|
||||
|
||||
@@ -406,10 +406,6 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
throttle_classes=[
|
||||
throttling.StartRecordingUserRateThrottle,
|
||||
throttling.StartRecordingDailyUserRateThrottle,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Unit tests for NoOpAnalytics and the analytics backend loader.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics import get_analytics
|
||||
from core.analytics.base import NoOpAnalytics
|
||||
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_analytics_cache():
|
||||
"""Clear the cached backend before and after each test."""
|
||||
get_analytics.cache_clear()
|
||||
yield
|
||||
get_analytics.cache_clear()
|
||||
|
||||
|
||||
# ==============================
|
||||
# NoOpAnalytics
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_noop_has_no_feature_flags_by_default():
|
||||
"""Should keep every feature flag disabled when none is declared."""
|
||||
backend = NoOpAnalytics()
|
||||
user = UserFactory()
|
||||
|
||||
assert not backend.get_user_feature_flags(user)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_noop_enables_declared_feature_flags():
|
||||
"""Should enable a feature flag declared as True."""
|
||||
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
|
||||
user = UserFactory()
|
||||
|
||||
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, None, "true"])
|
||||
def test_noop_only_enables_feature_flags_set_to_true(value):
|
||||
"""Should keep a feature flag disabled unless its value is exactly True."""
|
||||
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
|
||||
user = UserFactory()
|
||||
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_noop_ignores_unrelated_settings():
|
||||
"""Should accept settings meant for another backend without failing."""
|
||||
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
|
||||
|
||||
assert not backend.get_user_feature_flags(UserFactory())
|
||||
|
||||
|
||||
# ==============================
|
||||
# get_analytics
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
|
||||
"""Should return a no-op backend with no feature flag by default."""
|
||||
settings.ANALYTICS_BACKEND = None
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert not backend.get_user_feature_flags(UserFactory())
|
||||
|
||||
|
||||
def test_get_analytics_passes_feature_flags_to_noop(settings):
|
||||
"""Should forward the feature flags when no backend is configured."""
|
||||
settings.ANALYTICS_BACKEND = None
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
|
||||
"""Should instantiate the configured backend with its settings."""
|
||||
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start recording throttling.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...api.throttling import (
|
||||
StartRecordingDailyUserRateThrottle,
|
||||
StartRecordingUserRateThrottle,
|
||||
)
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def start_recording_throttle(monkeypatch):
|
||||
"""Lower the start recording rates."""
|
||||
rates = StartRecordingUserRateThrottle.THROTTLE_RATES
|
||||
monkeypatch.setitem(rates, "start_recording", "2/minute")
|
||||
monkeypatch.setitem(rates, "start_recording_daily", "100/day")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daily_start_recording_throttle(monkeypatch):
|
||||
"""Lower the daily start recording rate and control the clock."""
|
||||
rates = StartRecordingDailyUserRateThrottle.THROTTLE_RATES
|
||||
monkeypatch.setitem(rates, "start_recording", "100/minute")
|
||||
monkeypatch.setitem(rates, "start_recording_daily", "3/day")
|
||||
now = [1_000_000.0]
|
||||
monkeypatch.setattr(StartRecordingUserRateThrottle, "timer", lambda self: now[0])
|
||||
return now
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttled(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Starting a recording should return 429 when exceeding the per-minute rate."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _ in range(2):
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert 0 < int(response["Retry-After"]) <= 60
|
||||
assert Recording.objects.count() == 2
|
||||
assert mock_mediator_class.return_value.start.call_count == 2
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttle_counts_failed_attempts(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Failed recording starts should count toward the rate."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _ in range(2):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttle_per_user(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Starting a recording should succeed when another user is throttled."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
client = APIClient()
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
for _ in range(2):
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
other_user = UserFactory()
|
||||
client.force_login(other_user)
|
||||
room = RoomFactory(users=[(other_user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_throttle_does_not_limit_other_actions(
|
||||
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
|
||||
):
|
||||
"""Other room actions should succeed when starting a recording is throttled."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
rooms = RoomFactory.create_batch(3, users=[(user, "owner")])
|
||||
for room in rooms[:2]:
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{rooms[2].id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
Recording.objects.filter(room=rooms[0]).update(status="active")
|
||||
response = client.post(f"/api/v1.0/rooms/{rooms[0].id}/stop-recording/")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "New room"})
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.WorkerServiceMediator")
|
||||
@mock.patch("core.api.viewsets.get_worker_service")
|
||||
def test_start_recording_daily_throttled(
|
||||
mock_get_worker_service,
|
||||
mock_mediator_class,
|
||||
daily_start_recording_throttle,
|
||||
settings,
|
||||
):
|
||||
"""Starting a recording should return 429 when exceeding the daily rate."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
now = daily_start_recording_throttle
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for _ in range(3):
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
now[0] += 120
|
||||
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
assert 60 < int(response["Retry-After"]) <= 86400
|
||||
|
||||
now[0] += 86400
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
@@ -395,16 +395,6 @@ class Base(Configuration):
|
||||
environ_name="ROOM_CREATION_DAILY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"start_recording": values.Value(
|
||||
default="10/minute",
|
||||
environ_name="START_RECORDING_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"start_recording_daily": values.Value(
|
||||
default="100/day",
|
||||
environ_name="START_RECORDING_DAILY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"request_entry": values.Value(
|
||||
default="150/minute",
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
|
||||
Reference in New Issue
Block a user