Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine ebd4ab616b ♻️(backend) extract forbidden permission fields from the serializer
These fields previously triggered a suspicious operation exception
when passed to the API.

Make the list configurable so the serializer behavior can be
adjusted without requiring a new release.
2026-03-03 13:21:25 +01:00
lebaudantoine 397b9c80c6 🔒️(backend) enhance API input validation to strengthen security
During the bug bounty, attempts were made to pass unexpected hidden
fields to manipulate room behavior and join as a ghost.

Treat these parameters as suspicious. They are not sent by the
frontend, so their presence likely indicates tampering.

Explicitly allow the parameters but emit warning logs to help detect
and investigate suspicious activity.
2026-03-03 13:21:23 +01:00
lebaudantoine 0244baba29 (backend) install pydantic and django-pydantic-field to strengthen API
Super useful for validation when handling unstructured dictionaries.

Follow qbey's recommendation and align with the
suitenumerique/conversation project approach to improve schema
validation and data integrity.
2026-03-03 13:20:20 +01:00
68 changed files with 389 additions and 1833 deletions
-15
View File
@@ -8,24 +8,9 @@ and this project adheres to
## [Unreleased]
### Fixed
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
### Changed
- 🔒️(backend) enhance API input validation to strengthen security #1053
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
## [1.9.0] - 2026-03-02
+2 -20
View File
@@ -2,8 +2,6 @@
from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from ..models import RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
@@ -47,27 +45,11 @@ class RoomPermissions(permissions.BasePermission):
"""
def has_permission(self, request, view):
"""Only allow authenticated users for unsafe methods.
Room creation additionally requires the can_create entitlement.
Fail-closed: denies creation when the entitlements service is unavailable.
"""
"""Only allow authenticated users for unsafe methods."""
if request.method in permissions.SAFE_METHODS:
return True
if not request.user.is_authenticated:
return False
if view.action == "create":
try:
entitlements = get_user_entitlements(
request.user.sub, request.user.email
)
return entitlements.get("can_create", False)
except EntitlementsUnavailableError:
return False
return True
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room."""
+2 -45
View File
@@ -1,7 +1,6 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
from typing import Literal
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
@@ -14,7 +13,6 @@ from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
class UserSerializer(serializers.ModelSerializer):
@@ -28,25 +26,6 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint."""
can_create = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = [*UserSerializer.Meta.fields, "can_create"]
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
def get_can_create(self, user) -> bool:
"""Check entitlements for the current user."""
try:
entitlements = get_user_entitlements(user.sub, user.email)
return entitlements.get("can_create", False)
except EntitlementsUnavailableError:
return False
class ResourceAccessSerializerMixin:
"""
A serializer mixin to share controlling that the logged-in user submitting a room access object
@@ -225,27 +204,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class RecordingOptions(BaseModel):
"""Configuration options for recording.
Attributes:
language: ISO 639-1 language code compatible with whisperX.
When `None`, the transcription engine will attempt to
auto-detect the spoken language.
transcribe: Whether to transcribe the recorded audio.
When `None`, falls back to the application default.
original_mode: The original recording mode before any override.
Must be one of the valid RecordingModeChoices values when provided.
"""
language: str | None = None
transcribe: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
model_config = {"extra": "forbid"}
class StartRecordingSerializer(BaseValidationOnlySerializer):
"""Validate start recording requests."""
@@ -258,11 +216,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = SchemaField(
schema=RecordingOptions | None,
options = serializers.JSONField(
required=False,
allow_null=True,
help_text="Recording options",
default=dict,
)
+3 -5
View File
@@ -187,7 +187,7 @@ class UserViewSet(
"""
context = {"request": request}
return drf_response.Response(
serializers.UserMeSerializer(request.user, context=context).data
self.serializer_class(request.user, context=context).data
)
@@ -296,14 +296,12 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
options = serializer.validated_data["options"]
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
room=room, mode=mode, options=options
)
models.RecordingAccess.objects.create(
@@ -1,7 +1,6 @@
"""Authentication Backends for the Meet core app."""
import contextlib
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
@@ -11,7 +10,6 @@ from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import User
from core.services.marketing import (
ContactCreationError,
@@ -19,8 +17,6 @@ from core.services.marketing import (
get_marketing_service,
)
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -63,21 +59,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
# Warm the entitlements cache on login (force_refresh)
try:
get_user_entitlements(
user_sub=user.sub,
user_email=user.email,
user_info=claims,
force_refresh=True,
)
except EntitlementsUnavailableError:
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
logger.warning(
"Entitlements unavailable for user@%s during login",
email_domain,
)
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
-29
View File
@@ -1,29 +0,0 @@
"""Entitlements service layer."""
from core.entitlements.factory import get_entitlements_backend
class EntitlementsUnavailableError(Exception):
"""Raised when the entitlements backend cannot be reached or returns an error."""
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
"""Get user entitlements, delegating to the configured backend.
Args:
user_sub: The user's OIDC subject identifier.
user_email: The user's email address.
user_info: The full OIDC user_info dict (forwarded to backend).
force_refresh: If True, bypass backend cache and fetch fresh data.
Returns:
dict: {"can_create": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached
and no cache exists.
"""
backend = get_entitlements_backend()
return backend.get_user_entitlements(
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
)
@@ -1,27 +0,0 @@
"""Abstract base class for entitlements backends."""
from abc import ABC, abstractmethod
class EntitlementsBackend(ABC):
"""Abstract base class that defines the interface for entitlements backends."""
@abstractmethod
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
"""Fetch user entitlements.
Args:
user_sub: The user's OIDC subject identifier.
user_email: The user's email address.
user_info: The full OIDC user_info dict (backends may
extract claims from it).
force_refresh: If True, bypass any cache and fetch fresh data.
Returns:
dict: {"can_create": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.
"""
@@ -1,120 +0,0 @@
"""DeployCenter (Espace Operateur) entitlements backend."""
import logging
from django.conf import settings
from django.core.cache import cache
import requests
from core.entitlements import EntitlementsUnavailableError
from core.entitlements.backends.base import EntitlementsBackend
logger = logging.getLogger(__name__)
class DeployCenterEntitlementsBackend(EntitlementsBackend):
"""Backend that fetches entitlements from the DeployCenter API.
Args:
base_url: Full URL of the entitlements endpoint
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
service_id: The service identifier in DeployCenter.
api_key: API key for X-Service-Auth header.
timeout: HTTP request timeout in seconds.
oidc_claims: List of OIDC claim names to extract from user_info
and forward as query params (e.g. ["siret"]).
"""
def __init__( # pylint: disable=too-many-arguments
self,
base_url,
service_id,
api_key,
*,
timeout=10,
oidc_claims=None,
):
self.base_url = base_url
self.service_id = service_id
self.api_key = api_key
self.timeout = timeout
self.oidc_claims = oidc_claims or []
def _cache_key(self, user_sub):
return f"entitlements:user:{user_sub}"
def _make_request(self, user_email, user_info=None):
"""Make a request to the DeployCenter entitlements API.
Returns:
dict | None: The response data, or None on failure.
"""
params = {
"service_id": self.service_id,
"account_type": "user",
"account_email": user_email,
}
# Forward configured OIDC claims as query params
if user_info:
for claim in self.oidc_claims:
if claim in user_info:
params[claim] = user_info[claim]
headers = {
"X-Service-Auth": f"Bearer {self.api_key}",
}
try:
response = requests.get(
self.base_url,
params=params,
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
except (requests.RequestException, ValueError):
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
logger.warning(
"DeployCenter entitlements request failed for user@%s",
email_domain,
exc_info=True,
)
return None
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
"""Fetch user entitlements from DeployCenter with caching.
On cache miss or force_refresh: fetches from the API.
On API failure: falls back to stale cache if available,
otherwise raises EntitlementsUnavailableError.
"""
cache_key = self._cache_key(user_sub)
if not force_refresh:
cached = cache.get(cache_key)
if cached is not None:
return cached
data = self._make_request(user_email, user_info=user_info)
if data is None:
# API failed — try stale cache as fallback
cached = cache.get(cache_key)
if cached is not None:
return cached
raise EntitlementsUnavailableError(
"Failed to fetch user entitlements from DeployCenter"
)
entitlements = data.get("entitlements", {})
result = {
"can_create": entitlements.get("can_create", False),
}
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result
@@ -1,12 +0,0 @@
"""Local entitlements backend for development and testing."""
from core.entitlements.backends.base import EntitlementsBackend
class LocalEntitlementsBackend(EntitlementsBackend):
"""Local backend that always grants access."""
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
return {"can_create": True}
-13
View File
@@ -1,13 +0,0 @@
"""Factory for creating entitlements backend instances."""
import functools
from django.conf import settings
from django.utils.module_loading import import_string
@functools.cache
def get_entitlements_backend():
"""Return a singleton instance of the configured entitlements backend."""
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
+2 -2
View File
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='resource',
name='users',
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='resourceaccess',
@@ -1,5 +1,5 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -1,5 +1,5 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -199,308 +199,3 @@ def test_start_recording_success(
access = recording.accesses.first()
assert access.user == user
assert access.role == "owner"
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
def test_start_recording_options_language_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept a valid ISO 639-1 language code."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": value}
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
def test_start_recording_options_language_not_validated(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Invalid language codes are currently accepted — no format validation yet.
TODO: tighten this once language validation is introduced.
"""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": value}},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_language_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept null language (triggers auto-detection)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
def test_start_recording_options_transcribe_valid_true(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic true values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": True}
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
def test_start_recording_options_transcribe_valid_false(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept transcribe with any valid pydantic false values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"transcribe": False}
def test_start_recording_options_transcribe_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept transcribe=null (falls back to application default)."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept options=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": None},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with no options field at all."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_unknown_field_rejected(settings):
"""Should reject unknown fields in options (extra='forbid')."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"transcribe": value}},
format="json",
)
assert response.status_code == 400
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
def test_start_recording_options_original_mode_valid(
settings, mock_worker_service_factory, mock_worker_manager, value
):
"""Should accept valid recording mode choices for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"original_mode": value}
def test_start_recording_options_original_mode_null(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept original_mode=null."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": None}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
def test_start_recording_options_original_mode_omitted(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a request with original_mode omitted."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {}
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
def test_start_recording_options_original_mode_invalid(settings, value):
"""Should reject invalid recording mode values for original_mode."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"original_mode": value}},
format="json",
)
assert response.status_code == 400
-1
View File
@@ -125,7 +125,6 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
"can_create": True,
}
-504
View File
@@ -1,504 +0,0 @@
"""Tests for the entitlements module."""
# pylint: disable=redefined-outer-name
from unittest import mock
from django.test import override_settings
import pytest
import requests
import responses
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
from rest_framework.test import APIClient
from django.core.cache import cache as django_cache
from core import factories
from core.api.serializers import UserMeSerializer
from core.authentication.backends import OIDCAuthenticationBackend
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
from core.entitlements.backends.local import LocalEntitlementsBackend
from core.entitlements.factory import get_entitlements_backend
pytestmark = pytest.mark.django_db
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
@pytest.fixture(autouse=True)
def _clear_cache():
"""Clear Django cache between tests to prevent entitlements cache bleed."""
django_cache.clear()
# -- LocalEntitlementsBackend --
def test_local_backend_always_grants_access():
"""The local backend should always return can_create=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": True}
def test_local_backend_ignores_parameters():
"""The local backend should work regardless of parameters passed."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements(
"sub-123",
"user@example.com",
user_info={"some": "claim"},
force_refresh=True,
)
assert result == {"can_create": True}
# -- Factory --
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_factory_returns_local_backend():
"""The factory should instantiate the configured backend."""
get_entitlements_backend.cache_clear()
backend = get_entitlements_backend()
assert isinstance(backend, LocalEntitlementsBackend)
get_entitlements_backend.cache_clear()
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_factory_singleton():
"""The factory should return the same instance on repeated calls."""
get_entitlements_backend.cache_clear()
backend1 = get_entitlements_backend()
backend2 = get_entitlements_backend()
assert backend1 is backend2
get_entitlements_backend.cache_clear()
# -- get_user_entitlements public API --
@override_settings(
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_get_user_entitlements_with_local_backend():
"""The public API should delegate to the configured backend."""
get_entitlements_backend.cache_clear()
result = get_user_entitlements("sub-123", "user@example.com")
assert result["can_create"] is True
get_entitlements_backend.cache_clear()
# -- DeployCenterEntitlementsBackend --
@responses.activate
def test_deploycenter_backend_grants_access():
"""DeployCenter backend should return can_create from API response."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
request = responses.calls[0].request
assert "service_id=meet" in request.url
assert "account_email=user%40example.com" in request.url
assert request.headers["X-Service-Auth"] == "Bearer test-key"
@responses.activate
def test_deploycenter_backend_denies_access():
"""DeployCenter backend should return can_create=False when API says so."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_create": False}
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_uses_cache():
"""DeployCenter should use cached results when not force_refresh."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_create": True}
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_create": True}
assert len(responses.calls) == 1 # No additional API call
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_force_refresh_bypasses_cache():
"""force_refresh=True should bypass cache and hit the API."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1["can_create"] is True
result2 = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result2["can_create"] is False
assert len(responses.calls) == 2
@responses.activate
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
def test_deploycenter_backend_fallback_to_stale_cache():
"""When API fails, should return stale cached value if available."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
# Populate cache
backend.get_user_entitlements("sub-123", "user@example.com")
# Now API fails
responses.replace(
responses.GET,
DC_URL,
body=requests.ConnectionError("Connection error"),
)
# force_refresh to hit API, but should fall back to cache
result = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result == {"can_create": True}
@responses.activate
def test_deploycenter_backend_raises_when_no_cache():
"""When API fails and no cache exists, should raise."""
responses.add(
responses.GET,
DC_URL,
body=requests.ConnectionError("Connection error"),
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
)
with pytest.raises(EntitlementsUnavailableError):
backend.get_user_entitlements("sub-123", "user@example.com")
@responses.activate
def test_deploycenter_backend_sends_oidc_claims():
"""DeployCenter should forward configured OIDC claims."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_create": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="meet",
api_key="test-key",
oidc_claims=["organization"],
)
backend.get_user_entitlements(
"sub-123",
"user@example.com",
user_info={"organization": "org-42", "other": "ignored"},
)
request = responses.calls[0].request
assert "organization=org-42" in request.url
assert "other" not in request.url
# -- Auth backend integration --
def test_auth_backend_warms_cache_on_login():
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_create": True},
) as mock_ent:
backend.post_get_or_create_user(
user, {"email": user.email, "sub": "x"}, is_new_user=False
)
mock_ent.assert_called_once_with(
user_sub=user.sub,
user_email=user.email,
user_info={"email": user.email, "sub": "x"},
force_refresh=True,
)
def test_auth_backend_login_succeeds_when_access_denied():
"""Login should succeed even when can_create is False (gated in frontend)."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_create": False},
):
# Should not raise — user logs in, frontend gates access
backend.post_get_or_create_user(
user, {"email": user.email}, is_new_user=False
)
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
"""Login should succeed when entitlements service is unavailable."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
# Should not raise
backend.post_get_or_create_user(
user, {"email": user.email}, is_new_user=False
)
# -- UserMeSerializer (can_create field) --
def test_user_me_serializer_includes_can_create_true():
"""UserMeSerializer should include can_create=True when entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": True},
):
data = UserMeSerializer(user).data
assert data["can_create"] is True
def test_user_me_serializer_includes_can_create_false():
"""UserMeSerializer should include can_create=False when not entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": False},
):
data = UserMeSerializer(user).data
assert data["can_create"] is False
def test_user_me_serializer_can_create_fail_closed():
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
data = UserMeSerializer(user).data
assert data["can_create"] is False
# -- /users/me/ endpoint integration --
def test_api_users_me_includes_can_create():
"""GET /users/me/ should include can_create in the response."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert "can_create" in response.json()
assert response.json()["can_create"] is True
def test_api_users_me_can_create_false():
"""GET /users/me/ should return can_create=False when not entitled."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.json()["can_create"] is False
# -- Room creation entitlements enforcement --
def test_room_creation_blocked_when_not_entitled():
"""Room creation should return 403 when user has can_create=False."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_room_creation_blocked_when_entitlements_unavailable():
"""Room creation should return 403 when entitlements service
is unavailable (fail-closed)."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_room_creation_allowed_when_entitled():
"""Room creation should succeed when user has can_create=True."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": True},
):
response = client.post(
"/api/v1.0/rooms/",
data={"name": "test-room"},
format="json",
)
assert response.status_code == HTTP_201_CREATED
# -- Non-create room actions are NOT gated by entitlements --
def test_room_retrieve_allowed_when_not_entitled():
"""Room retrieval should work even when user has can_create=False."""
user = factories.UserFactory()
room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get(f"/api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
def test_room_list_allowed_when_not_entitled():
"""Room listing should work even when user has can_create=False."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_create": False},
):
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
-17
View File
@@ -706,23 +706,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Entitlements
ENTITLEMENTS_BACKEND = values.Value(
"core.entitlements.backends.local.LocalEntitlementsBackend",
environ_name="ENTITLEMENTS_BACKEND",
environ_prefix=None,
)
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
{},
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
environ_prefix=None,
)
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
300, # 5 minutes
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
environ_prefix=None,
)
# Calendar integrations
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
600, # 10 minutes
+55 -51
View File
@@ -979,11 +979,10 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1144,11 +1143,10 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -4495,10 +4493,33 @@
"tinyglobby": "^0.2.14"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -5393,26 +5414,13 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
@@ -6600,9 +6608,9 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -6726,11 +6734,10 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7384,10 +7391,9 @@
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -8783,10 +8789,9 @@
}
},
"node_modules/matcher-collection/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -8886,13 +8891,13 @@
}
},
"node_modules/minimatch": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz",
"integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -11874,10 +11879,9 @@
}
},
"node_modules/walk-sync/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7,5 +7,4 @@ export type ApiUser = {
last_name: string
language: BackendLanguage
timezone: string
can_create?: boolean
}
+40 -52
View File
@@ -148,8 +148,7 @@ const IntroText = styled('div', {
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn, user } = useUser()
const canCreate = user?.can_create === true
const { isLoggedIn } = useUser()
const {
userChoices: { username },
@@ -201,56 +200,45 @@ export const Home = () => {
})}
>
{isLoggedIn ? (
canCreate ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<p
className={css({
color: 'greyscale.700',
fontSize: '0.95rem',
})}
>
{t('noAccess')}
</p>
)
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<LoginButton proConnectHint={false} />
)}
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'visible' }}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
overflow: 'hidden',
})}
>
<div
@@ -753,7 +753,7 @@ export const Join = ({
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await videoTrack.setDeviceId({ exact: id })
await await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import type { PostHog } from 'posthog-js'
import { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
@@ -74,17 +74,13 @@ export const useWaitingParticipants = () => {
): Promise<void> => {
try {
setListEnabled(false)
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
)
)
for (const participant of waitingParticipants) {
await enterRoom({
roomId: roomId,
allowEntry,
participantId: participant.id,
})
}
await refetchWaiting()
} catch (e) {
console.error(e)
@@ -13,7 +13,7 @@ import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => {
@@ -58,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
@@ -1,28 +1,5 @@
import React, { ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
const Hint = styled('div', {
base: {
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'.lk-grid-layout > *:first-child:focus-within &': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
},
})
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
@@ -35,5 +12,21 @@ export interface KeyboardShortcutHintProps {
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return <Hint>{children}</Hint>
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -1,21 +1,8 @@
import type { CSSProperties } from 'react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client'
const participantNameStyles: CSSProperties = {
paddingBottom: '0.1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const participantNameScreenShareStyles: CSSProperties = {
...participantNameStyles,
marginLeft: '0.4rem',
}
export const ParticipantName = ({
participant,
isScreenShare = false,
@@ -30,14 +17,26 @@ export const ParticipantName = ({
if (isScreenShare) {
return (
<Text variant="sm" style={participantNameScreenShareStyles}>
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
)
@@ -183,7 +183,7 @@ export const ParticipantTile: (
}}
>
{isHandRaised && !isScreenShare && (
<span>
<>
<span>{positionInQueue}</span>
<RiHand
color="black"
@@ -197,7 +197,7 @@ export const ParticipantTile: (
animationIterationCount: '2',
}}
/>
</span>
</>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -210,12 +210,10 @@ export const ParticipantTile: (
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -231,7 +229,9 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -16,11 +15,6 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'recording',
handler: toggleTools,
})
return (
<div
className={css({
@@ -12,7 +12,6 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -22,8 +21,6 @@ export function DesktopControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
useRegisterKeyboardShortcut({
id: 'focus-toolbar',
handler: () => {
@@ -35,13 +32,6 @@ export function DesktopControlBar({
firstButton?.focus()
},
})
useRegisterKeyboardShortcut({
id: 'fullscreen',
handler: toggleFullScreen,
isDisabled: !isFullscreenAvailable,
})
return (
<div
ref={desktopControlBarEl}
@@ -31,9 +31,6 @@ import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
@@ -100,7 +97,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const { toggleSettingsDialog } = useSettingsDialog()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
@@ -115,13 +111,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useVideoResolutionSubscription()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
handler: useCallback(() => {
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
}, [toggleSettingsDialog]),
})
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
@@ -4,10 +4,8 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
const roomRegex = new RegExp(`^${roomIdPattern}$`)
export const isRoomValid = (roomIdOrUrl: string) =>
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => {
@@ -10,10 +10,7 @@ const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
export const CreatePopup = () => {
const { isLoggedIn, user } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const canCreate = user?.can_create === true
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
const { mutateAsync: createRoom } = useCreateRoom()
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
@@ -58,10 +55,10 @@ export const CreatePopup = () => {
console.error('Failed to create meeting room:', error)
}
}
if (isLoggedIn && canCreate && callbackId) {
if (isLoggedIn && callbackId) {
createMeetingRoom()
}
}, [isLoggedIn, canCreate, callbackId, createRoom])
}, [isLoggedIn, callbackId, createRoom])
return (
<div
@@ -3,20 +3,15 @@ import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import { H } from '@/primitives'
const tableStyle = css({
width: '100%',
borderCollapse: 'collapse',
overflowY: 'auto',
'& caption': {
fontWeight: 'bold',
marginBottom: '0.75rem',
textAlign: 'left',
},
'& th, & td': {
padding: '0.65rem 0',
textAlign: 'left',
fontWeight: 'normal',
},
'& tbody tr': {
borderBottom: '1px solid rgba(255,255,255,0.08)',
@@ -34,11 +29,12 @@ export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<H lvl={2}>{t('shortcuts.listLabel')}</H>
<table className={tableStyle}>
<caption>{t('shortcuts.listLabel')}</caption>
<thead>
<thead className="sr-only">
<tr>
<th scope="col">{t('shortcuts.columnAction')}</th>
<th scope="col">{t('shortcuts.columnShortcut')}</th>
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
@@ -22,8 +22,6 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
type DeviceItems = Array<{ value: string; label: string }>
const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
@@ -61,7 +59,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? EMPTY_PROPS
? {}
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
@@ -119,40 +117,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}
}, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => {
return [
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t])
const videoQualityItems = useMemo(() => {
return [
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]
}, [t])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
@@ -214,7 +178,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.publish.label')}
items={resolutionItems}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
@@ -229,7 +206,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
<Field
type="select"
label={t('resolution.subscribe.label')}
items={videoQualityItems}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
@@ -14,25 +14,7 @@ export const useSettingsDialog = () => {
settingsStore.areSettingsOpen = true
}
const closeSettingsDialog = () => {
settingsStore.areSettingsOpen = false
}
const toggleSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) {
closeSettingsDialog()
} else {
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
}
return {
openSettingsDialog,
closeSettingsDialog,
toggleSettingsDialog,
}
}
@@ -5,7 +5,6 @@ import { Shortcut } from './types'
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
@@ -30,11 +29,6 @@ export type ShortcutDescriptor = {
}
export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'open-shortcuts',
category: 'navigation',
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
},
{
id: 'focus-toolbar',
category: 'navigation',
@@ -25,9 +25,9 @@ export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
}) => {
return (
<>
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
{visualLabel}
</kbd>
<div className={cx(badgeStyle, className)} aria-hidden="true">
<span>{visualLabel}</span>
</div>
{srLabel && <span className="sr-only">{srLabel}</span>}
</>
)
@@ -31,9 +31,9 @@ export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
return (
<tr>
<th scope="row" className={text({ variant: 'body' })}>
<td className={text({ variant: 'body' })}>
{t(`actions.${descriptor.id}`)}
</th>
</td>
<td className={shortcutCellStyle}>
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
</td>
@@ -19,10 +19,7 @@ export const useKeyboardShortcuts = () => {
shiftKey,
altKey,
})
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
}
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
await shortcut()
+1 -2
View File
@@ -25,7 +25,6 @@
"heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
},
"selected": "ausgewählt",
"submit": "OK",
"footer": {
"links": {
@@ -46,7 +45,7 @@
"license": "Etalab 2.0 Lizenz"
},
"loginHint": {
"title": "Melden Sie sich mit Ihrem Konto an",
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"button": {
"ariaLabel": "Hinweis schließen",
-1
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
"joinMeetingTipHeading": "Wussten Sie schon?",
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
"moreLinkLabel": "Mehr erfahren neues Tab",
"moreLink": "Mehr erfahren",
"moreAbout": "über {{appTitle}}",
+1 -1
View File
@@ -590,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
"toolbarHint": "F2: zur Symbolleiste unten.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
+1 -2
View File
@@ -25,7 +25,6 @@
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
},
"selected": "selected",
"submit": "OK",
"footer": {
"links": {
@@ -46,7 +45,7 @@
"license": "etalab 2.0 license"
},
"loginHint": {
"title": "Log in with your account",
"title": "Log in with your ProConnect account",
"body": "Instead of waiting, log in with your ProConnect account.",
"button": {
"ariaLabel": "Close the suggestion",
-1
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting",
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
"moreLinkLabel": "Learn more - new tab",
"moreLink": "Learn more",
"moreAbout": "about {{appTitle}}",
+1 -1
View File
@@ -590,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
"toolbarHint": "F2: go to the bottom toolbar.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
+1 -2
View File
@@ -25,7 +25,6 @@
"heading": "Vérifier votre code de réunion",
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
},
"selected": "sélectionné",
"submit": "OK",
"footer": {
"links": {
@@ -46,7 +45,7 @@
"license": "licence etalab 2.0"
},
"loginHint": {
"title": "Connectez-vous avec votre compte",
"title": "Connectez-vous avec votre compte ProConnect",
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
"button": {
"ariaLabel": "Fermer la suggestion",
-1
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur {{appTitle}}",
+1 -1
View File
@@ -590,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Options pour {{name}}",
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
+1 -2
View File
@@ -24,7 +24,6 @@
"notFound": {
"heading": "Pagina niet gevonden"
},
"selected": "geselecteerd",
"submit": "OK",
"footer": {
"links": {
@@ -45,7 +44,7 @@
"license": "etalab 2.0 licentie"
},
"loginHint": {
"title": "Log in met je account",
"title": "Log in met je ProConnect-account",
"body": "In plaats van te wachten, log in met je ProConnect-account.",
"button": {
"ariaLabel": "Sluit de suggestie",
-1
View File
@@ -10,7 +10,6 @@
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
"joinMeetingTipHeading": "Wist u dat?",
"loginToCreateMeeting": "Log in om een vergadering te maken",
"noAccess": "U heeft geen toegang om vergaderingen te maken. Neem contact op met uw beheerder.",
"moreLinkLabel": "Meer informatie - nieuw tabblad",
"moreLink": "Meer informatie",
"moreAbout": "over {{appTitle}}",
+1 -1
View File
@@ -590,7 +590,7 @@
},
"participantTileFocus": {
"containerLabel": "Opties voor {{name}}",
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
"toolbarHint": "F2: naar de werkbalk onderaan.",
"pin": {
"enable": "Pinnen",
"disable": "Losmaken"
+1 -12
View File
@@ -1,7 +1,5 @@
import { ReactNode } from 'react'
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { VisuallyHidden } from '@/styled-system/jsx'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import type { RecipeVariantProps } from '@/styled-system/types'
@@ -21,7 +19,6 @@ export const MenuList = <T extends string | number = string>({
} & MenuProps<unknown> &
RecipeVariantProps<typeof menuRecipe>) => {
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
const { t } = useTranslation('global')
const classes = menuRecipe({
extraPadding: true,
variant: variant,
@@ -42,19 +39,11 @@ export const MenuList = <T extends string | number = string>({
className={classes.item}
key={value}
id={value as string}
textValue={typeof label === 'string' ? label : undefined}
onAction={() => {
onAction(value as T)
}}
>
{({ isSelected }) => (
<>
{label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
{label}
</MenuItem>
)
})}
+2 -14
View File
@@ -1,5 +1,5 @@
import { type ReactNode } from 'react'
import { styled, VisuallyHidden } from '@/styled-system/jsx'
import { styled } from '@/styled-system/jsx'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
@@ -9,7 +9,6 @@ import {
SelectProps as RACSelectProps,
SelectValue,
} from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -111,7 +110,6 @@ export const Select = <T extends string | number>({
...props
}: SelectProps<T>) => {
const IconComponent = iconComponent
const { t } = useTranslation('global')
return (
<RACSelect {...props}>
{label}
@@ -140,18 +138,8 @@ export const Select = <T extends string | number>({
}
id={item.value}
key={item.value}
textValue={
typeof item.label === 'string' ? item.label : undefined
}
>
{({ isSelected }) => (
<>
{item.label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
{item.label}
</ListBoxItem>
))}
</ListBox>
+1 -3
View File
@@ -11,8 +11,6 @@ import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
import { RecordingDownloadRoute } from '@/features/recording'
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
export const routes: Record<
| 'home'
| 'room'
@@ -39,7 +37,7 @@ export const routes: Record<
room: {
name: 'room',
to: (roomId: string) => `/${roomId.trim()}`,
path: roomIdRegex,
path: new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`),
Component: RoomRoute,
},
feedback: {
-21
View File
@@ -151,24 +151,3 @@
[data-lk-theme] .lk-participant-tile {
box-shadow: var(--lk-box-shadow);
}
/* Participant name ellipsis: truncate when overflowing */
.lk-participant-metadata {
gap: 1rem;
}
.lk-participant-metadata > *:first-child {
min-width: 0;
}
.lk-participant-metadata > *:first-child {
flex: 1;
}
.lk-participant-metadata > *:first-child .lk-participant-metadata-item,
.lk-participant-metadata
.lk-participant-metadata-item
.lk-participant-name-wrapper {
min-width: 0;
}
.lk-participant-metadata > *:first-child .lk-participant-metadata-item {
display: flex;
align-items: center;
}
@@ -128,10 +128,6 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressWebhook:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
@@ -141,10 +141,6 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressWebhook:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
@@ -156,10 +156,6 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressWebhook:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.16
version: 0.0.15
@@ -1,90 +0,0 @@
{{- if .Values.ingressWebhook.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressWebhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressWebhook.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressWebhook.annotations "kubernetes.io/ingress.class" .Values.ingressWebhook.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-webhook
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressWebhook.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressWebhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressWebhook.className }}
{{- end }}
{{- if .Values.ingressWebhook.tls.enabled }}
tls:
{{- if .Values.ingressWebhook.host }}
- secretName: {{ .Values.ingressWebhook.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressWebhook.host | quote }}
{{- end }}
{{- range .Values.ingressWebhook.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressWebhook.host }}
- host: {{ .Values.ingressWebhook.host | quote }}
http:
paths:
- path: {{ .Values.ingressWebhook.path }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Exact
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" . }}
port:
number: {{ .Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
{{- with .Values.ingressWebhook.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- range .Values.ingressWebhook.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ .Values.ingressWebhook.path }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Exact
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
{{- with $.Values.ingressWebhook.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- end }}
-25
View File
@@ -50,31 +50,6 @@ ingress:
## @param ingress.customBackends Add custom backends to ingress
customBackends: []
## @param ingressWebhook.enabled whether to enable the Ingress or not
## @param ingressWebhook.className IngressClass to use for the Ingress
## @param ingressWebhook.host Host for the Ingress
## @param ingressWebhook.path Path to use for the Ingress
ingressWebhook:
enabled: false
className: null
host: meet.example.com
path: /api/v1.0/rooms/webhooks-livekit/
## @param ingressWebhook.hosts Additional host to configure for the Ingress
hosts: []
# - chart-example.local
## @param ingressWebhook.tls.enabled Weather to enable TLS for the Ingress
## @param ingressWebhook.tls.secretName Secret name for TLS config
## @skip ingressWebhook.tls.additional
## @extra ingressWebhook.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressWebhook.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
## @param ingressWebhook.customBackends Add custom backends to ingress
customBackends: []
## @param ingressAdmin.enabled whether to enable the Ingress or not
## @param ingressAdmin.className IngressClass to use for the Ingress
+8 -5
View File
@@ -112,16 +112,19 @@ class MetadataManager:
if self._is_disabled or self.has_task_id(task_id):
return
_, filename, email, _, received_at, *_ = task_args
start_time = time.time()
initial_metadata = {
"start_time": start_time,
"start_time": time.time(),
"asr_model": settings.whisperx_asr_model,
"retries": 0,
}
_, filename, email, _, received_at, *_ = task_args
initial_metadata = {
**initial_metadata,
"filename": filename,
"email": email,
"queuing_time": round(start_time - received_at, 2),
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
}
self._save_metadata(task_id, initial_metadata)
+136 -109
View File
@@ -10,7 +10,9 @@ import openai
import sentry_sdk
from celery import Celery, signals
from celery.utils.log import get_task_logger
from requests import exceptions
from requests import Session, exceptions
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
@@ -28,7 +30,6 @@ from summary.core.prompt import (
PROMPT_USER_PART,
)
from summary.core.transcript_formatter import TranscriptFormatter
from summary.core.webhook_service import submit_content
settings = get_settings()
analytics = get_analytics()
@@ -55,17 +56,103 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
file_service = FileService()
file_service = FileService(logger=logger)
def transcribe_audio(task_id, filename, language):
"""Transcribe an audio file using WhisperX.
def create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
return session
Downloads the audio from MinIO, sends it to WhisperX for transcription,
and tracks metadata throughout the process.
Returns the transcription object, or None if the file could not be retrieved.
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
fomat:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
"""
lines = []
for action in llm_output.get("actions", []):
title = action.get("title", "").strip()
assignees = ", ".join(action.get("assignees", [])) or "-"
due_date = action.get("due_date") or "-"
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
lines.append(line)
if lines:
return "### Prochaines étapes\n\n" + "\n".join(lines)
return ""
def post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = create_retry_session()
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
filename: str,
email: str,
sub: str,
received_at: float,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task performs the following operations:
1. Retrieves the audio file from MinIO storage
2. Transcribes the audio using WhisperX model
3. Sends the results via webhook
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
recording_date: Date of the recording (localized display string).
recording_time: Time of the recording (localized display string).
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
)
task_id = self.request.id
logger.info("Initiating WhisperX client")
whisperx_client = openai.OpenAI(
api_key=settings.whisperx_api_key.get_secret_value(),
@@ -75,7 +162,9 @@ def transcribe_audio(task_id, filename, language):
# Transcription
try:
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
with (
file_service.prepare_audio_file(filename) as (audio_file, metadata),
):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
if language is None:
@@ -106,32 +195,16 @@ def transcribe_audio(task_id, filename, language):
except FileServiceException:
logger.exception("Unexpected error for filename: %s", filename)
return None
return
metadata_manager.track_transcription_metadata(task_id, transcription)
return transcription
def format_transcript(
transcription,
context_language,
language,
room,
recording_date,
recording_time,
download_link,
):
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
TranscriptFormatter to produce markdown content and a title.
Returns a (content, title) tuple.
"""
# For locale of context, use in decreasing priority context_language,
# language (of meeting), default context language
locale = get_locale(context_language, language)
formatter = TranscriptFormatter(locale)
return formatter.format(
content, title = formatter.format(
transcription,
room=room,
recording_date=recording_date,
@@ -139,90 +212,32 @@ def format_transcript(
download_link=download_link,
)
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
logger.debug("Submitting webhook to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
fomat:
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
"""
lines = []
for action in llm_output.get("actions", []):
title = action.get("title", "").strip()
assignees = ", ".join(action.get("assignees", [])) or "-"
due_date = action.get("due_date") or "-"
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
lines.append(line)
if lines:
return "### Prochaines étapes\n\n" + "\n".join(lines)
return ""
response = post_with_retries(settings.webhook_url, data)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
filename: str,
email: str,
sub: str,
received_at: float,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
language: Optional[str],
download_link: Optional[str],
context_language: Optional[str] = None,
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task orchestrates:
1. Audio transcription via WhisperX
2. Transcript formatting
3. Webhook submission
4. Conditional summarization queuing
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
recording_date: Date of the recording (localized display string).
recording_time: Time of the recording (localized display string).
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
"Webhook success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)
task_id = self.request.id
transcription = transcribe_audio(task_id, filename, language)
if transcription is None:
return
content, title = format_transcript(
transcription,
context_language,
language,
room,
recording_date,
recording_time,
download_link,
)
submit_content(content, title, email, sub)
metadata_manager.capture(task_id, settings.posthog_event_success)
# LLM Summarization
@@ -291,11 +306,12 @@ def summarize_transcription(
# a singleton client. This is a performance trade-off we accept to ensure per-user
# privacy controls in observability traces.
llm_observability = LLMObservability(
logger=logger,
user_has_tracing_consent=user_has_tracing_consent,
session_id=self.request.id,
user_id=owner_id,
)
llm_service = LLMService(llm_observability=llm_observability)
llm_service = LLMService(llm_observability=llm_observability, logger=logger)
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
@@ -338,9 +354,20 @@ def summarize_transcription(
logger.info("Summary cleaned")
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
summary_title = settings.summary_title_template.format(title=title)
submit_content(summary, summary_title, email, sub)
data = {
"title": settings.summary_title_template.format(title=title),
"content": summary,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
response = post_with_retries(settings.webhook_url, data)
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
llm_observability.flush()
logger.debug("LLM observability flushed")
+19 -19
View File
@@ -1,6 +1,5 @@
"""File service to encapsulate files' manipulations."""
import logging
import os
import subprocess
import tempfile
@@ -16,9 +15,6 @@ from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
class FileServiceException(Exception):
"""Base exception for file service operations."""
@@ -28,8 +24,10 @@ class FileServiceException(Exception):
class FileService:
"""Service for downloading and preparing files from MinIO storage."""
def __init__(self):
def __init__(self, logger):
"""Initialize FileService with MinIO client and configuration."""
self._logger = logger
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
@@ -55,16 +53,16 @@ class FileService:
The file is downloaded to a temporary location for local manipulation
such as validation, conversion, or processing before being used.
"""
logger.info("Download recording | object_key: %s", remote_object_key)
self._logger.info("Download recording | object_key: %s", remote_object_key)
if not remote_object_key:
logger.warning("Invalid object_key '%s'", remote_object_key)
self._logger.warning("Invalid object_key '%s'", remote_object_key)
raise ValueError("Invalid object_key")
extension = Path(remote_object_key).suffix.lower()
if extension not in self._allowed_extensions:
logger.warning("Invalid file extension '%s'", extension)
self._logger.warning("Invalid file extension '%s'", extension)
raise ValueError(f"Invalid file extension '{extension}'")
response = None
@@ -83,8 +81,8 @@ class FileService:
tmp.flush()
local_path = Path(tmp.name)
logger.info("Recording successfully downloaded")
logger.debug("Recording local file path: %s", local_path)
self._logger.info("Recording successfully downloaded")
self._logger.debug("Recording local file path: %s", local_path)
return local_path
@@ -102,7 +100,7 @@ class FileService:
file_metadata = mutagen.File(local_path).info
duration = file_metadata.length
logger.info(
self._logger.info(
"Recording file duration: %.2f seconds",
duration,
)
@@ -111,14 +109,14 @@ class FileService:
error_msg = "Recording too long. Limit is %.2fs seconds" % (
self._max_duration,
)
logger.error(error_msg)
self._logger.error(error_msg)
raise ValueError(error_msg)
return duration
def _extract_audio_from_video(self, video_path: Path) -> Path:
"""Extract audio from video file (e.g., MP4) and save as audio file."""
logger.info("Extracting audio from video file: %s", video_path)
self._logger.info("Extracting audio from video file: %s", video_path)
with tempfile.NamedTemporaryFile(
suffix=".m4a", delete=False, prefix="audio_extract_"
@@ -142,16 +140,16 @@ class FileService:
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
)
logger.info("Audio successfully extracted to: %s", output_path)
self._logger.info("Audio successfully extracted to: %s", output_path)
return output_path
except FileNotFoundError as e:
logger.error("ffmpeg not found. Please install ffmpeg.")
self._logger.error("ffmpeg not found. Please install ffmpeg.")
if output_path.exists():
os.remove(output_path)
raise RuntimeError("ffmpeg is not installed or not in PATH") from e
except subprocess.CalledProcessError as e:
logger.error("Audio extraction failed: %s", e.stderr.decode())
self._logger.error("Audio extraction failed: %s", e.stderr.decode())
if output_path.exists():
os.remove(output_path)
raise RuntimeError("Failed to extract audio.") from e
@@ -175,7 +173,7 @@ class FileService:
extension = downloaded_path.suffix.lower()
if extension in settings.recording_video_extensions:
logger.info("Video file detected, extracting audio...")
self._logger.info("Video file detected, extracting audio...")
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
processed_path = extracted_audio_path
else:
@@ -196,6 +194,8 @@ class FileService:
try:
os.remove(path)
logger.debug("Temporary file removed: %s", path)
self._logger.debug("Temporary file removed: %s", path)
except OSError as e:
logger.warning("Failed to remove temporary file %s: %s", path, e)
self._logger.warning(
"Failed to remove temporary file %s: %s", path, e
)
+7 -8
View File
@@ -1,6 +1,5 @@
"""LLM service to encapsulate LLM's calls."""
import logging
from typing import Any, Mapping, Optional
import openai
@@ -11,9 +10,6 @@ from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
class LLMObservability:
"""Manage observability and tracing for LLM calls using Langfuse.
@@ -25,11 +21,13 @@ class LLMObservability:
def __init__(
self,
logger,
session_id: str,
user_id: str,
user_has_tracing_consent: bool = False,
):
"""Initialize the LLMObservability client."""
self._logger = logger
self._observability_client: Optional[Langfuse] = None
self.session_id = session_id
self.user_id = user_id
@@ -77,7 +75,7 @@ class LLMObservability:
}
if not self.is_enabled:
logger.debug("Using regular OpenAI client (observability disabled)")
self._logger.debug("Using regular OpenAI client (observability disabled)")
return openai.OpenAI(**base_args)
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
@@ -85,7 +83,7 @@ class LLMObservability:
# is missing. Conditional import ensures Langfuse only initializes when enabled.
from langfuse.openai import openai as langfuse_openai # noqa: PLC0415
logger.debug("Using LangfuseOpenAI client (observability enabled)")
self._logger.debug("Using LangfuseOpenAI client (observability enabled)")
return langfuse_openai.OpenAI(**base_args)
def flush(self):
@@ -101,10 +99,11 @@ class LLMException(Exception):
class LLMService:
"""Service for performing calls to the LLM configured in the settings."""
def __init__(self, llm_observability):
def __init__(self, llm_observability, logger):
"""Init the LLMService once."""
self._client = llm_observability.get_openai_client()
self._observability = llm_observability
self._logger = logger
def call(
self,
@@ -141,5 +140,5 @@ class LLMService:
return response.choices[0].message.content
except Exception as e:
logger.exception("LLM call failed: %s", e)
self._logger.exception("LLM call failed: %s", e)
raise LLMException(f"LLM call failed: {e}") from e
@@ -1,73 +0,0 @@
"""Service for delivering content to external destinations."""
import json
import logging
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
return session
def _post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = _create_retry_session()
session.headers.update(
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
def submit_content(content, title, email, sub):
"""Submit content to the configured webhook destination.
Builds the payload, sends it with retries, and logs the outcome.
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug("Submitting to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(settings.webhook_url, data)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)