Compare commits

...

8 Commits

Author SHA1 Message Date
lebaudantoine 502d05396a ️(frontend) defer loading the Crisp script until idle
Load the Crisp JavaScript module only once the frontend is idle,
instead of during the initial page load.

Keeps the critical path lighter and prevents Crisp from competing
with the app's own bootstrap for network and CPU on slow devices.
2026-09-10 18:14:10 +02:00
lebaudantoine 04fd79b56b 🔒️(backend) reject inactive users in resource server backend
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.

Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
2026-09-10 11:10:53 +02:00
leo e336122cfa 💬(frontend) clarify video recording wording
Video recording from transcription panel did not explicitly
mention video, leading to confusion from some users. Make
wording more explicit.
2026-09-09 20:03:36 +02:00
lebaudantoine 172dc70649 (backend) allow configuring trace sampling
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.

Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
2026-09-09 20:02:57 +02:00
lebaudantoine e0ab7f191f 📈(frontend) include LiveKit SIDs in the connection analytics event
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.

Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
2026-09-09 13:38:44 +02:00
lebaudantoine 455b315dbb 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.

Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
2026-09-09 12:09:13 +02:00
lebaudantoine 3089b03062 🔇(backend) silence noisy request summary info logs
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.

Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine 60febb3b57 🔇(backend) silence expected 401 warnings on /me
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.

These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
2026-09-09 11:17:49 +02:00
16 changed files with 295 additions and 56 deletions
+9
View File
@@ -8,9 +8,18 @@ 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
## [1.31.0] - 2026-09-08
@@ -286,6 +286,10 @@ 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):
+25
View File
@@ -0,0 +1,25 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
+8 -10
View File
@@ -52,12 +52,6 @@ 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."""
@@ -74,6 +68,7 @@ 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"
@@ -153,10 +148,13 @@ class LiveKitEventsService:
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
except ValueError:
logger.warning(
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
data.event,
room_name,
)
return
# 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 return 422 for unknown event type."""
"""Should acknowledge (200) an unknown event type rather than reject it."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 422
assert response.json() == {
"status": "error",
}
assert response.status_code == 200
assert response.json() == {"status": "success"}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,7 +16,6 @@ from core.services.livekit_events import (
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
@@ -665,22 +664,27 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
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.
"""
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 = "unsupported_event"
mock_data.event = "some_future_event"
mock_receive.return_value = mock_data
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
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
)
@mock.patch.object(api.WebhookReceiver, "receive")
@@ -0,0 +1,96 @@
"""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()
+30 -1
View File
@@ -469,6 +469,9 @@ 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"
@@ -1110,6 +1113,12 @@ class Base(Configuration):
environ_prefix=None,
)
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -1122,10 +1131,16 @@ class Base(Configuration):
"style": "{",
},
},
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
"filters": ["silence_expected_401"],
},
},
# Override root logger to send it to console
@@ -1136,6 +1151,13 @@ class Base(Configuration):
),
},
"loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"core": {
"handlers": ["console"],
"level": values.Value(
@@ -1211,7 +1233,14 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
release=get_release(),
integrations=[DjangoIntegration()],
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
)
sentry_sdk.set_tag("application", "backend")
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
useEffect(() => {
if (!isAnalyticsEnabled) return
const handleConnection = () => {
const handleConnection = async () => {
// 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()
void captureMediaEvent('connection-event', {})
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
}
const handleReconnect = () => {
captureEvent('reconnect-event')
}
const handleReconnected = () => {
captureEvent('reconnected-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 handleSignalingConnect = () => {
@@ -2,23 +2,20 @@ import { RiQuestionLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { Crisp } from 'crisp-sdk-web'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
import { useIsSupportEnabled, openSupportChat } from '@/features/support/hooks/useSupport'
export const SupportMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled || !Crisp) {
if (!isSupportEnabled) {
return
}
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => {
Crisp?.chat.open()
}}
onAction={openSupportChat}
>
<RiQuestionLine size={20} />
{t('support')}
@@ -1,20 +1,45 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
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 = {
@@ -22,26 +47,70 @@ export type useSupportProps = {
isDisabled?: boolean
}
// Configure Crisp chat for real-time support across all pages.
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)
}
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
const { user } = useUser()
const [isInjected, setIsInjected] = useState(
() => crisp?.isCrispInjected() ?? false
)
useEffect(() => {
if (!id || Crisp.isCrispInjected() || isDisabled) return
Crisp.configure(id)
Crisp.setHideOnMobile(true)
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()
}
}, [id, isDisabled])
useEffect(() => {
if (!user) return
if (!user || !isInjected || isDisabled) return
initializeSupportSession(user)
}, [user])
}, [user, isInjected, isDisabled])
return null
}
// 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
// Some users block the chat widget, so check its availability safely.
const isCrispAvailable = () => {
try {
return !!window?.$crisp?.is
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Aufzeichnung starten"
"recording": "Auch eine Videoaufzeichnung starten"
},
"button": {
"start": "Meeting-Transkription starten",
+1 -1
View File
@@ -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 recording"
"recording": "Also start a video recording"
},
"button": {
"start": "Start transcribing the meeting",
+1 -1
View File
@@ -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"
"recording": "Iniciar también una grabación de vídeo"
},
"button": {
"start": "Empezar a transcribir la reunión",
+1 -1
View File
@@ -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"
"recording": "Démarrer aussi un enregistrement vidéo"
},
"button": {
"start": "Commencer à transcrire la réunion",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
"recording": "Start ook een opname"
"recording": "Start ook een video-opname"
},
"button": {
"start": "Begin met het transcriberen van de vergadering",