mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-21 15:47:09 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ce399b06 | |||
| 45175a2a54 | |||
| 8b22059b18 |
+1
-4
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -5,16 +5,26 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import svgr from 'vite-plugin-svgr'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
|
||||
const mediapipeVersion: string = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./node_modules/@mediapipe/tasks-vision/package.json',
|
||||
import.meta.url
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
const readPackageJson = (path: string) =>
|
||||
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
|
||||
|
||||
const mediapipeVersion: string = readPackageJson(
|
||||
'./node_modules/@mediapipe/tasks-vision/package.json'
|
||||
).version
|
||||
|
||||
const livekitMediapipeVersion: string = readPackageJson(
|
||||
'./node_modules/@livekit/track-processors/package.json'
|
||||
).dependencies['@mediapipe/tasks-vision']
|
||||
|
||||
if (mediapipeVersion !== livekitMediapipeVersion) {
|
||||
throw new Error(
|
||||
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
|
||||
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
|
||||
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
|
||||
`"${livekitMediapipeVersion}" in package.json.`
|
||||
)
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
|
||||
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
|
||||
aws_s3_access_key_id: str
|
||||
aws_s3_secret_access_key: SecretStr
|
||||
aws_s3_secure_access: bool = True
|
||||
aws_s3_region_name: str | None = None
|
||||
aws_transcript_path: str = "transcripts"
|
||||
aws_summary_path: str = "summaries"
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ class FileService:
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
region=settings.aws_s3_region_name,
|
||||
)
|
||||
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
|
||||
Reference in New Issue
Block a user