mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-10 17:35:44 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05f3610b1c | |||
| 06d9a2e7de |
+5
-9
@@ -8,18 +8,14 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
- ⚡️(frontend) defer loading the Crisp script until idle
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
|
||||
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
|
||||
if user is None and settings.OIDC_CREATE_USER:
|
||||
user = self.create_user(sub)
|
||||
|
||||
if user is not None and not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise SuspiciousOperation("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, sub):
|
||||
|
||||
@@ -52,6 +52,12 @@ class InvalidPayloadError(LiveKitWebhookError):
|
||||
status_code = 400
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||
"""Unsupported event type."""
|
||||
|
||||
status_code = 422
|
||||
|
||||
|
||||
class ActionFailedError(LiveKitWebhookError):
|
||||
"""Webhook action fails to process or complete."""
|
||||
|
||||
@@ -68,7 +74,6 @@ class LiveKitWebhookEventType(Enum):
|
||||
# Participant events
|
||||
PARTICIPANT_JOINED = "participant_joined"
|
||||
PARTICIPANT_LEFT = "participant_left"
|
||||
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
||||
|
||||
# Track events
|
||||
TRACK_PUBLISHED = "track_published"
|
||||
@@ -148,13 +153,10 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
||||
data.event,
|
||||
room_name,
|
||||
)
|
||||
return
|
||||
except ValueError as e:
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
|
||||
# Handle according to received webhook type
|
||||
handler = self._webhook_handlers.get(webhook_type.value)
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
||||
|
||||
|
||||
def test_unknown_event_type(client, mock_livekit_config):
|
||||
"""Should acknowledge (200) an unknown event type rather than reject it."""
|
||||
"""Should return 422 for unknown event type."""
|
||||
event_data = json.dumps({"event": "unknown_event_type"})
|
||||
|
||||
# Generate auth token for this specific payload
|
||||
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.services.livekit_events import (
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
UnsupportedEventTypeError,
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
@@ -664,27 +665,22 @@ def test_receive_missing_auth(service):
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
|
||||
"""Unknown event types are logged and ignored, not rejected.
|
||||
|
||||
LiveKit adds event types over time and does not retry 4xx responses, so
|
||||
raising here would silently drop the event.
|
||||
"""
|
||||
def test_receive_unsupported_event(mock_receive, service):
|
||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
mock_data.event = "some_future_event"
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
|
||||
service.receive(mock_request) # must not raise
|
||||
|
||||
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
|
||||
caplog.text
|
||||
)
|
||||
with pytest.raises(
|
||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||
):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Tests for the external API ResourceServerBackend."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.external_api.authentication import ResourceServerBackend
|
||||
from core.factories import UserFactory
|
||||
from core.models import User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _payload(sub):
|
||||
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_active():
|
||||
"""An existing active user matching the sub should be returned."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||
)
|
||||
|
||||
assert result == user
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_inactive():
|
||||
"""An inactive user should be rejected even with a valid token."""
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
|
||||
ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||
)
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_creates(settings):
|
||||
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||
)
|
||||
|
||||
assert result.sub == "new-sub"
|
||||
assert result.is_active is True
|
||||
assert User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
|
||||
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert not User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": True,
|
||||
"sub": user.sub,
|
||||
"scope": "openid lasuite_meet rooms:list",
|
||||
"client_id": "app",
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "login failed" in str(response.data).lower()
|
||||
@@ -469,9 +469,6 @@ class Base(Configuration):
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
|
||||
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
|
||||
)
|
||||
|
||||
# Easy thumbnails
|
||||
THUMBNAIL_EXTENSION = "webp"
|
||||
@@ -1233,14 +1230,7 @@ class Base(Configuration):
|
||||
dsn=cls.SENTRY_DSN,
|
||||
environment=cls.__name__.lower(), # build, test, development, production
|
||||
release=get_release(),
|
||||
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
|
||||
integrations=[
|
||||
DjangoIntegration(
|
||||
transaction_style="url",
|
||||
middleware_spans=True,
|
||||
cache_spans=True,
|
||||
)
|
||||
],
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
sentry_sdk.set_tag("application", "backend")
|
||||
|
||||
|
||||
@@ -71,30 +71,20 @@ export const ConnectionObserver = () => {
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
const handleConnection = async () => {
|
||||
const handleConnection = () => {
|
||||
// Preserve original connection timestamp across reconnections to measure
|
||||
// total session duration from first connect to final disconnect.
|
||||
if (connectionStartTimeRef.current != null) return
|
||||
connectionStartTimeRef.current = Date.now()
|
||||
const participantSid = room.localParticipant.sid
|
||||
const roomSid = await room.getSid().catch(() => undefined)
|
||||
void captureMediaEvent('connection-event', {
|
||||
livekit_room_sid: roomSid,
|
||||
livekit_participant_sid: participantSid,
|
||||
})
|
||||
void captureMediaEvent('connection-event', {})
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
captureEvent('reconnect-event')
|
||||
}
|
||||
|
||||
const handleReconnected = async () => {
|
||||
const participantSid = room.localParticipant.sid
|
||||
const roomSid = await room.getSid().catch(() => undefined)
|
||||
captureEvent('reconnected-event', {
|
||||
livekit_room_sid: roomSid,
|
||||
livekit_participant_sid: participantSid,
|
||||
})
|
||||
const handleReconnected = () => {
|
||||
captureEvent('reconnected-event')
|
||||
}
|
||||
|
||||
const handleSignalingConnect = () => {
|
||||
|
||||
+6
-3
@@ -2,20 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useIsSupportEnabled, openSupportChat } from '@/features/support/hooks/useSupport'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
|
||||
|
||||
export const SupportMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const isSupportEnabled = useIsSupportEnabled()
|
||||
|
||||
if (!isSupportEnabled) {
|
||||
if (!isSupportEnabled || !Crisp) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={openSupportChat}
|
||||
onAction={() => {
|
||||
Crisp?.chat.open()
|
||||
}}
|
||||
>
|
||||
<RiQuestionLine size={20} />
|
||||
{t('support')}
|
||||
|
||||
@@ -1,45 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { Crisp } from 'crisp-sdk-web'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
|
||||
|
||||
let crisp: CrispSdk | undefined
|
||||
let crispPromise: Promise<CrispSdk> | undefined
|
||||
|
||||
const loadCrisp = (): Promise<CrispSdk> => {
|
||||
crispPromise ??= import('crisp-sdk-web')
|
||||
.then((module) => {
|
||||
crisp = module.Crisp
|
||||
return module.Crisp
|
||||
})
|
||||
.catch((error) => {
|
||||
crispPromise = undefined
|
||||
throw error
|
||||
})
|
||||
|
||||
return crispPromise
|
||||
}
|
||||
|
||||
export const openSupportChat = () => {
|
||||
if (!crisp?.isCrispInjected()) return
|
||||
crisp.chat.open()
|
||||
}
|
||||
|
||||
export const initializeSupportSession = (user: ApiUser) => {
|
||||
if (!crisp?.isCrispInjected()) return
|
||||
|
||||
if (!Crisp.isCrispInjected()) return
|
||||
const { id, email } = user
|
||||
crisp.setTokenId(`meet-${id}`)
|
||||
if (email) crisp.user.setEmail(email)
|
||||
Crisp.setTokenId(`meet-${id}`)
|
||||
if (email) Crisp.user.setEmail(email)
|
||||
}
|
||||
|
||||
export const terminateSupportSession = () => {
|
||||
if (!crisp?.isCrispInjected()) return
|
||||
|
||||
crisp.setTokenId()
|
||||
crisp.session.reset()
|
||||
if (!Crisp.isCrispInjected()) return
|
||||
Crisp.setTokenId()
|
||||
Crisp.session.reset()
|
||||
}
|
||||
|
||||
export type useSupportProps = {
|
||||
@@ -47,70 +22,26 @@ export type useSupportProps = {
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
const IDLE_TIMEOUT_MS = 10_000
|
||||
|
||||
const scheduleWhenIdle = (callback: () => void): (() => void) => {
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
const handle = window.requestIdleCallback(callback, {
|
||||
timeout: IDLE_TIMEOUT_MS,
|
||||
})
|
||||
return () => window.cancelIdleCallback(handle)
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(callback, 1)
|
||||
return () => window.clearTimeout(handle)
|
||||
}
|
||||
|
||||
// Configure Crisp chat for real-time support across all pages.
|
||||
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
|
||||
const { user } = useUser()
|
||||
const [isInjected, setIsInjected] = useState(
|
||||
() => crisp?.isCrispInjected() ?? false
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || isDisabled) return
|
||||
|
||||
if (crisp?.isCrispInjected()) {
|
||||
setIsInjected(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const cancelIdle = scheduleWhenIdle(() => {
|
||||
void loadCrisp()
|
||||
.then((sdk) => {
|
||||
if (cancelled) return
|
||||
|
||||
if (!sdk.isCrispInjected()) {
|
||||
sdk.configure(id)
|
||||
sdk.setHideOnMobile(true)
|
||||
}
|
||||
|
||||
setIsInjected(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to initialize support chat', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelIdle()
|
||||
}
|
||||
if (!id || Crisp.isCrispInjected() || isDisabled) return
|
||||
Crisp.configure(id)
|
||||
Crisp.setHideOnMobile(true)
|
||||
}, [id, isDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !isInjected || isDisabled) return
|
||||
if (!user) return
|
||||
initializeSupportSession(user)
|
||||
}, [user, isInjected, isDisabled])
|
||||
}, [user])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Some users block the chat widget, so check its availability safely.
|
||||
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
|
||||
// So we need to safely check if Crisp is available and not blocked
|
||||
const isCrispAvailable = () => {
|
||||
try {
|
||||
return !!window?.$crisp?.is
|
||||
|
||||
@@ -480,7 +480,7 @@
|
||||
"destination": "Ein neues Dokument wird erstellt auf",
|
||||
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
||||
"language": "Meeting-Sprache:",
|
||||
"recording": "Auch eine Videoaufzeichnung starten"
|
||||
"recording": "Auch eine Aufzeichnung starten"
|
||||
},
|
||||
"button": {
|
||||
"start": "Meeting-Transkription starten",
|
||||
|
||||
@@ -480,7 +480,7 @@
|
||||
"destination": "A new document will be created on",
|
||||
"destinationUnknown": "A new document will be created",
|
||||
"language": "Meeting language:",
|
||||
"recording": "Also start a video recording"
|
||||
"recording": "Also start a recording"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start transcribing the meeting",
|
||||
|
||||
@@ -479,7 +479,7 @@
|
||||
"destination": "Se creará un nuevo documento en",
|
||||
"destinationUnknown": "Se creará un nuevo documento",
|
||||
"language": "Idioma de la reunión:",
|
||||
"recording": "Iniciar también una grabación de vídeo"
|
||||
"recording": "Iniciar también una grabación"
|
||||
},
|
||||
"button": {
|
||||
"start": "Empezar a transcribir la reunión",
|
||||
|
||||
@@ -480,7 +480,7 @@
|
||||
"destination": "Un nouveau document sera créé sur",
|
||||
"destinationUnknown": "Un nouveau document sera créé",
|
||||
"language": "Langue de la réunion :",
|
||||
"recording": "Démarrer aussi un enregistrement vidéo"
|
||||
"recording": "Démarrer aussi un enregistrement"
|
||||
},
|
||||
"button": {
|
||||
"start": "Commencer à transcrire la réunion",
|
||||
|
||||
@@ -480,7 +480,7 @@
|
||||
"destination": "Er wordt een nieuw document aangemaakt op",
|
||||
"destinationUnknown": "Een nieuw document wordt aangemaakt",
|
||||
"language": "Vergadertalen:",
|
||||
"recording": "Start ook een video-opname"
|
||||
"recording": "Start ook een opname"
|
||||
},
|
||||
"button": {
|
||||
"start": "Begin met het transcriberen van de vergadering",
|
||||
|
||||
Reference in New Issue
Block a user