mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-21 23:57:00 +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:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# 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 }}'
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -8,6 +8,31 @@ and this project adheres to
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
|
||||
@@ -50,10 +50,24 @@ paths:
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
requestBody:
|
||||
required: true
|
||||
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:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenRequest'
|
||||
@@ -117,6 +131,19 @@ paths:
|
||||
summary: Domain not authorized
|
||||
value:
|
||||
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:
|
||||
get:
|
||||
|
||||
@@ -18,7 +18,6 @@ class FeatureFlag:
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -292,11 +292,6 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
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):
|
||||
@@ -604,20 +599,3 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
# useless bad requests
|
||||
type = 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."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
@@ -42,14 +42,13 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle requests carrying a participant identifier. The
|
||||
identifier is returned by the first request-entry response and
|
||||
echoed back by the client from the second request onward, which is
|
||||
when throttling starts applying.
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
|
||||
Keying on the identifier rather than the IP address prevents
|
||||
penalising multiple users behind the same NAT/proxy, and is
|
||||
consistent with how the lobby identifies participants.
|
||||
Keying on the cookie rather than the IP address prevents penalising
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
security measure against brute-force or DoS attacks. This throttle exists
|
||||
@@ -59,14 +58,10 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return None # Only throttle unauthenticated requests.
|
||||
|
||||
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return None
|
||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||
|
||||
participant_id = serializer.validated_data.get("participant_id")
|
||||
|
||||
if not participant_id:
|
||||
return None # No throttling for unidentified requests
|
||||
if participant_id is None:
|
||||
return None # No throttling for cookieless requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
@@ -102,14 +97,3 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
|
||||
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous transit code exchange attempts.
|
||||
|
||||
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||
best-effort. The security of the exchange rests on the codes'
|
||||
entropy and single use.
|
||||
"""
|
||||
|
||||
scope = "exchange_access_token"
|
||||
|
||||
@@ -43,7 +43,6 @@ from rest_framework.settings import api_settings
|
||||
from core import analytics, enums, models, utils
|
||||
from core.api import throttling
|
||||
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.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import (
|
||||
@@ -76,7 +75,6 @@ from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -101,7 +99,6 @@ from core.services.room_roles import (
|
||||
RoomRoleService,
|
||||
)
|
||||
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.file import process_file_deletion
|
||||
from core.utils import generate_token
|
||||
@@ -240,96 +237,6 @@ class UserViewSet(
|
||||
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(
|
||||
mixins.CreateModelMixin,
|
||||
@@ -616,10 +523,13 @@ class RoomViewSet(
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
room=room,
|
||||
user=request.user,
|
||||
request=request,
|
||||
**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(
|
||||
detail=True,
|
||||
|
||||
@@ -9,8 +9,6 @@ from rest_framework import authentication, exceptions
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||
|
||||
|
||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
@@ -22,14 +20,9 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
return None # No authentication attempted
|
||||
|
||||
parts = auth_header.split()
|
||||
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
||||
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
|
||||
# backend may recognize it.
|
||||
return None
|
||||
|
||||
if len(parts) != 2:
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise exceptions.AuthenticationFailed(
|
||||
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||
"Authorization header must be: Bearer <token>"
|
||||
)
|
||||
|
||||
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] = {}
|
||||
|
||||
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):
|
||||
"""Check if the token claims the scope required by this action.
|
||||
"""Check if the JWT token contains the required scope for this action.
|
||||
|
||||
Args:
|
||||
request: DRF request object with authenticated user
|
||||
@@ -85,15 +33,38 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
Raises:
|
||||
PermissionDenied: If required scope is missing from token
|
||||
"""
|
||||
required_scope = self.get_required_scope(view)
|
||||
if required_scope is None:
|
||||
# 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 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:
|
||||
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:
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Insufficient permissions. Required scope: {required_scope}"
|
||||
@@ -102,37 +73,6 @@ class BaseScopePermission(permissions.BasePermission):
|
||||
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):
|
||||
"""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):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
parsers as drf_parsers,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
@@ -22,7 +25,6 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -42,6 +44,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
@@ -219,59 +222,3 @@ class RoomViewSet(
|
||||
"$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):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_alter_application_scopes'),
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -795,7 +795,6 @@ class ApplicationScope(models.TextChoices):
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
@@ -845,18 +844,6 @@ class Application(BaseModel):
|
||||
domain = get_domain_from_email(email)
|
||||
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):
|
||||
"""Domain authorized for application delegation."""
|
||||
|
||||
@@ -86,6 +86,23 @@ class LobbyService:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
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
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
@@ -116,9 +133,8 @@ class LobbyService:
|
||||
def request_entry(
|
||||
self,
|
||||
room: models.Room,
|
||||
user,
|
||||
request,
|
||||
username: str,
|
||||
participant_id: Optional[uuid.UUID] = None,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
@@ -133,48 +149,51 @@ class LobbyService:
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
participant = None
|
||||
if participant_id:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
is_new_participant = participant is None
|
||||
if is_new_participant:
|
||||
participant = self._create_participant(room.id, username)
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_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):
|
||||
participant = self.handle_participant_entry(room_id, participant.id, True)
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
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(
|
||||
room_id=room_id,
|
||||
user=user,
|
||||
username=participant.username,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant.id,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if is_new_participant:
|
||||
self._notify_entry_request(room_id)
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
|
||||
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:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=user,
|
||||
username=participant.username,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant.id,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
@@ -191,36 +210,27 @@ class LobbyService:
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
||||
"""Create and persist a new waiting participant.
|
||||
def enter(
|
||||
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(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
self._save_participant(room_id, participant)
|
||||
|
||||
return participant
|
||||
|
||||
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
|
||||
"""Persist a participant in the room's lobby."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, participant.id),
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
color=color,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_entry_request(room_id: str):
|
||||
"""Notify room participants of a new entry request."""
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=room_id,
|
||||
room_name=str(room_id),
|
||||
notification_data={
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
},
|
||||
@@ -229,6 +239,15 @@ class LobbyService:
|
||||
# If room not created yet, there is no participants to notify
|
||||
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(
|
||||
self, room_id: UUID, participant_id: str
|
||||
) -> Optional[LobbyParticipant]:
|
||||
@@ -275,7 +294,7 @@ class LobbyService:
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
allow_entry: bool,
|
||||
) -> LobbyParticipant:
|
||||
) -> None:
|
||||
"""Handle decision on participant entry.
|
||||
|
||||
Updates participant status based on allow_entry:
|
||||
@@ -293,7 +312,7 @@ class LobbyService:
|
||||
"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(
|
||||
self,
|
||||
@@ -301,7 +320,7 @@ class LobbyService:
|
||||
participant_id: str,
|
||||
status: LobbyParticipantStatus,
|
||||
timeout: int,
|
||||
) -> LobbyParticipant:
|
||||
) -> None:
|
||||
"""Update participant status with appropriate timeout."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
@@ -323,8 +342,6 @@ class LobbyService:
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
|
||||
return participant
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from ...models import ApplicationScope, Room, RoomAccessLevel
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room, RoomAccessLevel
|
||||
|
||||
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
|
||||
room = Room.objects.get()
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from ...models import ApplicationScope, RoomAccessLevel
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -160,41 +156,3 @@ def test_api_rooms_list_pagination_page_size():
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
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 ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -26,6 +29,7 @@ def test_request_entry_anonymous(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -43,10 +47,11 @@ def test_request_entry_anonymous(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -73,6 +78,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -90,10 +96,11 @@ def test_request_entry_authenticated_user(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -120,6 +127,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
client = APIClient()
|
||||
|
||||
# Configure test settings for cookies and cache
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
@@ -160,10 +168,11 @@ def test_request_entry_with_existing_participants(settings):
|
||||
# Verify successful response
|
||||
assert response.status_code == 200
|
||||
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
# Verify the lobby cookie was properly set for the new participant
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -188,6 +197,7 @@ def test_request_entry_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -196,7 +206,9 @@ def test_request_entry_public_room(settings):
|
||||
|
||||
with (
|
||||
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(
|
||||
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
|
||||
|
||||
# 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
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
@@ -218,14 +235,9 @@ def test_request_entry_public_room(settings):
|
||||
"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}_*")
|
||||
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"
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
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.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -243,8 +256,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch(
|
||||
"core.services.lobby.uuid.uuid4",
|
||||
mock.patch.object(
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
),
|
||||
mock.patch.object(
|
||||
@@ -259,6 +273,11 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
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
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
@@ -268,13 +287,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"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}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
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)
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
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)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# 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 (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
@@ -304,14 +322,16 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{
|
||||
"username": "user1",
|
||||
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
},
|
||||
{"username": "user1"},
|
||||
)
|
||||
|
||||
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
|
||||
assert response.json() == {
|
||||
"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}_*")
|
||||
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():
|
||||
"""Should return 400 for invalid request data."""
|
||||
@@ -622,14 +637,15 @@ def test_list_waiting_participants_empty(settings):
|
||||
@mock.patch.object(
|
||||
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
|
||||
):
|
||||
"""Requests without a participant identifier should not be throttled."""
|
||||
"""Anonymous users without a cookie should not be throttled."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -638,6 +654,9 @@ def test_request_entry_throttling_anonymous_unidentified(
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("mocked-cookie") is not None
|
||||
|
||||
client.cookies.clear() # Simulate a new cookieless request
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
@@ -651,32 +670,34 @@ def test_request_entry_throttling_anonymous_unidentified(
|
||||
@mock.patch.object(
|
||||
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
|
||||
):
|
||||
"""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)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
client.cookies.load({"mocked-cookie": participant_id})
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
@@ -695,6 +716,7 @@ def test_request_entry_throttling_authenticated_user(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -715,124 +737,3 @@ def test_request_entry_throttling_authenticated_user(
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.urls import reverse
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from livekit.api import TwirpError, UpdateParticipantRequest
|
||||
from livekit.protocol.models import ParticipantInfo
|
||||
@@ -22,18 +19,8 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import (
|
||||
ApplicationFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from core.models import ApplicationScope
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -100,7 +87,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -126,7 +113,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -166,7 +153,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
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,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
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,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -374,7 +361,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
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,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -425,7 +412,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -453,7 +440,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -482,7 +469,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
# Create participant in lobby cache first
|
||||
LobbyService()._save_participant(
|
||||
room.id,
|
||||
LobbyParticipant(
|
||||
id=participant_identity,
|
||||
username="John doe",
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
color="#123456",
|
||||
),
|
||||
)
|
||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
||||
|
||||
# Accept participant
|
||||
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"}
|
||||
|
||||
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
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.urls import reverse
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from livekit.api import TwirpError
|
||||
@@ -20,13 +17,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import (
|
||||
ApplicationFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from core.models import ApplicationScope
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -78,10 +69,7 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
||||
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}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -135,10 +117,7 @@ def test_toggle_hand_identity_derived_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
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."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "raised" in response.data
|
||||
@@ -165,7 +142,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
||||
url,
|
||||
{"raised": "not-a-boolean"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
@@ -229,7 +200,7 @@ def test_toggle_hand_raise_success_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -249,7 +220,7 @@ def test_toggle_hand_lower_success_anonymous(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -269,7 +240,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
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
|
||||
@@ -286,10 +257,7 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
||||
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}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"name": "Jane Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -321,10 +286,7 @@ def test_rename_participant_uses_identity_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -336,7 +298,7 @@ def test_rename_participant_empty_name(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
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
|
||||
@@ -347,9 +309,7 @@ def test_rename_participant_missing_name(room, token):
|
||||
"""Test rename with missing name field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "name" in response.data
|
||||
@@ -360,10 +320,7 @@ def test_rename_participant_name_too_long(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "a" * 256},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -391,7 +348,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
@@ -428,7 +382,7 @@ def test_rename_participant_success_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -448,7 +402,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
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
|
||||
@@ -465,7 +419,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
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
|
||||
@@ -482,7 +436,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -508,7 +462,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -522,7 +476,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -536,7 +490,7 @@ def test_toggle_hand_malformed_token(room):
|
||||
url,
|
||||
{"raised": True},
|
||||
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
|
||||
@@ -550,10 +504,7 @@ def test_toggle_hand_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -588,7 +536,7 @@ def test_rename_participant_malformed_token(room):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
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
|
||||
@@ -602,10 +550,7 @@ def test_rename_participant_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
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()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import (
|
||||
ApplicationFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ...models import ApplicationScope, RoleChoices, RoomAccessLevel
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -515,41 +507,3 @@ def test_api_rooms_retrieve_administrators(
|
||||
role=str(user_access.role),
|
||||
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
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from ...models import ApplicationScope
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -113,7 +110,7 @@ def test_start_subtitle_invalid_token():
|
||||
response = client.post(
|
||||
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
|
||||
@@ -131,7 +128,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
response = client.post(
|
||||
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
|
||||
@@ -151,7 +148,7 @@ def test_start_subtitle_valid_token(
|
||||
response = client.post(
|
||||
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
|
||||
@@ -181,7 +178,7 @@ def test_start_subtitle_twirp_error(
|
||||
response = client.post(
|
||||
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
|
||||
@@ -201,7 +198,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
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
|
||||
@@ -222,132 +219,10 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
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.json() == {
|
||||
"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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from ...models import ApplicationScope, RoomAccessLevel
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -441,46 +437,3 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"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
|
||||
# ruff: noqa: PLR0913, PLR0917
|
||||
|
||||
import uuid
|
||||
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.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -129,10 +131,63 @@ def test_get_cache_key(lobby_service, participant_id):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
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")
|
||||
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."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
user = AnonymousUser()
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
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"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id="test-participant-id",
|
||||
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_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."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
user = UserFactory()
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
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"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id="test-participant-id",
|
||||
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(
|
||||
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
|
||||
one is unknown to the lobby and therefore discarded - and the room is
|
||||
notified of the entry request."""
|
||||
|
||||
user = AnonymousUser()
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
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)
|
||||
|
||||
participant_data = LobbyParticipant(
|
||||
@@ -295,20 +345,14 @@ def test_request_entry_new_participant(
|
||||
id=participant_id,
|
||||
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, user, username, participant_id=forged_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
# The provided identifier was looked up, found unknown, and replaced
|
||||
# by a freshly minted participant
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
|
||||
mock_create.assert_called_once_with(room.id, username)
|
||||
mock_notify.assert_called_once_with(str(room.id))
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
"""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)
|
||||
|
||||
@@ -326,11 +372,10 @@ def test_request_entry_waiting_participant(
|
||||
id=participant_id,
|
||||
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)
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert livekit_config is None
|
||||
@@ -340,119 +385,80 @@ def test_request_entry_waiting_participant(
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
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."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
user = AnonymousUser()
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
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"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
|
||||
@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"
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
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."""
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
user = UserFactory()
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
UserResourceAccessFactory(resource=room, user=user, role="administrator")
|
||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||
{
|
||||
"id": participant_id,
|
||||
"username": username,
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
},
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
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"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, user, username, participant_id=participant_id
|
||||
)
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=user,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@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)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
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.utils.generate_color")
|
||||
def test_create_participant(
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""A created participant is waiting, colored, and persisted."""
|
||||
"""Test successful participant entry."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
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
|
||||
uuid.UUID(participant.id)
|
||||
mock_generate_color.assert_called_once_with(participant.id)
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
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(
|
||||
"mocked_cache_key",
|
||||
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")
|
||||
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
||||
"""A notification error must not break the entry request flow."""
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
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(
|
||||
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@@ -551,7 +584,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
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.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):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
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.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 result[0]["status"] == "waiting"
|
||||
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.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):
|
||||
"""Test listing multiple waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
@@ -612,7 +645,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
# Verify all participants have waiting status
|
||||
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.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):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
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.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):
|
||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
valid_participant = {
|
||||
"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)
|
||||
|
||||
# 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.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):
|
||||
"""Test listing only waiting participants (not accepted/denied)."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
@@ -713,7 +746,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
|
||||
room.id,
|
||||
participant_id,
|
||||
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,
|
||||
participant_id,
|
||||
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()
|
||||
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 = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
}
|
||||
cache.set(
|
||||
cache_key, participant_data, timeout=django_settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||
assert cache.get(cache_key) is not None
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
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():
|
||||
"""Invalid client_id should return 401."""
|
||||
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,
|
||||
basename="external_room",
|
||||
)
|
||||
external_router.register(
|
||||
"users",
|
||||
external_viewsets.UserViewSet,
|
||||
basename="external_user",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -325,7 +325,6 @@ class Base(Configuration):
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
@@ -345,11 +344,6 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
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(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
@@ -881,6 +875,11 @@ class Base(Configuration):
|
||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_COOKIE_NAME = values.Value(
|
||||
"lobbyParticipantId",
|
||||
environ_name="LOBBY_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
@@ -1003,66 +1002,6 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
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
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
@@ -1359,9 +1298,6 @@ class Test(Base):
|
||||
ADDONS_ENABLED = True
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -2252,11 +2252,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
version = "0.6.0"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
||||
Generated
+68
-65
@@ -12,7 +12,7 @@
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@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/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
@@ -24,17 +24,17 @@
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.404.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-i18next": "17.0.10",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
@@ -742,28 +742,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||
@@ -966,12 +966,12 @@
|
||||
"license": "GPL-3.0+"
|
||||
},
|
||||
"node_modules/@livekit/components-core": {
|
||||
"version": "0.12.13",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||
"version": "0.12.14",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
||||
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"loglevel": "1.9.1",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
@@ -979,17 +979,17 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.17.2",
|
||||
"livekit-client": "^2.20.1",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-react": {
|
||||
"version": "2.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
||||
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
||||
"version": "2.9.23",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
||||
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-core": "0.12.13",
|
||||
"@livekit/components-core": "0.12.14",
|
||||
"clsx": "2.1.1",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.0.12",
|
||||
@@ -999,8 +999,8 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||
"livekit-client": "^2.18.2",
|
||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
|
||||
"livekit-client": "^2.20.1",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"tslib": "^2.6.2"
|
||||
@@ -1027,9 +1027,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.46.6",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||
"version": "1.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@@ -1720,18 +1720,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.39.5",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
||||
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
||||
"version": "1.48.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.392.0"
|
||||
"@posthog/types": "^1.405.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.392.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
||||
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
||||
"version": "1.405.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/overlays": {
|
||||
@@ -4393,11 +4393,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.39.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
@@ -6400,9 +6403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6419,7 +6422,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -8109,20 +8112,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
||||
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.46.6",
|
||||
"@livekit/protocol": "1.50.4",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.5"
|
||||
"webrtc-adapter": "9.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
@@ -9120,17 +9123,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.395.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.404.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
||||
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.38.0",
|
||||
"@posthog/types": "^1.391.1",
|
||||
"core-js": "^3.38.1",
|
||||
"@posthog/core": "^1.43.1",
|
||||
"@posthog/types": "^1.397.0",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.29.2",
|
||||
"preact": "^10.29.3",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.3.0"
|
||||
}
|
||||
@@ -9422,9 +9425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -9434,7 +9437,7 @@
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -11645,9 +11648,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@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/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
@@ -31,17 +31,17 @@
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.404.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-i18next": "17.0.10",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
|
||||
+17
-24
@@ -12,7 +12,6 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
@@ -25,29 +24,23 @@ function App() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import { apiUrl } from './apiUrl'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
export const fetchApi = async <T = Record<string, unknown>>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> => {
|
||||
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), {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -137,6 +137,7 @@ export const captureMediaEvent = async (
|
||||
| 'media-device-topology'
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'device-in-use'
|
||||
| 'permissions-denied'
|
||||
| 'screen-share-permission-denied'
|
||||
| '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 { type ApiUser } from './ApiUser'
|
||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
/**
|
||||
* fetch the logged-in user from the api.
|
||||
@@ -26,13 +25,7 @@ export const fetchUser = (
|
||||
if (error instanceof ApiError && error.statusCode === 401) {
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
// Never attempt a silent login in embedded (token) mode: an OIDC
|
||||
// redirect inside the iframe would break the embed.
|
||||
if (
|
||||
opts.attemptSilent &&
|
||||
!getAccessToken() &&
|
||||
canAttemptSilentLogin()
|
||||
) {
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(30)
|
||||
} else {
|
||||
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()
|
||||
}, [])
|
||||
|
||||
// Tigger the message notification (temporary)
|
||||
// Trigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
// 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 { useRef } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
import { useParticipantTracks } from '@livekit/components-react'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
)
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
const [cameraTrack] = useParticipantTracks(
|
||||
[Source.Camera],
|
||||
participant?.identity
|
||||
)
|
||||
|
||||
const trackReference = {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const trackReference = useMemo(
|
||||
() => cameraTrack ?? { participant, source: Source.Camera },
|
||||
[cameraTrack, participant]
|
||||
)
|
||||
|
||||
if (!participant) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
|
||||
+11
-26
@@ -1,11 +1,9 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/primitives'
|
||||
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 }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => openMuteDialog(participant)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,13 +19,11 @@ import {
|
||||
import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { openMuteDialog } from '@/stores/muteDialog'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
|
||||
|
||||
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Source.Microphone,
|
||||
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
|
||||
const canMute = useCanMute(participant)
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
const label = isLocal(participant)
|
||||
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted || !canMute}
|
||||
onPress={async () =>
|
||||
!isMuted && isLocal(participant)
|
||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
|
||||
false
|
||||
)
|
||||
: setIsAlertOpen(true)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted || !canMute}
|
||||
onPress={async () =>
|
||||
!isMuted && isLocal(participant)
|
||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
|
||||
: openMuteDialog(participant)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||
import { recordingStore } from '@/stores/recording'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMutateRecording = () => {
|
||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'start'
|
||||
captureEvent('error-starting-recording')
|
||||
},
|
||||
})
|
||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'stop'
|
||||
captureEvent('error-stopping-recording')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
@@ -41,7 +40,7 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
|
||||
const headers = !isAdminOrOwner
|
||||
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
||||
: undefined
|
||||
|
||||
let response
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useRenameParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -16,10 +15,11 @@ export const useRenameParticipant = () => {
|
||||
throw new Error('LiveKit token is not available')
|
||||
}
|
||||
|
||||
const headers = getLiveKitAuthHeaders(token)
|
||||
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||
import { getLobbyParticipantId } from '@/stores/lobby'
|
||||
|
||||
export interface RequestEntryParams {
|
||||
roomId: string
|
||||
@@ -16,7 +15,6 @@ export enum ApiLobbyStatus {
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
id?: string
|
||||
status: ApiLobbyStatus
|
||||
livekit?: ApiLiveKit
|
||||
}
|
||||
@@ -25,12 +23,10 @@ export const requestEntry = async ({
|
||||
roomId,
|
||||
username = '',
|
||||
}: RequestEntryParams) => {
|
||||
const participantId = getLobbyParticipantId(roomId)
|
||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
...(participantId && { participant_id: participantId }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useRaiseHand = () => {
|
||||
const data = useRoomData()
|
||||
@@ -16,10 +15,11 @@ export const useRaiseHand = () => {
|
||||
throw new Error('LiveKit token is not available')
|
||||
}
|
||||
|
||||
const headers = getLiveKitAuthHeaders(token)
|
||||
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
raised,
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
@@ -25,7 +27,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
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 { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
@@ -227,6 +233,16 @@ export const Conference = ({
|
||||
onError={(e) => {
|
||||
const failure = MediaDeviceFailure.getFailure(e)
|
||||
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, {
|
||||
path: 'connect_publish',
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
userChoicesStore,
|
||||
} from '@/stores/userChoices'
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
|
||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||
import { SilentMicDetector } from './SilentMicDetector'
|
||||
@@ -218,12 +219,14 @@ const switchTrackDevice =
|
||||
function getPreviewMessages({
|
||||
cameraFound,
|
||||
cameraDenied,
|
||||
cameraInUse,
|
||||
micDenied,
|
||||
videoEnabled,
|
||||
videoStarted,
|
||||
}: {
|
||||
cameraFound: boolean
|
||||
cameraDenied: boolean
|
||||
cameraInUse: boolean
|
||||
micDenied: boolean
|
||||
videoEnabled: boolean
|
||||
videoStarted: boolean
|
||||
@@ -235,6 +238,9 @@ function getPreviewMessages({
|
||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||
return { hint: key, permissionsButtonLabel: key }
|
||||
}
|
||||
if (cameraInUse) {
|
||||
return { hint: 'cameraInUse', permissionsButtonLabel: null }
|
||||
}
|
||||
if (!videoEnabled) {
|
||||
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
||||
}
|
||||
@@ -328,18 +334,20 @@ const VideoPreview = ({
|
||||
const cameraDenied = useCannotUseDevice('videoinput')
|
||||
const micDenied = useCannotUseDevice('audioinput')
|
||||
const cameraMissing = useDeviceMissing('videoinput')
|
||||
const cameraInUse = useDeviceInUse('videoinput')
|
||||
|
||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||
|
||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||
cameraFound: !cameraMissing,
|
||||
cameraDenied,
|
||||
cameraInUse,
|
||||
micDenied,
|
||||
videoEnabled,
|
||||
videoStarted,
|
||||
})
|
||||
|
||||
const isError = cameraMissing || cameraDenied
|
||||
const isError = cameraMissing || cameraDenied || cameraInUse
|
||||
|
||||
return (
|
||||
<div className={styles.previewFrame}>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ApiLobbyStatus,
|
||||
type ApiRequestEntry,
|
||||
} from '../api/requestEntry'
|
||||
import { setLobbyParticipantId } from '@/stores/lobby'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
@@ -44,11 +43,6 @@ export const useLobby = ({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
|
||||
if (response.id) {
|
||||
setLobbyParticipantId(roomId, response.id)
|
||||
}
|
||||
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
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 { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||
import {
|
||||
FilesetResolver,
|
||||
ImageSegmenter,
|
||||
@@ -45,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
videoElement?: HTMLVideoElement
|
||||
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
|
||||
outputCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
@@ -56,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
segmentationMaskCanvas?: HTMLCanvasElement
|
||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
// Mask containg the inference result.
|
||||
// Mask containing the inference result.
|
||||
segmentationMask?: ImageData
|
||||
|
||||
// The resized image of the video source.
|
||||
@@ -86,7 +85,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
await this._initVirtualBackgroundImage()
|
||||
this._initVirtualBackgroundImage()
|
||||
this._createMainCanvas()
|
||||
this._createMaskCanvas()
|
||||
|
||||
@@ -104,11 +103,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
captureEvent('firefox-blurring-init', {})
|
||||
}
|
||||
|
||||
async _initVirtualBackgroundImage() {
|
||||
_initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const needsUpdate =
|
||||
@@ -116,19 +113,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.virtualBackgroundImage &&
|
||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||
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.crossOrigin = 'anonymous'
|
||||
this.virtualBackgroundImage.src = imagePath
|
||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
||||
}
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.options = opts
|
||||
await this._initVirtualBackgroundImage()
|
||||
this._initVirtualBackgroundImage()
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
|
||||
+1
-14
@@ -1,5 +1,4 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
BackgroundProcessor,
|
||||
@@ -48,16 +47,7 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
await 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 })
|
||||
}
|
||||
}
|
||||
return this.processor.init(opts)
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
@@ -69,9 +59,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
if (opts.type === 'virtual') {
|
||||
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
||||
}
|
||||
this.opts = opts
|
||||
|
||||
const newProcessorType =
|
||||
|
||||
+40
-21
@@ -20,8 +20,9 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import type {
|
||||
@@ -97,38 +98,42 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
const deviceInUse = useDeviceInUse(kind)
|
||||
const explainDeviceInUse = deviceInUse && context === 'room'
|
||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||
const silentMicWarning =
|
||||
kind === 'audioinput' &&
|
||||
silentMicStatus === 'silent' &&
|
||||
!cannotUseDevice &&
|
||||
!deviceMissing
|
||||
!deviceMissing &&
|
||||
!deviceInUse
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
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 () => {
|
||||
if (!enabled && deviceMissing) {
|
||||
setShowDeviceNotFound(true)
|
||||
setAlertError(MediaDeviceFailure.NotFound)
|
||||
return
|
||||
}
|
||||
if (!cannotUseDevice) {
|
||||
if (!cannotUseDevice && !deviceInUse) {
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (isRequestingPermission.current) return
|
||||
isRequestingPermission.current = true
|
||||
try {
|
||||
const granted = await requestDevicePermission(
|
||||
kind,
|
||||
context === 'join' ? 'join_preview' : 'room'
|
||||
)
|
||||
if (granted) {
|
||||
const acquired = await requestDevicePermission(kind, mediaPath)
|
||||
if (acquired) {
|
||||
toggle()
|
||||
} else {
|
||||
} else if (cannotUseDevice) {
|
||||
openPermissionsDialog(kind)
|
||||
} else if (explainDeviceInUse) {
|
||||
setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||
}
|
||||
} finally {
|
||||
isRequestingPermission.current = false
|
||||
@@ -179,13 +184,33 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
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 (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{(cannotUseDevice || deviceMissing) && (
|
||||
<PermissionNeededButton
|
||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||
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
|
||||
onPress={onPress}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
deviceMissing
|
||||
? t(`deviceNotFound.${kind}`)
|
||||
: cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
tooltip={toggleTooltip}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
<MediaDeviceErrorAlert
|
||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||
error={alertError}
|
||||
kind={kind}
|
||||
onClose={() => setShowDeviceNotFound(false)}
|
||||
onClose={() => setAlertError(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+3
-12
@@ -8,7 +8,6 @@ import {
|
||||
ProcessorType,
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
|
||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
@@ -38,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
LIGHT = 5,
|
||||
NORMAL = 10,
|
||||
LIGHT = 10,
|
||||
NORMAL = 20,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||
@@ -281,14 +280,6 @@ export const EffectsConfiguration = ({
|
||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||
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(
|
||||
(file: ApiFileItem) => {
|
||||
return async () => {
|
||||
@@ -766,7 +757,7 @@ export const EffectsConfiguration = ({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
|
||||
backgroundImage: `url(${option.file.url!})`,
|
||||
}}
|
||||
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'
|
||||
import { BackgroundProcessorFactory } from '../components/blur'
|
||||
import {
|
||||
classifyPermissionError,
|
||||
isLikelySystemNotFound,
|
||||
isSystemPermissionError,
|
||||
noteGumSuccess,
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
type PermissionKind,
|
||||
} from '@/stores/permissions'
|
||||
import { getOS } from '@/utils/os'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import {
|
||||
noteDeviceReady,
|
||||
onMediaPermissionError,
|
||||
} from '../utils/mediaPermissions'
|
||||
import {
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
@@ -39,64 +36,6 @@ const VOICE_AUDIO_CONSTRAINTS = {
|
||||
sampleSize: 16,
|
||||
} 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.
|
||||
const disableAudio = () => saveAudioInputEnabled(false)
|
||||
const disableVideo = () => saveVideoInputEnabled(false)
|
||||
@@ -104,24 +43,6 @@ const disableVideo = () => saveVideoInputEnabled(false)
|
||||
const stopAll = (stream: MediaStream) =>
|
||||
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 = {
|
||||
audioReady: boolean
|
||||
videoReady: boolean
|
||||
@@ -158,7 +79,7 @@ function useWarmupPermissions(): WarmupState {
|
||||
video: true,
|
||||
})
|
||||
)
|
||||
noteGumSuccess()
|
||||
noteDeviceReady()
|
||||
bothReady()
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -180,7 +101,7 @@ function useWarmupPermissions(): WarmupState {
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('microphone')
|
||||
noteDeviceReady('microphone')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||
.finally(() =>
|
||||
@@ -190,7 +111,7 @@ function useWarmupPermissions(): WarmupState {
|
||||
.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('camera')
|
||||
noteDeviceReady('camera')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||
.finally(() =>
|
||||
@@ -227,7 +148,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
let cancelled = false
|
||||
create()
|
||||
.then((newTrack) => {
|
||||
noteGumSuccess(permissionKind)
|
||||
noteDeviceReady(permissionKind)
|
||||
if (cancelled) {
|
||||
newTrack.stop()
|
||||
return
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
MediaDeviceFailure,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import {
|
||||
PERMISSION_BY_DEVICE_KIND,
|
||||
type PermissionDeniedScope,
|
||||
@@ -10,7 +15,11 @@ import {
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
} from '@/stores/permissions'
|
||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||
import {
|
||||
clearDeviceInUse,
|
||||
noteDeviceInUse,
|
||||
syncDeviceAvailability,
|
||||
} from '@/stores/deviceAvailability'
|
||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { getOS } from '@/utils/os'
|
||||
|
||||
@@ -21,6 +30,11 @@ type MediaDeviceAlert = {
|
||||
|
||||
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 = (
|
||||
scope: PermissionDeniedScope,
|
||||
kind?: PermissionKind
|
||||
@@ -63,6 +77,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||
switch (failure) {
|
||||
case MediaDeviceFailure.DeviceInUse:
|
||||
noteDeviceInUse(permissionKind)
|
||||
setAlert({ error: failure, kind })
|
||||
break
|
||||
case MediaDeviceFailure.NotFound:
|
||||
@@ -92,9 +107,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
break
|
||||
}
|
||||
}
|
||||
const onTrackPublished = (publication: LocalTrackPublication) => {
|
||||
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
|
||||
if (permissionKind) clearDeviceInUse(permissionKind)
|
||||
}
|
||||
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||
return () => {
|
||||
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||
clearDeviceInUse()
|
||||
}
|
||||
}, [room])
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { RefObject, useMemo, useState } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
@@ -21,7 +21,14 @@ export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
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({})
|
||||
|
||||
@@ -45,7 +52,7 @@ export function DesktopControlBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
ref={setControlBarElement}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isWeb } from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { MediaDeviceFailure, Track } from 'livekit-client'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
ConnectionStateToast,
|
||||
@@ -19,6 +19,7 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||
@@ -99,6 +100,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) {
|
||||
return
|
||||
}
|
||||
|
||||
reportError('device_switch_failure', error, {
|
||||
at: 'ControlBar.onDeviceError',
|
||||
source,
|
||||
@@ -144,6 +150,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<ConnectionStateToast />
|
||||
<RecordingProvider />
|
||||
<SettingsDialogProvider />
|
||||
<MuteAlertDialogProvider />
|
||||
<ReactionPortals />
|
||||
</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 { LogLevel, setLogLevel } from 'livekit-client'
|
||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||
|
||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
@@ -49,6 +50,7 @@ const Room = () => {
|
||||
|
||||
useKeyboardShortcuts()
|
||||
useWatchDeviceAvailability()
|
||||
useWatchDeviceReleased()
|
||||
|
||||
const clearRouterState = () => {
|
||||
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 type { ApiError } from '@/api/ApiError'
|
||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders'
|
||||
|
||||
export interface StartSubtitleParams {
|
||||
id: string
|
||||
@@ -15,7 +14,9 @@ const startSubtitle = ({
|
||||
}: StartSubtitleParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
||||
method: 'POST',
|
||||
headers: getLiveKitAuthHeaders(token),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ const useTranscriptionState = () => {
|
||||
segments: TranscriptionSegment[],
|
||||
participant?: Participant
|
||||
) => {
|
||||
console.log(participant, segments)
|
||||
|
||||
if (!participant || segments.length === 0) return
|
||||
|
||||
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.",
|
||||
"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": {
|
||||
"audio": "Audioeinstellungen",
|
||||
"video": "Videoeinstellungen"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
"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…",
|
||||
"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?",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"videoinput": "No camera 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": {
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"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…",
|
||||
"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?",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"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é."
|
||||
},
|
||||
"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": {
|
||||
"audio": "Paramètres audio",
|
||||
"video": "Paramètres video"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivé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…",
|
||||
"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 ?",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"videoinput": "Geen camera 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": {
|
||||
"audio": "Audio-instellingen",
|
||||
"video": "Video-instellingen"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||
"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…",
|
||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||
"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 { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import type { PermissionKind } from './permissions'
|
||||
|
||||
// Device presence (not permission): enumerateDevices() exposes kinds
|
||||
// before any grant. Optimistic defaults until the first sync.
|
||||
// Device availability (not permission):
|
||||
// - 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({
|
||||
hasCamera: true,
|
||||
hasMicrophone: true,
|
||||
cameraInUse: false,
|
||||
microphoneInUse: 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> => {
|
||||
try {
|
||||
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 { initializeAccessTokenFromFragment } from '@/features/auth/api/exchangeAccessToken'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
import {
|
||||
ProcessorConfig,
|
||||
ProcessorType,
|
||||
@@ -50,19 +48,10 @@ if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
||||
// we restore clear the processor config to avoid displaying a black screen.
|
||||
userChoicesStore.processorConfig = undefined
|
||||
} 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
|
||||
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||
// We bypass the cache to ensure we have access
|
||||
cache: 'reload',
|
||||
...(accessToken && {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
// 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 { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
|
||||
const mediapipeVersion: string = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./node_modules/@mediapipe/tasks-vision/package.json',
|
||||
import.meta.url
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
const readPackageJson = (path: string) =>
|
||||
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
|
||||
|
||||
const mediapipeVersion: string = readPackageJson(
|
||||
'./node_modules/@mediapipe/tasks-vision/package.json'
|
||||
).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/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
|
||||
Generated
+18
-8
@@ -9,7 +9,7 @@
|
||||
"version": "1.27.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
"mjml": "5.4.0"
|
||||
}
|
||||
},
|
||||
@@ -52,14 +52,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@html-to/text-cli": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
|
||||
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
|
||||
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||
"aspargvs": "~0.7.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"deepmerge-ts": "^8.0.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"selderee": "~0.12.0"
|
||||
},
|
||||
@@ -656,9 +656,19 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
"mjml": "5.4.0"
|
||||
},
|
||||
"private": true,
|
||||
|
||||
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
|
||||
aws_s3_access_key_id: str
|
||||
aws_s3_secret_access_key: SecretStr
|
||||
aws_s3_secure_access: bool = True
|
||||
aws_s3_region_name: str | None = None
|
||||
aws_transcript_path: str = "transcripts"
|
||||
aws_summary_path: str = "summaries"
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ class FileService:
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
region=settings.aws_s3_region_name,
|
||||
)
|
||||
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
|
||||
@@ -26,7 +26,7 @@ class RecordingMetadata(BaseModel):
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
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")
|
||||
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||
|
||||
Reference in New Issue
Block a user