mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-22 16:16:52 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06494371cc | |||
| 4f5d5d1b9a | |||
| e5f0b1c202 | |||
| 9e0d57a8c6 | |||
| 7ba0803b71 | |||
| beacfc3d3f | |||
| 52e5d99e83 | |||
| 15ca2b41b4 | |||
| e34f3dd219 | |||
| feb573551f | |||
| 1d0a0cc637 | |||
| eae93f771f | |||
| 45175a2a54 | |||
| 8b22059b18 | |||
| 87dbd8069d | |||
| c7420c59a3 | |||
| f46babfcbd | |||
| 7c465f2148 | |||
| d005f202c6 | |||
| a82023f8b0 |
@@ -51,12 +51,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 📈(frontend) track errors when starting or stopping a recording
|
||||||
|
- 🚸(frontend) explain camera-in-use failures on the join screen
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ✨(backend) accept form-urlencoded on the user token endpoint
|
||||||
|
- ✨(summary) configurable s3 region
|
||||||
|
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
||||||
|
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
||||||
|
- 💄(frontend) increase the blur intensity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 📝(docs) fix minor typos in comments and docstrings
|
||||||
|
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
|
||||||
|
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
|
||||||
|
- 🐛(frontend) treat client-initiated connect aborts as events
|
||||||
|
- 🐛(frontend) use state instead of a ref for MoreControls container
|
||||||
|
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
||||||
|
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
||||||
|
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
||||||
|
|
||||||
## [1.27.0] - 2026-08-14
|
## [1.27.0] - 2026-08-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Security is very important to us.
|
Security is very important to us.
|
||||||
|
|
||||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
|
If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
|
||||||
|
|
||||||
We appreciate your effort to make Visio more secure.
|
We appreciate your effort to make Visio more secure.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:18
|
image: node:22
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
|
|||||||
@@ -50,10 +50,24 @@ paths:
|
|||||||
|
|
||||||
The application must be authorized for the user's email domain.
|
The application must be authorized for the user's email domain.
|
||||||
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
|
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
|
||||||
|
|
||||||
|
Request parameters may be sent either as "application/x-www-form-urlencoded"
|
||||||
|
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
|
||||||
operationId: generateToken
|
operationId: generateToken
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
application/x-www-form-urlencoded:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TokenRequest'
|
||||||
|
examples:
|
||||||
|
tokenRequest:
|
||||||
|
summary: Request token for user delegation
|
||||||
|
value:
|
||||||
|
client_id: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||||
|
grant_type: "client_credentials"
|
||||||
|
scope: "user@example.com"
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/TokenRequest'
|
$ref: '#/components/schemas/TokenRequest'
|
||||||
@@ -117,6 +131,19 @@ paths:
|
|||||||
summary: Domain not authorized
|
summary: Domain not authorized
|
||||||
value:
|
value:
|
||||||
error: "This application is not authorized for this email domain."
|
error: "This application is not authorized for this email domain."
|
||||||
|
'415':
|
||||||
|
description: |
|
||||||
|
Unsupported media type. The request body must be sent as
|
||||||
|
"application/x-www-form-urlencoded" or "application/json".
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
examples:
|
||||||
|
unsupportedMediaType:
|
||||||
|
summary: Unsupported request content type
|
||||||
|
value:
|
||||||
|
detail: 'Unsupported media type "text/plain" in request.'
|
||||||
|
|
||||||
/rooms:
|
/rooms:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class FeatureFlag:
|
|||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
"connection_test": "CONNECTION_TEST_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,20 +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."""
|
|
||||||
|
|
||||||
code = serializers.CharField(trim_whitespace=True)
|
|
||||||
|
|
||||||
def validate_code(self, value):
|
|
||||||
"""Reject codes whose length cannot match a generated one."""
|
|
||||||
|
|
||||||
# Calculates urlsafe_b64encode length without padding
|
|
||||||
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,
|
||||||
@@ -102,14 +97,3 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle anonymous users requesting connection test tokens."""
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
scope = "connection_test"
|
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"
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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 import throttling
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.authentication.user_token import USER_ACCESS_TOKEN_TYPE_CLAIM
|
|
||||||
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
|
||||||
from core.recording.event.authentication import (
|
from core.recording.event.authentication import (
|
||||||
@@ -76,7 +75,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,
|
||||||
@@ -101,7 +99,6 @@ from core.services.room_roles import (
|
|||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
)
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
from core.services.transit_code import TransitCodeService
|
|
||||||
from core.tasks.connection_test import delete_connection_test_room
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
from core.utils import generate_token
|
from core.utils import generate_token
|
||||||
@@ -240,96 +237,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.
|
|
||||||
"""
|
|
||||||
if request.user and request.user.is_authenticated:
|
|
||||||
logger.warning(
|
|
||||||
"Transit code exchange refused: request is already "
|
|
||||||
"session-authenticated (user_id=%s)",
|
|
||||||
request.user.id,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied("Already authenticated.")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if not models.Application.has_active_scope(
|
|
||||||
code_data.get("client_id"), models.ApplicationScope.USERS_SESSION
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"Transit code exchange refused: application '%s' no longer "
|
|
||||||
"holds the '%s' grant",
|
|
||||||
code_data.get("client_id"),
|
|
||||||
models.ApplicationScope.USERS_SESSION,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied(
|
|
||||||
"This application can no longer create user sessions."
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = token_service.generate_jwt(
|
|
||||||
user,
|
|
||||||
"user:access",
|
|
||||||
{
|
|
||||||
"client_id": code_data.get("client_id", "unknown"),
|
|
||||||
"token_type": USER_ACCESS_TOKEN_TYPE_CLAIM,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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,
|
||||||
@@ -616,10 +523,13 @@ class RoomViewSet(
|
|||||||
|
|
||||||
participant, livekit = lobby_service.request_entry(
|
participant, livekit = lobby_service.request_entry(
|
||||||
room=room,
|
room=room,
|
||||||
user=request.user,
|
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,
|
||||||
|
|||||||
@@ -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,82 +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
|
|
||||||
from core.models import Application, ApplicationScope
|
|
||||||
|
|
||||||
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, or if the issuing application lost its grant.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
if not payload.get("client_id"):
|
|
||||||
logger.warning("Missing 'client_id' in user access token payload")
|
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
|
||||||
|
|
||||||
if not Application.has_active_scope(
|
|
||||||
payload["client_id"], ApplicationScope.USERS_SESSION
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"User access token refused: application '%s' no longer "
|
|
||||||
"holds the '%s' grant",
|
|
||||||
payload["client_id"],
|
|
||||||
ApplicationScope.USERS_SESSION,
|
|
||||||
)
|
|
||||||
raise exceptions.AuthenticationFailed("Application access revoked.")
|
|
||||||
@@ -20,60 +20,8 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
|
|
||||||
scope_map: Dict[str, str] = {}
|
scope_map: Dict[str, str] = {}
|
||||||
|
|
||||||
def get_required_scope(self, view):
|
|
||||||
"""Return the scope required by the view's current action.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The required scope, or None for an unsupported method so
|
|
||||||
DRF's router can answer 405.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
PermissionDenied: If the action is not in scope_map (deny by
|
|
||||||
default).
|
|
||||||
"""
|
|
||||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
|
||||||
action = getattr(view, "action", None)
|
|
||||||
if not action:
|
|
||||||
# DRF routers return a 405 for unsupported methods
|
|
||||||
return None
|
|
||||||
|
|
||||||
required_scope = self.scope_map.get(action)
|
|
||||||
if not required_scope:
|
|
||||||
# Action not in scope_map, deny by default
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return required_scope
|
|
||||||
|
|
||||||
def get_token_scopes(self, request):
|
|
||||||
"""Extract and normalize the scopes claimed by the token."""
|
|
||||||
token_scopes = (request.auth or {}).get("scope")
|
|
||||||
|
|
||||||
if not token_scopes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Ensure scopes is a list (handle both list and space-separated string)
|
|
||||||
if isinstance(token_scopes, str):
|
|
||||||
token_scopes = token_scopes.split()
|
|
||||||
|
|
||||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
|
||||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
|
||||||
|
|
||||||
return self.strip_scope_prefix(token_scopes)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def strip_scope_prefix(token_scopes):
|
|
||||||
"""Strip the OIDC resource server prefix, when configured."""
|
|
||||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
|
||||||
return [
|
|
||||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
|
||||||
for scope in token_scopes
|
|
||||||
]
|
|
||||||
return token_scopes
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""Check if the token claims the scope required by this action.
|
"""Check if the JWT token contains the required scope for this action.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: DRF request object with authenticated user
|
request: DRF request object with authenticated user
|
||||||
@@ -85,15 +33,38 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
Raises:
|
Raises:
|
||||||
PermissionDenied: If required scope is missing from token
|
PermissionDenied: If required scope is missing from token
|
||||||
"""
|
"""
|
||||||
required_scope = self.get_required_scope(view)
|
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||||
if required_scope is None:
|
action = getattr(view, "action", None)
|
||||||
|
if not action:
|
||||||
|
# DRF routers return a 405 for unsupported methods
|
||||||
return True
|
return True
|
||||||
|
|
||||||
token_scopes = self.get_token_scopes(request)
|
required_scope = self.scope_map.get(action)
|
||||||
|
if not required_scope:
|
||||||
|
# Action not in scope_map, deny by default
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_payload = request.auth
|
||||||
|
token_scopes = token_payload.get("scope")
|
||||||
|
|
||||||
if not token_scopes:
|
if not token_scopes:
|
||||||
raise exceptions.PermissionDenied("Insufficient permissions.")
|
raise exceptions.PermissionDenied("Insufficient permissions.")
|
||||||
|
|
||||||
|
# Ensure scopes is a list (handle both list and space-separated string)
|
||||||
|
if isinstance(token_scopes, str):
|
||||||
|
token_scopes = token_scopes.split()
|
||||||
|
|
||||||
|
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||||
|
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||||
|
|
||||||
|
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||||
|
token_scopes = [
|
||||||
|
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||||
|
for scope in token_scopes
|
||||||
|
]
|
||||||
|
|
||||||
if required_scope not in token_scopes:
|
if required_scope not in token_scopes:
|
||||||
raise exceptions.PermissionDenied(
|
raise exceptions.PermissionDenied(
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
@@ -102,37 +73,6 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class ApplicationScopePermission(BaseScopePermission):
|
|
||||||
"""Scope-based permission for application-authenticated endpoints."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def strip_scope_prefix(token_scopes):
|
|
||||||
"""Compare application scopes verbatim."""
|
|
||||||
return token_scopes
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
"""Check the scope claim, then the grant recorded in the database."""
|
|
||||||
granted = super().has_permission(request, view)
|
|
||||||
|
|
||||||
required_scope = self.get_required_scope(view)
|
|
||||||
|
|
||||||
if granted and required_scope:
|
|
||||||
client_id = (request.auth or {}).get("client_id")
|
|
||||||
|
|
||||||
if not models.Application.has_active_scope(client_id, required_scope):
|
|
||||||
logger.warning(
|
|
||||||
"Application '%s' presented scope '%s' without a matching "
|
|
||||||
"grant in database",
|
|
||||||
client_id,
|
|
||||||
required_scope,
|
|
||||||
)
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
f"Application is not granted the required scope: {required_scope}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return granted
|
|
||||||
|
|
||||||
|
|
||||||
class HasRequiredRoomScope(BaseScopePermission):
|
class HasRequiredRoomScope(BaseScopePermission):
|
||||||
"""Permission class for Room-related operations."""
|
"""Permission class for Room-related operations."""
|
||||||
|
|
||||||
@@ -146,14 +86,6 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class HasRequiredUserScope(ApplicationScopePermission):
|
|
||||||
"""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."""
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from rest_framework import decorators, mixins, viewsets
|
|||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
parsers as drf_parsers,
|
||||||
|
)
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
response as drf_response,
|
response as drf_response,
|
||||||
)
|
)
|
||||||
@@ -22,7 +25,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,
|
||||||
@@ -42,6 +44,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="token",
|
url_path="token",
|
||||||
url_name="token",
|
url_name="token",
|
||||||
|
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("application")
|
@FeatureFlag.require("application")
|
||||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||||
@@ -219,59 +222,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,
|
|
||||||
]
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
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",
|
|
||||||
request.user.id,
|
|
||||||
client_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+1
-1
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0022_alter_application_scopes'),
|
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -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):
|
||||||
@@ -845,18 +844,6 @@ class Application(BaseModel):
|
|||||||
domain = get_domain_from_email(email)
|
domain = get_domain_from_email(email)
|
||||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def has_active_scope(cls, client_id, scope) -> bool:
|
|
||||||
"""Check that an active application holds a scope."""
|
|
||||||
if not client_id or not scope:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return cls.objects.filter(
|
|
||||||
client_id=client_id,
|
|
||||||
is_active=True,
|
|
||||||
scopes__contains=[scope],
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class ApplicationDomain(BaseModel):
|
class ApplicationDomain(BaseModel):
|
||||||
"""Domain authorized for application delegation."""
|
"""Domain authorized for application delegation."""
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -116,9 +133,8 @@ class LobbyService:
|
|||||||
def request_entry(
|
def request_entry(
|
||||||
self,
|
self,
|
||||||
room: models.Room,
|
room: models.Room,
|
||||||
user,
|
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,48 +149,51 @@ 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(user)
|
user_role = room.get_role(request.user)
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=user, role=user_role):
|
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||||
participant = self.handle_participant_entry(room_id, participant.id, True)
|
if participant is None:
|
||||||
|
participant = LobbyParticipant(
|
||||||
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
|
username=username,
|
||||||
|
id=participant_id,
|
||||||
|
color=utils.generate_color(participant_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=user,
|
user=request.user,
|
||||||
username=participant.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
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=user,
|
user=request.user,
|
||||||
username=participant.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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,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,
|
||||||
},
|
},
|
||||||
@@ -229,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]:
|
||||||
@@ -275,7 +294,7 @@ class LobbyService:
|
|||||||
room_id: UUID,
|
room_id: UUID,
|
||||||
participant_id: str,
|
participant_id: str,
|
||||||
allow_entry: bool,
|
allow_entry: bool,
|
||||||
) -> LobbyParticipant:
|
) -> None:
|
||||||
"""Handle decision on participant entry.
|
"""Handle decision on participant entry.
|
||||||
|
|
||||||
Updates participant status based on allow_entry:
|
Updates participant status based on allow_entry:
|
||||||
@@ -293,7 +312,7 @@ class LobbyService:
|
|||||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||||
}
|
}
|
||||||
|
|
||||||
return self._update_participant_status(room_id, participant_id, **decision)
|
self._update_participant_status(room_id, participant_id, **decision)
|
||||||
|
|
||||||
def _update_participant_status(
|
def _update_participant_status(
|
||||||
self,
|
self,
|
||||||
@@ -301,7 +320,7 @@ class LobbyService:
|
|||||||
participant_id: str,
|
participant_id: str,
|
||||||
status: LobbyParticipantStatus,
|
status: LobbyParticipantStatus,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
) -> LobbyParticipant:
|
) -> None:
|
||||||
"""Update participant status with appropriate timeout."""
|
"""Update participant status with appropriate timeout."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
@@ -323,8 +342,6 @@ class LobbyService:
|
|||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
|
||||||
return participant
|
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -2,18 +2,15 @@
|
|||||||
Test rooms API endpoints in the Meet core app: create.
|
Test rooms API endpoints in the Meet core app: create.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import ApplicationScope, Room, RoomAccessLevel
|
from ...models import Room, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -315,39 +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)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"iss": settings.USER_ACCESS_TOKEN_ISSUER,
|
|
||||||
"aud": settings.USER_ACCESS_TOKEN_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL),
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"token_type": "user_access",
|
|
||||||
"client_id": application.client_id,
|
|
||||||
"scope": "user:access",
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=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,18 +2,14 @@
|
|||||||
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
|
||||||
|
|
||||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import ApplicationScope, RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -160,41 +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)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
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": application.client_id,
|
|
||||||
"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,14 +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
|
||||||
|
|
||||||
ttl = cache.ttl(lobby_keys[0])
|
|
||||||
assert ttl is not None
|
|
||||||
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
|
|
||||||
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):
|
||||||
@@ -235,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
|
||||||
@@ -243,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(
|
||||||
@@ -259,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",
|
||||||
@@ -268,13 +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"
|
|
||||||
ttl = cache.ttl(lobby_keys[0])
|
|
||||||
assert ttl is not None
|
|
||||||
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
@@ -282,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
|
||||||
@@ -295,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(
|
||||||
@@ -304,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",
|
||||||
@@ -325,11 +345,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
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 len(lobby_keys) == 1
|
||||||
|
|
||||||
ttl = cache.ttl(lobby_keys[0])
|
|
||||||
assert ttl is not None
|
|
||||||
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
|
|
||||||
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_invalid_data():
|
def test_request_entry_invalid_data():
|
||||||
"""Should return 400 for invalid request data."""
|
"""Should return 400 for invalid request data."""
|
||||||
@@ -622,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(
|
||||||
@@ -638,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/",
|
||||||
@@ -651,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
|
||||||
@@ -695,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(
|
||||||
@@ -715,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()
|
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ Test rooms API endpoints in the Meet core app: participants management.
|
|||||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import jwt
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError, UpdateParticipantRequest
|
from livekit.api import TwirpError, UpdateParticipantRequest
|
||||||
from livekit.protocol.models import ParticipantInfo
|
from livekit.protocol.models import ParticipantInfo
|
||||||
@@ -22,18 +19,8 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import (
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
ApplicationFactory,
|
from core.services.lobby import LobbyService
|
||||||
RoomFactory,
|
|
||||||
UserFactory,
|
|
||||||
UserResourceAccessFactory,
|
|
||||||
)
|
|
||||||
from core.models import ApplicationScope
|
|
||||||
from core.services.lobby import (
|
|
||||||
LobbyParticipant,
|
|
||||||
LobbyParticipantStatus,
|
|
||||||
LobbyService,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -100,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
|
||||||
@@ -126,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
|
||||||
@@ -166,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
|
||||||
@@ -313,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
|
||||||
@@ -343,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
|
||||||
@@ -374,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
|
||||||
@@ -394,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
|
||||||
@@ -425,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
|
||||||
@@ -453,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
|
||||||
@@ -482,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
|
||||||
@@ -862,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)
|
||||||
@@ -1041,142 +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()
|
||||||
|
|
||||||
|
|
||||||
def generate_user_access_token(user):
|
|
||||||
"""Generate a valid user access JWT signed with the token secret."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
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": application.client_id,
|
|
||||||
"scope": "user:access",
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.encode(
|
|
||||||
payload,
|
|
||||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mute_participant_bearer_scheme_defers_to_next_authentication(
|
|
||||||
mock_livekit_client,
|
|
||||||
):
|
|
||||||
"""Should defer a "Bearer" header to the next authentication backend.
|
|
||||||
|
|
||||||
The LiveKit backend only claims the "X-LiveKit-Token" scheme. Any other
|
|
||||||
scheme must be left untouched so the backends declared after it get a
|
|
||||||
chance to authenticate the request.
|
|
||||||
"""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
UserResourceAccessFactory(
|
|
||||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data == {"status": "success"}
|
|
||||||
|
|
||||||
mock_livekit_client.room.get_participant.assert_not_called()
|
|
||||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_mute_participant_bearer_scheme_defers_role_permissions_still_apply(
|
|
||||||
mock_livekit_client,
|
|
||||||
):
|
|
||||||
"""Should still enforce room privileges once another backend authenticated."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
|
||||||
user = UserFactory() # no UserResourceAccess for this room
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_mute_participant_unknown_scheme_defers_and_stays_anonymous(
|
|
||||||
mock_livekit_client,
|
|
||||||
):
|
|
||||||
"""Should leave the request unauthenticated when no backend claims the scheme."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_mute_participant_livekit_scheme_is_case_insensitive(mock_livekit_client):
|
|
||||||
"""Should claim the LiveKit scheme whatever its casing, and not defer it."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
|
|
||||||
token = utils.generate_token(str(room.id), AnonymousUser())
|
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data == {"status": "success"}
|
|
||||||
|
|
||||||
mock_livekit_client.room.get_participant.assert_called_once()
|
|
||||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_mute_participant_livekit_scheme_malformed_header_is_rejected(
|
|
||||||
mock_livekit_client,
|
|
||||||
):
|
|
||||||
"""Should reject a malformed header once the LiveKit scheme is claimed."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
|
|
||||||
token = utils.generate_token(str(room.id), AnonymousUser())
|
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {
|
|
||||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
|
||||||
}
|
|
||||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ Test rooms API endpoints: toggle hand and rename participant.
|
|||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import jwt
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
@@ -20,13 +17,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import (
|
from core.factories import RoomFactory, UserFactory
|
||||||
ApplicationFactory,
|
|
||||||
RoomFactory,
|
|
||||||
UserFactory,
|
|
||||||
UserResourceAccessFactory,
|
|
||||||
)
|
|
||||||
from core.models import ApplicationScope
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -78,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
|
||||||
@@ -96,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
|
||||||
@@ -116,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
|
||||||
@@ -135,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
|
||||||
@@ -149,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
|
||||||
@@ -165,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
|
||||||
@@ -189,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
|
||||||
@@ -207,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
|
||||||
@@ -229,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
|
||||||
@@ -249,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
|
||||||
@@ -269,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
|
||||||
@@ -286,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
|
||||||
@@ -304,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
|
||||||
@@ -321,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
|
||||||
@@ -336,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
|
||||||
@@ -347,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
|
||||||
@@ -360,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
|
||||||
@@ -391,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
|
||||||
@@ -406,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
|
||||||
@@ -428,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
|
||||||
@@ -448,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
|
||||||
@@ -465,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
|
||||||
@@ -482,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
|
||||||
@@ -508,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
|
||||||
@@ -522,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
|
||||||
@@ -536,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
|
||||||
@@ -550,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
|
||||||
@@ -568,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
|
||||||
@@ -588,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
|
||||||
@@ -602,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
|
||||||
@@ -620,206 +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()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def user_access_token(user):
|
|
||||||
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
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": application.client_id,
|
|
||||||
"scope": "user:access",
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.encode(
|
|
||||||
payload,
|
|
||||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_hand_bearer_scheme_defers_to_next_authentication(
|
|
||||||
mock_livekit_client, room, user, user_access_token
|
|
||||||
):
|
|
||||||
"""Test toggle hand defers a "Bearer" header instead of failing on it."""
|
|
||||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"raised": True},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_bearer_scheme_defers_to_next_authentication(
|
|
||||||
mock_livekit_client, room, user, user_access_token
|
|
||||||
):
|
|
||||||
"""Test rename defers a "Bearer" header instead of failing on it."""
|
|
||||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"name": "John Doe"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_hand_unknown_scheme_defers(mock_livekit_client, room):
|
|
||||||
"""Test toggle hand defers a scheme no backend recognizes."""
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"raised": True},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_unknown_scheme_defers(mock_livekit_client, room):
|
|
||||||
"""Test rename defers a scheme no backend recognizes."""
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"name": "John Doe"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {"detail": "Authentication credentials were not provided."}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_hand_session_authentication_is_not_accepted(
|
|
||||||
mock_livekit_client, room, user
|
|
||||||
):
|
|
||||||
"""Test toggle hand is not granted by a session, whatever the user's room role."""
|
|
||||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, {"raised": True}, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_session_authentication_is_not_accepted(
|
|
||||||
mock_livekit_client, room, user
|
|
||||||
):
|
|
||||||
"""Test rename is not granted by a session, whatever the user's room role."""
|
|
||||||
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, {"name": "John Doe"}, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_livekit_scheme_is_case_insensitive(
|
|
||||||
mock_livekit_client, room, token
|
|
||||||
):
|
|
||||||
"""Test rename claims the LiveKit scheme whatever its casing."""
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"name": "John Doe"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data == {"status": "success"}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_toggle_hand_livekit_scheme_malformed_header_is_rejected(
|
|
||||||
mock_livekit_client, room, token
|
|
||||||
):
|
|
||||||
"""Test toggle hand rejects a malformed header once the LiveKit scheme is claimed."""
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"raised": True},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {
|
|
||||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_livekit_scheme_malformed_header_is_rejected(
|
|
||||||
mock_livekit_client, room, token
|
|
||||||
):
|
|
||||||
"""Test rename rejects a malformed header once the LiveKit scheme is claimed."""
|
|
||||||
client = APIClient()
|
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
|
||||||
response = client.post(
|
|
||||||
url,
|
|
||||||
{"name": "John Doe"},
|
|
||||||
format="json",
|
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
|
||||||
assert response.data == {
|
|
||||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_not_called()
|
|
||||||
|
|||||||
@@ -3,24 +3,16 @@ 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
|
||||||
|
|
||||||
from ...factories import (
|
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
ApplicationFactory,
|
from ...models import RoleChoices, RoomAccessLevel
|
||||||
RoomFactory,
|
|
||||||
UserFactory,
|
|
||||||
UserResourceAccessFactory,
|
|
||||||
)
|
|
||||||
from ...models import ApplicationScope, RoleChoices, RoomAccessLevel
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -515,41 +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)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
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": application.client_id,
|
|
||||||
"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)
|
|
||||||
assert response.data["pin_code"] == room.pin_code
|
|
||||||
assert "accesses" in response.data
|
|
||||||
|
|||||||
@@ -4,18 +4,15 @@ Test rooms API endpoints in the Meet core app: start subtitle.
|
|||||||
# pylint: disable=W0621
|
# pylint: disable=W0621
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
import jwt
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import ApplicationScope
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -113,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
|
||||||
@@ -131,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
|
||||||
@@ -151,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
|
||||||
@@ -181,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
|
||||||
@@ -201,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
|
||||||
@@ -222,132 +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"
|
||||||
}
|
}
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def user_access_token():
|
|
||||||
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
|
|
||||||
user = UserFactory()
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"iss": settings.USER_ACCESS_TOKEN_ISSUER,
|
|
||||||
"aud": settings.USER_ACCESS_TOKEN_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL),
|
|
||||||
"user_id": str(user.id),
|
|
||||||
"token_type": "user_access",
|
|
||||||
"client_id": application.client_id,
|
|
||||||
"scope": "user:access",
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.encode(
|
|
||||||
payload,
|
|
||||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_subtitle_bearer_scheme_defers_to_next_authentication(
|
|
||||||
settings, mock_livekit_client, user_access_token
|
|
||||||
):
|
|
||||||
"""Test that a "Bearer" header is deferred instead of failing on the LiveKit backend.
|
|
||||||
|
|
||||||
The action declares LiveKitTokenAuthentication as its only backend, so a
|
|
||||||
scheme it does not own must be left to the next one. None follows, so the
|
|
||||||
request ends up unauthenticated: the body reports missing credentials
|
|
||||||
rather than an invalid LiveKit token.
|
|
||||||
"""
|
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": "Authentication credentials were not provided."
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_subtitle_unknown_scheme_defers(settings, mock_livekit_client):
|
|
||||||
"""Test that a scheme no backend recognizes is deferred, not rejected."""
|
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": "Authentication credentials were not provided."
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_subtitle_scheme_is_case_insensitive(
|
|
||||||
settings, mock_livekit_client, mock_livekit_token, mock_room_id
|
|
||||||
):
|
|
||||||
"""Test that the LiveKit scheme is claimed whatever its casing."""
|
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
|
||||||
|
|
||||||
room = RoomFactory(id=mock_room_id)
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION=f"x-livekit-token {mock_livekit_token}",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
|
|
||||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_subtitle_malformed_header_is_rejected(
|
|
||||||
settings, mock_livekit_client, mock_livekit_token
|
|
||||||
):
|
|
||||||
"""Test that a malformed header is rejected once the LiveKit scheme is claimed."""
|
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token} extra-part",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ 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
|
||||||
|
|
||||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import ApplicationScope, RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
from ...services.room_management import (
|
from ...services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
@@ -441,46 +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)
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
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": application.client_id,
|
|
||||||
"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"
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ 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
|
||||||
|
|
||||||
from django.conf import settings as django_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
|
||||||
|
|
||||||
@@ -129,10 +131,63 @@ def test_get_cache_key(lobby_service, participant_id):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
||||||
|
|
||||||
expected_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{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)
|
||||||
@@ -196,97 +251,92 @@ def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
|||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_public_room(
|
def test_request_entry_public_room(
|
||||||
mock_generate_config, lobby_service, participant_id, username, settings
|
mock_generate_config, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry to a public room."""
|
"""Test requesting entry to a public room."""
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
request = mock.Mock()
|
||||||
|
request.user = AnonymousUser()
|
||||||
user = AnonymousUser()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
cache.set(
|
mocked_participant = LobbyParticipant(
|
||||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
status=LobbyParticipantStatus.UNKNOWN,
|
||||||
{
|
username=username,
|
||||||
"id": participant_id,
|
id=participant_id,
|
||||||
"username": username,
|
color="#123456",
|
||||||
"status": "waiting",
|
|
||||||
"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)
|
||||||
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, user, 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"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_trusted_room(
|
def test_request_entry_trusted_room(
|
||||||
mock_generate_config, lobby_service, participant_id, username, settings
|
mock_generate_config, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
request = mock.Mock()
|
||||||
|
request.user = UserFactory()
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
cache.set(
|
mocked_participant = LobbyParticipant(
|
||||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
status=LobbyParticipantStatus.UNKNOWN,
|
||||||
{
|
username=username,
|
||||||
"id": participant_id,
|
id=participant_id,
|
||||||
"username": username,
|
color="#123456",
|
||||||
"status": "waiting",
|
|
||||||
"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)
|
||||||
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, user, 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"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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._create_participant")
|
@mock.patch("core.services.lobby.LobbyService.enter")
|
||||||
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
|
request = mock.Mock()
|
||||||
notified of the entry request."""
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
request.user = AnonymousUser()
|
||||||
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(
|
||||||
@@ -295,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, user, 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")
|
||||||
@@ -316,7 +360,9 @@ def test_request_entry_waiting_participant(
|
|||||||
mock_refresh, lobby_service, participant_id, username
|
mock_refresh, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
user = AnonymousUser()
|
request = mock.Mock()
|
||||||
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -326,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, user, 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
|
||||||
@@ -340,119 +385,80 @@ def test_request_entry_waiting_participant(
|
|||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_accepted_participant(
|
def test_request_entry_accepted_participant(
|
||||||
mock_generate_config, lobby_service, participant_id, username, settings
|
mock_generate_config, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
request = mock.Mock()
|
||||||
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)
|
||||||
|
|
||||||
cache.set(
|
mocked_participant = LobbyParticipant(
|
||||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
{
|
username=username,
|
||||||
"id": participant_id,
|
id=participant_id,
|
||||||
"username": username,
|
color="#123456",
|
||||||
"status": "accepted",
|
|
||||||
"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)
|
||||||
|
|
||||||
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, user, 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"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
|
||||||
def test_request_entry_accepted_participant_username_is_bound(
|
|
||||||
mock_generate_config, lobby_service, participant_id, settings
|
|
||||||
):
|
|
||||||
"""An accepted identifier must join under the username the host accepted.
|
|
||||||
|
|
||||||
The participant identifier is a bearer value: a stolen or replayed
|
|
||||||
identifier must not be able to enter the room under a different
|
|
||||||
display name than the one the acceptance decision was made on.
|
|
||||||
"""
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
|
||||||
user = AnonymousUser()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
cache.set(
|
|
||||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
|
||||||
{
|
|
||||||
"id": participant_id,
|
|
||||||
"username": "accepted-name",
|
|
||||||
"status": "accepted",
|
|
||||||
"color": "#123456",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(
|
|
||||||
room, user, "spoofed-name", participant_id=participant_id
|
|
||||||
)
|
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
|
||||||
assert livekit_config == {"token": "test-token"}
|
|
||||||
assert mock_generate_config.call_args.kwargs["username"] == "accepted-name"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_participant_with_role(
|
def test_request_entry_participant_with_role(
|
||||||
mock_generate_config, lobby_service, participant_id, username, settings
|
mock_generate_config, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a participant with a role on the room."""
|
"""Test requesting entry for a participant with a role on the room."""
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
request = mock.Mock()
|
||||||
|
request.user = UserFactory()
|
||||||
user = UserFactory()
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
UserResourceAccessFactory(resource=room, user=user, role="administrator")
|
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||||
|
|
||||||
cache.set(
|
mocked_participant = LobbyParticipant(
|
||||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
{
|
username=username,
|
||||||
"id": participant_id,
|
id=participant_id,
|
||||||
"username": username,
|
color="#123456",
|
||||||
"status": "accepted",
|
|
||||||
"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)
|
||||||
|
|
||||||
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, user, 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"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role="administrator",
|
role="administrator",
|
||||||
)
|
)
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -462,50 +468,77 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", django_settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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,
|
||||||
):
|
):
|
||||||
"""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=django_settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
|
)
|
||||||
|
mock_notify.assert_called_once_with(
|
||||||
|
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=R0917
|
||||||
|
@mock.patch("core.services.lobby.cache")
|
||||||
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
def test_enter_with_notification_error(
|
||||||
"""A notification error must not break the entry request flow."""
|
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")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
|
||||||
lobby_service._notify_entry_request("room-id")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
participant = lobby_service.enter(room.id, participant_id, username)
|
||||||
|
|
||||||
mock_notify.assert_called_once_with(
|
mock_generate_color.assert_called_once_with(participant_id)
|
||||||
room_name="room-id", notification_data={"type": "participantWaiting"}
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -551,7 +584,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
|||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.keys.assert_called_once_with(pattern)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_not_called()
|
mock_cache.get_many.assert_not_called()
|
||||||
|
|
||||||
@@ -560,7 +593,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.keys.return_value = [cache_key]
|
mock_cache.keys.return_value = [cache_key]
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
@@ -569,7 +602,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.keys.assert_called_once_with(pattern)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||||
|
|
||||||
@@ -578,8 +611,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||||
"""Test listing multiple waiting participants with valid data."""
|
"""Test listing multiple waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
participant1 = {
|
participant1 = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -612,7 +645,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
# Verify all participants have waiting status
|
# Verify all participants have waiting status
|
||||||
assert all(p["status"] == "waiting" for p in result)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.keys.assert_called_once_with(pattern)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
@@ -621,7 +654,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.keys.return_value = [cache_key]
|
mock_cache.keys.return_value = [cache_key]
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
@@ -635,8 +668,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
valid_participant = {
|
valid_participant = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -665,7 +698,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||||
|
|
||||||
# Verify both cache keys were queried
|
# Verify both cache keys were queried
|
||||||
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.keys.assert_called_once_with(pattern)
|
mock_cache.keys.assert_called_once_with(pattern)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
@@ -674,8 +707,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||||
"""Test listing only waiting participants (not accepted/denied)."""
|
"""Test listing only waiting participants (not accepted/denied)."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
participant1 = {
|
participant1 = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -713,7 +746,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
|
|||||||
room.id,
|
room.id,
|
||||||
participant_id,
|
participant_id,
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
timeout=django_settings.LOBBY_ACCEPTED_TIMEOUT,
|
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -727,7 +760,7 @@ def test_handle_participant_entry_deny(mock_update, lobby_service, participant_i
|
|||||||
room.id,
|
room.id,
|
||||||
participant_id,
|
participant_id,
|
||||||
status=LobbyParticipantStatus.DENIED,
|
status=LobbyParticipantStatus.DENIED,
|
||||||
timeout=django_settings.LOBBY_DENIED_TIMEOUT,
|
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -868,16 +901,14 @@ def test_clear_participant_cache(lobby_service):
|
|||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
participant_id = "test-participant-id"
|
participant_id = "test-participant-id"
|
||||||
|
|
||||||
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
participant_data = {
|
participant_data = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
}
|
}
|
||||||
cache.set(
|
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||||
cache_key, participant_data, timeout=django_settings.LOBBY_WAITING_TIMEOUT
|
|
||||||
)
|
|
||||||
assert cache.get(cache_key) is not None
|
assert cache.get(cache_key) is not None
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
@@ -889,7 +920,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
participant_id = "nonexistent-participant"
|
participant_id = "nonexistent-participant"
|
||||||
|
|
||||||
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for the TransitCodeService.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@patch("core.services.transit_code.cache.delete", return_value=False)
|
|
||||||
def test_consume_code_returns_none_when_delete_loses_the_race(mock_delete):
|
|
||||||
"""If the code was already deleted by a concurrent request, consumption fails."""
|
|
||||||
user = UserFactory()
|
|
||||||
service = TransitCodeService()
|
|
||||||
code = service.create_code(user, client_id="my-app")
|
|
||||||
assert service.consume_code(code) is None
|
|
||||||
mock_delete.assert_called_once()
|
|
||||||
@@ -1,270 +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 ApplicationFactory, RoomFactory, UserFactory
|
|
||||||
from core.models import ApplicationScope, RoleChoices
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def generate_user_access_token(user, application=None, **overrides):
|
|
||||||
"""Generate a valid user access JWT signed with the token secret."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
if application is None:
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
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": application.client_id,
|
|
||||||
"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_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_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_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
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_access_token_application_scope_revoked():
|
|
||||||
"""Revoking the application's grant invalidates its outstanding tokens."""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
token = generate_user_access_token(user, application=application)
|
|
||||||
|
|
||||||
application.scopes = []
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "application access revoked" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_access_token_application_deactivated():
|
|
||||||
"""Deactivating the application invalidates its outstanding tokens."""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
token = generate_user_access_token(user, application=application)
|
|
||||||
|
|
||||||
application.is_active = False
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "application access revoked" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_access_token_unknown_application():
|
|
||||||
"""A token whose client_id matches no application is refused."""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
token = generate_user_access_token(user, client_id="not-an-application")
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "application access revoked" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_access_token_does_not_override_existing_session():
|
|
||||||
"""A Bearer token must not override the identity of a live session."""
|
|
||||||
session_user = UserFactory()
|
|
||||||
token_user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(session_user)
|
|
||||||
client.credentials(
|
|
||||||
HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(token_user)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.data["email"] == session_user.email
|
|
||||||
@@ -1,262 +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 ApplicationFactory, UserFactory
|
|
||||||
from core.models import ApplicationScope
|
|
||||||
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_get_method(client):
|
|
||||||
"""The exchange endpoint should not accept GET."""
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/exchange-access-token/")
|
|
||||||
assert response.status_code == 405
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
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["user_id"] == str(user.id)
|
|
||||||
assert payload["client_id"] == application.client_id
|
|
||||||
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()
|
|
||||||
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
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()
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_access_token_refused_when_already_authenticated(client):
|
|
||||||
"""A session-authenticated browser must not exchange a transit code."""
|
|
||||||
user = UserFactory()
|
|
||||||
session_user = UserFactory()
|
|
||||||
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
client.force_login(session_user)
|
|
||||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "already authenticated" in str(response.data).lower()
|
|
||||||
|
|
||||||
# The code was not consumed: it stays valid for its intended,
|
|
||||||
# cookieless embedded context.
|
|
||||||
client.logout()
|
|
||||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_access_token_application_scope_revoked(client):
|
|
||||||
"""A code is refused once the application's grant is revoked."""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
application.scopes = []
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "no longer create user sessions" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_access_token_application_deactivated(client):
|
|
||||||
"""A code is refused once the application is disabled."""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
application.is_active = False
|
|
||||||
application.save()
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "no longer create user sessions" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_access_token_unknown_application(client):
|
|
||||||
"""A code whose client_id matches no application is refused."""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
code = TransitCodeService().create_code(user, client_id="not-an-application")
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert "no longer create user sessions" in str(response.data).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_exchange_access_token_end_to_end(client):
|
|
||||||
"""A token obtained from the exchange must authenticate on the core API.
|
|
||||||
|
|
||||||
Regression test: token issuance and token validation must stay in
|
|
||||||
sync on the claims they set and require (e.g. 'token_type').
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
|
||||||
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
api_client = APIClient()
|
|
||||||
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access_token']}")
|
|
||||||
me = api_client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert me.status_code == 200
|
|
||||||
assert me.data["email"] == user.email
|
|
||||||
@@ -5,6 +5,7 @@ Tests for external API /token endpoint
|
|||||||
# pylint: disable=W0621
|
# pylint: disable=W0621
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
@@ -88,6 +89,155 @@ def test_api_applications_generate_token_success(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_form_urlencoded(settings):
|
||||||
|
"""The token endpoint should accept "application/x-www-form-urlencoded"
|
||||||
|
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
|
||||||
|
token endpoints, so that standard OAuth 2.0 client libraries work
|
||||||
|
out of the box."""
|
||||||
|
UserFactory(email="user@example.com")
|
||||||
|
application = ApplicationFactory(
|
||||||
|
is_active=True,
|
||||||
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
|
)
|
||||||
|
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
|
application.client_secret = plain_secret
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
(
|
||||||
|
f"client_id={application.client_id}"
|
||||||
|
f"&client_secret={plain_secret}"
|
||||||
|
"&grant_type=client_credentials"
|
||||||
|
"&scope=user%40example.com"
|
||||||
|
),
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.data
|
||||||
|
|
||||||
|
response.data.pop("access_token")
|
||||||
|
|
||||||
|
assert response.data == {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||||
|
"scope": "rooms:list rooms:create",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
|
||||||
|
"""Invalid credentials sent as form-urlencoded should be parsed and
|
||||||
|
rejected with 401, proving the request body is properly decoded."""
|
||||||
|
user = UserFactory(email="user@example.com")
|
||||||
|
application = ApplicationFactory(is_active=True)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
urlencode(
|
||||||
|
{
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": "wrong-secret",
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_form_urlencoded_missing_fields():
|
||||||
|
"""Missing required fields in a form-urlencoded request should return
|
||||||
|
a 400 validation error, like for JSON requests."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
urlencode({"grant_type": "client_credentials"}),
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
for field in ("client_id", "client_secret", "scope"):
|
||||||
|
assert field in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
|
||||||
|
"""An unsupported grant_type sent as form-urlencoded should return 400."""
|
||||||
|
user = UserFactory(email="user@example.com")
|
||||||
|
application = ApplicationFactory(is_active=True)
|
||||||
|
|
||||||
|
plain_secret = "test-secret-123"
|
||||||
|
application.client_secret = plain_secret
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
urlencode(
|
||||||
|
{
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": plain_secret,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"scope": user.email,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "grant_type" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_form_urlencoded_special_characters():
|
||||||
|
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
|
||||||
|
client_secret should survive form-urlencoded decoding."""
|
||||||
|
UserFactory(email="user@example.com")
|
||||||
|
application = ApplicationFactory(
|
||||||
|
is_active=True,
|
||||||
|
scopes=[ApplicationScope.ROOMS_LIST],
|
||||||
|
)
|
||||||
|
|
||||||
|
plain_secret = "s3cr3t&with=special+chars%42"
|
||||||
|
application.client_secret = plain_secret
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
urlencode(
|
||||||
|
{
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"client_secret": plain_secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": "user@example.com",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_applications_generate_token_unsupported_media_type():
|
||||||
|
"""Content types other than JSON and form-urlencoded should still be
|
||||||
|
rejected with 415 Unsupported Media Type."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(
|
||||||
|
"/external-api/v1.0/application/token/",
|
||||||
|
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
def test_api_applications_generate_token_invalid_client_id():
|
def test_api_applications_generate_token_invalid_client_id():
|
||||||
"""Invalid client_id should return 401."""
|
"""Invalid client_id should return 401."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
|||||||
@@ -1,209 +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_addons_test_token(user, scopes):
|
|
||||||
"""Generate a valid JWT token signed with the addons secret for testing."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"iss": django_settings.ADDONS_TOKEN_ISSUER,
|
|
||||||
"aud": django_settings.ADDONS_TOKEN_AUDIENCE,
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + timedelta(seconds=django_settings.ADDONS_TOKEN_TTL),
|
|
||||||
"scope": " ".join(scopes),
|
|
||||||
"user_id": str(user.id),
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.encode(
|
|
||||||
payload,
|
|
||||||
django_settings.ADDONS_TOKEN_SECRET_KEY,
|
|
||||||
algorithm=django_settings.ADDONS_TOKEN_ALG,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_test_token(user, scopes, application=None):
|
|
||||||
"""Generate a valid application JWT token for testing."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
scope_string = " ".join(scopes)
|
|
||||||
|
|
||||||
if application is None:
|
|
||||||
application = ApplicationFactory(scopes=scopes)
|
|
||||||
|
|
||||||
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_scope_claim_exceeding_db_grant():
|
|
||||||
"""A 'users:session' claim beyond the grant recorded in database is refused."""
|
|
||||||
user = UserFactory()
|
|
||||||
application = ApplicationFactory(scopes=[ApplicationScope.ROOMS_RETRIEVE])
|
|
||||||
|
|
||||||
token = generate_test_token(
|
|
||||||
user, [ApplicationScope.USERS_SESSION], application=application
|
|
||||||
)
|
|
||||||
|
|
||||||
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 "not granted" in str(response.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_transit_code_get_forbidden():
|
|
||||||
"""Minting a transit code with a GET should not be allowed."""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
response = client.get("/external-api/v1.0/users/transit-code/")
|
|
||||||
|
|
||||||
assert response.status_code == 405
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_transit_code_resource_server_not_supported():
|
|
||||||
"""A resource server token must not be able to mint a transit code."""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
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/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
mock_rs_authenticate.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_transit_code_rejects_addons_token():
|
|
||||||
"""An addons token must not be able to mint a transit code.
|
|
||||||
|
|
||||||
The token carries the 'users:session' scope and is signed with the addons
|
|
||||||
secret, so only the missing backend stands between it and a transit code.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
token = generate_addons_test_token(user, [ApplicationScope.USERS_SESSION])
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
|
||||||
|
|
||||||
with mock.patch.object(
|
|
||||||
ResourceServerAuthentication, "authenticate", return_value=None
|
|
||||||
):
|
|
||||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
@@ -48,11 +48,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(
|
||||||
|
|||||||
@@ -325,7 +325,6 @@ class Base(Configuration):
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"core.authentication.backends.SessionAuthenticationWith401",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
"rest_framework.parsers.JSONParser",
|
"rest_framework.parsers.JSONParser",
|
||||||
@@ -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",
|
||||||
@@ -881,6 +875,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,66 +1002,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,
|
|
||||||
)
|
|
||||||
USER_ACCESS_TOKEN_TYPE_CLAIM = values.Value(
|
|
||||||
"user_token",
|
|
||||||
environ_name="USER_ACCESS_TOKEN_TYPE_CLAIM",
|
|
||||||
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(
|
||||||
@@ -1359,9 +1298,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
|
CONNECTION_TEST_ENABLED = True
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -2252,11 +2252,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.5"
|
version = "0.6.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Generated
+68
-65
@@ -12,7 +12,7 @@
|
|||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@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.14",
|
||||||
@@ -24,17 +24,17 @@
|
|||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"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.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.20.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.404.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.10",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
@@ -742,28 +742,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.2.11"
|
"@floating-ui/utils": "^0.2.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/dom": {
|
"node_modules/@floating-ui/dom": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.3",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/utils": "^0.2.10"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/utils": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||||
@@ -966,12 +966,12 @@
|
|||||||
"license": "GPL-3.0+"
|
"license": "GPL-3.0+"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-core": {
|
"node_modules/@livekit/components-core": {
|
||||||
"version": "0.12.13",
|
"version": "0.12.14",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
||||||
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "1.7.4",
|
"@floating-ui/dom": "1.7.6",
|
||||||
"loglevel": "1.9.1",
|
"loglevel": "1.9.1",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
@@ -979,17 +979,17 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"livekit-client": "^2.17.2",
|
"livekit-client": "^2.20.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-react": {
|
"node_modules/@livekit/components-react": {
|
||||||
"version": "2.9.21",
|
"version": "2.9.23",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
||||||
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-core": "0.12.13",
|
"@livekit/components-core": "0.12.14",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
@@ -999,8 +999,8 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
|
||||||
"livekit-client": "^2.18.2",
|
"livekit-client": "^2.20.1",
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
"react-dom": ">=18",
|
"react-dom": ">=18",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -1027,9 +1027,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.46.6",
|
"version": "1.50.4",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -1720,18 +1720,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.39.5",
|
"version": "1.48.3",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||||
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.392.0"
|
"@posthog/types": "^1.405.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.392.0",
|
"version": "1.405.0",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||||
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -4393,11 +4393,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.39.0",
|
"version": "3.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
@@ -6400,9 +6403,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.1",
|
"version": "26.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6419,7 +6422,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
@@ -8109,20 +8112,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.20.0",
|
"version": "2.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
||||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.46.6",
|
"@livekit/protocol": "1.50.4",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"sdp-transform": "^2.15.0",
|
"sdp-transform": "^2.15.0",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"typed-emitter": "^2.1.0",
|
"typed-emitter": "^2.1.0",
|
||||||
"webrtc-adapter": "9.0.5"
|
"webrtc-adapter": "9.0.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/dom-mediacapture-record": "^1"
|
"@types/dom-mediacapture-record": "^1"
|
||||||
@@ -9120,17 +9123,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.395.0",
|
"version": "1.404.1",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
||||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "(Apache-2.0 AND MIT)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.38.0",
|
"@posthog/core": "^1.43.1",
|
||||||
"@posthog/types": "^1.391.1",
|
"@posthog/types": "^1.397.0",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.49.0",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.2",
|
"preact": "^10.29.3",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
@@ -9422,9 +9425,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.8",
|
"version": "17.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
@@ -9434,7 +9437,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"i18next": ">= 26.2.0",
|
"i18next": ">= 26.2.0",
|
||||||
"react": ">= 16.8.0",
|
"react": ">= 16.8.0",
|
||||||
"typescript": "^5 || ^6"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
@@ -11645,9 +11648,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sdp": "^3.2.0"
|
"sdp": "^3.2.0"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@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.14",
|
||||||
@@ -31,17 +31,17 @@
|
|||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"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.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.20.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.404.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.10",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
|
|||||||
+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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const captureMediaEvent = async (
|
|||||||
| 'media-device-topology'
|
| 'media-device-topology'
|
||||||
| 'media-device-success'
|
| 'media-device-success'
|
||||||
| 'device-not-found'
|
| 'device-not-found'
|
||||||
|
| 'device-in-use'
|
||||||
| 'permissions-denied'
|
| 'permissions-denied'
|
||||||
| 'screen-share-permission-denied'
|
| 'screen-share-permission-denied'
|
||||||
| 'silent-mic-detected'
|
| 'silent-mic-detected'
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
|
||||||
import { setAccessToken } from '@/stores/accessToken'
|
|
||||||
import {
|
|
||||||
consumeTransitCodeFromFragment,
|
|
||||||
isEmbedded,
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEmbedded()) {
|
|
||||||
console.warn('Transit code ignored outside an embedded context')
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|
||||||
// Note: the exchange only happens in an embedding context. This check lives
|
|
||||||
// in initializeAccessTokenFromFragment, the single funnel for all bootstrap paths.
|
|
||||||
// The gate still mounts top-level to scrub the fragment, but bootstrap then resolves
|
|
||||||
// immediately without exchanging.
|
|
||||||
//
|
|
||||||
// Latch the decision on the initial hash: bootstrap scrubs it immediately, and the
|
|
||||||
// gate must not switch back to the fast path while the exchange is 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(() => {
|
|
||||||
if (isMounted) {
|
|
||||||
setIsReady(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return isReady ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<LoadingScreen header={false} footer={false} delay={1000} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the app is rendered inside an embedding context (iframe).
|
|
||||||
*
|
|
||||||
* Comparing window references never throws, even when the parent is
|
|
||||||
* cross-origin. Defaults to false outside a browser environment.
|
|
||||||
*/
|
|
||||||
export const isEmbedded = (): boolean => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return window.self !== window.top
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
|
|||||||
resetChatStore()
|
resetChatStore()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Tigger the message notification (temporary)
|
// Trigger the message notification (temporary)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useToast } from 'react-aria'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { setPinnedTrack } from '@/stores/layout'
|
import { setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { useParticipantTracks } from '@livekit/components-react'
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
)
|
)
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
const [cameraTrack] = useParticipantTracks(
|
||||||
|
[Source.Camera],
|
||||||
|
participant?.identity
|
||||||
|
)
|
||||||
|
|
||||||
const trackReference = {
|
const trackReference = useMemo(
|
||||||
participant,
|
() => cameraTrack ?? { participant, source: Source.Camera },
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
[cameraTrack, participant]
|
||||||
source: Source.Camera,
|
)
|
||||||
}
|
|
||||||
|
if (!participant) return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
|||||||
+11
-26
@@ -1,11 +1,9 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
import { Participant, Track } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
import { openMuteDialog } from '@/stores/muteDialog'
|
||||||
|
|
||||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
|
|||||||
source: Track.Source.Microphone,
|
source: Track.Source.Microphone,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Button
|
||||||
<Button
|
isDisabled={isMuted}
|
||||||
isDisabled={isMuted}
|
size={'sm'}
|
||||||
size={'sm'}
|
variant={'primaryTextDark'}
|
||||||
variant={'primaryTextDark'}
|
square
|
||||||
square
|
onPress={() => openMuteDialog(participant)}
|
||||||
onPress={() => setIsAlertOpen(true)}
|
tooltip={t('muteParticipant', { name })}
|
||||||
tooltip={t('muteParticipant', { name })}
|
>
|
||||||
>
|
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
</Button>
|
||||||
</Button>
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ import {
|
|||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useState } from 'react'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
import { openMuteDialog } from '@/stores/muteDialog'
|
||||||
import { ParticipantName } from './ParticipantName'
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
|
|||||||
|
|
||||||
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
participant: participant,
|
participant: participant,
|
||||||
source: Source.Microphone,
|
source: Source.Microphone,
|
||||||
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
|
|
||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
const label = isLocal(participant)
|
const label = isLocal(participant)
|
||||||
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Button
|
||||||
<Button
|
square
|
||||||
square
|
variant="greyscale"
|
||||||
variant="greyscale"
|
size="sm"
|
||||||
size="sm"
|
tooltip={label}
|
||||||
tooltip={label}
|
aria-label={label}
|
||||||
aria-label={label}
|
isDisabled={isMuted || !canMute}
|
||||||
isDisabled={isMuted || !canMute}
|
onPress={async () =>
|
||||||
onPress={async () =>
|
!isMuted && isLocal(participant)
|
||||||
!isMuted && isLocal(participant)
|
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
|
||||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
|
: openMuteDialog(participant)
|
||||||
false
|
}
|
||||||
)
|
data-attr="participants-mute"
|
||||||
: setIsAlertOpen(true)
|
>
|
||||||
}
|
{isMuted ? (
|
||||||
data-attr="participants-mute"
|
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||||
>
|
) : (
|
||||||
{isMuted ? (
|
<RiMicFill
|
||||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
className={css({
|
||||||
) : (
|
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||||
<RiMicFill
|
animation: isSpeaking
|
||||||
className={css({
|
? 'pulse_background 800ms infinite'
|
||||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
: undefined,
|
||||||
animation: isSpeaking
|
})}
|
||||||
? 'pulse_background 800ms infinite'
|
aria-hidden={true}
|
||||||
: undefined,
|
/>
|
||||||
})}
|
)}
|
||||||
aria-hidden={true}
|
</Button>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||||
import { recordingStore } from '@/stores/recording'
|
import { recordingStore } from '@/stores/recording'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useMutateRecording = () => {
|
export const useMutateRecording = () => {
|
||||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||||
useStartRecording({
|
useStartRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'start'
|
recordingStore.isErrorDialogOpen = 'start'
|
||||||
|
captureEvent('error-starting-recording')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||||
useStopRecording({
|
useStopRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'stop'
|
recordingStore.isErrorDialogOpen = 'stop'
|
||||||
|
captureEvent('error-stopping-recording')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
|||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
@@ -41,7 +40,7 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = !isAdminOrOwner
|
const headers = !isAdminOrOwner
|
||||||
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let response
|
let response
|
||||||
|
|||||||
@@ -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 }),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 useRaiseHand = () => {
|
export const useRaiseHand = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -16,10 +15,11 @@ export const useRaiseHand = () => {
|
|||||||
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}/toggle-hand/`, {
|
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
raised,
|
raised,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
usePersistentUserChoices,
|
usePersistentUserChoices,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
|
ConnectionError,
|
||||||
|
ConnectionErrorReason,
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
@@ -25,7 +27,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
import {
|
||||||
|
captureEvent,
|
||||||
|
captureMediaEvent,
|
||||||
|
reportError,
|
||||||
|
} from '@/features/analytics/telemetry'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
@@ -227,6 +233,16 @@ export const Conference = ({
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const failure = MediaDeviceFailure.getFailure(e)
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||||
|
|
||||||
|
// connect() was aborted by a disconnect() before the join completed
|
||||||
|
if (
|
||||||
|
e instanceof ConnectionError &&
|
||||||
|
e.reason === ConnectionErrorReason.Cancelled
|
||||||
|
) {
|
||||||
|
void captureEvent('connection-cancelled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reportError('livekit_room_error', e, {
|
reportError('livekit_room_error', e, {
|
||||||
path: 'connect_publish',
|
path: 'connect_publish',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
|
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
|
||||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||||
import { SilentMicDetector } from './SilentMicDetector'
|
import { SilentMicDetector } from './SilentMicDetector'
|
||||||
@@ -218,12 +219,14 @@ const switchTrackDevice =
|
|||||||
function getPreviewMessages({
|
function getPreviewMessages({
|
||||||
cameraFound,
|
cameraFound,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
|
cameraInUse,
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
}: {
|
}: {
|
||||||
cameraFound: boolean
|
cameraFound: boolean
|
||||||
cameraDenied: boolean
|
cameraDenied: boolean
|
||||||
|
cameraInUse: boolean
|
||||||
micDenied: boolean
|
micDenied: boolean
|
||||||
videoEnabled: boolean
|
videoEnabled: boolean
|
||||||
videoStarted: boolean
|
videoStarted: boolean
|
||||||
@@ -235,6 +238,9 @@ function getPreviewMessages({
|
|||||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||||
return { hint: key, permissionsButtonLabel: key }
|
return { hint: key, permissionsButtonLabel: key }
|
||||||
}
|
}
|
||||||
|
if (cameraInUse) {
|
||||||
|
return { hint: 'cameraInUse', permissionsButtonLabel: null }
|
||||||
|
}
|
||||||
if (!videoEnabled) {
|
if (!videoEnabled) {
|
||||||
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
||||||
}
|
}
|
||||||
@@ -328,18 +334,20 @@ const VideoPreview = ({
|
|||||||
const cameraDenied = useCannotUseDevice('videoinput')
|
const cameraDenied = useCannotUseDevice('videoinput')
|
||||||
const micDenied = useCannotUseDevice('audioinput')
|
const micDenied = useCannotUseDevice('audioinput')
|
||||||
const cameraMissing = useDeviceMissing('videoinput')
|
const cameraMissing = useDeviceMissing('videoinput')
|
||||||
|
const cameraInUse = useDeviceInUse('videoinput')
|
||||||
|
|
||||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||||
|
|
||||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||||
cameraFound: !cameraMissing,
|
cameraFound: !cameraMissing,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
|
cameraInUse,
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isError = cameraMissing || cameraDenied
|
const isError = cameraMissing || cameraDenied || cameraInUse
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.previewFrame}>
|
<div className={styles.previewFrame}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ApiLobbyStatus,
|
ApiLobbyStatus,
|
||||||
type ApiRequestEntry,
|
type ApiRequestEntry,
|
||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
import { setLobbyParticipantId } from '@/stores/lobby'
|
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
@@ -44,11 +43,6 @@ export const useLobby = ({
|
|||||||
roomId,
|
roomId,
|
||||||
username,
|
username,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.id) {
|
|
||||||
setLobbyParticipantId(roomId, response.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||||
|
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
|
||||||
|
import type { PermissionKind } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const RETRY_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no browser event for "another app released the device", so
|
||||||
|
* while a device is flagged in use, re-probe it periodically. Only clears
|
||||||
|
* the flag (the toggle becomes usable again); it never re-enables the
|
||||||
|
* device on the user's behalf.
|
||||||
|
*/
|
||||||
|
export function useWatchDeviceReleased() {
|
||||||
|
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||||
|
useWatchKind('camera', cameraInUse)
|
||||||
|
useWatchKind('microphone', microphoneInUse)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWatchKind(kind: PermissionKind, inUse: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inUse) return
|
||||||
|
const id = setInterval(
|
||||||
|
() => void probeDeviceReleased(kind),
|
||||||
|
RETRY_INTERVAL_MS
|
||||||
|
)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [kind, inUse])
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
|
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
|
||||||
|
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||||
|
|
||||||
|
export const MuteAlertDialogProvider = () => {
|
||||||
|
const { participant } = useSnapshot(muteDialogStore)
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
|
||||||
|
const lastNameRef = useRef('')
|
||||||
|
if (participant) {
|
||||||
|
lastNameRef.current = participant.name || participant.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={!!participant}
|
||||||
|
name={lastNameRef.current}
|
||||||
|
onClose={closeMuteDialog}
|
||||||
|
onSubmit={() => {
|
||||||
|
const target = muteDialogStore.participant
|
||||||
|
if (!target) return
|
||||||
|
muteParticipant(target).then(closeMuteDialog)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
-14
@@ -1,5 +1,4 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
ImageSegmenter,
|
ImageSegmenter,
|
||||||
@@ -45,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
videoElement?: HTMLVideoElement
|
videoElement?: HTMLVideoElement
|
||||||
videoElementLoaded?: boolean
|
videoElementLoaded?: boolean
|
||||||
|
|
||||||
// Canvas containg the video processing result, of which we extract as stream.
|
// Canvas containing the video processing result, of which we extract as stream.
|
||||||
outputCanvas?: HTMLCanvasElement
|
outputCanvas?: HTMLCanvasElement
|
||||||
outputCanvasCtx?: CanvasRenderingContext2D
|
outputCanvasCtx?: CanvasRenderingContext2D
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
segmentationMaskCanvas?: HTMLCanvasElement
|
segmentationMaskCanvas?: HTMLCanvasElement
|
||||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||||
|
|
||||||
// Mask containg the inference result.
|
// Mask containing the inference result.
|
||||||
segmentationMask?: ImageData
|
segmentationMask?: ImageData
|
||||||
|
|
||||||
// The resized image of the video source.
|
// The resized image of the video source.
|
||||||
@@ -86,7 +85,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.sourceSettings = this.source!.getSettings()
|
this.sourceSettings = this.source!.getSettings()
|
||||||
this.videoElement = opts.element as HTMLVideoElement
|
this.videoElement = opts.element as HTMLVideoElement
|
||||||
|
|
||||||
await this._initVirtualBackgroundImage()
|
this._initVirtualBackgroundImage()
|
||||||
this._createMainCanvas()
|
this._createMainCanvas()
|
||||||
this._createMaskCanvas()
|
this._createMaskCanvas()
|
||||||
|
|
||||||
@@ -104,11 +103,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
captureEvent('firefox-blurring-init', {})
|
captureEvent('firefox-blurring-init', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
throw new Error(
|
return
|
||||||
'Virtual background is only supported for virtual background'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
@@ -116,19 +113,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.virtualBackgroundImage &&
|
this.virtualBackgroundImage &&
|
||||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||||
if (this.options.imagePath || needsUpdate) {
|
if (this.options.imagePath || needsUpdate) {
|
||||||
// Embedded (token) mode: img.src cannot carry the Authorization
|
|
||||||
// header, resolve the media to a blob object URL first. Identity
|
|
||||||
// in regular mode.
|
|
||||||
const imagePath = await resolveMediaUrl(this.options.imagePath!)
|
|
||||||
this.virtualBackgroundImage = document.createElement('img')
|
this.virtualBackgroundImage = document.createElement('img')
|
||||||
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
||||||
this.virtualBackgroundImage.src = imagePath
|
this.virtualBackgroundImage.src = this.options.imagePath!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
this.options = opts
|
this.options = opts
|
||||||
await this._initVirtualBackgroundImage()
|
this._initVirtualBackgroundImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_initWorker() {
|
||||||
|
|||||||
+1
-14
@@ -1,5 +1,4 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
|
||||||
import {
|
import {
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
BackgroundProcessor,
|
BackgroundProcessor,
|
||||||
@@ -48,16 +47,7 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||||
await this.processor.init(opts)
|
return this.processor.init(opts)
|
||||||
// Embedded (token) mode: the constructor passed the raw imagePath,
|
|
||||||
// whose native load cannot carry the Authorization header. Swap it
|
|
||||||
// for a resolved blob object URL. No-op in regular mode.
|
|
||||||
if (this.opts.type === 'virtual') {
|
|
||||||
const imagePath = await resolveMediaUrl(this.opts.imagePath)
|
|
||||||
if (imagePath !== this.opts.imagePath) {
|
|
||||||
await this.processor.updateTransformerOptions({ imagePath })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||||
@@ -69,9 +59,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
if (opts.type === 'virtual') {
|
|
||||||
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
|
||||||
}
|
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
||||||
const newProcessorType =
|
const newProcessorType =
|
||||||
|
|||||||
+40
-21
@@ -20,8 +20,9 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
|||||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
|
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
||||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import type {
|
import type {
|
||||||
@@ -97,38 +98,42 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const deviceMissing = useDeviceMissing(kind)
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
|
const deviceInUse = useDeviceInUse(kind)
|
||||||
|
const explainDeviceInUse = deviceInUse && context === 'room'
|
||||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||||
const silentMicWarning =
|
const silentMicWarning =
|
||||||
kind === 'audioinput' &&
|
kind === 'audioinput' &&
|
||||||
silentMicStatus === 'silent' &&
|
silentMicStatus === 'silent' &&
|
||||||
!cannotUseDevice &&
|
!cannotUseDevice &&
|
||||||
!deviceMissing
|
!deviceMissing &&
|
||||||
|
!deviceInUse
|
||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const isRequestingPermission = useRef(false)
|
const isRequestingPermission = useRef(false)
|
||||||
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
||||||
|
|
||||||
|
const mediaPath = context === 'join' ? 'join_preview' : 'room'
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!enabled && deviceMissing) {
|
if (!enabled && deviceMissing) {
|
||||||
setShowDeviceNotFound(true)
|
setAlertError(MediaDeviceFailure.NotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!cannotUseDevice) {
|
if (!cannotUseDevice && !deviceInUse) {
|
||||||
toggle()
|
toggle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isRequestingPermission.current) return
|
if (isRequestingPermission.current) return
|
||||||
isRequestingPermission.current = true
|
isRequestingPermission.current = true
|
||||||
try {
|
try {
|
||||||
const granted = await requestDevicePermission(
|
const acquired = await requestDevicePermission(kind, mediaPath)
|
||||||
kind,
|
if (acquired) {
|
||||||
context === 'join' ? 'join_preview' : 'room'
|
|
||||||
)
|
|
||||||
if (granted) {
|
|
||||||
toggle()
|
toggle()
|
||||||
} else {
|
} else if (cannotUseDevice) {
|
||||||
openPermissionsDialog(kind)
|
openPermissionsDialog(kind)
|
||||||
|
} else if (explainDeviceInUse) {
|
||||||
|
setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingPermission.current = false
|
isRequestingPermission.current = false
|
||||||
@@ -179,13 +184,33 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
return <ActiveSpeakerWrapper />
|
return <ActiveSpeakerWrapper />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getToggleTooltip = () => {
|
||||||
|
if (deviceMissing) return t(`deviceNotFound.${kind}`)
|
||||||
|
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
|
||||||
|
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
|
return toggleLabel
|
||||||
|
}
|
||||||
|
const toggleTooltip = getToggleTooltip()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{(cannotUseDevice || deviceMissing) && (
|
{(cannotUseDevice || deviceMissing) && (
|
||||||
<PermissionNeededButton
|
<PermissionNeededButton
|
||||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||||
onPress={
|
onPress={
|
||||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
deviceMissing
|
||||||
|
? () => setAlertError(MediaDeviceFailure.NotFound)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deviceInUse && (
|
||||||
|
<PermissionNeededButton
|
||||||
|
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : undefined}
|
||||||
|
onPress={
|
||||||
|
explainDeviceInUse
|
||||||
|
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -204,22 +229,16 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
shySelected
|
shySelected
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
tooltip={
|
tooltip={toggleTooltip}
|
||||||
deviceMissing
|
|
||||||
? t(`deviceNotFound.${kind}`)
|
|
||||||
: cannotUseDevice
|
|
||||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
|
||||||
: toggleLabel
|
|
||||||
}
|
|
||||||
{...computedToggleButtonProps}
|
{...computedToggleButtonProps}
|
||||||
{...overrideToggleButtonProps}
|
{...overrideToggleButtonProps}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
error={alertError}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
onClose={() => setShowDeviceNotFound(false)}
|
onClose={() => setAlertError(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-12
@@ -8,7 +8,6 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
} from '../blur'
|
} from '../blur'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
|
|
||||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
@@ -38,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
|
|||||||
|
|
||||||
enum BlurRadius {
|
enum BlurRadius {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
LIGHT = 5,
|
LIGHT = 10,
|
||||||
NORMAL = 10,
|
NORMAL = 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||||
@@ -281,14 +280,6 @@ export const EffectsConfiguration = ({
|
|||||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||||
false
|
false
|
||||||
|
|
||||||
// Thumbnails are browser-native loads (CSS url()) which cannot carry
|
|
||||||
// the Authorization header in embedded (token) mode: resolve them. The
|
|
||||||
// processor configs keep the stable raw URLs - they are persisted in
|
|
||||||
// the user choices - and the processors resolve them internally.
|
|
||||||
const resolveMediaUrl = useResolvedMediaUrls(
|
|
||||||
(filesQ.data?.results ?? []).map((file) => file.url)
|
|
||||||
)
|
|
||||||
|
|
||||||
const getHandleSelectChangeFile = useCallback(
|
const getHandleSelectChangeFile = useCallback(
|
||||||
(file: ApiFileItem) => {
|
(file: ApiFileItem) => {
|
||||||
return async () => {
|
return async () => {
|
||||||
@@ -766,7 +757,7 @@ export const EffectsConfiguration = ({
|
|||||||
bgSize: 'cover',
|
bgSize: 'cover',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
|
backgroundImage: `url(${option.file.url!})`,
|
||||||
}}
|
}}
|
||||||
data-attr={`toggle-virtual-${option.file.id}`}
|
data-attr={`toggle-virtual-${option.file.id}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||||
|
import { useCannotUseDevice } from './useCannotUseDevice'
|
||||||
|
import { useDeviceMissing } from './useDeviceMissing'
|
||||||
|
|
||||||
|
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
|
||||||
|
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
|
|
||||||
|
if (cannotUseDevice || deviceMissing) return false
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'videoinput':
|
||||||
|
return cameraInUse
|
||||||
|
case 'audioinput':
|
||||||
|
return microphoneInUse
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,16 +10,13 @@ import {
|
|||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { BackgroundProcessorFactory } from '../components/blur'
|
import { BackgroundProcessorFactory } from '../components/blur'
|
||||||
import {
|
import {
|
||||||
classifyPermissionError,
|
|
||||||
isLikelySystemNotFound,
|
|
||||||
isSystemPermissionError,
|
isSystemPermissionError,
|
||||||
noteGumSuccess,
|
|
||||||
notePermissionDeniedFromGum,
|
|
||||||
noteSystemPermissionDenied,
|
|
||||||
type PermissionKind,
|
type PermissionKind,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
import { getOS } from '@/utils/os'
|
import {
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
noteDeviceReady,
|
||||||
|
onMediaPermissionError,
|
||||||
|
} from '../utils/mediaPermissions'
|
||||||
import {
|
import {
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
@@ -39,64 +36,6 @@ const VOICE_AUDIO_CONSTRAINTS = {
|
|||||||
sampleSize: 16,
|
sampleSize: 16,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
|
|
||||||
audioinput: 'microphone',
|
|
||||||
videoinput: 'camera',
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaPath = 'join_preview' | 'room'
|
|
||||||
|
|
||||||
const onMediaPermissionError = (
|
|
||||||
e: Error,
|
|
||||||
kind?: PermissionKind,
|
|
||||||
path: MediaPath = 'join_preview'
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
|
||||||
) {
|
|
||||||
void classifyPermissionError(e, kind).then((scope) => {
|
|
||||||
if (scope === 'system') {
|
|
||||||
noteSystemPermissionDenied(kind)
|
|
||||||
} else {
|
|
||||||
notePermissionDeniedFromGum(kind)
|
|
||||||
}
|
|
||||||
captureMediaEvent('permissions-denied', {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
denied_scope: scope,
|
|
||||||
os: getOS(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
|
|
||||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
|
||||||
// settings, missing Android app permissions).
|
|
||||||
void isLikelySystemNotFound(e, kind).then((system) => {
|
|
||||||
if (system) {
|
|
||||||
noteSystemPermissionDenied(kind)
|
|
||||||
captureMediaEvent('permissions-denied', {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
denied_scope: 'system',
|
|
||||||
os: getOS(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
captureMediaEvent('device-not-found', { path, kind })
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
|
|
||||||
reportError(
|
|
||||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
|
||||||
e,
|
|
||||||
{ path, kind }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level: effect dependencies, must be referentially stable.
|
// Module-level: effect dependencies, must be referentially stable.
|
||||||
const disableAudio = () => saveAudioInputEnabled(false)
|
const disableAudio = () => saveAudioInputEnabled(false)
|
||||||
const disableVideo = () => saveVideoInputEnabled(false)
|
const disableVideo = () => saveVideoInputEnabled(false)
|
||||||
@@ -104,24 +43,6 @@ const disableVideo = () => saveVideoInputEnabled(false)
|
|||||||
const stopAll = (stream: MediaStream) =>
|
const stopAll = (stream: MediaStream) =>
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
|
||||||
export const requestDevicePermission = async (
|
|
||||||
kind: 'audioinput' | 'videoinput',
|
|
||||||
path: MediaPath = 'join_preview'
|
|
||||||
): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const track =
|
|
||||||
kind === 'audioinput'
|
|
||||||
? await createLocalAudioTrack()
|
|
||||||
: await createLocalVideoTrack()
|
|
||||||
track.stop()
|
|
||||||
noteGumSuccess(PERMISSION_KIND[kind])
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WarmupState = {
|
type WarmupState = {
|
||||||
audioReady: boolean
|
audioReady: boolean
|
||||||
videoReady: boolean
|
videoReady: boolean
|
||||||
@@ -158,7 +79,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
video: true,
|
video: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
noteGumSuccess()
|
noteDeviceReady()
|
||||||
bothReady()
|
bothReady()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -180,7 +101,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ audio: true })
|
.getUserMedia({ audio: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteGumSuccess('microphone')
|
noteDeviceReady('microphone')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -190,7 +111,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ video: true })
|
.getUserMedia({ video: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteGumSuccess('camera')
|
noteDeviceReady('camera')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -227,7 +148,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
create()
|
create()
|
||||||
.then((newTrack) => {
|
.then((newTrack) => {
|
||||||
noteGumSuccess(permissionKind)
|
noteDeviceReady(permissionKind)
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
newTrack.stop()
|
newTrack.stop()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
|
import {
|
||||||
|
type LocalTrackPublication,
|
||||||
|
MediaDeviceFailure,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
} from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
PERMISSION_BY_DEVICE_KIND,
|
PERMISSION_BY_DEVICE_KIND,
|
||||||
type PermissionDeniedScope,
|
type PermissionDeniedScope,
|
||||||
@@ -10,7 +15,11 @@ import {
|
|||||||
notePermissionDeniedFromGum,
|
notePermissionDeniedFromGum,
|
||||||
noteSystemPermissionDenied,
|
noteSystemPermissionDenied,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
import {
|
||||||
|
clearDeviceInUse,
|
||||||
|
noteDeviceInUse,
|
||||||
|
syncDeviceAvailability,
|
||||||
|
} from '@/stores/deviceAvailability'
|
||||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||||
import { getOS } from '@/utils/os'
|
import { getOS } from '@/utils/os'
|
||||||
|
|
||||||
@@ -21,6 +30,11 @@ type MediaDeviceAlert = {
|
|||||||
|
|
||||||
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
||||||
|
|
||||||
|
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
|
||||||
|
[Track.Source.Camera]: 'camera',
|
||||||
|
[Track.Source.Microphone]: 'microphone',
|
||||||
|
}
|
||||||
|
|
||||||
const capturePermissionsDenied = (
|
const capturePermissionsDenied = (
|
||||||
scope: PermissionDeniedScope,
|
scope: PermissionDeniedScope,
|
||||||
kind?: PermissionKind
|
kind?: PermissionKind
|
||||||
@@ -63,6 +77,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||||
switch (failure) {
|
switch (failure) {
|
||||||
case MediaDeviceFailure.DeviceInUse:
|
case MediaDeviceFailure.DeviceInUse:
|
||||||
|
noteDeviceInUse(permissionKind)
|
||||||
setAlert({ error: failure, kind })
|
setAlert({ error: failure, kind })
|
||||||
break
|
break
|
||||||
case MediaDeviceFailure.NotFound:
|
case MediaDeviceFailure.NotFound:
|
||||||
@@ -92,9 +107,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const onTrackPublished = (publication: LocalTrackPublication) => {
|
||||||
|
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
|
||||||
|
if (permissionKind) clearDeviceInUse(permissionKind)
|
||||||
|
}
|
||||||
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
|
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||||
|
clearDeviceInUse()
|
||||||
}
|
}
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
|||||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||||
import { MoreOptions } from './MoreOptions'
|
import { MoreOptions } from './MoreOptions'
|
||||||
import { useRef } from 'react'
|
import { RefObject, useMemo, useState } from 'react'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
@@ -21,7 +21,14 @@ export function DesktopControlBar({
|
|||||||
onDeviceError,
|
onDeviceError,
|
||||||
}: Readonly<ControlBarAuxProps>) {
|
}: Readonly<ControlBarAuxProps>) {
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
|
||||||
|
const [controlBarElement, setControlBarElement] =
|
||||||
|
useState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
|
||||||
|
() => ({ current: controlBarElement }),
|
||||||
|
[controlBarElement]
|
||||||
|
)
|
||||||
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ export function DesktopControlBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={desktopControlBarEl}
|
ref={setControlBarElement}
|
||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isWeb } from '@livekit/components-core'
|
import { isWeb } from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { MediaDeviceFailure, Track } from 'livekit-client'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
@@ -19,6 +19,7 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
|
|||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
|
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||||
@@ -99,6 +100,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reportError('device_switch_failure', error, {
|
reportError('device_switch_failure', error, {
|
||||||
at: 'ControlBar.onDeviceError',
|
at: 'ControlBar.onDeviceError',
|
||||||
source,
|
source,
|
||||||
@@ -144,6 +150,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
<RecordingProvider />
|
<RecordingProvider />
|
||||||
<SettingsDialogProvider />
|
<SettingsDialogProvider />
|
||||||
|
<MuteAlertDialogProvider />
|
||||||
<ReactionPortals />
|
<ReactionPortals />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
MediaDeviceFailure,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import {
|
||||||
|
classifyPermissionError,
|
||||||
|
isLikelySystemNotFound,
|
||||||
|
noteGumSuccess,
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
noteSystemPermissionDenied,
|
||||||
|
type PermissionKind,
|
||||||
|
} from '@/stores/permissions'
|
||||||
|
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
|
||||||
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { getOS } from '@/utils/os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared handling of getUserMedia() outcomes, used by both:
|
||||||
|
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
|
||||||
|
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
|
||||||
|
*/
|
||||||
|
export type MediaPath = 'join_preview' | 'room'
|
||||||
|
|
||||||
|
export const PERMISSION_KIND: Record<
|
||||||
|
'audioinput' | 'videoinput',
|
||||||
|
PermissionKind
|
||||||
|
> = {
|
||||||
|
audioinput: 'microphone',
|
||||||
|
videoinput: 'camera',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noteDeviceReady = (kind?: PermissionKind) => {
|
||||||
|
noteGumSuccess(kind)
|
||||||
|
clearDeviceInUse(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onMediaPermissionError = (
|
||||||
|
e: Error,
|
||||||
|
kind?: PermissionKind,
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
) => {
|
||||||
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
|
|
||||||
|
if (failure === MediaDeviceFailure.PermissionDenied) {
|
||||||
|
void classifyPermissionError(e, kind).then((scope) => {
|
||||||
|
if (scope === 'system') {
|
||||||
|
noteSystemPermissionDenied(kind)
|
||||||
|
} else {
|
||||||
|
notePermissionDeniedFromGum(kind)
|
||||||
|
}
|
||||||
|
captureMediaEvent('permissions-denied', {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
denied_scope: scope,
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure === MediaDeviceFailure.NotFound) {
|
||||||
|
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||||
|
// settings, missing Android app permissions).
|
||||||
|
void isLikelySystemNotFound(e, kind).then((system) => {
|
||||||
|
if (system) {
|
||||||
|
noteSystemPermissionDenied(kind)
|
||||||
|
captureMediaEvent('permissions-denied', {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
denied_scope: 'system',
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
captureMediaEvent('device-not-found', { path, kind })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure === MediaDeviceFailure.DeviceInUse) {
|
||||||
|
noteDeviceInUse(kind)
|
||||||
|
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Other" is still reported as an error.
|
||||||
|
reportError(
|
||||||
|
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||||
|
e,
|
||||||
|
{ path, kind }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silent availability check for a device that was reported "in use":
|
||||||
|
* acquires and releases it without any error reporting, so it can be
|
||||||
|
* polled. Clears the in-use flag on success.
|
||||||
|
*/
|
||||||
|
export const probeDeviceReleased = async (
|
||||||
|
kind: PermissionKind
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(
|
||||||
|
kind === 'camera' ? { video: true } : { audio: true }
|
||||||
|
)
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
noteDeviceReady(kind)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the browser permission prompt for one device kind by acquiring
|
||||||
|
* and immediately releasing a track. Resolves to whether access was granted.
|
||||||
|
*/
|
||||||
|
export const requestDevicePermission = async (
|
||||||
|
kind: 'audioinput' | 'videoinput',
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const track =
|
||||||
|
kind === 'audioinput'
|
||||||
|
? await createLocalAudioTrack()
|
||||||
|
: await createLocalVideoTrack()
|
||||||
|
track.stop()
|
||||||
|
noteDeviceReady(PERMISSION_KIND[kind])
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { useConfig } from '@/api/useConfig.ts'
|
import { useConfig } from '@/api/useConfig.ts'
|
||||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
import { LogLevel, setLogLevel } from 'livekit-client'
|
||||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||||
|
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
||||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||||
|
|
||||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||||
@@ -49,6 +50,7 @@ const Room = () => {
|
|||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
useWatchDeviceAvailability()
|
useWatchDeviceAvailability()
|
||||||
|
useWatchDeviceReleased()
|
||||||
|
|
||||||
const clearRouterState = () => {
|
const clearRouterState = () => {
|
||||||
if (window?.history?.state) {
|
if (window?.history?.state) {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
const LIVEKIT_AUTH_SCHEME = 'X-LiveKit-Token'
|
|
||||||
|
|
||||||
export const getLiveKitAuthHeaders = (token: string) => {
|
|
||||||
return {
|
|
||||||
Authorization: `${LIVEKIT_AUTH_SCHEME} ${token}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import type { ApiError } from '@/api/ApiError'
|
import type { ApiError } from '@/api/ApiError'
|
||||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders'
|
|
||||||
|
|
||||||
export interface StartSubtitleParams {
|
export interface StartSubtitleParams {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,7 +14,9 @@ const startSubtitle = ({
|
|||||||
}: StartSubtitleParams): Promise<ApiRoom> => {
|
}: StartSubtitleParams): Promise<ApiRoom> => {
|
||||||
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getLiveKitAuthHeaders(token),
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ const useTranscriptionState = () => {
|
|||||||
segments: TranscriptionSegment[],
|
segments: TranscriptionSegment[],
|
||||||
participant?: Participant
|
participant?: Participant
|
||||||
) => {
|
) => {
|
||||||
|
console.log(participant, segments)
|
||||||
|
|
||||||
if (!participant || segments.length === 0) return
|
if (!participant || segments.length === 0) return
|
||||||
|
|
||||||
if (segments.length > 1) {
|
if (segments.length > 1) {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useLocationProperty } from 'wouter/use-browser-location'
|
|
||||||
|
|
||||||
const hashSelector = () =>
|
|
||||||
typeof window !== 'undefined' ? window.location.hash : ''
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reactive window.location.hash, subscribed to wouter's navigation
|
|
||||||
* events (the same low-level primitive wouter builds useSearch upon).
|
|
||||||
*/
|
|
||||||
export const useHash = (): string => useLocationProperty(hashSelector, () => '')
|
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||||
|
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audioeinstellungen",
|
"audio": "Audioeinstellungen",
|
||||||
"video": "Videoeinstellungen"
|
"video": "Videoeinstellungen"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||||
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
|
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||||
"cameraStarting": "Kamera wird gestartet…",
|
"cameraStarting": "Kamera wird gestartet…",
|
||||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "No camera detected. Check that it is properly wired.",
|
"videoinput": "No camera detected. Check that it is properly wired.",
|
||||||
"audioinput": "No microphone detected. Check that it is properly wired."
|
"audioinput": "No microphone detected. Check that it is properly wired."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
|
||||||
|
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio settings",
|
"audio": "Audio settings",
|
||||||
"video": "Video settings"
|
"video": "Video settings"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is disabled.",
|
"cameraDisabled": "Camera is disabled.",
|
||||||
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
||||||
|
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
|
||||||
"cameraStarting": "Camera is starting…",
|
"cameraStarting": "Camera is starting…",
|
||||||
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
||||||
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
|
||||||
|
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Paramètres audio",
|
"audio": "Paramètres audio",
|
||||||
"video": "Paramètres video"
|
"video": "Paramètres video"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "La caméra est désactivée.",
|
"cameraDisabled": "La caméra est désactivée.",
|
||||||
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
|
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
|
||||||
"cameraStarting": "La caméra va démarrer…",
|
"cameraStarting": "La caméra va démarrer…",
|
||||||
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
||||||
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
||||||
|
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio-instellingen",
|
"audio": "Audio-instellingen",
|
||||||
"video": "Video-instellingen"
|
"video": "Video-instellingen"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||||
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
|
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
||||||
"cameraStarting": "Camera wordt ingeschakeld…",
|
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||||
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { proxy } from 'valtio'
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
accessToken: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User access token for the embedded (iframe) mode.
|
|
||||||
*
|
|
||||||
* When Meet is rendered inside an iframe, third-party session cookies are
|
|
||||||
* blocked: the host application passes a single-use transit code in the
|
|
||||||
* URL fragment, exchanged at startup for a user access token (see
|
|
||||||
* features/auth/api/exchangeAccessToken) that authenticates every api
|
|
||||||
* call exactly like a session cookie would.
|
|
||||||
*
|
|
||||||
* The token deliberately lives in this in-memory store only: unlike other
|
|
||||||
* stores, it is never persisted (no subscribe/localStorage) and never
|
|
||||||
* appears in a URL. It is lost on reload, in which case the host page is
|
|
||||||
* expected to mint a fresh transit code.
|
|
||||||
*
|
|
||||||
* A non-null token also tells the app it is running in embedded mode:
|
|
||||||
* components can react to it with useSnapshot(accessTokenStore).
|
|
||||||
*/
|
|
||||||
export const accessTokenStore = proxy<State>({
|
|
||||||
accessToken: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const setAccessToken = (accessToken: string | null) => {
|
|
||||||
accessTokenStore.accessToken = accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAccessToken = () => accessTokenStore.accessToken
|
|
||||||
@@ -1,14 +1,39 @@
|
|||||||
import { proxy } from 'valtio'
|
import { proxy } from 'valtio'
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
import type { PermissionKind } from './permissions'
|
||||||
|
|
||||||
// Device presence (not permission): enumerateDevices() exposes kinds
|
// Device availability (not permission):
|
||||||
// before any grant. Optimistic defaults until the first sync.
|
// - presence: enumerateDevices() exposes kinds before any grant.
|
||||||
|
// Optimistic defaults until the first sync.
|
||||||
|
// - in use: the device exists and is allowed, but getUserMedia() failed
|
||||||
|
// because another application or tab is holding it.
|
||||||
export const deviceAvailabilityStore = proxy({
|
export const deviceAvailabilityStore = proxy({
|
||||||
hasCamera: true,
|
hasCamera: true,
|
||||||
hasMicrophone: true,
|
hasMicrophone: true,
|
||||||
|
cameraInUse: false,
|
||||||
|
microphoneInUse: false,
|
||||||
synced: false,
|
synced: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
|
||||||
|
camera: 'cameraInUse',
|
||||||
|
microphone: 'microphoneInUse',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
|
||||||
|
|
||||||
|
const setDeviceInUse = (inUse: boolean, kind?: PermissionKind) => {
|
||||||
|
for (const k of kind ? [kind] : ALL_KINDS) {
|
||||||
|
deviceAvailabilityStore[IN_USE_KEY[k]] = inUse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noteDeviceInUse = (kind?: PermissionKind) =>
|
||||||
|
setDeviceInUse(true, kind)
|
||||||
|
|
||||||
|
export const clearDeviceInUse = (kind?: PermissionKind) =>
|
||||||
|
setDeviceInUse(false, kind)
|
||||||
|
|
||||||
export const syncDeviceAvailability = async (): Promise<void> => {
|
export const syncDeviceAvailability = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { proxy } from 'valtio'
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
participantIds: Record<string, string | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layoutStore = proxy<State>({
|
|
||||||
participantIds: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const setLobbyParticipantId = (
|
|
||||||
roomId: string,
|
|
||||||
participantId: string
|
|
||||||
) => {
|
|
||||||
layoutStore.participantIds[roomId] = participantId
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearParticipantId = (roomId: string) => {
|
|
||||||
delete layoutStore.participantIds[roomId]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getLobbyParticipantId = (roomId: string) =>
|
|
||||||
layoutStore.participantIds[roomId]
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { proxy, ref } from 'valtio'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
participant: Participant | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const muteDialogStore = proxy<State>({
|
||||||
|
participant: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const openMuteDialog = (participant: Participant) => {
|
||||||
|
muteDialogStore.participant = ref(participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const closeMuteDialog = () => {
|
||||||
|
muteDialogStore.participant = null
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { proxy, subscribe } from 'valtio'
|
import { proxy, subscribe } from 'valtio'
|
||||||
import { initializeAccessTokenFromFragment } from '@/features/auth/api/exchangeAccessToken'
|
|
||||||
import { getAccessToken } from '@/stores/accessToken'
|
|
||||||
import {
|
import {
|
||||||
ProcessorConfig,
|
ProcessorConfig,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
@@ -50,19 +48,10 @@ if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
|||||||
// we restore clear the processor config to avoid displaying a black screen.
|
// we restore clear the processor config to avoid displaying a black screen.
|
||||||
userChoicesStore.processorConfig = undefined
|
userChoicesStore.processorConfig = undefined
|
||||||
} else if (userChoicesStore.processorConfig.fileId) {
|
} else if (userChoicesStore.processorConfig.fileId) {
|
||||||
// Embedded (token) mode: this module loads before the transit code
|
|
||||||
// exchange has settled - wait for it, and carry the Bearer header,
|
|
||||||
// otherwise the check below would wrongly clear the config.
|
|
||||||
await initializeAccessTokenFromFragment()
|
|
||||||
const accessToken = getAccessToken()
|
|
||||||
|
|
||||||
// Checking if the image is still available / accessible
|
// Checking if the image is still available / accessible
|
||||||
await fetch(userChoicesStore.processorConfig.imagePath, {
|
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||||
// We bypass the cache to ensure we have access
|
// We bypass the cache to ensure we have access
|
||||||
cache: 'reload',
|
cache: 'reload',
|
||||||
...(accessToken && {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// if we cannot fetch the image (likely a 401 from the backend because
|
// if we cannot fetch the image (likely a 401 from the backend because
|
||||||
|
|||||||
@@ -5,16 +5,26 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
|||||||
import svgr from 'vite-plugin-svgr'
|
import svgr from 'vite-plugin-svgr'
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||||
|
|
||||||
const mediapipeVersion: string = JSON.parse(
|
const readPackageJson = (path: string) =>
|
||||||
readFileSync(
|
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
|
||||||
new URL(
|
|
||||||
'./node_modules/@mediapipe/tasks-vision/package.json',
|
const mediapipeVersion: string = readPackageJson(
|
||||||
import.meta.url
|
'./node_modules/@mediapipe/tasks-vision/package.json'
|
||||||
),
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
).version
|
).version
|
||||||
|
|
||||||
|
const livekitMediapipeVersion: string = readPackageJson(
|
||||||
|
'./node_modules/@livekit/track-processors/package.json'
|
||||||
|
).dependencies['@mediapipe/tasks-vision']
|
||||||
|
|
||||||
|
if (mediapipeVersion !== livekitMediapipeVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
|
||||||
|
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
|
||||||
|
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
|
||||||
|
`"${livekitMediapipeVersion}" in package.json.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd())
|
const env = loadEnv(mode, process.cwd())
|
||||||
|
|||||||
Generated
+18
-8
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.27.0",
|
"version": "1.27.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.0",
|
"@html-to/text-cli": "0.6.1",
|
||||||
"mjml": "5.4.0"
|
"mjml": "5.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -52,14 +52,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@html-to/text-cli": {
|
"node_modules/@html-to/text-cli": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
|
||||||
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
|
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||||
"aspargvs": "~0.7.0",
|
"aspargvs": "~0.7.0",
|
||||||
"deepmerge-ts": "^7.1.5",
|
"deepmerge-ts": "^8.0.1",
|
||||||
"htmlparser2": "^10.1.0",
|
"htmlparser2": "^10.1.0",
|
||||||
"selderee": "~0.12.0"
|
"selderee": "~0.12.0"
|
||||||
},
|
},
|
||||||
@@ -656,9 +656,19 @@
|
|||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge-ts": {
|
"node_modules/deepmerge-ts": {
|
||||||
"version": "7.1.5",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
||||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/rebeccastevens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.0",
|
"@html-to/text-cli": "0.6.1",
|
||||||
"mjml": "5.4.0"
|
"mjml": "5.4.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
|
|||||||
aws_s3_access_key_id: str
|
aws_s3_access_key_id: str
|
||||||
aws_s3_secret_access_key: SecretStr
|
aws_s3_secret_access_key: SecretStr
|
||||||
aws_s3_secure_access: bool = True
|
aws_s3_secure_access: bool = True
|
||||||
|
aws_s3_region_name: str | None = None
|
||||||
aws_transcript_path: str = "transcripts"
|
aws_transcript_path: str = "transcripts"
|
||||||
aws_summary_path: str = "summaries"
|
aws_summary_path: str = "summaries"
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ class FileService:
|
|||||||
access_key=settings.aws_s3_access_key_id,
|
access_key=settings.aws_s3_access_key_id,
|
||||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||||
secure=settings.aws_s3_secure_access,
|
secure=settings.aws_s3_secure_access,
|
||||||
|
region=settings.aws_s3_region_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._bucket_name = settings.aws_storage_bucket_name
|
self._bucket_name = settings.aws_storage_bucket_name
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class RecordingMetadata(BaseModel):
|
|||||||
|
|
||||||
cloud_storage_url: Url = Field(
|
cloud_storage_url: Url = Field(
|
||||||
title="Cloud Storage URL",
|
title="Cloud Storage URL",
|
||||||
description="The URL of the metadata file for speaker assignement.",
|
description="The URL of the metadata file for speaker assignment.",
|
||||||
)
|
)
|
||||||
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
||||||
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||||
|
|||||||
Reference in New Issue
Block a user