mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-16 13:38:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2bfbfd0cf |
@@ -8,68 +8,6 @@ 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
|
||||||
@@ -79,9 +17,6 @@ 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
|
||||||
|
|
||||||
@@ -107,12 +42,6 @@ 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,11 +65,6 @@ 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,6 +104,3 @@ 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.27.0"
|
version = "1.24.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.27.0"
|
version = "1.24.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ 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,8 +17,6 @@ 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,11 +292,6 @@ 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):
|
||||||
@@ -604,25 +599,3 @@ 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,14 +42,13 @@ 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 requests carrying a participant identifier. The
|
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||||
identifier is returned by the first request-entry response and
|
return None to skip throttling — the cookie will be set on the first
|
||||||
echoed back by the client from the second request onward, which is
|
response, and throttling will apply from the second request onward.
|
||||||
when throttling starts applying.
|
|
||||||
|
|
||||||
Keying on the identifier rather than the IP address prevents
|
Keying on the cookie rather than the IP address prevents penalising
|
||||||
penalising multiple users behind the same NAT/proxy, and is
|
multiple users behind the same NAT/proxy, and is consistent with how
|
||||||
consistent with how the lobby identifies participants.
|
LobbyService 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
|
||||||
@@ -59,14 +58,10 @@ 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.
|
||||||
|
|
||||||
serializer = serializers.RequestEntrySerializer(data=request.data)
|
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||||
if not serializer.is_valid():
|
|
||||||
return None
|
|
||||||
|
|
||||||
participant_id = serializer.validated_data.get("participant_id")
|
if participant_id is None:
|
||||||
|
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,
|
||||||
@@ -90,26 +85,3 @@ 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,10 +2,8 @@
|
|||||||
# 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
|
||||||
@@ -29,9 +27,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -41,7 +36,6 @@ 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
|
||||||
@@ -75,7 +69,6 @@ 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,
|
||||||
@@ -100,10 +93,7 @@ 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
|
||||||
@@ -239,76 +229,6 @@ 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,
|
||||||
@@ -598,7 +518,10 @@ class RoomViewSet(
|
|||||||
request=request,
|
request=request,
|
||||||
**serializer.validated_data,
|
**serializer.validated_data,
|
||||||
)
|
)
|
||||||
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
response = 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,
|
||||||
@@ -1640,68 +1563,3 @@ 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,8 +9,6 @@ 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."""
|
||||||
@@ -22,14 +20,9 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
return None # No authentication attempted
|
return None # No authentication attempted
|
||||||
|
|
||||||
parts = auth_header.split()
|
parts = auth_header.split()
|
||||||
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
# 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(
|
||||||
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
"Authorization header must be: Bearer <token>"
|
||||||
)
|
)
|
||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""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,14 +86,6 @@ 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,7 +22,6 @@ 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,
|
||||||
@@ -219,62 +218,3 @@ 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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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,7 +795,6 @@ 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, gettext, override
|
from django.utils.translation import get_language, 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 = gettext("Your recording is ready") # Force translation
|
subject = str(_("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 gettext("Transcription")
|
return _("Transcription")
|
||||||
|
|
||||||
dt = recording_datetime
|
dt = recording_datetime
|
||||||
if owner_timezone:
|
if owner_timezone:
|
||||||
|
|||||||
@@ -137,13 +137,6 @@ 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
|
||||||
@@ -235,11 +228,6 @@ 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,6 +86,23 @@ 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.
|
||||||
@@ -118,7 +135,6 @@ 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.
|
||||||
|
|
||||||
@@ -133,20 +149,22 @@ class LobbyService:
|
|||||||
5. If denied, do nothing.
|
5. If denied, do nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
participant = None
|
participant_id = self._get_or_create_participant_id(request)
|
||||||
if participant_id:
|
participant = self._get_participant(room.id, 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):
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
if participant is None:
|
||||||
self._save_participant(room.id, participant)
|
participant = LobbyParticipant(
|
||||||
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
|
username=username,
|
||||||
|
id=participant_id,
|
||||||
|
color=utils.generate_color(participant_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
@@ -154,18 +172,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 is_new_participant:
|
if participant is None:
|
||||||
self._notify_entry_request(room_id)
|
participant = self.enter(room.id, participant_id, username)
|
||||||
|
|
||||||
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
|
||||||
@@ -175,7 +193,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,36 +210,27 @@ 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 _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
def enter(
|
||||||
"""Create and persist a new waiting participant.
|
self, room_id: UUID, participant_id: str, username: str
|
||||||
|
) -> LobbyParticipant:
|
||||||
|
"""Add participant to waiting lobby.
|
||||||
|
|
||||||
Participant identifiers are minted here, server-side, exclusively.
|
Create a new participant entry in waiting status and notify room
|
||||||
|
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=utils.generate_color(participant_id),
|
color=color,
|
||||||
)
|
|
||||||
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=room_id,
|
room_name=str(room_id),
|
||||||
notification_data={
|
notification_data={
|
||||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||||
},
|
},
|
||||||
@@ -230,6 +239,15 @@ 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,7 +8,6 @@ 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,
|
||||||
@@ -89,30 +88,3 @@ 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()
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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,7 +5,6 @@ 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
|
||||||
|
|
||||||
@@ -419,63 +418,3 @@ 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,15 +2,10 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -317,38 +312,3 @@ 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,12 +2,8 @@
|
|||||||
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
|
||||||
@@ -160,40 +156,3 @@ 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,6 +14,9 @@ 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
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ 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
|
||||||
@@ -43,10 +47,11 @@ def test_request_entry_anonymous(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# The participant identifier is returned in the response body; no
|
# Verify the lobby cookie was properly set
|
||||||
# cookie is involved anymore
|
cookie = response.cookies.get("mocked-cookie")
|
||||||
assert not response.cookies
|
assert cookie is not None
|
||||||
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() == {
|
||||||
@@ -73,6 +78,7 @@ 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
|
||||||
@@ -90,10 +96,11 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# The participant identifier is returned in the response body; no
|
# Verify the lobby cookie was properly set
|
||||||
# cookie is involved anymore
|
cookie = response.cookies.get("mocked-cookie")
|
||||||
assert not response.cookies
|
assert cookie is not None
|
||||||
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() == {
|
||||||
@@ -120,6 +127,7 @@ 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
|
||||||
@@ -160,10 +168,11 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
# Verify successful response
|
# Verify successful response
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# The participant identifier is returned in the response body; no
|
# Verify the lobby cookie was properly set for the new participant
|
||||||
# cookie is involved anymore
|
cookie = response.cookies.get("mocked-cookie")
|
||||||
assert not response.cookies
|
assert cookie is not None
|
||||||
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() == {
|
||||||
@@ -188,6 +197,7 @@ 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
|
||||||
@@ -196,7 +206,9 @@ 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("core.services.lobby.uuid.uuid4", return_value="123"),
|
mock.patch.object(
|
||||||
|
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"}
|
||||||
),
|
),
|
||||||
@@ -209,6 +221,11 @@ 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",
|
||||||
@@ -218,10 +235,9 @@ def test_request_entry_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# The accepted participant is persisted, out of the waiting list
|
# Verify lobby cache is still empty after the request
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert len(lobby_keys) == 1
|
assert not lobby_keys
|
||||||
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):
|
||||||
@@ -231,6 +247,7 @@ 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
|
||||||
@@ -239,8 +256,9 @@ 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(
|
mock.patch.object(
|
||||||
"core.services.lobby.uuid.uuid4",
|
LobbyService,
|
||||||
|
"_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(
|
||||||
@@ -255,6 +273,11 @@ 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",
|
||||||
@@ -264,10 +287,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# The accepted participant is persisted, out of the waiting list
|
# Verify lobby cache is still empty after the request
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert len(lobby_keys) == 1
|
assert not lobby_keys
|
||||||
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):
|
||||||
@@ -275,6 +297,7 @@ 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
|
||||||
@@ -288,7 +311,9 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simulate a returning participant echoing its identifier
|
# Simulate a browser with existing participant cookie
|
||||||
|
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(
|
||||||
@@ -297,14 +322,16 @@ 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",
|
||||||
@@ -610,14 +637,15 @@ 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_unidentified(
|
def test_request_entry_throttling_anonymous_without_cookie(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Requests without a participant identifier should not be throttled."""
|
"""Anonymous users without a cookie 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(
|
||||||
@@ -626,6 +654,9 @@ def test_request_entry_throttling_anonymous_unidentified(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/",
|
||||||
@@ -639,32 +670,34 @@ def test_request_entry_throttling_anonymous_unidentified(
|
|||||||
@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_identified(
|
def test_request_entry_throttling_anonymous_with_cookie(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Identified requests should be throttled after exceeding the rate limit."""
|
"""Anonymous users with a cookie 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", "participant_id": participant_id},
|
{"username": "test_user"},
|
||||||
)
|
)
|
||||||
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", "participant_id": participant_id},
|
{"username": "test_user"},
|
||||||
)
|
)
|
||||||
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", "participant_id": participant_id},
|
{"username": "test_user"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
@@ -683,6 +716,7 @@ 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(
|
||||||
@@ -703,124 +737,3 @@ 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,11 +20,7 @@ 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 (
|
from core.services.lobby import LobbyService
|
||||||
LobbyParticipant,
|
|
||||||
LobbyParticipantStatus,
|
|
||||||
LobbyService,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -91,7 +87,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -117,7 +113,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -157,7 +153,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -304,7 +300,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -334,7 +330,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -365,7 +361,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -385,7 +381,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -416,7 +412,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -444,7 +440,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -473,7 +469,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -853,15 +849,7 @@ 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()._save_participant(
|
LobbyService().enter(room.id, participant_identity, "John doe")
|
||||||
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)
|
||||||
@@ -1032,6 +1020,3 @@ 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,10 +69,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -87,10 +84,7 @@ 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,
|
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -107,10 +101,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -126,10 +117,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -140,9 +128,7 @@ 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(
|
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
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
|
||||||
@@ -156,7 +142,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"X-LiveKit-Token {token}",
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -180,10 +166,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
||||||
{"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
|
||||||
@@ -198,10 +181,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -220,7 +200,7 @@ def test_toggle_hand_raise_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -240,7 +220,7 @@ def test_toggle_hand_lower_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": False},
|
{"raised": False},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -260,7 +240,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -277,10 +257,7 @@ 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,
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -295,10 +272,7 @@ 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,
|
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -312,10 +286,7 @@ 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,
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -327,7 +298,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"X-LiveKit-Token {token}"
|
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -338,9 +309,7 @@ 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(
|
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
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
|
||||||
@@ -351,10 +320,7 @@ 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,
|
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -382,7 +348,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"X-LiveKit-Token {wrong_token}",
|
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -397,10 +363,7 @@ 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,
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -419,7 +382,7 @@ def test_rename_participant_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -439,7 +402,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"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -456,7 +419,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -473,7 +436,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"X-LiveKit-Token {anonymous_token}",
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -499,7 +462,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -513,7 +476,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"X-LiveKit-Token {expired_token}",
|
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -527,7 +490,7 @@ def test_toggle_hand_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -541,10 +504,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -559,10 +519,7 @@ 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,
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -579,7 +536,7 @@ def test_rename_participant_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -593,10 +550,7 @@ 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,
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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
|
||||||
@@ -611,16 +565,10 @@ 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,
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
{"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,14 +3,11 @@ 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
|
||||||
|
|
||||||
@@ -510,40 +507,3 @@ 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="X-LiveKit-Token invalid-token",
|
HTTP_AUTHORIZATION="Bearer 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"X-LiveKit-Token {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"Bearer {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"X-LiveKit-Token {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"Bearer {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"X-LiveKit-Token {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"Bearer {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"X-LiveKit-Token {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -219,13 +219,10 @@ 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"X-LiveKit-Token {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"Bearer {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,12 +3,8 @@ 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
|
||||||
|
|
||||||
@@ -441,45 +437,3 @@ 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,7 +720,6 @@ 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
|
||||||
|
|
||||||
@@ -824,33 +823,3 @@ 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,6 +3,7 @@ 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
|
||||||
@@ -10,6 +11,7 @@ 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
|
||||||
|
|
||||||
@@ -133,6 +135,59 @@ 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)
|
||||||
@@ -211,12 +266,11 @@ 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(
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
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"}
|
||||||
@@ -250,12 +304,11 @@ 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(
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
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"}
|
||||||
@@ -272,19 +325,18 @@ 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._notify_entry_request")
|
@mock.patch("core.services.lobby.LobbyService.enter")
|
||||||
@mock.patch("core.services.lobby.LobbyService._create_participant")
|
|
||||||
def test_request_entry_new_participant(
|
def test_request_entry_new_participant(
|
||||||
mock_create, mock_notify, lobby_service, participant_id, username
|
mock_enter, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""A new participant gets a server-minted identifier - any provided
|
"""Test requesting entry for a new participant."""
|
||||||
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(
|
||||||
@@ -293,20 +345,14 @@ def test_request_entry_new_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
mock_create.return_value = participant_data
|
mock_enter.return_value = participant_data
|
||||||
|
|
||||||
forged_id = str(uuid.uuid4())
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
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
|
||||||
# The provided identifier was looked up, found unknown, and replaced
|
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||||
# by a freshly minted participant
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
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")
|
||||||
@@ -315,6 +361,7 @@ 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)
|
||||||
@@ -325,11 +372,10 @@ 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(
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
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
|
||||||
@@ -344,6 +390,7 @@ 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)
|
||||||
|
|
||||||
@@ -353,13 +400,12 @@ 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(
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
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"}
|
||||||
@@ -382,6 +428,7 @@ 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)
|
||||||
|
|
||||||
@@ -393,13 +440,12 @@ 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(
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
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"}
|
||||||
@@ -426,47 +472,73 @@ 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")
|
||||||
def test_create_participant(
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
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,
|
|
||||||
):
|
):
|
||||||
"""A created participant is waiting, colored, and persisted."""
|
"""Test successful participant entry."""
|
||||||
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._create_participant(room.id, username)
|
participant = lobby_service.enter(room.id, participant_id, username)
|
||||||
|
|
||||||
# The identifier is minted server-side
|
mock_generate_color.assert_called_once_with(participant_id)
|
||||||
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.patch("core.utils.notify_participants")
|
|
||||||
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
|
||||||
"""A notification error must not break the entry request flow."""
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
|
||||||
|
|
||||||
lobby_service._notify_entry_request("room-id")
|
|
||||||
|
|
||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name="room-id", notification_data={"type": "participantWaiting"}
|
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")
|
||||||
|
def test_enter_with_notification_error(
|
||||||
|
mock_notify,
|
||||||
|
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")
|
||||||
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
participant = lobby_service.enter(room.id, participant_id, username)
|
||||||
|
|
||||||
|
mock_generate_color.assert_called_once_with(participant_id)
|
||||||
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""
|
|
||||||
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,11 +30,6 @@ 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()
|
||||||
@@ -48,11 +43,6 @@ 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,7 +12,6 @@ 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
|
||||||
@@ -68,7 +67,6 @@ 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.
|
||||||
|
|
||||||
@@ -84,7 +82,6 @@ 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.
|
||||||
@@ -138,8 +135,6 @@ def generate_token( # noqa: PLR0917
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if ttl is not None:
|
|
||||||
token = token.with_ttl(ttl)
|
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,6 @@ 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": [
|
||||||
@@ -345,11 +344,6 @@ 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",
|
||||||
@@ -360,11 +354,6 @@ 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 = (
|
||||||
@@ -671,30 +660,6 @@ 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
|
||||||
)
|
)
|
||||||
@@ -881,6 +846,11 @@ 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(
|
||||||
@@ -1003,61 +973,6 @@ 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(
|
||||||
@@ -1354,12 +1269,6 @@ 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.27.0"
|
version = "1.24.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.27.0"
|
version = "1.24.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ 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,11 +4,6 @@ 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
+20
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.27.0",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.27.0",
|
"version": "1.24.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,13 +15,14 @@
|
|||||||
"@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.14",
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@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",
|
||||||
@@ -1048,12 +1049,18 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@livekit/track-processors/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",
|
||||||
@@ -4709,6 +4716,15 @@
|
|||||||
"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.27.0",
|
"version": "1.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -22,13 +22,14 @@
|
|||||||
"@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.14",
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"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"}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
+17
-24
@@ -12,7 +12,6 @@ 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'
|
||||||
|
|
||||||
@@ -25,29 +24,23 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TransitCodeGate>
|
{!isSDKContext && <AppInitialization />}
|
||||||
{!isSDKContext && <AppInitialization />}
|
<Suspense fallback={null}>
|
||||||
<Suspense fallback={null}>
|
<I18nProvider locale={i18n.language}>
|
||||||
<I18nProvider locale={i18n.language}>
|
<Layout>
|
||||||
<Layout>
|
<Switch>
|
||||||
<Switch>
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Route key={i} path={route.path} component={route.Component} />
|
||||||
<Route
|
))}
|
||||||
key={i}
|
<Route component={NotFoundScreen} />
|
||||||
path={route.path}
|
</Switch>
|
||||||
component={route.Component}
|
</Layout>
|
||||||
/>
|
<ReactQueryDevtools
|
||||||
))}
|
initialIsOpen={false}
|
||||||
<Route component={NotFoundScreen} />
|
buttonPosition="bottom-left"
|
||||||
</Switch>
|
/>
|
||||||
</Layout>
|
</I18nProvider>
|
||||||
<ReactQueryDevtools
|
</Suspense>
|
||||||
initialIsOpen={false}
|
|
||||||
buttonPosition="bottom-left"
|
|
||||||
/>
|
|
||||||
</I18nProvider>
|
|
||||||
</Suspense>
|
|
||||||
</TransitCodeGate>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
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,9 +45,6 @@ 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, { useLayoutEffect, useMemo } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const avatar = cva({
|
const avatar = cva({
|
||||||
base: {
|
base: {
|
||||||
@@ -28,34 +28,13 @@ 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 = getFirstGrapheme(words[0])
|
const first = words[0].charAt(0)
|
||||||
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
|
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||||
return (first + second).toLocaleUpperCase()
|
return (first + second).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
@@ -65,37 +44,7 @@ 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 = useMemo(() => getInitials(name), [name])
|
const initials = getInitials(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 }}
|
||||||
@@ -108,17 +57,16 @@ 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"
|
||||||
transform={`translate(0 ${offsetY})`}
|
dy="-0.08em"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
{initials}
|
{initials.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,31 +2,24 @@ 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 { devices, activeDeviceId } = useMediaDeviceSelect({
|
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
kind: 'audiooutput',
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canTestAudioOutput() || !activeDeviceId) return
|
const updateActiveId = async (deviceId: string) => {
|
||||||
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
try {
|
||||||
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
await audioRef?.current?.setSinkId(deviceId)
|
||||||
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
} catch (error) {
|
||||||
return
|
console.error(`Error setting sinkId: ${error}`)
|
||||||
}
|
}
|
||||||
reportError(
|
}
|
||||||
'device_switch_failure',
|
updateActiveId(activeDeviceId)
|
||||||
new Error(`Error setting sinkId: ${error}`)
|
}, [activeDeviceId])
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [devices, activeDeviceId])
|
|
||||||
|
|
||||||
// prevent pausing the sound
|
// prevent pausing the sound
|
||||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
@@ -35,13 +28,9 @@ export const SoundTester = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
onPress={async () => {
|
onPress={() => {
|
||||||
try {
|
audioRef?.current?.play()
|
||||||
await audioRef?.current?.play()
|
setIsPlaying(true)
|
||||||
setIsPlaying(true)
|
|
||||||
} catch {
|
|
||||||
setIsPlaying(false)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
isDisabled={isPlaying}
|
isDisabled={isPlaying}
|
||||||
@@ -55,7 +44,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)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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,8 +1,15 @@
|
|||||||
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'
|
|
||||||
import { filterExceptions } from '../exceptionFilters'
|
let posthog: PostHog | null = null
|
||||||
|
|
||||||
|
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) => {
|
||||||
@@ -31,6 +38,7 @@ export const useAnalytics = ({
|
|||||||
flags_api_host,
|
flags_api_host,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: useAnalyticsProps) => {
|
}: useAnalyticsProps) => {
|
||||||
|
const [location] = useLocation()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,14 +49,6 @@ 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,5 +58,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
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()) })
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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,7 +2,6 @@ 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.
|
||||||
@@ -26,13 +25,7 @@ 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
|
||||||
// Never attempt a silent login in embedded (token) mode: an OIDC
|
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||||
// redirect inside the iframe would break the embed.
|
|
||||||
if (
|
|
||||||
opts.attemptSilent &&
|
|
||||||
!getAccessToken() &&
|
|
||||||
canAttemptSilentLogin()
|
|
||||||
) {
|
|
||||||
attemptSilentLogin(30)
|
attemptSilentLogin(30)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ 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.
|
||||||
@@ -44,11 +42,6 @@ export const useSyncUserPreferencesWithBackend = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncBrowserPreferencesToBackend().catch((error) => {
|
syncBrowserPreferencesToBackend()
|
||||||
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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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 '../utils'
|
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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',
|
|
||||||
})
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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' }),
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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])
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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,7 +15,6 @@ 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 (
|
||||||
@@ -161,11 +160,7 @@ const Home = () => {
|
|||||||
window.location.replace(data.external_home_url)
|
window.location.replace(data.external_home_url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRedirectFailed(true)
|
setRedirectFailed(true)
|
||||||
captureEvent('external-home-unreachable', {
|
console.error('Site is not reachable:', error)
|
||||||
error_name: error instanceof Error ? error.name : 'Unknown',
|
|
||||||
error_message:
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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,7 +4,6 @@ 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(
|
||||||
@@ -56,9 +55,7 @@ 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
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to decode notification payload:', error)
|
||||||
context: 'Failed to decode notification payload:',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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()
|
||||||
@@ -12,9 +11,7 @@ export const useLowerHandParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('An error occurred while lowering hands :', 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,7 +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 { 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()
|
||||||
@@ -23,11 +22,8 @@ export const useParticipantRole = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError(
|
console.error(
|
||||||
'generic_failure',
|
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
new Error(
|
|
||||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError('generic_failure', e)
|
console.error(e)
|
||||||
setListEnabled(true)
|
setListEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ 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')
|
||||||
@@ -21,7 +19,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!container || !container.isConnected) return null
|
if (!container) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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
|
||||||
@@ -61,29 +59,21 @@ 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 {
|
||||||
pipWindow =
|
const 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) {
|
||||||
flushSync(() => {
|
documentPictureInPictureStore.window = null
|
||||||
documentPictureInPictureStore.window = null
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
||||||
@@ -92,10 +82,8 @@ export const usePictureInPicture = () => {
|
|||||||
})
|
})
|
||||||
documentPictureInPictureStore.window = ref(pipWindow)
|
documentPictureInPictureStore.window = ref(pipWindow)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||||
context: 'pip_init_failure',
|
console.error('Failed to open Picture-in-Picture window', error)
|
||||||
})
|
|
||||||
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,
|
||||||
translate: '0 3.25rem',
|
transform: 'translateY(3.25rem)',
|
||||||
transition: 'opacity, translate',
|
transition: 'opacity, transform',
|
||||||
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,
|
||||||
translate: '0 0',
|
transform: 'translateY(0)',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
shouldBeCenteredWithToggleButton,
|
shouldBeCenteredWithToggleButton,
|
||||||
setShouldBeCenteredWithToggleButton,
|
setShouldBeCenteredWithToggleButton,
|
||||||
] = useState(false)
|
] = useState(false)
|
||||||
const [offsetX, setOffsetX] = useState(0)
|
const [rightOffset, setRightOffset] = 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) {
|
||||||
setOffsetX(0)
|
setRightOffset(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
|
||||||
setOffsetX((prev) => prev + shift)
|
setRightOffset((prev) => prev - shift * 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
style={
|
style={
|
||||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||||
? { transform: `translateX(${offsetX}px)` }
|
? { marginRight: `${rightOffset}px` }
|
||||||
: { margin: '0 15px' }
|
: { margin: '0 15px' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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'
|
||||||
@@ -28,7 +29,6 @@ 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,
|
||||||
})
|
})
|
||||||
captureEvent('screen-recording-requested', {})
|
posthog.capture('screen-recording-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScreenRecording = async () => {
|
const handleScreenRecording = async () => {
|
||||||
@@ -100,15 +100,13 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
captureEvent('screen-recording-started', {
|
posthog.capture('screen-recording-started', {
|
||||||
includeTranscript: includeTranscript,
|
includeTranscript: includeTranscript,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to handle recording:', error)
|
||||||
context: 'Failed to handle recording:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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'
|
||||||
@@ -34,7 +35,6 @@ 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,
|
||||||
})
|
})
|
||||||
captureEvent('transcript-requested', {})
|
posthog.capture('transcript-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTranscript = async () => {
|
const handleTranscript = async () => {
|
||||||
@@ -121,15 +121,13 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
captureEvent('transcript-started', {
|
posthog.capture('transcript-started', {
|
||||||
includeScreenRecording: includeScreenRecording,
|
includeScreenRecording: includeScreenRecording,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to handle transcript:', error)
|
||||||
context: 'Failed to handle transcript:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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()
|
||||||
@@ -9,9 +8,7 @@ export const useRoomMetadata = () => {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(metadata)
|
return JSON.parse(metadata)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to parse room metadata:', error)
|
||||||
context: 'Failed to parse room metadata:',
|
|
||||||
})
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ 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
|
||||||
@@ -35,12 +27,7 @@ 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
|
||||||
}) => {
|
}) => {
|
||||||
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
return fetchApi<ApiRoom>(
|
||||||
|
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
|
||||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ 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()
|
||||||
@@ -33,15 +31,12 @@ 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) {
|
||||||
reportError(
|
console.error('Cannot mute participant: missing auth token')
|
||||||
'participant_mute_api_failure',
|
|
||||||
new Error('Cannot mute participant: missing auth token')
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = !isAdminOrOwner
|
const headers = !isAdminOrOwner
|
||||||
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let response
|
let response
|
||||||
@@ -58,11 +53,8 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError(
|
console.error(
|
||||||
'participant_mute_api_failure',
|
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
new Error(
|
|
||||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,11 +65,8 @@ export const useMuteParticipant = () => {
|
|||||||
destinationIdentities: [participant.identity],
|
destinationIdentities: [participant.identity],
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError(
|
console.error(
|
||||||
'participant_mute_api_failure',
|
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||||
new Error(
|
|
||||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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()
|
||||||
@@ -12,9 +11,7 @@ export const useMuteParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('participant_mute_api_failure', error, {
|
console.error('An error occurred while muting participants :', 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,8 +2,6 @@ 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
|
||||||
@@ -17,25 +15,11 @@ 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,
|
||||||
onMutate: async ({ roomId, room: partialRoom }) => {
|
onSuccess: options?.onSuccess,
|
||||||
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,6 +1,5 @@
|
|||||||
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()
|
||||||
@@ -16,10 +15,11 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -16,7 +15,6 @@ export enum ApiLobbyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiRequestEntry {
|
export interface ApiRequestEntry {
|
||||||
id?: string
|
|
||||||
status: ApiLobbyStatus
|
status: ApiLobbyStatus
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
}
|
}
|
||||||
@@ -25,12 +23,10 @@ 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