✨(backend) add two new endpoints to start and stop a recording
The LiveKit egress worker interactions are proxied through the backend for security reasons. Allowing clients to directly use tokens with sufficient grants to start recordings could lead to misuse, enabling users to spam the egress worker API and potentially initiate a DDOS attack on the egress service. To prevent this, only users with room-specific privileges can initiate recordings. We make sure only one recording at the time can be made on a room. The requested recording mode is stored so it can be referenced later when the recording is saved, triggering a callback action as needed. A feature flag was also introduced for this capability; while this is a simple approach, a more robust system for managing feature flags could be valuable long-term. For now, KISS (Keep It Simple, Stupid) applies. The viewset endpoints were designed to be as straightforward as possible— let me know if anything can be improved.
This commit is contained in:
committed by
aleb_the_flash
parent
f6f1222f47
commit
b84628ee95
@@ -1,5 +1,7 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
@@ -87,3 +89,23 @@ class HasAbilityPermission(IsAuthenticated):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
|
||||
class HasPrivilegesOnRoom(IsAuthenticated):
|
||||
"""Check if user has privileges on a given room."""
|
||||
|
||||
message = "You must have privileges to start a recording."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Determine if user has privileges on room."""
|
||||
return obj.is_owner(request.user) or obj.is_administrator(request.user)
|
||||
|
||||
|
||||
class IsRecordingEnabled(permissions.BasePermission):
|
||||
"""Check if the recording feature is enabled."""
|
||||
|
||||
message = "Access denied, recording is disabled."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Determine if access is allowed based on settings."""
|
||||
return settings.RECORDING_ENABLE
|
||||
|
||||
@@ -156,3 +156,25 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class StartRecordingSerializer(serializers.Serializer):
|
||||
"""Validate start recording requests."""
|
||||
|
||||
mode = serializers.ChoiceField(
|
||||
choices=models.RecordingModeChoices.choices,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Recording mode is required.",
|
||||
"invalid_choice": "Invalid recording mode. Choose between "
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -14,16 +15,34 @@ from rest_framework import (
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
)
|
||||
from core.recording.worker.factories import (
|
||||
get_worker_service,
|
||||
)
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
@@ -233,6 +252,89 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Start recording a room."""
|
||||
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
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)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
)
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
try:
|
||||
worker_manager.start(recording)
|
||||
except RecordingStartError:
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording successfully started for room {room.slug}"},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Stop room recording."""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(
|
||||
room=room, status=models.RecordingStatusChoices.ACTIVE
|
||||
)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound(
|
||||
"No active recording found for this room."
|
||||
) from e
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
try:
|
||||
worker_manager.stop(recording)
|
||||
except RecordingStopError:
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to stop for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording stopped for room {room.slug}."}
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
Reference in New Issue
Block a user