mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-16 13:38:35 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ee27b175b | |||
| 162d663ee2 | |||
| 726acb0e66 | |||
| 062dc32400 | |||
| 1270885132 | |||
| 11c331dfa7 | |||
| cc9dae66db | |||
| 8d000fc6d9 | |||
| b7abd0ae6e | |||
| 40e4f17c65 | |||
| 77c5329f8a | |||
| c8ec1c8a9d | |||
| 01e004e272 | |||
| cbfb97eb54 | |||
| ac503b3ae5 | |||
| 52f119db02 | |||
| 387ae17c22 | |||
| 1eb6f0b9e7 | |||
| 047a4c9f3f | |||
| 6c4f0632b8 | |||
| ff7a1a4f33 | |||
| 7d1ce5f215 | |||
| 22ab89994b | |||
| e1a28f315d | |||
| dffcb83fff | |||
| c838229ec9 | |||
| ab40ec365d | |||
| 199c0297d4 | |||
| 089db20a2e | |||
| f0c08bea92 | |||
| 03e90b6178 | |||
| c53a2f8af4 | |||
| 7461cd28ce | |||
| 5723f29cef | |||
| 68a5e84f5d | |||
| b84ee74ee2 | |||
| 0dd2478c3e | |||
| 8f27b89d21 | |||
| b780d2845a | |||
| 751d029ac9 | |||
| aaa51a4457 | |||
| c8a3ef6f61 | |||
| 5d50671b3c | |||
| 8615bf879c | |||
| 186d16c46f | |||
| fb3ee56702 | |||
| 48c0cb320e | |||
| d810c9e0de | |||
| 134d9a188f | |||
| ea7188059d | |||
| b8958e6e87 | |||
| 23bb3c39d0 | |||
| e0ff28ed48 | |||
| 61e8b597dc | |||
| f3626a2dc6 | |||
| 41e937c1f1 | |||
| d988c72208 | |||
| b96d591db3 | |||
| bffc51ac4c | |||
| 22a1713c60 | |||
| c23f449520 | |||
| 0536896373 | |||
| f49c61d9bf | |||
| b593516802 | |||
| 1328098c45 | |||
| 58205f81d4 | |||
| b7892431be | |||
| 00a2bd9558 | |||
| bc003f928e | |||
| d756825fd7 | |||
| b01a47bfd7 | |||
| 06e73d7a5e | |||
| 6c69c3d6e3 | |||
| 7fd4d20ea7 |
@@ -8,6 +8,68 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.27.0] - 2026-08-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||||
|
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 📈(frontend) downgrade unreachable external home URL from error to event
|
||||||
|
- 🐛(frontend) handle 401 responses when syncing user preferences
|
||||||
|
- 🐛(frontend) harden speaker test against missing sinks and play errors
|
||||||
|
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||||
|
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||||
|
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
||||||
|
- 🐛(frontend) stop reporting screen-share denials as errors
|
||||||
|
- 🐛(frontend) generalize screen-share error modal beyond macOS
|
||||||
|
- 📈(frontend) stop double-reporting media device failures
|
||||||
|
|
||||||
|
## [1.26.0] - 2026-08-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 📈(frontend) capture media diagnostics on media errors
|
||||||
|
- ✨(frontend) add an audio gauge to the microphone select menu
|
||||||
|
- ✨(frontend) add a sound tester to the output select menu
|
||||||
|
- ✨(frontend) prompt for permissions when toggling a denied device
|
||||||
|
- ⚗️(frontend) capture console.error in PostHog
|
||||||
|
- 📈(frontend) snapshot media devices on the happy path
|
||||||
|
- 🚸(frontend) guide users when the OS blocks browser media access
|
||||||
|
- ✨(frontend) add a silent-microphone watcher on join and room screens
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) encapsulate error tracking behind a telemetry module
|
||||||
|
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
|
||||||
|
- 🔧(frontend) sync persisted device ids with the actual selected devices
|
||||||
|
- 💄(frontend) hide the ProConnect button on narrow viewports
|
||||||
|
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
|
||||||
|
- 🐛(frontend) fix permission store regression
|
||||||
|
- 🐛(frontend) handle missing device errors gracefully
|
||||||
|
- 🐛(frontend) display the meeting id in the join screen page title
|
||||||
|
|
||||||
|
## [1.25.2] - 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) serve MediaPipe assets under a versioned path
|
||||||
|
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
|
||||||
|
|
||||||
|
## [1.25.1] - 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
|
||||||
|
|
||||||
|
## [1.25.0] - 2026-08-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
- ✨(summary) report exception type in failure analytics
|
||||||
@@ -17,6 +79,9 @@ and this project adheres to
|
|||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||||
- ✨(frontend) let users set default configuration for generated links
|
- ✨(frontend) let users set default configuration for generated links
|
||||||
- ✨(frontend) expose media state to external gateways
|
- ✨(frontend) expose media state to external gateways
|
||||||
|
- ✨(frontend) add connection test feature
|
||||||
|
- ✨(sdk) allow passing a background color to the calendar iframe
|
||||||
|
- ✨(sdk) add a room configuration popup from CreateMeetingButton
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -42,6 +107,12 @@ and this project adheres to
|
|||||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||||
- 🚸(frontend) show two initials in the Avatar when possible
|
- 🚸(frontend) show two initials in the Avatar when possible
|
||||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
||||||
|
- 🐛(frontend) stop the installed app reopening the room it came from
|
||||||
|
- 🐛(backend) serialize lazy title in summary payload
|
||||||
|
- 💄(frontend) show pointer cursor on interactive switches
|
||||||
|
- 🐛(frontend) fix icon centering in the Switch primitive
|
||||||
|
- 🐛(frontend) keep Unicode initials intact in avatar
|
||||||
|
- 🐛(frontend) prevent concurrent settings updates from overwriting each other
|
||||||
|
|
||||||
## [1.24.0] - 2026-07-21
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ server {
|
|||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/mediapipe/wasm/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
@@ -104,3 +104,6 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
|||||||
APPLICATION_JWT_SECRET_KEY=devKey
|
APPLICATION_JWT_SECRET_KEY=devKey
|
||||||
APPLICATION_BASE_URL=http://localhost:3000
|
APPLICATION_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Diagnostics
|
||||||
|
CONNECTION_TEST_ENABLED = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ def get_frontend_configuration(request):
|
|||||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||||
},
|
},
|
||||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||||
|
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class FeatureFlag:
|
|||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
|
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||||
|
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
|||||||
"""Validate request entry data."""
|
"""Validate request entry data."""
|
||||||
|
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True)
|
||||||
|
participant_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
def validate_participant_id(self, value):
|
||||||
|
"""The id is a bearer credential: never trusted, only looked up."""
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
@@ -599,3 +604,25 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
|||||||
# useless bad requests
|
# useless bad requests
|
||||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||||
|
|
||||||
|
# todo if I can pass the max length directly to the char field
|
||||||
|
code = serializers.CharField(max_length=255, trim_whitespace=True)
|
||||||
|
|
||||||
|
def validate_code(self, value):
|
||||||
|
"""Reject codes whose length cannot match a generated one.
|
||||||
|
|
||||||
|
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
|
||||||
|
url-safe characters. Checking the length against the configured
|
||||||
|
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
|
||||||
|
before any cache lookup.
|
||||||
|
"""
|
||||||
|
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||||
|
|
||||||
|
if len(value) != expected_length:
|
||||||
|
raise serializers.ValidationError("Invalid transit code format.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Throttling modules for the API."""
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
def sentry_monitoring_throttle_failure(message):
|
def sentry_monitoring_throttle_failure(message):
|
||||||
"""Log when a failure occurs to detect rate limiting issues."""
|
"""Log when a failure occurs to detect rate limiting issues."""
|
||||||
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
def get_cache_key(self, request, view):
|
def get_cache_key(self, request, view):
|
||||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||||
|
|
||||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
Only throttle requests carrying a participant identifier. The
|
||||||
return None to skip throttling — the cookie will be set on the first
|
identifier is returned by the first request-entry response and
|
||||||
response, and throttling will apply from the second request onward.
|
echoed back by the client from the second request onward, which is
|
||||||
|
when throttling starts applying.
|
||||||
|
|
||||||
Keying on the cookie rather than the IP address prevents penalising
|
Keying on the identifier rather than the IP address prevents
|
||||||
multiple users behind the same NAT/proxy, and is consistent with how
|
penalising multiple users behind the same NAT/proxy, and is
|
||||||
LobbyService identifies participants.
|
consistent with how the lobby identifies participants.
|
||||||
|
|
||||||
Note: as per DRF documentation, application-level throttling is not a
|
Note: as per DRF documentation, application-level throttling is not a
|
||||||
security measure against brute-force or DoS attacks. This throttle exists
|
security measure against brute-force or DoS attacks. This throttle exists
|
||||||
@@ -58,10 +59,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
if request.user and request.user.is_authenticated:
|
if request.user and request.user.is_authenticated:
|
||||||
return None # Only throttle unauthenticated requests.
|
return None # Only throttle unauthenticated requests.
|
||||||
|
|
||||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return None
|
||||||
|
|
||||||
if participant_id is None:
|
participant_id = serializer.validated_data.get("participant_id")
|
||||||
return None # No throttling for cookieless requests
|
|
||||||
|
if not participant_id:
|
||||||
|
return None # No throttling for unidentified requests
|
||||||
|
|
||||||
return self.cache_format % {
|
return self.cache_format % {
|
||||||
"scope": self.scope,
|
"scope": self.scope,
|
||||||
@@ -85,3 +90,26 @@ class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
scope = "roomkit_join"
|
scope = "roomkit_join"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
|
||||||
|
"""Throttle authenticated users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous transit code exchange attempts.
|
||||||
|
|
||||||
|
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||||
|
best-effort. The security of the exchange rests on the codes'
|
||||||
|
entropy and single use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
scope = "exchange_access_token"
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -27,6 +29,9 @@ from rest_framework import (
|
|||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
permissions as drf_permissions,
|
||||||
|
)
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
response as drf_response,
|
response as drf_response,
|
||||||
)
|
)
|
||||||
@@ -36,6 +41,7 @@ from rest_framework import (
|
|||||||
from rest_framework.settings import api_settings
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
from core import analytics, enums, models, utils
|
from core import analytics, enums, models, utils
|
||||||
|
from core.api import throttling
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
@@ -69,6 +75,7 @@ from core.recording.worker.mediator import (
|
|||||||
WorkerServiceMediator,
|
WorkerServiceMediator,
|
||||||
)
|
)
|
||||||
from core.services.invitation import InvitationService
|
from core.services.invitation import InvitationService
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
LiveKitWebhookError,
|
LiveKitWebhookError,
|
||||||
@@ -93,7 +100,10 @@ from core.services.room_roles import (
|
|||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
)
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
from core.utils import generate_token
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from ..models import RoomAccessLevel
|
from ..models import RoomAccessLevel
|
||||||
@@ -229,6 +239,76 @@ class UserViewSet(
|
|||||||
self.serializer_class(request.user, context=context).data
|
self.serializer_class(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="exchange-access-token",
|
||||||
|
permission_classes=[],
|
||||||
|
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("user_access_token")
|
||||||
|
def exchange_access_token(self, request):
|
||||||
|
"""Exchange a single-use transit code for a user access token.
|
||||||
|
|
||||||
|
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||||
|
random string obtained through the external API and delivered to
|
||||||
|
the embedded frontend via a URL fragment, is the credential. Each
|
||||||
|
code can be exchanged exactly once (consuming it deletes it from
|
||||||
|
the cache); replaying a consumed code is denied and logged.
|
||||||
|
|
||||||
|
The issued JWT authenticates the user the code was minted for on
|
||||||
|
the whole core API, exactly like a session cookie would (similar
|
||||||
|
to lib-jitsi-meet's token authentication), and never appears in
|
||||||
|
any URL. Role-based permissions apply unchanged.
|
||||||
|
"""
|
||||||
|
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||||
|
|
||||||
|
if code_data is None:
|
||||||
|
logger.warning("Invalid, expired or already used transit code")
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"Invalid, expired or already used transit code."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-check the user at exchange time so that a deactivation after
|
||||||
|
# the transit code was minted is taken into account.
|
||||||
|
try:
|
||||||
|
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
|
||||||
|
except models.User.DoesNotExist as excpt:
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"This account can no longer access the application."
|
||||||
|
) from excpt
|
||||||
|
|
||||||
|
token_service = JwtTokenService(
|
||||||
|
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||||
|
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# todo - discuss wether it's the relevant scope
|
||||||
|
data = token_service.generate_jwt(
|
||||||
|
user,
|
||||||
|
"user:access",
|
||||||
|
{
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": code_data.get("client_id", "unknown"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||||
|
user.id,
|
||||||
|
code_data.get("client_id", "unknown"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(data)
|
||||||
|
|
||||||
|
|
||||||
class RoomViewSet(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
@@ -518,10 +598,7 @@ class RoomViewSet(
|
|||||||
request=request,
|
request=request,
|
||||||
**serializer.validated_data,
|
**serializer.validated_data,
|
||||||
)
|
)
|
||||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||||
lobby_service.prepare_response(response, participant.id)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -1563,3 +1640,68 @@ class FileViewSet(
|
|||||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnosticsViewSet(viewsets.ViewSet):
|
||||||
|
"""Endpoints helping users and support diagnose connectivity issues.
|
||||||
|
|
||||||
|
Diagnostics are grouped behind a single prefix so upcoming checks
|
||||||
|
(rtcstats collection, ICE candidate reports, etc.) can be added as new
|
||||||
|
actions rather than new top-level routes.
|
||||||
|
|
||||||
|
They are open to anonymous users: someone who cannot join a room is
|
||||||
|
exactly who needs to run a test, and they may well not be logged in.
|
||||||
|
Each action therefore carries its own throttle scope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [drf_permissions.AllowAny]
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["POST"],
|
||||||
|
url_path="connection",
|
||||||
|
url_name="connection",
|
||||||
|
throttle_classes=[
|
||||||
|
throttling.ConnectionTestUserRateThrottle,
|
||||||
|
throttling.ConnectionTestAnonRateThrottle,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("connection_test")
|
||||||
|
def connection(self, request):
|
||||||
|
"""Return a short-lived LiveKit token for an ephemeral test room.
|
||||||
|
|
||||||
|
Going through the room API is not an option here: it is tied to
|
||||||
|
registered meetings, lobby rules and longer-lived tokens. Each call
|
||||||
|
gets its own room so two people testing at the same time never meet.
|
||||||
|
"""
|
||||||
|
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
|
||||||
|
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||||
|
|
||||||
|
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
|
||||||
|
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
|
||||||
|
# is available.
|
||||||
|
if settings.CELERY_ENABLED:
|
||||||
|
max_age = (
|
||||||
|
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||||
|
+ settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS
|
||||||
|
)
|
||||||
|
delete_connection_test_room.apply_async(
|
||||||
|
args=[room],
|
||||||
|
countdown=max_age,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{
|
||||||
|
"livekit": {
|
||||||
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
"room": room,
|
||||||
|
"token": generate_token(
|
||||||
|
room=room,
|
||||||
|
user=request.user,
|
||||||
|
username="Connection Test",
|
||||||
|
ttl=timedelta(seconds=expires_in),
|
||||||
|
),
|
||||||
|
"expires_in": expires_in,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
|
|||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||||
|
|
||||||
|
|
||||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||||
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
return None # No authentication attempted
|
return None # No authentication attempted
|
||||||
|
|
||||||
parts = auth_header.split()
|
parts = auth_header.split()
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
||||||
|
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
|
||||||
|
# backend may recognize it.
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(parts) != 2:
|
||||||
raise exceptions.AuthenticationFailed(
|
raise exceptions.AuthenticationFailed(
|
||||||
"Authorization header must be: Bearer <token>"
|
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||||
)
|
)
|
||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""User access JWT authentication for the Meet core API.
|
||||||
|
|
||||||
|
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||||
|
session cookies are blocked) to authenticate requests on the core API with
|
||||||
|
a JWT, obtained by exchanging a single-use transit code (see
|
||||||
|
core.services.transit_code and the users exchange-access-token endpoint)
|
||||||
|
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||||
|
|
||||||
|
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||||
|
user, not to a resource: once authenticated, the request is treated
|
||||||
|
exactly like a session-authenticated one, and the existing role-based
|
||||||
|
permissions apply unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework import exceptions
|
||||||
|
|
||||||
|
from core.external_api.authentication import BaseJWTAuthentication
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||||
|
"""JWT authentication for user access tokens.
|
||||||
|
|
||||||
|
Validates user access tokens issued by the users exchange-access-token
|
||||||
|
endpoint and authenticates the user they were issued for. A bearer
|
||||||
|
token that does not verify against the user access token secret is
|
||||||
|
deferred to the next authentication backend; a token that does verify
|
||||||
|
but carries wrong claims is rejected.
|
||||||
|
|
||||||
|
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||||
|
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||||
|
returns None before reading the Authorization header, deferring every
|
||||||
|
request to the next authentication backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the backend with user access token settings."""
|
||||||
|
super().__init__(
|
||||||
|
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||||
|
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||||
|
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate the token type and the issuance-audit claim.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If the token verified against the user
|
||||||
|
access token secret but does not carry the expected claims.
|
||||||
|
"""
|
||||||
|
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||||
|
logger.warning("Wrong 'token_type' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
# Every token we issue carries the client_id of the application the
|
||||||
|
# transit code was minted for: its absence means the token does not
|
||||||
|
# come from the exchange endpoint.
|
||||||
|
if not payload.get("client_id"):
|
||||||
|
logger.warning("Missing 'client_id' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
@@ -86,6 +86,14 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HasRequiredUserScope(BaseScopePermission):
|
||||||
|
"""Scope-based permissions for the external user endpoints."""
|
||||||
|
|
||||||
|
scope_map = {
|
||||||
|
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RoomPermissions(permissions.BasePermission):
|
class RoomPermissions(permissions.BasePermission):
|
||||||
"""Permissions applying to the room API endpoint."""
|
"""Permissions applying to the room API endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -218,3 +219,62 @@ class RoomViewSet(
|
|||||||
"$set": {"email": self.request.user.email},
|
"$set": {"email": self.request.user.email},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.GenericViewSet):
|
||||||
|
"""Application-delegated API for user operations.
|
||||||
|
|
||||||
|
Provides JWT-authenticated access to user operations for external
|
||||||
|
applications acting on behalf of users. All operations are
|
||||||
|
scope-based. Meant to grow with the other user actions exposed to
|
||||||
|
third parties.
|
||||||
|
|
||||||
|
Supported operations:
|
||||||
|
- transit-code: Mint a single-use transit code for the delegated user
|
||||||
|
(requires 'users:session' scope)
|
||||||
|
"""
|
||||||
|
|
||||||
|
authentication_classes = [
|
||||||
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
]
|
||||||
|
permission_classes = [
|
||||||
|
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||||
|
]
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="transit-code",
|
||||||
|
url_name="transit-code",
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("user_access_token")
|
||||||
|
def generate_transit_code(self, request):
|
||||||
|
"""Mint a transit code for the delegated user.
|
||||||
|
|
||||||
|
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||||
|
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||||
|
frontend exchanges it once on
|
||||||
|
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||||
|
equivalent to session-cookie authentication and never exposed in a URL.
|
||||||
|
"""
|
||||||
|
auth_method = type(request.successful_authenticator).__name__
|
||||||
|
client_id = (request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
|
||||||
|
request.user.id,
|
||||||
|
client_id,
|
||||||
|
auth_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{
|
||||||
|
"transit_code": code,
|
||||||
|
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||||
|
},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='application',
|
||||||
|
name='scopes',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -795,6 +795,7 @@ class ApplicationScope(models.TextChoices):
|
|||||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||||
|
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||||
|
|
||||||
|
|
||||||
class Application(BaseModel):
|
class Application(BaseModel):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.translation import get_language, override
|
from django.utils.translation import get_language, gettext, override
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -121,7 +121,7 @@ class NotificationService:
|
|||||||
msg_plain = render_to_string(
|
msg_plain = render_to_string(
|
||||||
"mail/text/screen_recording.txt", personalized_context
|
"mail/text/screen_recording.txt", personalized_context
|
||||||
)
|
)
|
||||||
subject = str(_("Your recording is ready")) # Force translation
|
subject = gettext("Your recording is ready") # Force translation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(
|
send_mail(
|
||||||
@@ -192,7 +192,7 @@ class NotificationService:
|
|||||||
"""Generate title from context or return default."""
|
"""Generate title from context or return default."""
|
||||||
if recording_datetime is None:
|
if recording_datetime is None:
|
||||||
with override(locale):
|
with override(locale):
|
||||||
return _("Transcription")
|
return gettext("Transcription")
|
||||||
|
|
||||||
dt = recording_datetime
|
dt = recording_datetime
|
||||||
if owner_timezone:
|
if owner_timezone:
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
room_name = data.room.name or data.egress_info.room_name
|
room_name = data.room.name or data.egress_info.room_name
|
||||||
|
|
||||||
|
if self._is_connection_test_room(room_name):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring webhook event for connection test room '%s'.",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if self._filter_regex and not self._filter_regex.search(room_name):
|
if self._filter_regex and not self._filter_regex.search(room_name):
|
||||||
logger.info("Filtered webhook event for room '%s'", room_name)
|
logger.info("Filtered webhook event for room '%s'", room_name)
|
||||||
return
|
return
|
||||||
@@ -228,6 +235,11 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
|
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||||
|
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -86,23 +86,6 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
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
|
@staticmethod
|
||||||
def can_bypass_lobby(room, user, role) -> bool:
|
def can_bypass_lobby(room, user, role) -> bool:
|
||||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||||
@@ -135,6 +118,7 @@ class LobbyService:
|
|||||||
room: models.Room,
|
room: models.Room,
|
||||||
request,
|
request,
|
||||||
username: str,
|
username: str,
|
||||||
|
participant_id: Optional[uuid.UUID] = None,
|
||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
"""Request entry to a room for a participant.
|
"""Request entry to a room for a participant.
|
||||||
|
|
||||||
@@ -149,22 +133,20 @@ class LobbyService:
|
|||||||
5. If denied, do nothing.
|
5. If denied, do nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
participant_id = self._get_or_create_participant_id(request)
|
participant = None
|
||||||
participant = self._get_participant(room.id, participant_id)
|
if participant_id:
|
||||||
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
|
is_new_participant = participant is None
|
||||||
|
if is_new_participant:
|
||||||
|
participant = self._create_participant(room.id, username)
|
||||||
|
|
||||||
room_id = str(room.id)
|
room_id = str(room.id)
|
||||||
user_role = room.get_role(request.user)
|
user_role = room.get_role(request.user)
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||||
if participant is None:
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
participant = LobbyParticipant(
|
self._save_participant(room.id, participant)
|
||||||
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(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
@@ -172,18 +154,18 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant.id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
livekit_config = None
|
livekit_config = None
|
||||||
|
|
||||||
if participant is None:
|
if is_new_participant:
|
||||||
participant = self.enter(room.id, participant_id, username)
|
self._notify_entry_request(room_id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||||
self.refresh_waiting_status(room.id, participant_id)
|
self.refresh_waiting_status(room.id, participant.id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||||
# wrongly named, contains access token to join a room
|
# wrongly named, contains access token to join a room
|
||||||
@@ -193,7 +175,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant.id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,27 +192,36 @@ class LobbyService:
|
|||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
def enter(
|
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
"""Create and persist a new waiting participant.
|
||||||
) -> LobbyParticipant:
|
|
||||||
"""Add participant to waiting lobby.
|
|
||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
Participant identifiers are minted here, server-side, exclusively.
|
||||||
participants of the new entry request.
|
|
||||||
"""
|
"""
|
||||||
|
participant_id = str(uuid.uuid4())
|
||||||
color = utils.generate_color(participant_id)
|
|
||||||
|
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.WAITING,
|
status=LobbyParticipantStatus.WAITING,
|
||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=utils.generate_color(participant_id),
|
||||||
|
)
|
||||||
|
self._save_participant(room_id, participant)
|
||||||
|
|
||||||
|
return participant
|
||||||
|
|
||||||
|
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
|
||||||
|
"""Persist a participant in the room's lobby."""
|
||||||
|
cache.set(
|
||||||
|
self._get_cache_key(room_id, participant.id),
|
||||||
|
participant.to_dict(),
|
||||||
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_entry_request(room_id: str):
|
||||||
|
"""Notify room participants of a new entry request."""
|
||||||
try:
|
try:
|
||||||
utils.notify_participants(
|
utils.notify_participants(
|
||||||
room_name=str(room_id),
|
room_name=room_id,
|
||||||
notification_data={
|
notification_data={
|
||||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||||
},
|
},
|
||||||
@@ -239,15 +230,6 @@ class LobbyService:
|
|||||||
# If room not created yet, there is no participants to notify
|
# If room not created yet, there is no participants to notify
|
||||||
logger.exception("Failed to notify room participants")
|
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(
|
def _get_participant(
|
||||||
self, room_id: UUID, participant_id: str
|
self, room_id: UUID, participant_id: str
|
||||||
) -> Optional[LobbyParticipant]:
|
) -> Optional[LobbyParticipant]:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
|
DeleteRoomRequest,
|
||||||
ListRoomsRequest,
|
ListRoomsRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
@@ -88,3 +89,30 @@ class RoomManagement:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
@async_to_sync
|
||||||
|
async def delete_room(self, room_name: str):
|
||||||
|
"""Delete a LiveKit room and disconnect all participants.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RoomNotFoundException: the room does not exist in LiveKit.
|
||||||
|
RoomManagementException: the deletion otherwise fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lkapi = utils.create_livekit_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
|
||||||
|
logger.info("Deleted LiveKit room %s", room_name)
|
||||||
|
except TwirpError as e:
|
||||||
|
if e.code == "not_found":
|
||||||
|
logger.warning(
|
||||||
|
"Room %s not found in LiveKit, skipping deletion",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise RoomNotFoundException("Room does not exist") from e
|
||||||
|
|
||||||
|
logger.exception("Unexpected error deleting room %s", room_name)
|
||||||
|
raise RoomManagementException("Could not delete room") from e
|
||||||
|
finally:
|
||||||
|
await lkapi.aclose()
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Service handling the lifecycle of transit codes.
|
||||||
|
|
||||||
|
A transit code is an opaque, cryptographically random, single-use code
|
||||||
|
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||||
|
user access token on the core API without a session cookie. The code
|
||||||
|
carries no information by itself: everything it references (user, client)
|
||||||
|
is stored server-side in the cache, and consumed atomically on exchange.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
class TransitCodeService:
|
||||||
|
"""Create and consume single-use transit codes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_key(code):
|
||||||
|
"""Build the cache key for a code.
|
||||||
|
|
||||||
|
The code is hashed so that a dump of the cache never reveals
|
||||||
|
directly usable codes.
|
||||||
|
"""
|
||||||
|
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||||
|
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||||
|
|
||||||
|
def create_code(self, user, client_id="unknown"):
|
||||||
|
"""Generate a transit code for a user, and store it.
|
||||||
|
|
||||||
|
The code expires after TRANSIT_CODE_TTL seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The opaque code to hand to the client.
|
||||||
|
"""
|
||||||
|
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||||
|
# entropy: unguessable and safe to transit through a URL fragment.
|
||||||
|
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
self._cache_key(code),
|
||||||
|
{
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": client_id,
|
||||||
|
},
|
||||||
|
timeout=settings.TRANSIT_CODE_TTL,
|
||||||
|
)
|
||||||
|
|
||||||
|
return code
|
||||||
|
|
||||||
|
def consume_code(self, code):
|
||||||
|
"""Consume a transit code, enforcing single use.
|
||||||
|
|
||||||
|
The code is deleted from the cache upon consumption. `cache.delete`
|
||||||
|
returns whether a key was actually deleted, so if two requests race
|
||||||
|
on the same code, only one of them wins.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: The data stored at creation time ('user_id',
|
||||||
|
'client_id'), or None if the code is unknown, expired or
|
||||||
|
already consumed.
|
||||||
|
"""
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = self._cache_key(code)
|
||||||
|
data = cache.get(key)
|
||||||
|
|
||||||
|
if data is None or not cache.delete(key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Celery tasks for the core app."""
|
||||||
|
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"delete_connection_test_room",
|
||||||
|
"process_file_deletion",
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Tasks related to connection test rooms."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
from core.tasks._task import task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def delete_connection_test_room(room_name: str):
|
||||||
|
"""Force-delete an ephemeral connection-test room.
|
||||||
|
|
||||||
|
Used as a hard cap so a participant cannot keep an auto-refreshed
|
||||||
|
LiveKit session open indefinitely after requesting a test token.
|
||||||
|
"""
|
||||||
|
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
|
||||||
|
if not room_name.startswith(prefix):
|
||||||
|
logger.error(
|
||||||
|
"Refusing to delete room '%s': expected prefix '%s'.",
|
||||||
|
room_name,
|
||||||
|
prefix,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
RoomManagement().delete_room(room_name)
|
||||||
|
except RoomNotFoundException:
|
||||||
|
# Room may already be gone after empty/departure timeout.
|
||||||
|
logger.info("Connection test room '%s' already gone.", room_name)
|
||||||
|
except RoomManagementException:
|
||||||
|
logger.exception("Failed to delete connection test room '%s'.", room_name)
|
||||||
@@ -5,6 +5,7 @@ Test event notification.
|
|||||||
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
|
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import smtplib
|
import smtplib
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -418,3 +419,63 @@ def test_notify_summary_service_post_args_without_metadata(
|
|||||||
mock_is_feature_flag_enabled.assert_called_once_with(
|
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||||
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Regression test for a non-JSON-serializable payload when timestamps are missing.
|
||||||
|
|
||||||
|
When the LiveKit egress can no longer be found, ``_get_recording_timestamps``
|
||||||
|
returns ``(None, None)`` and ``_generate_title`` falls back to its default
|
||||||
|
title. That default must be a real ``str``: it used to return a lazy
|
||||||
|
``gettext_lazy`` proxy, which ``json.dumps`` cannot serialize, so the real
|
||||||
|
``requests.post(json=payload)`` call crashed in production with
|
||||||
|
``TypeError: Object of type __proxy__ is not JSON serializable``.
|
||||||
|
"""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = False
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(room__name="Daily")
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="fr-fr",
|
||||||
|
timezone="Europe/Paris",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
# Egress timestamps unavailable -> default-title branch in _generate_title.
|
||||||
|
mock_get_recording_timestamps.return_value = (None, None)
|
||||||
|
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-77"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
title = payload["push_to_docs_config"]["title"]
|
||||||
|
|
||||||
|
# The title must be a plain ``str``, not a lazy translation proxy...
|
||||||
|
assert isinstance(title, str)
|
||||||
|
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||||
|
json.dumps(payload)
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
Test rooms API endpoints in the Meet core app: create.
|
Test rooms API endpoints in the Meet core app: create.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -312,3 +317,38 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
room = Room.objects.get()
|
room = Room.objects.get()
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should create a room exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
room = Room.objects.get()
|
||||||
|
assert room.accesses.filter(role="owner", user=user).exists()
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
Test rooms API endpoints in the Meet core app: list.
|
Test rooms API endpoints in the Meet core app: list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
|
|||||||
assert len(content["results"]) == 3
|
assert len(content["results"]) == 3
|
||||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||||
assert content["previous"] is None
|
assert content["previous"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should list rooms exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
RoomFactory() # another user's room, not listed
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
assert response.data["results"][0]["id"] == str(room.id)
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ from rest_framework.test import APIClient
|
|||||||
from ... import utils
|
from ... import utils
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
from ...services.lobby import (
|
|
||||||
LobbyService,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
# Configure test settings for cookies and cache
|
# Configure test settings for cookies and cache
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Add two participants already waiting in the lobby
|
# Add two participants already waiting in the lobby
|
||||||
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
# Verify successful response
|
# Verify successful response
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set for the new participant
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
|
||||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
),
|
),
|
||||||
@@ -221,11 +209,6 @@ def test_request_entry_public_room(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "123"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
@@ -235,9 +218,10 @@ def test_request_entry_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
# The accepted participant is persisted, out of the waiting list
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert not lobby_keys
|
assert len(lobby_keys) == 1
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
@@ -247,7 +231,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -256,9 +239,8 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch(
|
||||||
LobbyService,
|
"core.services.lobby.uuid.uuid4",
|
||||||
"_get_or_create_participant_id",
|
|
||||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
),
|
),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -273,11 +255,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
@@ -287,9 +264,10 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
# The accepted participant is persisted, out of the waiting list
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert not lobby_keys
|
assert len(lobby_keys) == 1
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
@@ -297,7 +275,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Add a waiting participant to the room's lobby cache
|
# Add a waiting participant to the room's lobby cache
|
||||||
@@ -311,9 +288,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simulate a browser with existing participant cookie
|
# Simulate a returning participant echoing its identifier
|
||||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -322,16 +297,14 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
):
|
):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "user1"},
|
{
|
||||||
|
"username": "user1",
|
||||||
|
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
@@ -637,15 +610,14 @@ def test_list_waiting_participants_empty(settings):
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
)
|
)
|
||||||
def test_request_entry_throttling_anonymous_without_cookie(
|
def test_request_entry_throttling_anonymous_unidentified(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Anonymous users without a cookie should not be throttled."""
|
"""Requests without a participant identifier should not be throttled."""
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -654,9 +626,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
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(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
@@ -670,34 +639,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
)
|
)
|
||||||
def test_request_entry_throttling_anonymous_with_cookie(
|
def test_request_entry_throttling_anonymous_identified(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
"""Identified requests should be throttled after exceeding the rate limit."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||||
|
|
||||||
participant_id = str(uuid.uuid4())
|
participant_id = str(uuid.uuid4())
|
||||||
client.cookies.load({"mocked-cookie": participant_id})
|
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
@@ -716,7 +683,6 @@ def test_request_entry_throttling_authenticated_user(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -737,3 +703,124 @@ def test_request_entry_throttling_authenticated_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_with_participant_id(settings):
|
||||||
|
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
participant_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Echoing the identifier must be recognized as the same
|
||||||
|
# participant: no duplicate in the lobby
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == participant_id
|
||||||
|
assert response.json()["status"] == "waiting"
|
||||||
|
|
||||||
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_unknown_participant_id_not_seeded(settings):
|
||||||
|
"""An identifier unknown to the room's lobby must not be honored."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
forged_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": forged_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] != forged_id
|
||||||
|
|
||||||
|
# Nothing was stored under the forged identifier
|
||||||
|
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_participant_id_bound_to_room(settings):
|
||||||
|
"""An identifier minted for one room must not be honored in another."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
participant_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] != participant_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_legacy_cookie_ignored():
|
||||||
|
"""The retired cookie channel must not be honored anymore."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
legacy_participant_id = str(uuid.uuid4())
|
||||||
|
client.cookies["lobbyParticipantId"] = legacy_participant_id
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
returned_id = response.json()["id"]
|
||||||
|
assert returned_id != legacy_participant_id
|
||||||
|
uuid.UUID(returned_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_malformed_participant_id(settings):
|
||||||
|
"""A non-UUID identifier is rejected by the serializer with a 400."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": "../../../evil-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "participant_id" in response.json()
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from rest_framework.test import APIClient
|
|||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import (
|
||||||
|
LobbyParticipant,
|
||||||
|
LobbyParticipantStatus,
|
||||||
|
LobbyService,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -113,7 +117,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -153,7 +157,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -300,7 +304,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -330,7 +334,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -361,7 +365,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -381,7 +385,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -412,7 +416,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -440,7 +444,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -469,7 +473,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -849,7 +853,15 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
|
|||||||
participant_identity = str(uuid4())
|
participant_identity = str(uuid4())
|
||||||
|
|
||||||
# Create participant in lobby cache first
|
# Create participant in lobby cache first
|
||||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
LobbyService()._save_participant(
|
||||||
|
room.id,
|
||||||
|
LobbyParticipant(
|
||||||
|
id=participant_identity,
|
||||||
|
username="John doe",
|
||||||
|
status=LobbyParticipantStatus.WAITING,
|
||||||
|
color="#123456",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Accept participant
|
# Accept participant
|
||||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||||
@@ -1020,3 +1032,6 @@ def test_remove_participant_not_found(mock_livekit_client):
|
|||||||
assert response.data == {"error": "Participant not found"}
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -84,7 +87,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": False},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -101,7 +107,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -117,7 +126,10 @@ def test_toggle_hand_identity_derived_from_token(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -128,7 +140,9 @@ def test_toggle_hand_missing_raised_field(room, token):
|
|||||||
"""Test toggle hand with missing raised field returns 400."""
|
"""Test toggle hand with missing raised field returns 400."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
response = client.post(
|
||||||
|
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "raised" in response.data
|
assert "raised" in response.data
|
||||||
@@ -142,7 +156,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
|||||||
url,
|
url,
|
||||||
{"raised": "not-a-boolean"},
|
{"raised": "not-a-boolean"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -166,7 +180,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -181,7 +198,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
@@ -200,7 +220,7 @@ def test_toggle_hand_raise_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -220,7 +240,7 @@ def test_toggle_hand_lower_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": False},
|
{"raised": False},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -240,7 +260,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -257,7 +277,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -272,7 +295,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "Jane Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -286,7 +312,10 @@ def test_rename_participant_uses_identity_from_token(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -298,7 +327,7 @@ def test_rename_participant_empty_name(room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -309,7 +338,9 @@ def test_rename_participant_missing_name(room, token):
|
|||||||
"""Test rename with missing name field returns 400."""
|
"""Test rename with missing name field returns 400."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
response = client.post(
|
||||||
|
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "name" in response.data
|
assert "name" in response.data
|
||||||
@@ -320,7 +351,10 @@ def test_rename_participant_name_too_long(room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "a" * 256},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -348,7 +382,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -363,7 +397,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
@@ -382,7 +419,7 @@ def test_rename_participant_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -402,7 +439,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -419,7 +456,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -436,7 +473,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -462,7 +499,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -476,7 +513,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -490,7 +527,7 @@ def test_toggle_hand_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -504,7 +541,10 @@ def test_toggle_hand_room_not_found(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -519,7 +559,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -536,7 +579,7 @@ def test_rename_participant_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -550,7 +593,10 @@ def test_rename_participant_room_not_found(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -565,10 +611,16 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
assert response.data == {"error": "Participant not found"}
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -507,3 +510,40 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
role=str(user_access.role),
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should retrieve a room exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["id"] == str(room.id)
|
||||||
|
# Authenticated as the owner: privileged fields are included
|
||||||
|
assert response.data["pin_code"] == room.pin_code
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def test_start_subtitle_invalid_token():
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION="Bearer invalid-token",
|
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -128,7 +128,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@@ -148,7 +148,7 @@ def test_start_subtitle_valid_token(
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -178,7 +178,7 @@ def test_start_subtitle_twirp_error(
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
@@ -198,7 +198,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -219,10 +219,13 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "Invalid LiveKit token: Signature verification failed"
|
"detail": "Invalid LiveKit token: Signature verification failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_authenticated_with_user_access_token():
|
||||||
|
"""Role-based permissions apply unchanged with a user access token."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "member")])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
# A simple member cannot update the room
|
||||||
|
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# An administrator can
|
||||||
|
room.accesses.filter(user=user).update(role="administrator")
|
||||||
|
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.name == "new name"
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ def test_receive_unsupported_event(mock_receive, service):
|
|||||||
|
|
||||||
# Mock returned data with unsupported event type
|
# Mock returned data with unsupported event type
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(uuid.uuid4())
|
||||||
mock_data.event = "unsupported_event"
|
mock_data.event = "unsupported_event"
|
||||||
mock_receive.return_value = mock_data
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
@@ -823,3 +824,33 @@ def test_receive_filter_processes_matching_events(
|
|||||||
service.receive(mock_request)
|
service.receive(mock_request)
|
||||||
|
|
||||||
mock_handle_room_started.assert_called_once()
|
mock_handle_room_started.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
|
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||||
|
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||||
|
def test_receive_ignores_connection_test_room(
|
||||||
|
mock_handle_room_started,
|
||||||
|
mock_handle_room_finished,
|
||||||
|
mock_receive,
|
||||||
|
mock_livekit_config,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Should ignore all webhook events for connection test rooms in receive()."""
|
||||||
|
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
|
||||||
|
mock_request = mock.MagicMock()
|
||||||
|
mock_request.headers = {"Authorization": "test_token"}
|
||||||
|
mock_request.body = b"{}"
|
||||||
|
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
|
||||||
|
mock_data.event = "room_started"
|
||||||
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
|
service = LiveKitEventsService()
|
||||||
|
service.receive(mock_request)
|
||||||
|
|
||||||
|
mock_handle_room_started.assert_not_called()
|
||||||
|
mock_handle_room_finished.assert_not_called()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Test lobby service.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -11,7 +10,6 @@ from unittest import mock
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -135,59 +133,6 @@ def test_get_cache_key(lobby_service, participant_id):
|
|||||||
assert cache_key == expected_key
|
assert cache_key == expected_key
|
||||||
|
|
||||||
|
|
||||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
|
||||||
"""Test extracting participant ID from cookie."""
|
|
||||||
request = mock.Mock()
|
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
|
||||||
|
|
||||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
|
||||||
|
|
||||||
assert participant_id == "existing-id"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
|
||||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
|
||||||
"""Test creating new participant ID when cookie is missing."""
|
|
||||||
request = mock.Mock()
|
|
||||||
request.COOKIES = {}
|
|
||||||
|
|
||||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
|
||||||
|
|
||||||
assert participant_id == "generated-id"
|
|
||||||
mock_uuid4.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
|
||||||
"""Test response preparation with existing cookie."""
|
|
||||||
response = HttpResponse()
|
|
||||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
|
||||||
|
|
||||||
lobby_service.prepare_response(response, participant_id)
|
|
||||||
|
|
||||||
# Verify cookie wasn't set again
|
|
||||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
assert cookie.value == "existing-cookie"
|
|
||||||
assert cookie.value != participant_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
|
||||||
"""Test response preparation with new cookie."""
|
|
||||||
response = HttpResponse()
|
|
||||||
|
|
||||||
lobby_service.prepare_response(response, participant_id)
|
|
||||||
|
|
||||||
# Verify cookie was set
|
|
||||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == participant_id
|
|
||||||
assert cookie["httponly"] is True
|
|
||||||
assert cookie["secure"] is True
|
|
||||||
assert cookie["samesite"] == "Lax"
|
|
||||||
|
|
||||||
# It's a session cookies (no max_age specified):
|
|
||||||
assert not cookie["max-age"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_public_room(lobby_service):
|
def test_can_bypass_lobby_public_room(lobby_service):
|
||||||
"""Should return True for public rooms regardless of user auth and role."""
|
"""Should return True for public rooms regardless of user auth and role."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -266,11 +211,12 @@ def test_request_entry_public_room(
|
|||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, request, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
@@ -304,11 +250,12 @@ def test_request_entry_trusted_room(
|
|||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, request, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
@@ -325,18 +272,19 @@ def test_request_entry_trusted_room(
|
|||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._create_participant")
|
||||||
def test_request_entry_new_participant(
|
def test_request_entry_new_participant(
|
||||||
mock_enter, lobby_service, participant_id, username
|
mock_create, mock_notify, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a new participant."""
|
"""A new participant gets a server-minted identifier - any provided
|
||||||
|
one is unknown to the lobby and therefore discarded - and the room is
|
||||||
|
notified of the entry request."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||||
|
|
||||||
participant_data = LobbyParticipant(
|
participant_data = LobbyParticipant(
|
||||||
@@ -345,14 +293,20 @@ def test_request_entry_new_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
mock_enter.return_value = participant_data
|
mock_create.return_value = participant_data
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
forged_id = str(uuid.uuid4())
|
||||||
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, request, username, participant_id=forged_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant == participant_data
|
assert participant == participant_data
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
# The provided identifier was looked up, found unknown, and replaced
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
# by a freshly minted participant
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
|
||||||
|
mock_create.assert_called_once_with(room.id, username)
|
||||||
|
mock_notify.assert_called_once_with(str(room.id))
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||||
@@ -361,7 +315,6 @@ def test_request_entry_waiting_participant(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -372,10 +325,11 @@ def test_request_entry_waiting_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, request, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
@@ -390,7 +344,6 @@ def test_request_entry_accepted_participant(
|
|||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = AnonymousUser()
|
request.user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -400,12 +353,13 @@ def test_request_entry_accepted_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, request, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
@@ -428,7 +382,6 @@ def test_request_entry_participant_with_role(
|
|||||||
"""Test requesting entry for a participant with a role on the room."""
|
"""Test requesting entry for a participant with a role on the room."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = UserFactory()
|
request.user = UserFactory()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -440,12 +393,13 @@ def test_request_entry_participant_with_role(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, request, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
@@ -472,73 +426,47 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
def test_create_participant(
|
||||||
def test_enter_success(
|
|
||||||
mock_notify,
|
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
lobby_service,
|
lobby_service,
|
||||||
participant_id,
|
|
||||||
username,
|
username,
|
||||||
|
settings,
|
||||||
):
|
):
|
||||||
"""Test successful participant entry."""
|
"""A created participant is waiting, colored, and persisted."""
|
||||||
mock_generate_color.return_value = "#123456"
|
mock_generate_color.return_value = "#123456"
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
participant = lobby_service.enter(room.id, participant_id, username)
|
participant = lobby_service._create_participant(room.id, username)
|
||||||
|
|
||||||
mock_generate_color.assert_called_once_with(participant_id)
|
# The identifier is minted server-side
|
||||||
|
uuid.UUID(participant.id)
|
||||||
|
mock_generate_color.assert_called_once_with(participant.id)
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
assert participant.username == username
|
assert participant.username == username
|
||||||
assert participant.id == participant_id
|
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
|
||||||
|
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key",
|
"mocked_cache_key",
|
||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
mock_notify.assert_called_once_with(
|
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
|
||||||
@mock.patch("core.utils.generate_color")
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_enter_with_notification_error(
|
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
||||||
mock_notify,
|
"""A notification error must not break the entry request flow."""
|
||||||
mock_generate_color,
|
|
||||||
mock_cache,
|
|
||||||
lobby_service,
|
|
||||||
participant_id,
|
|
||||||
username,
|
|
||||||
):
|
|
||||||
"""Test participant entry with notification error."""
|
|
||||||
mock_generate_color.return_value = "#123456"
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
lobby_service._notify_entry_request("room-id")
|
||||||
participant = lobby_service.enter(room.id, participant_id, username)
|
|
||||||
|
|
||||||
mock_generate_color.assert_called_once_with(participant_id)
|
mock_notify.assert_called_once_with(
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||||
assert participant.username == username
|
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
mock_cache.set.assert_called_once_with(
|
|
||||||
"mocked_cache_key",
|
|
||||||
participant.to_dict(),
|
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for the RoomManagement service."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
|
def test_delete_room_calls_livekit(mock_create_livekit_client):
|
||||||
|
"""DeleteRoom is forwarded to the LiveKit API."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock()
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
|
mock_api.room.delete_room.assert_awaited_once()
|
||||||
|
request = mock_api.room.delete_room.await_args.args[0]
|
||||||
|
assert request.room == "room-abc"
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
|
def test_delete_room_raises_not_found(mock_create_livekit_client):
|
||||||
|
"""Missing rooms raise RoomNotFoundException."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError("not_found", "room not found", status=404)
|
||||||
|
)
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
with pytest.raises(RoomNotFoundException):
|
||||||
|
RoomManagement().delete_room("missing-room")
|
||||||
|
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||||
|
def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||||
|
"""Unexpected Twirp errors raise RoomManagementException."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError("internal", "boom", status=500)
|
||||||
|
)
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
with pytest.raises(RoomManagementException):
|
||||||
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the TransitCodeService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_code_returns_unique_opaque_codes():
|
||||||
|
"""Each created code should be a distinct high-entropy string."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
codes = {service.create_code(user) for _ in range(5)}
|
||||||
|
|
||||||
|
assert len(codes) == 5
|
||||||
|
for code in codes:
|
||||||
|
assert len(code) >= 43
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_code_returns_stored_data_once():
|
||||||
|
"""Consuming a code should return its data exactly once."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
code = service.create_code(user, client_id="my-app")
|
||||||
|
|
||||||
|
assert service.consume_code(code) == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "my-app",
|
||||||
|
}
|
||||||
|
# Single use: a second consumption fails
|
||||||
|
assert service.consume_code(code) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_code_unknown_or_empty():
|
||||||
|
"""Unknown or empty codes should not be consumable."""
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
assert service.consume_code("unknown-code") is None
|
||||||
|
assert service.consume_code("") is None
|
||||||
|
assert service.consume_code(None) is None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for connection test Celery tasks."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_calls_room_management(mock_delete_room, settings):
|
||||||
|
"""RoomManagement.delete_room is called for connection-test rooms."""
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
delete_connection_test_room("connection-test-abc")
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with("connection-test-abc")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room, settings):
|
||||||
|
"""Refuse to delete rooms outside the connection-test namespace."""
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
delete_connection_test_room("production-room")
|
||||||
|
|
||||||
|
mock_delete_room.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room, settings):
|
||||||
|
"""Missing rooms are treated as already cleaned up."""
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
|
||||||
|
|
||||||
|
delete_connection_test_room("connection-test-gone")
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with("connection-test-gone")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||||
|
def test_delete_connection_test_room_logs_other_failures(mock_delete_room, settings):
|
||||||
|
"""Unexpected LiveKit failures are swallowed after logging."""
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
|
||||||
|
|
||||||
|
delete_connection_test_room("connection-test-fail")
|
||||||
|
|
||||||
|
mock_delete_room.assert_called_once_with("connection-test-fail")
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Test diagnostics API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.api.throttling import (
|
||||||
|
ConnectionTestAnonRateThrottle,
|
||||||
|
ConnectionTestUserRateThrottle,
|
||||||
|
)
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_diagnostics_connection_url():
|
||||||
|
"""The connection check is exposed under the diagnostics namespace."""
|
||||||
|
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_diagnostics_connection_rejects_get():
|
||||||
|
"""Only POST is exposed, the endpoint has no side effect to trigger."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, client):
|
||||||
|
"""Each request gets a dedicated room and a short-lived token."""
|
||||||
|
|
||||||
|
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
|
||||||
|
response_a = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
response_b = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response_a.status_code == 200
|
||||||
|
assert response_b.status_code == 200
|
||||||
|
|
||||||
|
data_a = response_a.json()
|
||||||
|
data_b = response_b.json()
|
||||||
|
|
||||||
|
room_a = data_a["livekit"]["room"]
|
||||||
|
room_b = data_b["livekit"]["room"]
|
||||||
|
|
||||||
|
assert room_a.startswith("connection-test-")
|
||||||
|
assert room_b.startswith("connection-test-")
|
||||||
|
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||||
|
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||||
|
assert room_a != room_b
|
||||||
|
assert data_a["livekit"]["url"]
|
||||||
|
assert data_a["livekit"]["token"]
|
||||||
|
assert data_a["livekit"]["expires_in"] == 600
|
||||||
|
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, client):
|
||||||
|
"""Connection test tokens expire quickly for users."""
|
||||||
|
|
||||||
|
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
config = response.json()["livekit"]
|
||||||
|
payload = jwt.decode(
|
||||||
|
config["token"],
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_exp": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config["expires_in"] == 300
|
||||||
|
assert payload["video"]["room"] == config["room"]
|
||||||
|
assert payload["name"] == "Connection Test"
|
||||||
|
assert payload["video"]["roomAdmin"] is False
|
||||||
|
assert payload["exp"] - payload["nbf"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings()
|
||||||
|
def test_api_diagnostics_connection_token_for_authenticated_user(settings, client):
|
||||||
|
"""Logged-in users get a token bound to their own identity."""
|
||||||
|
|
||||||
|
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
payload = jwt.decode(
|
||||||
|
response.json()["livekit"]["token"],
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_exp": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["sub"] == str(user.sub)
|
||||||
|
assert payload["video"]["roomAdmin"] is False
|
||||||
|
assert payload["exp"] - payload["nbf"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
|
||||||
|
def test_api_diagnostics_connection_schedules_room_deletion(
|
||||||
|
mock_apply_async, settings, client
|
||||||
|
):
|
||||||
|
"""When Celery is enabled, schedule a hard room delete after max age."""
|
||||||
|
|
||||||
|
settings.CELERY_ENABLED = True
|
||||||
|
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||||
|
settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = 10
|
||||||
|
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
room = response.json()["livekit"]["room"]
|
||||||
|
mock_apply_async.assert_called_once_with(args=[room], countdown=310)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
|
||||||
|
def test_api_diagnostics_connection_skips_room_deletion_without_celery(
|
||||||
|
mock_apply_async, settings, client
|
||||||
|
):
|
||||||
|
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
|
||||||
|
|
||||||
|
settings.CELERY_ENABLED = False
|
||||||
|
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"throttle_class",
|
||||||
|
[ConnectionTestAnonRateThrottle, ConnectionTestUserRateThrottle],
|
||||||
|
)
|
||||||
|
def test_api_diagnostics_connection_is_throttled(throttle_class, client):
|
||||||
|
"""Both throttles stay wired to the action once routed through the viewset."""
|
||||||
|
with (
|
||||||
|
mock.patch.object(throttle_class, "allow_request", return_value=False),
|
||||||
|
mock.patch.object(throttle_class, "wait", return_value=42),
|
||||||
|
):
|
||||||
|
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
|
||||||
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_diagnostics_connection_feature_flag(client, settings):
|
||||||
|
"""Should return a not found error when the connection diagnostics feature is disabled."""
|
||||||
|
|
||||||
|
settings.CONNECTION_TEST_ENABLED = False
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Tests for user access JWT authentication on the core API.
|
||||||
|
|
||||||
|
The token authenticates the user on the whole API, exactly like a session
|
||||||
|
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||||
|
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||||
|
with a user access token lives in the room test files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import RoomFactory, UserFactory
|
||||||
|
from core.models import RoleChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user, **overrides):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
payload = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_users_me():
|
||||||
|
"""A user access token should authenticate the user on /users/me/."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["email"] == user.email
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_expired():
|
||||||
|
"""An expired user access token should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = generate_user_access_token(
|
||||||
|
user,
|
||||||
|
iat=now - timedelta(hours=3),
|
||||||
|
exp=now - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "token expired" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_invalid_signature():
|
||||||
|
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=600),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
},
|
||||||
|
"wrong-secret-key-padded-for-minimum-len!",
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_wrong_token_type():
|
||||||
|
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, token_type="addons")
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token type" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_missing_client_id_claim():
|
||||||
|
"""A token without the issuance-audit claim should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, client_id=None)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token claims" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_inactive_user():
|
||||||
|
"""A user access token for an inactive user should be rejected."""
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_feature_disabled(settings):
|
||||||
|
"""When the feature is disabled, user access tokens should be ignored."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_does_not_break_session_authentication():
|
||||||
|
"""A session-authenticated user should keep full access to the API."""
|
||||||
|
user = UserFactory()
|
||||||
|
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||||
|
"""An application-delegation JWT must not authenticate on the core API."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||||
|
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=600),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "some-client",
|
||||||
|
"delegated": True,
|
||||||
|
"scope": "rooms:retrieve",
|
||||||
|
},
|
||||||
|
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
# The user token backend must defer (wrong signature) and the request
|
||||||
|
# must end up unauthenticated.
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Test users API endpoints in the Meet core app: exchange transit code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_user_access_token(token, settings):
|
||||||
|
"""Decode a user access token with the token secret."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unknown_code(settings):
|
||||||
|
"""Generate a well-formed code that was never stored."""
|
||||||
|
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Return an anonymous API client with a random source IP.
|
||||||
|
|
||||||
|
A fresh IP per test isolates the anonymous throttle history, both
|
||||||
|
between the tests of this module and between test runs.
|
||||||
|
"""
|
||||||
|
# `secrets` rather than `random`: the global random module is seeded
|
||||||
|
# deterministically by the factories, its sequence repeats across runs.
|
||||||
|
remote_addr = (
|
||||||
|
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||||
|
f".{secrets.randbelow(254) + 1}"
|
||||||
|
)
|
||||||
|
return APIClient(REMOTE_ADDR=remote_addr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_missing_code(client):
|
||||||
|
"""The exchange endpoint should validate its input."""
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "code" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_malformed_code(client):
|
||||||
|
"""A code whose length cannot match a generated one should be a 400."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": "not-a-valid-code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "invalid transit code format" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_unknown_code(client, settings):
|
||||||
|
"""A well-formed but unknown code should be denied."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "invalid, expired or already used" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_success(client, settings):
|
||||||
|
"""A valid transit code should be exchangeable for an access token."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user, client_id="my-app")
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||||
|
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||||
|
assert response.data["scope"] == "user:access"
|
||||||
|
|
||||||
|
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||||
|
assert payload["token_type"] == "user_access"
|
||||||
|
assert payload["user_id"] == str(user.id)
|
||||||
|
assert payload["client_id"] == "my-app"
|
||||||
|
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_single_use(client):
|
||||||
|
"""A transit code should be exchangeable exactly once."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Replaying the same code must be denied
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "invalid, expired or already used" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_inactive_user(client):
|
||||||
|
"""A code minted for a now-inactive user should be denied."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user)
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer access" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_feature_disabled(client, settings):
|
||||||
|
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
code = TransitCodeService().create_code(user)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_throttled(client, settings):
|
||||||
|
"""Anonymous exchange attempts should be rate limited."""
|
||||||
|
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||||
|
initial_rate = throttle_rates["exchange_access_token"]
|
||||||
|
# The rates dict is mutated in place: restore it explicitly, the
|
||||||
|
# `settings` fixture only rolls back attribute assignments.
|
||||||
|
throttle_rates["exchange_access_token"] = "2/minute"
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
finally:
|
||||||
|
throttle_rates["exchange_access_token"] = initial_rate
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Tests for external API /users endpoints (transit codes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test_token(user, scopes):
|
||||||
|
"""Generate a valid application JWT token for testing."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
scope_string = " ".join(scopes)
|
||||||
|
|
||||||
|
application = ApplicationFactory()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||||
|
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now
|
||||||
|
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
|
"client_id": str(application.client_id),
|
||||||
|
"scope": scope_string,
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"delegated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_requires_authentication():
|
||||||
|
"""Minting a transit code without authentication should return 401."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_missing_scope():
|
||||||
|
"""A token without the 'users:session' scope should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "users:session" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_success(settings):
|
||||||
|
"""A delegated user with the scope should be able to mint a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||||
|
|
||||||
|
code = response.data["transit_code"]
|
||||||
|
# Opaque, high-entropy random string
|
||||||
|
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||||
|
|
||||||
|
# The code is stored server-side and references the delegated user
|
||||||
|
code_data = TransitCodeService().consume_code(code)
|
||||||
|
assert code_data == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": mock.ANY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_with_rs_token():
|
||||||
|
"""A resource-server-authenticated user should be able to mint a code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# todo - add a decorator instead
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
"authenticate",
|
||||||
|
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||||
|
) as mock_rs_authenticate:
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
mock_rs_authenticate.assert_called_once()
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
code_data = TransitCodeService().consume_code(response.data["transit_code"])
|
||||||
|
assert code_data == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "rs-client",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_with_rs_token_missing_scope():
|
||||||
|
"""A resource server token without the scope should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# todo - add a decorator instead
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
"authenticate",
|
||||||
|
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
|
||||||
|
):
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "users:session" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_feature_disabled(settings):
|
||||||
|
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_inactive_user():
|
||||||
|
"""An inactive user should not be able to mint a transit code."""
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
|
||||||
@@ -30,6 +30,11 @@ router.register(
|
|||||||
addons_viewsets.SessionViewSet,
|
addons_viewsets.SessionViewSet,
|
||||||
basename="addons_sessions",
|
basename="addons_sessions",
|
||||||
)
|
)
|
||||||
|
router.register(
|
||||||
|
"diagnostics",
|
||||||
|
viewsets.DiagnosticsViewSet,
|
||||||
|
basename="diagnostics",
|
||||||
|
)
|
||||||
|
|
||||||
# - External API
|
# - External API
|
||||||
external_router = SimpleRouter()
|
external_router = SimpleRouter()
|
||||||
@@ -43,6 +48,11 @@ external_router.register(
|
|||||||
external_viewsets.RoomViewSet,
|
external_viewsets.RoomViewSet,
|
||||||
basename="external_room",
|
basename="external_room",
|
||||||
)
|
)
|
||||||
|
external_router.register(
|
||||||
|
"users",
|
||||||
|
external_viewsets.UserViewSet,
|
||||||
|
basename="external_user",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import mimetypes
|
|||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -67,6 +68,7 @@ def generate_token( # noqa: PLR0917
|
|||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
ttl: Optional[timedelta] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ def generate_token( # noqa: PLR0917
|
|||||||
role (Optional[str]): Room's access role if any
|
role (Optional[str]): Room's access role if any
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
@@ -135,6 +138,8 @@ def generate_token( # noqa: PLR0917
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if ttl is not None:
|
||||||
|
token = token.with_ttl(ttl)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||||
"core.authentication.backends.SessionAuthenticationWith401",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
@@ -344,6 +345,11 @@ class Base(Configuration):
|
|||||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"exchange_access_token": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
"creation_callback": values.Value(
|
"creation_callback": values.Value(
|
||||||
default="600/minute",
|
default="600/minute",
|
||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
@@ -354,6 +360,11 @@ class Base(Configuration):
|
|||||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"connection_test": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -660,6 +671,30 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
CONNECTION_TEST_ENABLED = values.BooleanValue(
|
||||||
|
environ_name="CONNECTION_TEST_ENABLED",
|
||||||
|
environ_prefix=None,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||||
|
300,
|
||||||
|
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# The effective room max age is always computed as
|
||||||
|
# CONNECTION_TEST_TOKEN_TTL_SECONDS + this value. Token expiration does
|
||||||
|
# not automatically delete rooms, so once that age is reached, the
|
||||||
|
# cleanup worker will explicitly delete the room if it still exists.
|
||||||
|
CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = values.PositiveIntegerValue(
|
||||||
|
10,
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX = values.Value(
|
||||||
|
"connection-test",
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -846,11 +881,6 @@ class Base(Configuration):
|
|||||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
LOBBY_COOKIE_NAME = values.Value(
|
|
||||||
"lobbyParticipantId",
|
|
||||||
environ_name="LOBBY_COOKIE_NAME",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calendar integrations
|
# Calendar integrations
|
||||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
@@ -973,6 +1003,61 @@ class Base(Configuration):
|
|||||||
environ_name="APPLICATION_BASE_URL",
|
environ_name="APPLICATION_BASE_URL",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User access tokens (embedded frontend / iframe support)
|
||||||
|
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||||
|
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||||
|
"HS256",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||||
|
"lasuite-meet",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Lifetime of the user access token obtained through the exchange
|
||||||
|
# endpoint. It never transits through a URL, so it can cover a full
|
||||||
|
# meeting (default: 2 hours).
|
||||||
|
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||||
|
7200,
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Lifetime of the single-use transit code handed to the frontend
|
||||||
|
# through a URL fragment. Kept very short by design: it must only
|
||||||
|
# survive the redirect and the exchange call.
|
||||||
|
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||||
|
60,
|
||||||
|
environ_name="TRANSIT_CODE_TTL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||||
|
"transit-code",
|
||||||
|
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||||
|
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||||
|
48,
|
||||||
|
environ_name="TRANSIT_CODE_NBYTES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||||
|
"Bearer",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||||
@@ -1269,6 +1354,12 @@ class Test(Base):
|
|||||||
ADDONS_ENABLED = True
|
ADDONS_ENABLED = True
|
||||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
|
USER_ACCESS_TOKEN_ENABLED = True
|
||||||
|
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||||
|
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||||
|
|
||||||
|
CONNECTION_TEST_ENABLED = True
|
||||||
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ WORKDIR /home/frontend
|
|||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
|
||||||
|
ARG VITE_APP_TITLE
|
||||||
|
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ server {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location ^~ /assets/mediapipe/wasm/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
|||||||
Generated
+4
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.27.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,14 +15,13 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.1",
|
||||||
@@ -1049,18 +1048,12 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
|
||||||
"version": "0.10.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
|
||||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -4716,15 +4709,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/derive-valtio": {
|
|
||||||
"version": "0.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
|
|
||||||
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"valtio": ">=2.0.0-rc.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.24.0",
|
"version": "1.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -22,14 +22,13 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.1",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "/",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
+24
-17
@@ -12,6 +12,7 @@ import { routes } from './routes'
|
|||||||
import './i18n/init'
|
import './i18n/init'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { AppInitialization } from '@/components/AppInitialization'
|
import { AppInitialization } from '@/components/AppInitialization'
|
||||||
|
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||||
|
|
||||||
@@ -24,23 +25,29 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{!isSDKContext && <AppInitialization />}
|
<TransitCodeGate>
|
||||||
<Suspense fallback={null}>
|
{!isSDKContext && <AppInitialization />}
|
||||||
<I18nProvider locale={i18n.language}>
|
<Suspense fallback={null}>
|
||||||
<Layout>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Switch>
|
<Layout>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Switch>
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
))}
|
<Route
|
||||||
<Route component={NotFoundScreen} />
|
key={i}
|
||||||
</Switch>
|
path={route.path}
|
||||||
</Layout>
|
component={route.Component}
|
||||||
<ReactQueryDevtools
|
/>
|
||||||
initialIsOpen={false}
|
))}
|
||||||
buttonPosition="bottom-left"
|
<Route component={NotFoundScreen} />
|
||||||
/>
|
</Switch>
|
||||||
</I18nProvider>
|
</Layout>
|
||||||
</Suspense>
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
buttonPosition="bottom-left"
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
</Suspense>
|
||||||
|
</TransitCodeGate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { ApiError } from './ApiError'
|
import { ApiError } from './ApiError'
|
||||||
import { apiUrl } from './apiUrl'
|
import { apiUrl } from './apiUrl'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
export const fetchApi = async <T = Record<string, unknown>>(
|
export const fetchApi = async <T = Record<string, unknown>>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const csrfToken = getCsrfToken()
|
const csrfToken = getCsrfToken()
|
||||||
|
// Embedded (iframe) mode: the user access token obtained through the
|
||||||
|
// transit code exchange authenticates requests in place of the session
|
||||||
|
// cookie, which is blocked in third-party contexts.
|
||||||
|
const accessToken = getAccessToken()
|
||||||
const response = await fetch(apiUrl(url), {
|
const response = await fetch(apiUrl(url), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||||
|
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export interface ApiConfig {
|
|||||||
subtitle: {
|
subtitle: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
diagnostics: {
|
||||||
|
connection_test_enabled?: boolean
|
||||||
|
}
|
||||||
telephony: {
|
telephony: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
international_phone_number?: string
|
international_phone_number?: string
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
import React from 'react'
|
import React, { useLayoutEffect, useMemo } from 'react'
|
||||||
|
|
||||||
const avatar = cva({
|
const avatar = cva({
|
||||||
base: {
|
base: {
|
||||||
@@ -28,13 +28,34 @@ const avatar = cva({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Instantiating a segmenter is expensive; create it once and reuse it.
|
||||||
|
const graphemeSegmenter =
|
||||||
|
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||||
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first user-perceived character. Some Unicode characters span
|
||||||
|
* multiple UTF-16 code units, so a naive index into the string can split them
|
||||||
|
* and yield a broken glyph.
|
||||||
|
*/
|
||||||
|
const getFirstGrapheme = (value: string): string => {
|
||||||
|
if (!value) return ''
|
||||||
|
if (graphemeSegmenter) {
|
||||||
|
const [first] = graphemeSegmenter.segment(value)
|
||||||
|
return first?.segment ?? ''
|
||||||
|
}
|
||||||
|
// Fallback: keeps single code points intact (including surrogate pairs).
|
||||||
|
return Array.from(value)[0] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
const getInitials = (name?: string): string => {
|
const getInitials = (name?: string): string => {
|
||||||
if (!name) return ''
|
if (!name) return ''
|
||||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||||
if (words.length === 0) return ''
|
if (words.length === 0) return ''
|
||||||
const first = words[0].charAt(0)
|
const first = getFirstGrapheme(words[0])
|
||||||
const second = words.length > 1 ? words[1].charAt(0) : ''
|
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
|
||||||
return (first + second).toUpperCase()
|
return (first + second).toLocaleUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
@@ -44,7 +65,37 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
|
|
||||||
export const Avatar = React.memo(
|
export const Avatar = React.memo(
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
const initials = getInitials(name)
|
const initials = useMemo(() => getInitials(name), [name])
|
||||||
|
const textRef = React.useRef<SVGTextElement>(null)
|
||||||
|
const [offsetY, setOffsetY] = React.useState(0)
|
||||||
|
|
||||||
|
// Optically center the initials: measure the ink bounding box of the
|
||||||
|
// rendered glyphs and shift them so the box's center sits at the middle
|
||||||
|
// of the viewBox. Works for any font, weight or glyph shape, unlike a
|
||||||
|
// hand-tuned dy offset. getBBox() is in local (pre-transform)
|
||||||
|
// coordinates, so applying the translation never changes the measure.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const text = textRef.current
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
const center = () => {
|
||||||
|
const box = text.getBBox()
|
||||||
|
// A hidden element measures as an empty box; keep the default then.
|
||||||
|
if (box.height === 0) return
|
||||||
|
setOffsetY(50 - (box.y + box.height / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
center()
|
||||||
|
// Glyph metrics can change once webfonts finish loading.
|
||||||
|
let cancelled = false
|
||||||
|
document.fonts?.ready.then(() => {
|
||||||
|
if (!cancelled) center()
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [initials])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
@@ -57,16 +108,17 @@ export const Avatar = React.memo(
|
|||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
|
ref={textRef}
|
||||||
x="50"
|
x="50"
|
||||||
y="50"
|
y="50"
|
||||||
dy="-0.08em"
|
transform={`translate(0 ${offsetY})`}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
{initials.toUpperCase()}
|
{initials}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,24 +2,31 @@ import { Button } from '@/primitives'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||||
|
|
||||||
export const SoundTester = () => {
|
export const SoundTester = () => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
const { devices, activeDeviceId } = useMediaDeviceSelect({
|
||||||
|
kind: 'audiooutput',
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateActiveId = async (deviceId: string) => {
|
if (!canTestAudioOutput() || !activeDeviceId) return
|
||||||
try {
|
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
||||||
await audioRef?.current?.setSinkId(deviceId)
|
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
||||||
} catch (error) {
|
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
||||||
console.error(`Error setting sinkId: ${error}`)
|
return
|
||||||
}
|
}
|
||||||
}
|
reportError(
|
||||||
updateActiveId(activeDeviceId)
|
'device_switch_failure',
|
||||||
}, [activeDeviceId])
|
new Error(`Error setting sinkId: ${error}`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [devices, activeDeviceId])
|
||||||
|
|
||||||
// prevent pausing the sound
|
// prevent pausing the sound
|
||||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
@@ -28,9 +35,13 @@ export const SoundTester = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
audioRef?.current?.play()
|
try {
|
||||||
setIsPlaying(true)
|
await audioRef?.current?.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
} catch {
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
isDisabled={isPlaying}
|
isDisabled={isPlaying}
|
||||||
@@ -44,7 +55,7 @@ export const SoundTester = () => {
|
|||||||
{/* eslint-disable jsx-a11y/media-has-caption */}
|
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src="sounds/uprise.mp3"
|
src="/sounds/uprise.mp3"
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={() => setIsPlaying(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CaptureResult } from 'posthog-js'
|
||||||
|
|
||||||
|
const IGNORED_EXCEPTION_PATTERNS = [
|
||||||
|
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const shouldIgnoreException = (value: unknown): boolean =>
|
||||||
|
typeof value === 'string' &&
|
||||||
|
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
|
||||||
|
|
||||||
|
export const filterExceptions = (
|
||||||
|
event: CaptureResult | null
|
||||||
|
): CaptureResult | null => {
|
||||||
|
if (event?.event !== '$exception') return event
|
||||||
|
|
||||||
|
const exceptionList = event.properties?.['$exception_list']
|
||||||
|
const values: unknown[] = Array.isArray(exceptionList)
|
||||||
|
? exceptionList.map((exception) => exception?.value)
|
||||||
|
: []
|
||||||
|
|
||||||
|
values.push(event.properties?.['$exception_message'])
|
||||||
|
|
||||||
|
return values.some(shouldIgnoreException) ? null : event
|
||||||
|
}
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useLocation } from 'wouter'
|
|
||||||
import { type PostHog } from 'posthog-js'
|
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { getPosthog } from '../utils'
|
||||||
let posthog: PostHog | null = null
|
import { filterExceptions } from '../exceptionFilters'
|
||||||
|
|
||||||
const getPosthog = async () => {
|
|
||||||
if (!posthog) posthog = (await import('posthog-js')).default
|
|
||||||
return posthog
|
|
||||||
}
|
|
||||||
|
|
||||||
export const startAnalyticsSession = (data: ApiUser) => {
|
export const startAnalyticsSession = (data: ApiUser) => {
|
||||||
getPosthog().then((ph) => {
|
getPosthog().then((ph) => {
|
||||||
@@ -38,7 +31,6 @@ export const useAnalytics = ({
|
|||||||
flags_api_host,
|
flags_api_host,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: useAnalyticsProps) => {
|
}: useAnalyticsProps) => {
|
||||||
const [location] = useLocation()
|
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +41,14 @@ export const useAnalytics = ({
|
|||||||
api_host: host,
|
api_host: host,
|
||||||
flags_api_host: flags_api_host,
|
flags_api_host: flags_api_host,
|
||||||
person_profiles: 'always',
|
person_profiles: 'always',
|
||||||
|
capture_pageview: 'history_change',
|
||||||
|
capture_pageleave: true,
|
||||||
|
capture_exceptions: {
|
||||||
|
capture_unhandled_errors: true,
|
||||||
|
capture_unhandled_rejections: true,
|
||||||
|
capture_console_errors: true,
|
||||||
|
},
|
||||||
|
before_send: filterExceptions,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, [id, host, flags_api_host, isDisabled])
|
}, [id, host, flags_api_host, isDisabled])
|
||||||
@@ -58,12 +58,5 @@ export const useAnalytics = ({
|
|||||||
startAnalyticsSession(user)
|
startAnalyticsSession(user)
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
|
||||||
useEffect(() => {
|
|
||||||
getPosthog().then((ph) => {
|
|
||||||
ph.capture('$pageview')
|
|
||||||
})
|
|
||||||
}, [location])
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { getPosthog } from './utils'
|
||||||
|
|
||||||
|
export const captureEvent = (
|
||||||
|
event: string,
|
||||||
|
props?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
void getPosthog()
|
||||||
|
.then((ph) => {
|
||||||
|
ph.capture(event, props)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* telemetry must never break the app */
|
||||||
|
})
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(`[telemetry] ${event}`, props)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogCode =
|
||||||
|
// media
|
||||||
|
| 'join_preview_failure'
|
||||||
|
| 'room_media_failure'
|
||||||
|
| 'livekit_room_error'
|
||||||
|
| 'device_switch_failure'
|
||||||
|
| 'permission_poll_failure'
|
||||||
|
| 'media_devices_error_event'
|
||||||
|
// non-media families
|
||||||
|
| 'participant_mute_api_failure'
|
||||||
|
| 'permissions_api_failure'
|
||||||
|
| 'effects_processor_failure'
|
||||||
|
| 'clipboard_failure'
|
||||||
|
| 'fullscreen_failure'
|
||||||
|
| 'publish_sources_failure'
|
||||||
|
| 'disconnect_failure'
|
||||||
|
| 'generic_failure'
|
||||||
|
|
||||||
|
export const reportError = (
|
||||||
|
logCode: LogCode,
|
||||||
|
error: unknown,
|
||||||
|
extraInfo: Record<string, unknown> = {}
|
||||||
|
): void => {
|
||||||
|
const e = error instanceof Error ? error : new Error(String(error))
|
||||||
|
void getPosthog()
|
||||||
|
.then((ph) => {
|
||||||
|
ph.captureException(e, {
|
||||||
|
log_code: logCode,
|
||||||
|
error_name: e.name,
|
||||||
|
error_message: e.message,
|
||||||
|
...extraInfo,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(`[${logCode}]`, e, extraInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceSnapshot {
|
||||||
|
cam_count: number
|
||||||
|
mic_count: number
|
||||||
|
out_count: number
|
||||||
|
labels_visible: boolean
|
||||||
|
saved_cam_present: boolean | null
|
||||||
|
saved_mic_present: boolean | null
|
||||||
|
saved_video_device_id_set: boolean
|
||||||
|
saved_audio_device_id_set: boolean
|
||||||
|
audio_enabled: boolean | null
|
||||||
|
video_enabled: boolean | null
|
||||||
|
cam_permission: PermissionState | 'unknown'
|
||||||
|
mic_permission: PermissionState | 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the persisted LiveKit user choices without importing the store. */
|
||||||
|
const readPersistedChoices = (): {
|
||||||
|
videoDeviceId?: string
|
||||||
|
audioDeviceId?: string
|
||||||
|
videoEnabled?: boolean
|
||||||
|
audioEnabled?: boolean
|
||||||
|
} => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryPermission = async (
|
||||||
|
name: 'camera' | 'microphone'
|
||||||
|
): Promise<PermissionState | 'unknown'> => {
|
||||||
|
try {
|
||||||
|
const status = await navigator.permissions.query({
|
||||||
|
name: name as PermissionName,
|
||||||
|
})
|
||||||
|
return status.state
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
|
||||||
|
const choices = readPersistedChoices()
|
||||||
|
let devices: MediaDeviceInfo[] = []
|
||||||
|
try {
|
||||||
|
devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
} catch {
|
||||||
|
/* snapshot stays partial */
|
||||||
|
}
|
||||||
|
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
|
||||||
|
const present = (k: MediaDeviceKind, id?: string) =>
|
||||||
|
id ? ofKind(k).some((d) => d.deviceId === id) : null
|
||||||
|
|
||||||
|
const [cam_permission, mic_permission] = await Promise.all([
|
||||||
|
queryPermission('camera'),
|
||||||
|
queryPermission('microphone'),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
cam_count: ofKind('videoinput').length,
|
||||||
|
mic_count: ofKind('audioinput').length,
|
||||||
|
out_count: ofKind('audiooutput').length,
|
||||||
|
labels_visible: devices.some((d) => !!d.label),
|
||||||
|
saved_cam_present: present('videoinput', choices.videoDeviceId),
|
||||||
|
saved_mic_present: present('audioinput', choices.audioDeviceId),
|
||||||
|
saved_video_device_id_set: !!choices.videoDeviceId,
|
||||||
|
saved_audio_device_id_set: !!choices.audioDeviceId,
|
||||||
|
audio_enabled: choices.audioEnabled ?? null,
|
||||||
|
video_enabled: choices.videoEnabled ?? null,
|
||||||
|
cam_permission,
|
||||||
|
mic_permission,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const captureMediaEvent = async (
|
||||||
|
event:
|
||||||
|
| 'media-device-error'
|
||||||
|
| 'media-acquisition'
|
||||||
|
| 'media-device-topology'
|
||||||
|
| 'media-device-success'
|
||||||
|
| 'device-not-found'
|
||||||
|
| 'permissions-denied'
|
||||||
|
| 'screen-share-permission-denied'
|
||||||
|
| 'silent-mic-detected'
|
||||||
|
| 'silent-mic-analyser-unavailable'
|
||||||
|
| 'silent-mic-recovered'
|
||||||
|
| 'visit-room'
|
||||||
|
| 'connection-event',
|
||||||
|
props: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PostHog } from 'posthog-js'
|
||||||
|
|
||||||
|
let posthog: PostHog | null = null
|
||||||
|
|
||||||
|
export const getPosthog = async () => {
|
||||||
|
if (!posthog) posthog = (await import('posthog-js')).default
|
||||||
|
return posthog
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { setAccessToken } from '@/stores/accessToken'
|
||||||
|
import { consumeTransitCodeFromFragment } from '../utils/transitCode'
|
||||||
|
|
||||||
|
type ApiAccessToken = {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
scope: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a single-use transit code for a user access token.
|
||||||
|
*
|
||||||
|
* The endpoint is unauthenticated: the code itself is the credential.
|
||||||
|
*/
|
||||||
|
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
|
||||||
|
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runInitialization = async (): Promise<void> => {
|
||||||
|
const code = consumeTransitCodeFromFragment()
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { access_token } = await exchangeAccessToken(code)
|
||||||
|
setAccessToken(access_token)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Transit code exchange failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialization: Promise<void> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the embedded (iframe) authentication, if applicable.
|
||||||
|
*
|
||||||
|
* When, and only when, a transit code is present in the URL fragment,
|
||||||
|
* exchange it for a user access token and keep it in the in-memory
|
||||||
|
* accessToken store: fetchApi then sends it as a Bearer header on every
|
||||||
|
* api call, authenticating the user exactly like a session cookie would.
|
||||||
|
*
|
||||||
|
* Must complete before anything fires an authenticated query, which the
|
||||||
|
* TransitCodeGate component guarantees by gating the app tree on it.
|
||||||
|
*
|
||||||
|
* Memoized: the fragment is consumed and the code exchanged exactly once,
|
||||||
|
* however many times this is called (StrictMode double-invoked effects,
|
||||||
|
* among others). Subsequent calls await the same promise.
|
||||||
|
*
|
||||||
|
* A failed exchange (expired or already used code) is not fatal: the app
|
||||||
|
* starts unauthenticated, falling back to the regular session flow.
|
||||||
|
*/
|
||||||
|
export const initializeAccessTokenFromFragment = (): Promise<void> => {
|
||||||
|
if (!initialization) {
|
||||||
|
initialization = runInitialization()
|
||||||
|
}
|
||||||
|
return initialization
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch the logged-in user from the api.
|
* fetch the logged-in user from the api.
|
||||||
@@ -25,7 +26,13 @@ export const fetchUser = (
|
|||||||
if (error instanceof ApiError && error.statusCode === 401) {
|
if (error instanceof ApiError && error.statusCode === 401) {
|
||||||
// make sure to not resolve the promise while trying to silent login
|
// make sure to not resolve the promise while trying to silent login
|
||||||
// so that consumers of fetchUser don't think the work already ended
|
// so that consumers of fetchUser don't think the work already ended
|
||||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
// Never attempt a silent login in embedded (token) mode: an OIDC
|
||||||
|
// redirect inside the iframe would break the embed.
|
||||||
|
if (
|
||||||
|
opts.attemptSilent &&
|
||||||
|
!getAccessToken() &&
|
||||||
|
canAttemptSilentLogin()
|
||||||
|
) {
|
||||||
attemptSilentLogin(30)
|
attemptSilentLogin(30)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { queryClient } from '@/api/queryClient'
|
|||||||
import { updateUserPreferences } from './updateUserPreferences'
|
import { updateUserPreferences } from './updateUserPreferences'
|
||||||
import { convertToBackendLanguage } from '@/utils/languages'
|
import { convertToBackendLanguage } from '@/utils/languages'
|
||||||
import { useUser } from './useUser'
|
import { useUser } from './useUser'
|
||||||
|
import { ApiError } from '@/api/ApiError.ts'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||||
@@ -42,6 +44,11 @@ export const useSyncUserPreferencesWithBackend = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncBrowserPreferencesToBackend()
|
syncBrowserPreferencesToBackend().catch((error) => {
|
||||||
|
if (error instanceof ApiError && error.statusCode === 401) return
|
||||||
|
reportError('generic_failure', error, {
|
||||||
|
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
|
||||||
|
})
|
||||||
|
})
|
||||||
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
import { useHash } from '@/hooks/useHash'
|
||||||
|
import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken'
|
||||||
|
import { hasTransitCodeInFragment } from '../utils/transitCode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the app tree on the embedded (iframe) authentication bootstrap.
|
||||||
|
*
|
||||||
|
* Without a transit code in the URL fragment — the overwhelmingly common
|
||||||
|
* case — the component early returns children synchronously: no state,
|
||||||
|
* no effect, no extra render, no loading screen.
|
||||||
|
*
|
||||||
|
* When a transit code is present, children are not mounted until it has
|
||||||
|
* been exchanged for a user access token, so that every authenticated
|
||||||
|
* query already carries the Authorization header. A loading screen is
|
||||||
|
* displayed in the meantime, as UserAware does.
|
||||||
|
*/
|
||||||
|
export const TransitCodeGate = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => {
|
||||||
|
const hash = useHash()
|
||||||
|
|
||||||
|
// Latch the decision on the initial hash: the bootstrap scrubs the
|
||||||
|
// fragment as soon as it starts, and the gate must not flip back to the
|
||||||
|
// fast path while the exchange is still in flight.
|
||||||
|
const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
|
||||||
|
|
||||||
|
if (!needsExchange) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TransitCodeExchange>{children}</TransitCodeExchange>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only ever mounted when a transit code is present: runs the memoized
|
||||||
|
* bootstrap (safe against StrictMode double-invoked effects) and holds
|
||||||
|
* children back until it settles.
|
||||||
|
*/
|
||||||
|
const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
initializeAccessTokenFromFragment().finally(() => {
|
||||||
|
console.log('$$ transit code exchange finished')
|
||||||
|
if (isMounted) {
|
||||||
|
console.log('$$ setIsReady')
|
||||||
|
setIsReady(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
console.log('$$ isReady', isReady)
|
||||||
|
|
||||||
|
return isReady ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a URL fragment carries a transit code. Pure check, does not
|
||||||
|
* consume anything.
|
||||||
|
*/
|
||||||
|
export const hasTransitCodeInFragment = (hash: string): boolean => {
|
||||||
|
if (!hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return new URLSearchParams(hash.replace(/^#/, '')).has(
|
||||||
|
TRANSIT_CODE_FRAGMENT_PARAM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the transit code from the URL fragment, if any.
|
||||||
|
*
|
||||||
|
* The fragment is scrubbed from the address bar immediately, before any
|
||||||
|
* network call, so the code never lingers in the browser history. Any
|
||||||
|
* other fragment content is preserved.
|
||||||
|
*/
|
||||||
|
export const consumeTransitCodeFromFragment = (): string | null => {
|
||||||
|
if (typeof window === 'undefined' || !window.location.hash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.hash.substring(1))
|
||||||
|
const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
params.delete(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||||
|
const remaining = params.toString()
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
(remaining ? `#${remaining}` : '')
|
||||||
|
)
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChatRow } from '@/stores/chat'
|
import { ChatRow } from '@/stores/chat'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
import { formatChatMessageLinks } from '../utils'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const defaultGrammar = Object.freeze(createDefaultGrammar())
|
||||||
|
|
||||||
|
export function formatChatMessageLinks(message: string): ReactNode {
|
||||||
|
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
|
||||||
|
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
|
||||||
|
if (typeof tok === `string`) {
|
||||||
|
return tok
|
||||||
|
} else {
|
||||||
|
const content = tok.content.toString()
|
||||||
|
const href =
|
||||||
|
tok.type === `url`
|
||||||
|
? /^http(s?):\/\//.test(content)
|
||||||
|
? content
|
||||||
|
: `https://${content}`
|
||||||
|
: `mailto:${content}`
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="lk-chat-link"
|
||||||
|
key={i}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
|
export type LiveKitConnectionDetails = {
|
||||||
|
url: string
|
||||||
|
room: string
|
||||||
|
token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionTestResponse = {
|
||||||
|
livekit: LiveKitConnectionDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchConnectionTestDetails = () =>
|
||||||
|
fetchApi<ConnectionTestResponse>('/diagnostics/connection/', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { Checker, Track, type CheckInfo } from 'livekit-client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addresses are useful to a network administrator (they identify the egress IP
|
||||||
|
* and the SFU endpoint actually reached) but they also land in a downloadable
|
||||||
|
* report. Flip this to false to keep only the candidate types and protocols.
|
||||||
|
*/
|
||||||
|
const INCLUDE_CANDIDATE_ADDRESSES = true
|
||||||
|
|
||||||
|
/** Beyond this, the log becomes noise rather than evidence. */
|
||||||
|
const MAX_LOGGED_PAIRS = 8
|
||||||
|
|
||||||
|
export type IceCandidateInfo = {
|
||||||
|
/** host, srflx, prflx or relay. */
|
||||||
|
type?: string
|
||||||
|
/** Transport to the first hop: udp or tcp. */
|
||||||
|
protocol?: string
|
||||||
|
/** Transport used by the relay itself (udp, tcp, tls). Local relay only. */
|
||||||
|
relayProtocol?: string
|
||||||
|
/** Chrome reports an mDNS `.local` name here for host candidates. */
|
||||||
|
address?: string
|
||||||
|
port?: number
|
||||||
|
/** Local candidates only, and not reported by every browser. */
|
||||||
|
networkType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IceCandidatePair = {
|
||||||
|
/** The pair the browser is actually sending media on. */
|
||||||
|
selected: boolean
|
||||||
|
nominated?: boolean
|
||||||
|
local: IceCandidateInfo
|
||||||
|
remote: IceCandidateInfo
|
||||||
|
/** Round trip time in milliseconds. */
|
||||||
|
rttMs?: number
|
||||||
|
availableOutgoingBitrate?: number
|
||||||
|
bytesSent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IceCandidateReport = {
|
||||||
|
selected: IceCandidatePair | null
|
||||||
|
/** Every pair that completed its connectivity checks, selected one first. */
|
||||||
|
working: IceCandidatePair[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE_WIDTH = 320
|
||||||
|
const PROBE_HEIGHT = 180
|
||||||
|
const PROBE_FPS = 15
|
||||||
|
const SETTLE_DELAY_MS = 3000
|
||||||
|
|
||||||
|
type Stats = Record<string, unknown> & { type?: string }
|
||||||
|
|
||||||
|
const readCandidate = (stats?: Stats): IceCandidateInfo => {
|
||||||
|
if (!stats) return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: stats.candidateType as string | undefined,
|
||||||
|
protocol: stats.protocol as string | undefined,
|
||||||
|
relayProtocol: stats.relayProtocol as string | undefined,
|
||||||
|
networkType: stats.networkType as string | undefined,
|
||||||
|
...(INCLUDE_CANDIDATE_ADDRESSES
|
||||||
|
? {
|
||||||
|
address: stats.address as string | undefined,
|
||||||
|
port: stats.port as number | undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeCandidate = (candidate: IceCandidateInfo) => {
|
||||||
|
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
|
||||||
|
const endpoint =
|
||||||
|
candidate.address === undefined
|
||||||
|
? ''
|
||||||
|
: ` ${candidate.address}:${candidate.port ?? '?'}`
|
||||||
|
return `${candidate.type ?? 'unknown'} ${transport}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCandidates = (report: RTCStatsReport): IceCandidateReport => {
|
||||||
|
let selectedId: string | undefined
|
||||||
|
|
||||||
|
report.forEach((stats: Stats) => {
|
||||||
|
if (stats.type === 'transport' && stats.selectedCandidatePairId) {
|
||||||
|
selectedId = stats.selectedCandidatePairId as string
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const working: IceCandidatePair[] = []
|
||||||
|
|
||||||
|
report.forEach((stats: Stats) => {
|
||||||
|
// `succeeded` means the pair completed its connectivity checks; failed,
|
||||||
|
// waiting and in-progress pairs are not evidence of anything working.
|
||||||
|
if (stats.type !== 'candidate-pair' || stats.state !== 'succeeded') return
|
||||||
|
|
||||||
|
const rtt = stats.currentRoundTripTime as number | undefined
|
||||||
|
|
||||||
|
working.push({
|
||||||
|
selected: selectedId !== undefined && stats.id === selectedId,
|
||||||
|
nominated: stats.nominated as boolean | undefined,
|
||||||
|
local: readCandidate(report.get(stats.localCandidateId as string)),
|
||||||
|
remote: readCandidate(report.get(stats.remoteCandidateId as string)),
|
||||||
|
rttMs: rtt === undefined ? undefined : Math.round(rtt * 1000),
|
||||||
|
availableOutgoingBitrate: stats.availableOutgoingBitrate as
|
||||||
|
| number
|
||||||
|
| undefined,
|
||||||
|
bytesSent: stats.bytesSent as number | undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Firefox does not report transport.selectedCandidatePairId: fall back to the
|
||||||
|
// nominated pair, then to the one that actually carried bytes.
|
||||||
|
let selected = working.find((pair) => pair.selected) ?? null
|
||||||
|
if (!selected) {
|
||||||
|
selected =
|
||||||
|
working.find((pair) => pair.nominated) ??
|
||||||
|
working
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.bytesSent ?? 0) - (a.bytesSent ?? 0))[0] ??
|
||||||
|
null
|
||||||
|
if (selected) selected.selected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
working.sort((a, b) => Number(b.selected) - Number(a.selected))
|
||||||
|
|
||||||
|
return { selected, working }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A synthetic track avoids asking for camera or microphone permission: this
|
||||||
|
* check must work for someone who denied both.
|
||||||
|
*/
|
||||||
|
const createProbeTrack = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = PROBE_WIDTH
|
||||||
|
canvas.height = PROBE_HEIGHT
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Could not get canvas context')
|
||||||
|
|
||||||
|
let frame = 0
|
||||||
|
let rafId = 0
|
||||||
|
const draw = () => {
|
||||||
|
frame = (frame + 4) % 360
|
||||||
|
context.fillStyle = `hsl(${frame}, 100%, 50%)`
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
rafId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
|
||||||
|
const track = canvas.captureStream(PROBE_FPS).getVideoTracks()[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
stop: () => {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
track.stop()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SelectedCandidateCheck extends Checker {
|
||||||
|
private result: IceCandidateReport | null = null
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
const selected = this.result?.selected
|
||||||
|
if (!selected) return 'Selected ICE candidate pair'
|
||||||
|
|
||||||
|
const transport =
|
||||||
|
selected.local.relayProtocol ?? selected.local.protocol ?? 'unknown'
|
||||||
|
const rtt =
|
||||||
|
selected.rttMs === undefined ? '' : ` · RTT ${selected.rttMs} ms`
|
||||||
|
return `${selected.local.type ?? 'unknown'} over ${transport}${rtt}`
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async perform() {
|
||||||
|
await this.connect()
|
||||||
|
|
||||||
|
const probe = createProbeTrack()
|
||||||
|
try {
|
||||||
|
let publication
|
||||||
|
try {
|
||||||
|
publication = await this.room.localParticipant.publishTrack(
|
||||||
|
probe.track,
|
||||||
|
{
|
||||||
|
// The token restricts `can_publish_sources`, so a raw
|
||||||
|
// MediaStreamTrack published as `unknown` is rejected server side.
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
simulcast: false,
|
||||||
|
videoEncoding: { maxBitrate: 300_000, maxFramerate: PROBE_FPS },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// A server-side grant problem is not a diagnosis of the user's network.
|
||||||
|
this.appendWarning(
|
||||||
|
`Could not publish the probe track: ${
|
||||||
|
error instanceof Error ? error.message : 'unknown error'
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
this.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ICE keeps promoting pairs for a moment after the track goes up.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS))
|
||||||
|
|
||||||
|
// Stats come from the publisher peer connection: in an empty test room
|
||||||
|
// there is no subscriber transport to inspect.
|
||||||
|
const report = await publication.track?.getRTCStatsReport()
|
||||||
|
this.result = report ? parseCandidates(report) : null
|
||||||
|
} finally {
|
||||||
|
probe.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = this.result?.selected
|
||||||
|
const working = this.result?.working ?? []
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
this.appendWarning('No working candidate pair reported by the browser')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appendMessage(`selected: ${describeCandidate(selected.local)}`)
|
||||||
|
this.appendMessage(`server: ${describeCandidate(selected.remote)}`)
|
||||||
|
if (selected.rttMs !== undefined) {
|
||||||
|
this.appendMessage(`round trip time: ${selected.rttMs} ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appendMessage(`working candidate pairs: ${working.length}`)
|
||||||
|
for (const pair of working.slice(0, MAX_LOGGED_PAIRS)) {
|
||||||
|
const rtt = pair.rttMs === undefined ? '' : ` · ${pair.rttMs} ms`
|
||||||
|
this.appendMessage(
|
||||||
|
`${pair.selected ? '→' : ' '} ${describeCandidate(pair.local)} → ${describeCandidate(pair.remote)}${rtt}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (working.length > MAX_LOGGED_PAIRS) {
|
||||||
|
this.appendMessage(
|
||||||
|
`… and ${working.length - MAX_LOGGED_PAIRS} more, see the report`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.local.type === 'relay') {
|
||||||
|
this.appendWarning(
|
||||||
|
'Media is relayed through TURN. Direct connections are likely blocked by a firewall.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ((selected.local.relayProtocol ?? selected.local.protocol) !== 'udp') {
|
||||||
|
this.appendWarning(
|
||||||
|
'Media is not using UDP, which usually means degraded quality under load.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo(): CheckInfo {
|
||||||
|
const info = super.getInfo()
|
||||||
|
info.data = this.result ?? undefined
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Disclosure,
|
||||||
|
DisclosurePanel,
|
||||||
|
Heading,
|
||||||
|
Button as RACButton,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { RiArrowDownSFill } from '@remixicon/react'
|
||||||
|
import { css, cx } from '@/styled-system/css'
|
||||||
|
import type { ConnectionTestStepResult } from '../types'
|
||||||
|
import { StepStatusIndicator } from './StepStatusIndicator'
|
||||||
|
|
||||||
|
/** Each step is its own bounded card, collapsed or not. */
|
||||||
|
const cardClass = css({
|
||||||
|
border: '1px solid {colors.greyscale.900}',
|
||||||
|
borderRadius: '5px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed columns so the status labels line up across every row, whether or not
|
||||||
|
* the row is expandable.
|
||||||
|
*/
|
||||||
|
const rowClass = css({
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0, 1fr) 7rem 1.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
width: '100%',
|
||||||
|
paddingX: '1rem',
|
||||||
|
paddingY: '0.75rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
})
|
||||||
|
|
||||||
|
const identityClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.125rem',
|
||||||
|
minWidth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerClass = css({
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 120ms',
|
||||||
|
_hover: { backgroundColor: 'greyscale.50' },
|
||||||
|
'&[data-focus-visible]': {
|
||||||
|
outline: '2px solid {colors.focusRing}',
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Expanded headers stay tinted so the open card reads as one block. */
|
||||||
|
const triggerExpandedClass = css({
|
||||||
|
backgroundColor: 'greyscale.100',
|
||||||
|
_hover: { backgroundColor: 'greyscale.100' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelClass = css({
|
||||||
|
textStyle: 'body',
|
||||||
|
color: 'greyscale.1000',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
})
|
||||||
|
|
||||||
|
const valueClass = css({
|
||||||
|
fontFamily: 'mono',
|
||||||
|
textStyle: 'xs',
|
||||||
|
color: 'greyscale.500',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
})
|
||||||
|
|
||||||
|
const chevronClass = css({
|
||||||
|
color: 'primary.800',
|
||||||
|
justifySelf: 'end',
|
||||||
|
transition: 'transform 150ms',
|
||||||
|
})
|
||||||
|
|
||||||
|
const chevronExpandedClass = css({ transform: 'rotate(180deg)' })
|
||||||
|
|
||||||
|
const headingResetClass = css({
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontWeight: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelClass = css({
|
||||||
|
backgroundColor: 'white',
|
||||||
|
})
|
||||||
|
|
||||||
|
const logListClass = css({
|
||||||
|
listStyle: 'none',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const logItemClass = css({
|
||||||
|
fontFamily: 'mono',
|
||||||
|
textStyle: 'xs',
|
||||||
|
color: 'greyscale.700',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
})
|
||||||
|
|
||||||
|
const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={identityClass}>
|
||||||
|
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
|
||||||
|
{step.summary && <span className={valueClass}>{step.summary}</span>}
|
||||||
|
</span>
|
||||||
|
<StepStatusIndicator
|
||||||
|
status={step.status}
|
||||||
|
label={t(`status.${step.status}`)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionTestStepRow = ({
|
||||||
|
step,
|
||||||
|
}: {
|
||||||
|
step: ConnectionTestStepResult
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const isSettled = step.status !== 'pending' && step.status !== 'running'
|
||||||
|
const hasLogs = isSettled && Boolean(step.logs?.length)
|
||||||
|
|
||||||
|
if (!hasLogs) {
|
||||||
|
return (
|
||||||
|
<div className={cx(cardClass, rowClass)}>
|
||||||
|
<StepRowContent step={step} />
|
||||||
|
{/* Empty chevron column keeps non-expandable rows aligned. */}
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Disclosure className={cardClass}>
|
||||||
|
{({ isExpanded }) => (
|
||||||
|
<>
|
||||||
|
<Heading level={3} className={headingResetClass}>
|
||||||
|
<RACButton
|
||||||
|
slot="trigger"
|
||||||
|
className={cx(
|
||||||
|
rowClass,
|
||||||
|
triggerClass,
|
||||||
|
isExpanded ? triggerExpandedClass : undefined
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StepRowContent step={step} />
|
||||||
|
<RiArrowDownSFill
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx(
|
||||||
|
chevronClass,
|
||||||
|
isExpanded ? chevronExpandedClass : undefined
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</RACButton>
|
||||||
|
</Heading>
|
||||||
|
<DisclosurePanel
|
||||||
|
className={panelClass}
|
||||||
|
aria-label={t('detailsFor', { step: t(`steps.${step.id}`) })}
|
||||||
|
style={{ padding: isExpanded ? '0.75rem 1rem' : '0 1rem' }}
|
||||||
|
>
|
||||||
|
{/* Collapsed panels stay in the DOM for aria-controls, but the log
|
||||||
|
lines themselves are only mounted when actually visible. */}
|
||||||
|
{isExpanded && (
|
||||||
|
<ul className={logListClass}>
|
||||||
|
{step.logs?.map((log, index) => (
|
||||||
|
<li key={`${log.level}-${index}`} className={logItemClass}>
|
||||||
|
{log.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ProgressBar } from 'react-aria-components'
|
||||||
|
import { css, cx } from '@/styled-system/css'
|
||||||
|
import type { ConnectionTestStats } from '../types'
|
||||||
|
import { statusSquareClass } from './stepAppearance'
|
||||||
|
|
||||||
|
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
|
||||||
|
|
||||||
|
/** Only a failure earns a colour: everything else stays near-black. */
|
||||||
|
const stateColorClass: Record<SummaryState, string> = {
|
||||||
|
idle: css({ color: 'greyscale.1000' }),
|
||||||
|
running: css({ color: 'greyscale.1000' }),
|
||||||
|
passed: css({ color: 'greyscale.1000' }),
|
||||||
|
partial: css({ color: 'greyscale.1000' }),
|
||||||
|
failed: css({ color: 'danger.600' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = css({
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: '5px',
|
||||||
|
border: '1px solid {colors.greyscale.900}',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: { base: '1.25rem', xsm: '1.75rem' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
// Blocks are spaced here; everything inside a block stays tight.
|
||||||
|
gap: '1.5rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const eyebrowClass = css({
|
||||||
|
textStyle: 'sm',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const headlineClass = css({
|
||||||
|
// Sized for the longest state string ("N vérifications en échec"), not for
|
||||||
|
// the shortest one.
|
||||||
|
fontSize: { base: '28', xsm: '40' },
|
||||||
|
lineHeight: '1.1',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
textWrap: 'balance',
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hintClass = css({
|
||||||
|
textStyle: 'sm',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: '34rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dividerClass = css({
|
||||||
|
// Lighter than the card border: an inner rule should never compete with it.
|
||||||
|
borderTop: '1px solid {colors.greyscale.100}',
|
||||||
|
paddingTop: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressRowClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackClass = css({
|
||||||
|
height: '0.375rem',
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 'full',
|
||||||
|
backgroundColor: 'greyscale.200',
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fillClass = css({
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'full',
|
||||||
|
backgroundColor: 'primary.800',
|
||||||
|
transition: 'width 200ms ease-out',
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressValueClass = css({
|
||||||
|
textStyle: 'sm',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
color: 'greyscale.700',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
// Reserved width so the bar does not resize when the digits change.
|
||||||
|
minWidth: '3rem',
|
||||||
|
textAlign: 'right',
|
||||||
|
})
|
||||||
|
|
||||||
|
const countersClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem 1.5rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const counterClass = css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
textStyle: 'sm',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
})
|
||||||
|
|
||||||
|
const counterSquareClass = css({
|
||||||
|
width: '0.5rem',
|
||||||
|
height: '0.5rem',
|
||||||
|
borderRadius: '2px',
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const counterValueClass = css({
|
||||||
|
fontWeight: 'medium',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
color: 'greyscale.1000',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A zero count is context, not a result: it recedes instead of shouting. */
|
||||||
|
const emptyCounterClass = css({ color: 'greyscale.400' })
|
||||||
|
const emptySquareClass = css({
|
||||||
|
backgroundColor: 'transparent!',
|
||||||
|
border: '1px solid {colors.greyscale.250}',
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionsClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.75rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const Counter = ({
|
||||||
|
squareClass,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
squareClass: string
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}) => {
|
||||||
|
const isEmpty = value === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(counterClass, isEmpty ? emptyCounterClass : undefined)}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx(
|
||||||
|
counterSquareClass,
|
||||||
|
squareClass,
|
||||||
|
isEmpty ? emptySquareClass : undefined
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
counterValueClass,
|
||||||
|
isEmpty ? emptyCounterClass : undefined
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionTestSummary = ({
|
||||||
|
stats,
|
||||||
|
isRunning,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
stats: ConnectionTestStats
|
||||||
|
isRunning: boolean
|
||||||
|
children?: ReactNode
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
|
||||||
|
const state: SummaryState = isRunning
|
||||||
|
? 'running'
|
||||||
|
: !stats.hasStarted
|
||||||
|
? 'idle'
|
||||||
|
: stats.failed > 0
|
||||||
|
? 'failed'
|
||||||
|
: stats.skipped > 0
|
||||||
|
? 'partial'
|
||||||
|
: 'passed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cardClass}>
|
||||||
|
<div className={headerClass}>
|
||||||
|
<h1 className={eyebrowClass}>{t('title')}</h1>
|
||||||
|
|
||||||
|
{/* Announced once per state change rather than on every step update. */}
|
||||||
|
<p className={cx(headlineClass, stateColorClass[state])} role="status">
|
||||||
|
{state === 'failed'
|
||||||
|
? t('summary.failed', { count: stats.failed })
|
||||||
|
: t(`summary.${state}`)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.hasStarted && (
|
||||||
|
<div className={dividerClass}>
|
||||||
|
<div className={progressRowClass}>
|
||||||
|
<ProgressBar
|
||||||
|
aria-label={t('progressLabel')}
|
||||||
|
value={stats.progress}
|
||||||
|
className={css({ flex: 1 })}
|
||||||
|
>
|
||||||
|
{({ percentage }) => (
|
||||||
|
<div className={trackClass}>
|
||||||
|
<div
|
||||||
|
className={fillClass}
|
||||||
|
style={{ width: `${percentage ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProgressBar>
|
||||||
|
<span className={progressValueClass}>
|
||||||
|
{t('progress', { done: stats.settled, total: stats.total })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={countersClass}>
|
||||||
|
<Counter
|
||||||
|
squareClass={statusSquareClass.success}
|
||||||
|
value={stats.passed}
|
||||||
|
label={t('counts.passed')}
|
||||||
|
/>
|
||||||
|
<Counter
|
||||||
|
squareClass={statusSquareClass.skipped}
|
||||||
|
value={stats.skipped}
|
||||||
|
label={t('counts.skipped')}
|
||||||
|
/>
|
||||||
|
<Counter
|
||||||
|
squareClass={statusSquareClass.failed}
|
||||||
|
value={stats.failed}
|
||||||
|
label={t('counts.failed')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && <div className={actionsClass}>{children}</div>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { css, cx } from '@/styled-system/css'
|
||||||
|
import type { ConnectionTestStepStatus } from '../types'
|
||||||
|
import { statusSquareClass, statusTextClass } from './stepAppearance'
|
||||||
|
|
||||||
|
const wrapperClass = css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
textStyle: 'sm',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})
|
||||||
|
|
||||||
|
const squareClass = css({
|
||||||
|
width: '0.625rem',
|
||||||
|
height: '0.625rem',
|
||||||
|
borderRadius: '2px',
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status is carried by the label; the square is decorative so the meaning does
|
||||||
|
* not depend on colour alone.
|
||||||
|
*/
|
||||||
|
export const StepStatusIndicator = ({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
status: ConnectionTestStepStatus
|
||||||
|
label: string
|
||||||
|
className?: string
|
||||||
|
}) => (
|
||||||
|
<span className={cx(wrapperClass, className)}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx(squareClass, statusSquareClass[status])}
|
||||||
|
/>
|
||||||
|
<span className={statusTextClass[status]}>{label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import type { ConnectionTestStepStatus } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panda extracts styles statically, so every status needs its own literal
|
||||||
|
* `css()` call: `css({ backgroundColor: someVariable })` would emit nothing.
|
||||||
|
*/
|
||||||
|
export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
|
||||||
|
pending: css({
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: '1px solid {colors.greyscale.300}',
|
||||||
|
}),
|
||||||
|
running: css({
|
||||||
|
backgroundColor: 'primary.800',
|
||||||
|
animation: 'pulse_background 1.2s ease-in-out infinite',
|
||||||
|
}),
|
||||||
|
success: css({ backgroundColor: 'success.600' }),
|
||||||
|
failed: css({ backgroundColor: 'danger.600' }),
|
||||||
|
skipped: css({ backgroundColor: 'greyscale.300' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour is carried by the square; the label stays near-black except on failure. */
|
||||||
|
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
|
||||||
|
pending: css({ color: 'greyscale.500' }),
|
||||||
|
running: css({ color: 'greyscale.700' }),
|
||||||
|
success: css({ color: 'greyscale.1000' }),
|
||||||
|
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
|
||||||
|
skipped: css({ color: 'greyscale.500' }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CheckStatus,
|
||||||
|
ConnectionCheck,
|
||||||
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
getBrowser,
|
||||||
|
type CheckInfo,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
|
||||||
|
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
|
||||||
|
import {
|
||||||
|
createInitialSteps,
|
||||||
|
type ConnectionTestLog,
|
||||||
|
type ConnectionTestStepId,
|
||||||
|
type ConnectionTestStepResult,
|
||||||
|
type ConnectionTestStepStatus,
|
||||||
|
} from '../types'
|
||||||
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'selectedCandidate',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
|
||||||
|
[CheckStatus.IDLE]: 'pending',
|
||||||
|
[CheckStatus.RUNNING]: 'running',
|
||||||
|
[CheckStatus.SUCCESS]: 'success',
|
||||||
|
[CheckStatus.FAILED]: 'failed',
|
||||||
|
[CheckStatus.SKIPPED]: 'skipped',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getUserMedia rejections that mean "the user said no", not "the device is broken". */
|
||||||
|
const PERMISSION_ERROR_NAMES = new Set([
|
||||||
|
'NotAllowedError',
|
||||||
|
'PermissionDeniedError',
|
||||||
|
'SecurityError',
|
||||||
|
])
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||||
|
error instanceof Error ? error.message : fallback
|
||||||
|
|
||||||
|
const isPermissionError = (error: unknown) =>
|
||||||
|
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
|
||||||
|
|
||||||
|
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||||
|
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||||
|
summary: info.description,
|
||||||
|
logs: info.logs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||||
|
const grouped: Record<string, string[]> = {
|
||||||
|
audioinput: [],
|
||||||
|
audiooutput: [],
|
||||||
|
videoinput: [],
|
||||||
|
}
|
||||||
|
for (const device of devices) {
|
||||||
|
// Browsers are free to report kinds we don't know about yet.
|
||||||
|
const bucket = (grouped[device.kind] ??= [])
|
||||||
|
bucket.push(device.label || device.deviceId)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a single step. `aborted` is deliberately distinct from `failed`:
|
||||||
|
* a cancelled run must not be reported to the user as a broken device.
|
||||||
|
*/
|
||||||
|
type StepOutcome =
|
||||||
|
| { state: 'success' }
|
||||||
|
| { state: 'failed'; error: unknown }
|
||||||
|
| { state: 'aborted' }
|
||||||
|
|
||||||
|
const ABORTED: StepOutcome = { state: 'aborted' }
|
||||||
|
|
||||||
|
export const useConnectionTestRunner = () => {
|
||||||
|
const [steps, setSteps] = useState(createInitialSteps)
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const updateStep = useCallback(
|
||||||
|
(id: ConnectionTestStepId, patch: Partial<ConnectionTestStepResult>) => {
|
||||||
|
setSteps((current) =>
|
||||||
|
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const skipSteps = useCallback(
|
||||||
|
(
|
||||||
|
ids: ConnectionTestStepId[],
|
||||||
|
summary: string,
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
) => {
|
||||||
|
// One state update for the whole batch instead of one per step.
|
||||||
|
const targets = new Set(ids)
|
||||||
|
setSteps((current) =>
|
||||||
|
current.map((step) =>
|
||||||
|
targets.has(step.id)
|
||||||
|
? { ...step, status: 'skipped', summary, logs }
|
||||||
|
: step
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const runStep = useCallback(
|
||||||
|
async (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
signal: AbortSignal,
|
||||||
|
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||||
|
): Promise<StepOutcome> => {
|
||||||
|
if (signal.aborted) return ABORTED
|
||||||
|
|
||||||
|
updateStep(id, {
|
||||||
|
status: 'running',
|
||||||
|
summary: undefined,
|
||||||
|
logs: undefined,
|
||||||
|
data: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
if (signal.aborted) return ABORTED
|
||||||
|
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||||
|
updateStep(id, { status: 'success', ...result })
|
||||||
|
return { state: 'success' }
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return ABORTED
|
||||||
|
updateStep(id, {
|
||||||
|
status: 'failed',
|
||||||
|
summary: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
return { state: 'failed', error }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateStep]
|
||||||
|
)
|
||||||
|
|
||||||
|
const runTest = useCallback(async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const { signal } = controller
|
||||||
|
|
||||||
|
setIsRunning(true)
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runStep('browser', signal, async () => {
|
||||||
|
const browser = getBrowser()
|
||||||
|
if (!browser) throw new Error('Browser not detected')
|
||||||
|
return {
|
||||||
|
summary: `${browser.name} ${browser.version}`,
|
||||||
|
data: {
|
||||||
|
name: browser.name,
|
||||||
|
version: browser.version,
|
||||||
|
os: browser.os,
|
||||||
|
osVersion: browser.osVersion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const microphone = await runStep('microphone', signal, async () => {
|
||||||
|
const track = await createLocalAudioTrack()
|
||||||
|
const label =
|
||||||
|
track.mediaStreamTrack.label ||
|
||||||
|
track.mediaStreamTrack.getSettings().deviceId ||
|
||||||
|
''
|
||||||
|
track.stop()
|
||||||
|
return { summary: label, data: { label } }
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (
|
||||||
|
microphone.state === 'failed' &&
|
||||||
|
isPermissionError(microphone.error)
|
||||||
|
) {
|
||||||
|
openPermissionsDialog('audioinput')
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = await runStep('camera', signal, async () => {
|
||||||
|
const track = await createLocalVideoTrack()
|
||||||
|
const settings = track.mediaStreamTrack.getSettings()
|
||||||
|
const label = track.mediaStreamTrack.label || ''
|
||||||
|
// Released immediately, like the microphone probe: the capture
|
||||||
|
// indicator must not stay on between this check and publishVideo.
|
||||||
|
track.stop()
|
||||||
|
return {
|
||||||
|
summary: label,
|
||||||
|
data: {
|
||||||
|
label,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (camera.state === 'failed' && isPermissionError(camera.error)) {
|
||||||
|
openPermissionsDialog('videoinput')
|
||||||
|
}
|
||||||
|
|
||||||
|
await runStep('devices', signal, async () => {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
return {
|
||||||
|
summary: String(devices.length),
|
||||||
|
data: groupDevicesByKind(devices),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
let checker: ConnectionCheck
|
||||||
|
try {
|
||||||
|
const { livekit } = await fetchConnectionTestDetails()
|
||||||
|
if (signal.aborted) return
|
||||||
|
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return
|
||||||
|
skipSteps(
|
||||||
|
LIVEKIT_STEP_IDS,
|
||||||
|
getErrorMessage(error, 'Failed to fetch test token')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveKit's ConnectionCheck exposes no cancellation: each check owns its
|
||||||
|
// own room and disconnects it when it settles. The best we can do is
|
||||||
|
// never start the next one once the run has been aborted (runStep
|
||||||
|
// short-circuits on `signal.aborted`).
|
||||||
|
await runStep('websocket', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkWebsocket())
|
||||||
|
)
|
||||||
|
await runStep('webrtc', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkWebRTC())
|
||||||
|
)
|
||||||
|
await runStep('turn', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkTURN())
|
||||||
|
)
|
||||||
|
await runStep('reconnect', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkReconnect())
|
||||||
|
)
|
||||||
|
await runStep('selectedCandidate', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.createAndRunCheck(SelectedCandidateCheck))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (microphone.state !== 'success') {
|
||||||
|
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||||
|
} else {
|
||||||
|
await runStep('publishAudio', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishAudio())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (camera.state !== 'success') {
|
||||||
|
skipSteps(['publishVideo'], 'Camera permission required')
|
||||||
|
} else {
|
||||||
|
await runStep('publishVideo', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishVideo())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [runStep, skipSteps])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
setIsRunning(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Leaving the page mid-run must stop the pending checks rather than let them
|
||||||
|
// keep a LiveKit session open behind an unmounted component.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
isRunning,
|
||||||
|
runTest,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RiCloseLine,
|
||||||
|
RiDownload2Line,
|
||||||
|
RiErrorWarningLine,
|
||||||
|
RiPlayLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Center, VStack } from '@/styled-system/jsx'
|
||||||
|
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||||
|
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||||
|
import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
|
||||||
|
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
|
||||||
|
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
|
||||||
|
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
|
|
||||||
|
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||||
|
|
||||||
|
const sectionClass = css({
|
||||||
|
width: '100%',
|
||||||
|
borderTop: '2px solid {colors.greyscale.900}',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const sectionTitleClass = css({
|
||||||
|
textStyle: 'h2',
|
||||||
|
color: 'greyscale.1000',
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rowsClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const helpClass = css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid {colors.greyscale.200}',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textStyle: 'sm',
|
||||||
|
color: 'greyscale.800',
|
||||||
|
})
|
||||||
|
|
||||||
|
const helpIconClass = css({
|
||||||
|
color: 'danger.600',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '2px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ConnectionTest = () => {
|
||||||
|
const { data, isLoading } = useConfig()
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const { steps, isRunning, runTest, reset } = useConnectionTestRunner()
|
||||||
|
|
||||||
|
const stats = useMemo(() => summarizeSteps(steps), [steps])
|
||||||
|
const stepsById = useMemo(
|
||||||
|
() => new Map(steps.map((step) => [step.id, step] as const)),
|
||||||
|
[steps]
|
||||||
|
)
|
||||||
|
const isPublishVideoRunning =
|
||||||
|
stepsById.get('publishVideo')?.status === 'running'
|
||||||
|
|
||||||
|
// LiveKit appends a bare <video> to document.body during publishVideo.
|
||||||
|
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||||
|
isPublishVideoRunning
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||||
|
}
|
||||||
|
}, [isPublishVideoRunning])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for config to load, otherwise we'd redirect off a page
|
||||||
|
// that's actually enabled.
|
||||||
|
if (!isLoading && !data?.diagnostics?.connection_test_enabled) {
|
||||||
|
navigateTo('home', undefined, { replace: true })
|
||||||
|
}
|
||||||
|
}, [isLoading, data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<Permissions />
|
||||||
|
<CenteredContent withBackButton>
|
||||||
|
<Center>
|
||||||
|
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
|
||||||
|
<ConnectionTestSummary stats={stats} isRunning={isRunning}>
|
||||||
|
{isRunning ? (
|
||||||
|
// A disabled "run" button while the test runs is dead weight:
|
||||||
|
// cancelling is the only thing left to do.
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onPress={reset}
|
||||||
|
icon={<RiCloseLine size={18} aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onPress={runTest}
|
||||||
|
icon={<RiPlayLine size={18} aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
{stats.hasStarted ? t('runAgain') : t('runTest')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{stats.hasStarted && !isRunning && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => downloadConnectionTestReport(steps)}
|
||||||
|
icon={<RiDownload2Line size={18} aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
{t('downloadReport')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ConnectionTestSummary>
|
||||||
|
|
||||||
|
{stats.hasStarted &&
|
||||||
|
CONNECTION_TEST_GROUPS.map((group) => (
|
||||||
|
<section key={group.id} className={sectionClass}>
|
||||||
|
<h2 className={sectionTitleClass}>
|
||||||
|
{t(`groups.${group.id}`)}
|
||||||
|
</h2>
|
||||||
|
<div className={rowsClass}>
|
||||||
|
{group.steps.map((id) => {
|
||||||
|
const step = stepsById.get(id)
|
||||||
|
return step ? (
|
||||||
|
<ConnectionTestStepRow key={id} step={step} />
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{stats.failed > 0 && !isRunning && (
|
||||||
|
<p className={helpClass}>
|
||||||
|
<RiErrorWarningLine
|
||||||
|
size={18}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={helpIconClass}
|
||||||
|
/>
|
||||||
|
{t('help.firewall')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionTest
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export type ConnectionTestStepId =
|
||||||
|
| 'browser'
|
||||||
|
| 'microphone'
|
||||||
|
| 'camera'
|
||||||
|
| 'devices'
|
||||||
|
| 'websocket'
|
||||||
|
| 'webrtc'
|
||||||
|
| 'turn'
|
||||||
|
| 'reconnect'
|
||||||
|
| 'selectedCandidate'
|
||||||
|
| 'publishAudio'
|
||||||
|
| 'publishVideo'
|
||||||
|
|
||||||
|
export type ConnectionTestStepStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'skipped'
|
||||||
|
|
||||||
|
export type ConnectionTestLog = {
|
||||||
|
level: 'info' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionTestStepResult = {
|
||||||
|
id: ConnectionTestStepId
|
||||||
|
status: ConnectionTestStepStatus
|
||||||
|
summary?: string
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionTestGroupId = 'local' | 'network'
|
||||||
|
|
||||||
|
/** Display order: everything local first, then everything that leaves the machine. */
|
||||||
|
export const CONNECTION_TEST_GROUPS: ReadonlyArray<{
|
||||||
|
id: ConnectionTestGroupId
|
||||||
|
steps: ReadonlyArray<ConnectionTestStepId>
|
||||||
|
}> = [
|
||||||
|
{ id: 'local', steps: ['browser', 'microphone', 'camera', 'devices'] },
|
||||||
|
{
|
||||||
|
id: 'network',
|
||||||
|
steps: [
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'selectedCandidate',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] =
|
||||||
|
CONNECTION_TEST_GROUPS.flatMap((group) => [...group.steps])
|
||||||
|
|
||||||
|
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||||
|
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||||
|
|
||||||
|
export type ConnectionTestStats = {
|
||||||
|
total: number
|
||||||
|
settled: number
|
||||||
|
passed: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
hasStarted: boolean
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single pass over the steps: the page needs half a dozen derived booleans and
|
||||||
|
* counters, and scanning the array once per render beats one `.some()` per flag.
|
||||||
|
*/
|
||||||
|
export const summarizeSteps = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
): ConnectionTestStats => {
|
||||||
|
let passed = 0
|
||||||
|
let failed = 0
|
||||||
|
let skipped = 0
|
||||||
|
let pending = 0
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.status === 'success') passed += 1
|
||||||
|
else if (step.status === 'failed') failed += 1
|
||||||
|
else if (step.status === 'skipped') skipped += 1
|
||||||
|
else if (step.status === 'pending') pending += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = steps.length
|
||||||
|
const settled = passed + failed + skipped
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
settled,
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
hasStarted: pending < total,
|
||||||
|
progress: total === 0 ? 0 : Math.round((settled / total) * 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ConnectionTestStepResult } from '../types'
|
||||||
|
|
||||||
|
export type ConnectionTestReport = {
|
||||||
|
generatedAt: string
|
||||||
|
userAgent: string
|
||||||
|
steps: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
status: ConnectionTestStepResult['status']
|
||||||
|
summary?: string
|
||||||
|
logs?: ConnectionTestStepResult['logs']
|
||||||
|
data?: ConnectionTestStepResult['data']
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildConnectionTestReport = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
): ConnectionTestReport => ({
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
steps: Object.fromEntries(
|
||||||
|
steps.map(({ id, status, summary, logs, data }) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
...(summary !== undefined ? { summary } : {}),
|
||||||
|
...(logs?.length ? { logs } : {}),
|
||||||
|
...(data !== undefined ? { data } : {}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const downloadConnectionTestReport = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
) => {
|
||||||
|
const report = buildConnectionTestReport(steps)
|
||||||
|
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||||
|
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `connection-test-${timestamp}.json`
|
||||||
|
// Firefox only follows the click when the anchor is in the document, and
|
||||||
|
// revoking the URL in the same tick cancels the download in some browsers.
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { accessTokenStore } from '@/stores/accessToken'
|
||||||
|
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive companion of resolveMediaUrl for browser-native consumers
|
||||||
|
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
|
||||||
|
* returns a stable lookup, identity in regular mode.
|
||||||
|
*
|
||||||
|
* Object URLs come from the shared session-lifetime cache and are never
|
||||||
|
* revoked here: they may be used concurrently by the background
|
||||||
|
* processors.
|
||||||
|
*/
|
||||||
|
export const useResolvedMediaUrls = (
|
||||||
|
urls: (string | null | undefined)[]
|
||||||
|
): ((url: string) => string) => {
|
||||||
|
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||||
|
const { accessToken } = useSnapshot(accessTokenStore)
|
||||||
|
|
||||||
|
// Stable dependency for the effect, insensitive to array identity
|
||||||
|
const urlsKey = urls.filter(Boolean).sort().join('\n')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken || !urlsKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const resolveAll = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
urlsKey.split('\n').map(async (url) => {
|
||||||
|
try {
|
||||||
|
return [url, await resolveMediaUrl(url)] as const
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return [url, url] as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (isMounted) {
|
||||||
|
setResolved(Object.fromEntries(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveAll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [accessToken, urlsKey])
|
||||||
|
|
||||||
|
// Stable identity so that consumers can safely list the resolver in
|
||||||
|
// their memo dependencies: it only changes when resolutions land.
|
||||||
|
return useCallback((url: string) => resolved[url] ?? url, [resolved])
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
|
// Session-lifetime cache: object URLs are shared between every consumer
|
||||||
|
// of a given media (background processors, thumbnails) and are therefore
|
||||||
|
// never revoked - their number is bounded by the user's custom
|
||||||
|
// backgrounds, and they die with the page like the access token does.
|
||||||
|
const objectUrlCache = new Map<string, string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an authenticated /media/ URL for the embedded (token) mode.
|
||||||
|
*
|
||||||
|
* Media files are served behind an nginx auth_request subrequest that
|
||||||
|
* authenticates the original request. In regular mode the session cookie
|
||||||
|
* rides along browser-native loads (img.src, CSS url()) and the URL is
|
||||||
|
* returned unchanged, without any fetch. In embedded mode the
|
||||||
|
* third-party cookie is blocked and native loads cannot carry the
|
||||||
|
* Authorization header, so the media is fetched here with the Bearer
|
||||||
|
* header - which the media-auth endpoint accepts, as it sits behind the
|
||||||
|
* default authentication stack - and exposed as a blob object URL.
|
||||||
|
*/
|
||||||
|
export const resolveMediaUrl = async (url: string): Promise<string> => {
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = objectUrlCache.get(url)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve media url ${url}: HTTP ${response.status}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(await response.blob())
|
||||||
|
objectUrlCache.set(url, objectUrl)
|
||||||
|
|
||||||
|
return objectUrl
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { css } from '@/styled-system/css'
|
|||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
@@ -160,7 +161,11 @@ const Home = () => {
|
|||||||
window.location.replace(data.external_home_url)
|
window.location.replace(data.external_home_url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRedirectFailed(true)
|
setRedirectFailed(true)
|
||||||
console.error('Site is not reachable:', error)
|
captureEvent('external-home-unreachable', {
|
||||||
|
error_name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
error_message:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useGridLayout } from '../hooks/useGridLayout'
|
|||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
extends
|
extends
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
|
|||||||
// fixme - handle dynamic audio output changes
|
// fixme - handle dynamic audio output changes
|
||||||
export const useNotificationSound = () => {
|
export const useNotificationSound = () => {
|
||||||
const notificationsSnap = useSnapshot(notificationsStore)
|
const notificationsSnap = useSnapshot(notificationsStore)
|
||||||
const [play] = useSound('./sounds/notifications.mp3', {
|
const [play] = useSound('/sounds/notifications.mp3', {
|
||||||
sprite: {
|
sprite: {
|
||||||
participantJoined: [0, 1150],
|
participantJoined: [0, 1150],
|
||||||
handRaised: [1400, 180],
|
handRaised: [1400, 180],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NotificationDuration } from './NotificationDuration'
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import type { NotificationPayload } from './NotificationPayload'
|
import type { NotificationPayload } from './NotificationPayload'
|
||||||
import type { RecordingMode } from '@/features/recording'
|
import type { RecordingMode } from '@/features/recording'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const notifyAutoMutedOnJoin = () => {
|
export const notifyAutoMutedOnJoin = () => {
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
@@ -55,7 +56,9 @@ export const decodeNotificationDataReceived = (
|
|||||||
return parsed as NotificationPayload
|
return parsed as NotificationPayload
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle errors appropriately for your application
|
// Handle errors appropriately for your application
|
||||||
console.error('Failed to decode notification payload:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to decode notification payload:',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useLowerHandParticipant } from './lowerHandParticipant'
|
import { useLowerHandParticipant } from './lowerHandParticipant'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useLowerHandParticipants = () => {
|
export const useLowerHandParticipants = () => {
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('An error occurred while lowering hands :', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'An error occurred while lowering hands :',
|
||||||
|
})
|
||||||
throw new Error('An error occurred while lowering hands.', {
|
throw new Error('An error occurred while lowering hands.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useParticipantRole = () => {
|
export const useParticipantRole = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -22,8 +23,11 @@ export const useParticipantRole = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
'generic_failure',
|
||||||
|
new Error(
|
||||||
|
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
reportError('generic_failure', e)
|
||||||
setListEnabled(true)
|
setListEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
||||||
|
|
||||||
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
|
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
|
||||||
|
sync: true,
|
||||||
|
})
|
||||||
|
|
||||||
const container = useMemo(() => {
|
const container = useMemo(() => {
|
||||||
return pipStoreSnap?.window?.document.getElementById('root')
|
return pipStoreSnap?.window?.document.getElementById('root')
|
||||||
@@ -19,7 +21,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!container) return null
|
if (!container || !container.isConnected) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ref, useSnapshot } from 'valtio'
|
import { ref, useSnapshot } from 'valtio'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const IS_PIP_SUPPORTED =
|
export const IS_PIP_SUPPORTED =
|
||||||
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
||||||
@@ -59,21 +61,29 @@ export const usePictureInPicture = () => {
|
|||||||
if (!IS_PIP_SUPPORTED) return null
|
if (!IS_PIP_SUPPORTED) return null
|
||||||
if (isOpen) return null
|
if (isOpen) return null
|
||||||
|
|
||||||
|
let pipWindow: Window
|
||||||
try {
|
try {
|
||||||
const pipWindow =
|
pipWindow =
|
||||||
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(window as any).documentPictureInPicture.requestWindow({
|
(window as any).documentPictureInPicture.requestWindow({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
initializeTitleAndLanguage(pipWindow, t('title'))
|
initializeTitleAndLanguage(pipWindow, t('title'))
|
||||||
initializePortalContainer(pipWindow)
|
initializePortalContainer(pipWindow)
|
||||||
syncStyles(pipWindow)
|
syncStyles(pipWindow)
|
||||||
|
|
||||||
const cleanUp = () => {
|
const cleanUp = () => {
|
||||||
if (documentPictureInPictureStore.window === pipWindow) {
|
if (documentPictureInPictureStore.window === pipWindow) {
|
||||||
documentPictureInPictureStore.window = null
|
flushSync(() => {
|
||||||
|
documentPictureInPictureStore.window = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
||||||
@@ -82,8 +92,10 @@ export const usePictureInPicture = () => {
|
|||||||
})
|
})
|
||||||
documentPictureInPictureStore.window = ref(pipWindow)
|
documentPictureInPictureStore.window = ref(pipWindow)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
reportError('generic_failure', error, {
|
||||||
console.error('Failed to open Picture-in-Picture window', error)
|
context: 'pip_init_failure',
|
||||||
|
})
|
||||||
|
pipWindow.close()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
|
|||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transform: 'translateY(3.25rem)',
|
translate: '0 3.25rem',
|
||||||
transition: 'opacity, transform',
|
transition: 'opacity, translate',
|
||||||
transitionDuration: '0.5s',
|
transitionDuration: '0.5s',
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
|
|||||||
isVisible: {
|
isVisible: {
|
||||||
true: {
|
true: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transform: 'translateY(0)',
|
translate: '0 0',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
shouldBeCenteredWithToggleButton,
|
shouldBeCenteredWithToggleButton,
|
||||||
setShouldBeCenteredWithToggleButton,
|
setShouldBeCenteredWithToggleButton,
|
||||||
] = useState(false)
|
] = useState(false)
|
||||||
const [rightOffset, setRightOffset] = useState(0)
|
const [offsetX, setOffsetX] = useState(0)
|
||||||
|
|
||||||
const updateArrows = useCallback(() => {
|
const updateArrows = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
||||||
setRightOffset(0)
|
setOffsetX(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
const containerCenterX = containerRect.left + containerRect.width / 2
|
const containerCenterX = containerRect.left + containerRect.width / 2
|
||||||
const shift = toggleCenterX - containerCenterX
|
const shift = toggleCenterX - containerCenterX
|
||||||
if (Math.abs(shift) < 0.5) return
|
if (Math.abs(shift) < 0.5) return
|
||||||
setRightOffset((prev) => prev - shift * 2)
|
setOffsetX((prev) => prev + shift)
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
style={
|
style={
|
||||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||||
? { marginRight: `${rightOffset}px` }
|
? { transform: `translateX(${offsetX}px)` }
|
||||||
: { margin: '0 15px' }
|
: { margin: '0 15px' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
notifyRecordingSaveInProgress,
|
notifyRecordingSaveInProgress,
|
||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { NoAccessView } from './NoAccessView'
|
import { NoAccessView } from './NoAccessView'
|
||||||
import { ControlsButton } from './ControlsButton'
|
import { ControlsButton } from './ControlsButton'
|
||||||
@@ -29,6 +28,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
|
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const ScreenRecordingSidePanel = () => {
|
export const ScreenRecordingSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingRequested,
|
type: NotificationType.ScreenRecordingRequested,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-requested', {})
|
captureEvent('screen-recording-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScreenRecording = async () => {
|
const handleScreenRecording = async () => {
|
||||||
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-started', {
|
captureEvent('screen-recording-started', {
|
||||||
includeTranscript: includeTranscript,
|
includeTranscript: includeTranscript,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle recording:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to handle recording:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
notifyRecordingSaveInProgress,
|
notifyRecordingSaveInProgress,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
@@ -35,6 +34,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionRequested,
|
type: NotificationType.TranscriptionRequested,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-requested', {})
|
captureEvent('transcript-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTranscript = async () => {
|
const handleTranscript = async () => {
|
||||||
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-started', {
|
captureEvent('transcript-started', {
|
||||||
includeScreenRecording: includeScreenRecording,
|
includeScreenRecording: includeScreenRecording,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle transcript:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to handle transcript:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRoomInfo } from '@livekit/components-react'
|
import { useRoomInfo } from '@livekit/components-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useRoomMetadata = () => {
|
export const useRoomMetadata = () => {
|
||||||
const { metadata } = useRoomInfo()
|
const { metadata } = useRoomInfo()
|
||||||
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(metadata)
|
return JSON.parse(metadata)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse room metadata:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to parse room metadata:',
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export type RoomConfiguration = {
|
|||||||
everyone_can_mute?: boolean | null
|
everyone_can_mute?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||||
|
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||||
|
|
||||||
|
export type ApiResourceAccess = {
|
||||||
|
id: string
|
||||||
|
role: ParticipantRole
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiRoom = {
|
export type ApiRoom = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -27,7 +35,12 @@ export type ApiRoom = {
|
|||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: RoomConfiguration
|
configuration?: RoomConfiguration
|
||||||
|
/**
|
||||||
|
* Only present in the API response when the requesting user is an
|
||||||
|
* administrator or owner of the room (see RoomSerializer.to_representation
|
||||||
|
* in the backend). Its presence can therefore be used to detect
|
||||||
|
* administrability outside of a LiveKit session, where the room_role
|
||||||
|
* participant attribute is not available.
|
||||||
|
*/
|
||||||
|
accesses?: ApiResourceAccess[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
|
||||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { fetchApi } from '@/api/fetchApi'
|
|||||||
|
|
||||||
export const fetchRoom = ({
|
export const fetchRoom = ({
|
||||||
roomId,
|
roomId,
|
||||||
username = '',
|
username,
|
||||||
}: {
|
}: {
|
||||||
roomId: string
|
roomId: string
|
||||||
username?: string
|
username?: string
|
||||||
}) => {
|
}) => {
|
||||||
return fetchApi<ApiRoom>(
|
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||||
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
|
|
||||||
)
|
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { fetchApi } from '@/api/fetchApi'
|
|||||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
@@ -31,12 +33,15 @@ export const useMuteParticipant = () => {
|
|||||||
|
|
||||||
// Guard against undefined token for non-admin users
|
// Guard against undefined token for non-admin users
|
||||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
||||||
console.error('Cannot mute participant: missing auth token')
|
reportError(
|
||||||
|
'participant_mute_api_failure',
|
||||||
|
new Error('Cannot mute participant: missing auth token')
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = !isAdminOrOwner
|
const headers = !isAdminOrOwner
|
||||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let response
|
let response
|
||||||
@@ -53,8 +58,11 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
'participant_mute_api_failure',
|
||||||
|
new Error(
|
||||||
|
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,8 +73,11 @@ export const useMuteParticipant = () => {
|
|||||||
destinationIdentities: [participant.identity],
|
destinationIdentities: [participant.identity],
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
'participant_mute_api_failure',
|
||||||
|
new Error(
|
||||||
|
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useMuteParticipant } from './muteParticipant'
|
import { useMuteParticipant } from './muteParticipant'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useMuteParticipants = () => {
|
export const useMuteParticipants = () => {
|
||||||
const { muteParticipant } = useMuteParticipant()
|
const { muteParticipant } = useMuteParticipant()
|
||||||
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('An error occurred while muting participants :', error)
|
reportError('participant_mute_api_failure', error, {
|
||||||
|
context: 'An error occurred while muting participants :',
|
||||||
|
})
|
||||||
throw new Error('An error occurred while muting participants.', {
|
throw new Error('An error occurred while muting participants.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { type ApiRoom } from './ApiRoom'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||||
import type { ApiError } from '@/api/ApiError'
|
import type { ApiError } from '@/api/ApiError'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
|
||||||
export type PatchRoomParams = {
|
export type PatchRoomParams = {
|
||||||
roomId: string
|
roomId: string
|
||||||
@@ -15,11 +17,25 @@ export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const patchRoomMutationKey = ['patchRoom']
|
||||||
|
|
||||||
export function usePatchRoom(
|
export function usePatchRoom(
|
||||||
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
|
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
|
||||||
) {
|
) {
|
||||||
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
|
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
|
||||||
|
mutationKey: patchRoomMutationKey,
|
||||||
mutationFn: patchRoom,
|
mutationFn: patchRoom,
|
||||||
onSuccess: options?.onSuccess,
|
onMutate: async ({ roomId, room: partialRoom }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: [keys.room, roomId] })
|
||||||
|
queryClient.setQueryData<ApiRoom>([keys.room, roomId], (previous) =>
|
||||||
|
previous ? { ...previous, ...partialRoom } : previous
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, { roomId }) => {
|
||||||
|
if (queryClient.isMutating({ mutationKey: patchRoomMutationKey }) === 1) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [keys.room, roomId] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useRenameParticipant = () => {
|
export const useRenameParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
|
|||||||
throw new Error('LiveKit token is not available')
|
throw new Error('LiveKit token is not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = getLiveKitAuthHeaders(token)
|
||||||
return fetchApi(`rooms/${data.id}/rename/`, {
|
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { getLobbyParticipantId } from '@/stores/lobby'
|
||||||
|
|
||||||
export interface RequestEntryParams {
|
export interface RequestEntryParams {
|
||||||
roomId: string
|
roomId: string
|
||||||
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiRequestEntry {
|
export interface ApiRequestEntry {
|
||||||
|
id?: string
|
||||||
status: ApiLobbyStatus
|
status: ApiLobbyStatus
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
}
|
}
|
||||||
@@ -23,10 +25,12 @@ export const requestEntry = async ({
|
|||||||
roomId,
|
roomId,
|
||||||
username = '',
|
username = '',
|
||||||
}: RequestEntryParams) => {
|
}: RequestEntryParams) => {
|
||||||
|
const participantId = getLobbyParticipantId(roomId)
|
||||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
|
...(participantId && { participant_id: participantId }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user