Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine d53158b7c1 🐛(frontend) treat client-initiated connect aborts as events
`connect()` calls can be aborted by a `disconnect()` before the
join completes — typically when the user leaves, refreshes,
navigates away early, or when the component remounts. This is
expected behavior, not a failure.

Stop reporting these as errors. Track them as analytics events
instead, so we can still monitor their volume without polluting
error dashboards.

Volume is relatively low, but keeping visibility helps troubleshoot
users who complain about difficulty connecting to a room, and could
also flag a component that is re-rendering and killing the
connection.
2026-08-20 23:21:17 +02:00
Florent Chehab 45175a2a54 ♻️(summary) uniformized S3 settings case
Quick commit to uniformize the S3 related settings case.
2026-08-20 12:50:26 +02:00
Florent Chehab 8b22059b18 (summary) configurable s3 region
When using other S3 providers than minio,
We neeed to configure the region
2026-08-20 12:41:48 +02:00
7 changed files with 21 additions and 195 deletions
+1 -4
View File
@@ -8,13 +8,10 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) add OpenShift-compatible recording download endpoint
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
+1 -91
View File
@@ -12,13 +12,12 @@ from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import Http404, StreamingHttpResponse
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from botocore.exceptions import BotoCoreError, ClientError
from django_filters import rest_framework as django_filters
from rest_framework import (
decorators,
@@ -114,14 +113,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
class StorageUnavailable(drf_exceptions.APIException):
"""Exception raised when recording storage cannot be reached."""
status_code = drf_status.HTTP_503_SERVICE_UNAVAILABLE
default_detail = "The recording storage is temporarily unavailable."
default_code = "storage_unavailable"
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
@@ -1229,87 +1220,6 @@ class RecordingViewSet(
return drf_response.Response("authorized", headers=request.headers, status=200)
@decorators.action(detail=False, methods=["get"], url_path="download")
def media_download(self, request, *args, **kwargs):
"""
Stream a recording file directly from S3 to the browser.
This endpoint is registered at /media/recordings/<uuid>.<ext> in core/urls.py
and handles the download flow on OpenShift where the Nginx auth_request
mechanism is not available.
Django acts as a streaming proxy: bytes flow from S3 (internal cluster URL)
directly to the client response. This avoids any page navigation or redirect,
so the browser triggers a native download without leaving the current page.
Access control mirrors media_auth: the user must have the "retrieve" ability
on the recording and the recording must be in a saved state.
"""
recording_id = self.kwargs.get("recording_id")
extension = self.kwargs.get("extension")
if extension not in [file.value for file in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this id.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(request.user)
if not abilities["retrieve"]:
logger.debug(
"User '%s' lacks permission for recording download", request.user.id
)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
s3_client = default_storage.connection.meta.client
try:
s3_object = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
)
except ClientError as error:
error_code = error.response.get("Error", {}).get("Code")
if error_code in {"404", "NoSuchKey", "NoSuchObject"}:
raise drf_exceptions.NotFound(
"No recording file found in storage."
) from error
logger.exception("Unable to retrieve recording from storage")
raise StorageUnavailable() from error
except BotoCoreError as error:
logger.exception("Unable to retrieve recording from storage")
raise StorageUnavailable() from error
content_type = s3_object.get("ContentType") or "video/mp4"
content_length = s3_object.get("ContentLength")
filename = f"{recording_id}.{extension}"
body = s3_object["Body"]
def stream():
try:
yield from body.iter_chunks(chunk_size=65536)
finally:
body.close()
streaming_response = StreamingHttpResponse(
stream(),
content_type=content_type,
)
streaming_response["Content-Disposition"] = f'attachment; filename="{filename}"'
if content_length:
streaming_response["Content-Length"] = content_length
return streaming_response
# pylint: disable=too-many-public-methods
class FileViewSet(
@@ -3,12 +3,8 @@ Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from botocore.exceptions import ClientError, EndpointConnectionError
from freezegun import freeze_time
from rest_framework.test import APIClient
@@ -18,11 +14,6 @@ from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def media_recording_url(recording):
"""Return the direct media URL for a recording."""
return f"/media/recordings/{recording.id!s}.{recording.extension}"
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
@@ -34,9 +25,6 @@ def test_api_recording_retrieve_anonymous():
"detail": "Authentication credentials were not provided."
}
response = client.get(media_recording_url(recording))
assert response.status_code == 401
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
@@ -56,9 +44,6 @@ def test_api_recording_retrieve_authenticated():
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
response = client.get(media_recording_url(recording))
assert response.status_code == 403
def test_api_recording_retrieve_members():
"""
@@ -78,11 +63,6 @@ def test_api_recording_retrieve_members():
"detail": "You do not have permission to perform this action."
}
recording.status = RecordingStatusChoices.SAVED
recording.save(update_fields=["status"])
response = client.get(media_recording_url(recording))
assert response.status_code == 403
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
@@ -157,78 +137,6 @@ def test_api_recording_retrieve_owners(settings):
}
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_recording_media_download_authorized(role):
"""Owners and administrators can download a saved recording."""
user = UserFactory()
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(recording=recording, user=user, role=role)
client = APIClient()
client.force_login(user)
body = mock.Mock(iter_chunks=mock.Mock(return_value=[b"recording content"]))
s3_object = {
"Body": body,
"ContentType": "video/mp4",
"ContentLength": len(b"recording content"),
}
with mock.patch.object(
default_storage.connection.meta.client,
"get_object",
return_value=s3_object,
) as get_object:
response = client.get(media_recording_url(recording))
assert response.status_code == 200
get_object.assert_called_once_with(
Bucket=default_storage.bucket_name,
Key=recording.key,
)
assert response["Content-Type"] == "video/mp4"
assert response["Content-Disposition"] == (
f'attachment; filename="{recording.id}.{recording.extension}"'
)
assert b"".join(response.streaming_content) == b"recording content"
body.close.assert_called_once_with()
def test_api_recording_media_download_missing_object():
"""A missing recording object should return 404."""
user = UserFactory()
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
error = ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "The object does not exist."}},
"GetObject",
)
with mock.patch.object(
default_storage.connection.meta.client, "get_object", side_effect=error
):
response = client.get(media_recording_url(recording))
assert response.status_code == 404
def test_api_recording_media_download_storage_unavailable():
"""A storage connection failure should return 503."""
user = UserFactory()
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
error = EndpointConnectionError(endpoint_url="https://storage.test")
with mock.patch.object(
default_storage.connection.meta.client, "get_object", side_effect=error
):
response = client.get(media_recording_url(recording))
assert response.status_code == 503
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
-7
View File
@@ -60,13 +60,6 @@ urlpatterns = [
]
),
),
# The request is handled by RecordingViewSet.media_download which validates
# access rights and streams the recording file directly from MinIO to the browser.
path(
"media/recordings/<uuid:recording_id>.<slug:extension>",
viewsets.RecordingViewSet.as_view({"get": "media_download"}),
name="recording_media_download",
),
]
if settings.EXTERNAL_API_ENABLED:
@@ -6,6 +6,8 @@ import {
usePersistentUserChoices,
} from '@livekit/components-react'
import {
ConnectionError,
ConnectionErrorReason,
DisconnectReason,
MediaDeviceFailure,
Room,
@@ -25,7 +27,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import {
captureEvent,
captureMediaEvent,
reportError,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
@@ -227,6 +233,16 @@ export const Conference = ({
onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, {
path: 'connect_publish',
})
+1
View File
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
aws_s3_access_key_id: str
aws_s3_secret_access_key: SecretStr
aws_s3_secure_access: bool = True
aws_s3_region_name: str | None = None
aws_transcript_path: str = "transcripts"
aws_summary_path: str = "summaries"
+1
View File
@@ -282,6 +282,7 @@ class FileService:
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,
region=settings.aws_s3_region_name,
)
self._bucket_name = settings.aws_storage_bucket_name