Compare commits

...

9 Commits

Author SHA1 Message Date
lebaudantoine 8d9ac96d2b wip invalidate cache when room is finished 2025-01-06 19:37:46 +01:00
lebaudantoine 5428aec43e wip fix errors related to re-rendering and web workers 2025-01-05 01:13:17 +01:00
lebaudantoine a2408aebff wip encrypt and encode passphrase while storing it in cache 2025-01-04 23:58:49 +01:00
lebaudantoine 3e3ca6a87b use redis instead of cache 2025-01-04 23:11:45 +01:00
lebaudantoine 9830940d61 rename misleading name
slug is another term in our app
this is the room id
2025-01-04 23:11:22 +01:00
lebaudantoine dd7e3a7c44 wip generate a random passphrase cached by slug 2025-01-04 14:12:45 +01:00
lebaudantoine 1cf26eec19 wip get passphrase from the backend 2025-01-04 13:20:55 +01:00
lebaudantoine 83d4028e84 wip hardcode a passphrase and enable e2ee
inspired by LiveKit repo, follow their example
2025-01-04 13:07:26 +01:00
lebaudantoine ac65404ad6 ⚗️(backend) annotate when and what is required for ee2e
commit will be dropped.
2025-01-04 12:07:28 +01:00
9 changed files with 169 additions and 9 deletions
+5 -4
View File
@@ -130,17 +130,18 @@ class RoomSerializer(serializers.ModelSerializer):
del output["configuration"]
if role is not None or instance.is_public:
slug = f"{instance.id!s}"
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"room": room_id,
"token": utils.generate_token(
room=slug, user=request.user, username=username
room=room_id, user=request.user, username=username
),
"passphrase": utils.get_cached_passphrase(room_id)
}
output["is_administrable"] = is_admin
return output
+36
View File
@@ -47,6 +47,8 @@ from core.recording.worker.mediator import (
from . import permissions, serializers
from livekit import api as livekit_api
# pylint: disable=too-many-ancestors
logger = getLogger(__name__)
@@ -210,6 +212,10 @@ class RoomViewSet(
Allow unregistered rooms when activated.
For unregistered rooms we only return a null id and the livekit room and token.
"""
# todo - determine whether encryption is needed store a shared secret in memory or in redis
# todo - check if a secret already exists, else create one.
try:
instance = self.get_object()
except Http404:
@@ -343,6 +349,36 @@ class RoomViewSet(
{"message": f"Recording stopped for room {room.slug}."}
)
@decorators.action(
detail=False,
methods=["post"],
url_path="livekit-webhook",
permission_classes=[],
authentication_classes=[],
)
def handle_livekit_webhook(self, request, pk=None): # pylint: disable=unused-argument
"""Handle LiveKit webhook events."""
auth_token = request.headers.get("Authorization")
if not auth_token:
return drf_response.Response(
{"error": "Missing LiveKit authentication token"},
status=drf_status.HTTP_401_UNAUTHORIZED
)
token_verifier = livekit_api.TokenVerifier()
webhook_receiver = livekit_api.WebhookReceiver(token_verifier)
webhook_data = webhook_receiver.receive(request.body.decode("utf-8"), auth_token)
# Todo - livekit triggers a webhook for all events, see if we can restrict webhook to a limited number of events.
# Todo - handle Egress stopped / aborted events.
if webhook_data.event == "room_finished":
room_id = webhook_data.room.name
utils.clear_cache_passphrase(room_id)
return drf_response.Response({"message": f"Event processed"})
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
+41
View File
@@ -14,6 +14,47 @@ from django.conf import settings
from livekit.api import AccessToken, VideoGrants
import secrets
import string
from django.core.cache import cache
from cryptography.fernet import Fernet
import base64
def generate_random_passphrase(length=26):
"""Generate a random passphrase using letters and digits"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def build_room_passphrase_key(room_id: str) -> str:
"""Build cache key for room passphrase."""
return f"room_passphrase:{room_id}"
def get_cached_passphrase(room_id: str) -> str:
"""Get or generate encrypted passphrase for a room.
Retrieves existing passphrase from cache or generates,
encrypts and caches a new one if not found.
"""
cypher = Fernet(settings.PASSPHRASE_ENCRYPTION_KEY.encode())
cache_key = build_room_passphrase_key(room_id)
encrypted_passphrase = cache.get(cache_key)
if encrypted_passphrase is None:
passphrase = generate_random_passphrase()
encrypted_passphrase = cypher.encrypt(passphrase.encode()).decode()
cache.set(cache_key, encrypted_passphrase, timeout=86400) # 24 hours
return passphrase
return cypher.decrypt(encrypted_passphrase.encode()).decode()
def clear_room_passphrase(room_id: str) -> None:
"""Remove room passphrase from cache."""
cache.delete(build_room_passphrase_key(room_id))
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
+2
View File
@@ -483,6 +483,8 @@ class Base(Configuration):
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
PASSPHRASE_ENCRYPTION_KEY = values.Value(environ_name="PASSPHRASE_ENCRYPTION_KEY", environ_prefix=None)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -8,6 +8,7 @@ export type ApiRoom = {
url: string
room: string
token: string
passphrase: string
}
configuration?: {
[key: string]: string | number | boolean
@@ -1,8 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import {
Room,
RoomOptions,
ExternalE2EEKeyProvider,
DeviceUnsupportedError,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -17,6 +22,9 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
// todo - release worker when quitting the room, same for the key provider?
// todo - check, seems the demo app from livekit trigger the web worker twice because of re-rendering
export const Conference = ({
roomId,
userConfig,
@@ -63,12 +71,49 @@ export const Conference = ({
retry: false,
})
const e2eeEnabled = true
const workerRef = useRef<Worker | null>(null)
const keyProvider = useRef<any | null>(null)
const getKeyProvider = () => {
if (!keyProvider.current && typeof window !== 'undefined') {
keyProvider.current = new ExternalE2EEKeyProvider()
}
return keyProvider.current
}
const getWorker = () => {
if (!e2eeEnabled) {
return
}
if (!workerRef.current && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
}
return workerRef.current
}
const e2eePassphrase = data?.livekit?.passphrase
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false)
const roomOptions = useMemo((): RoomOptions => {
const worker = getWorker()
const keyProvider = getKeyProvider()
// todo - explain why
const videoCodec = e2eeEnabled ? undefined : 'vp9'
const e2ee = e2eeEnabled ? { keyProvider, worker } : undefined
return {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
// todo - explain why
red: !e2eeEnabled,
videoCodec,
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -76,12 +121,35 @@ export const Conference = ({
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
e2ee,
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
useEffect(() => {
console.log('enter', e2eePassphrase)
if (e2eePassphrase) {
const keyProvider = getKeyProvider()
keyProvider
.setKey(e2eePassphrase)
.then(() => {
room.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
alert(
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`
)
console.error(e)
} else {
throw e
}
})
})
.then(() => setE2eeSetupComplete(true))
}
}, [room, e2eePassphrase])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const { t } = useTranslation('rooms')
@@ -102,6 +170,10 @@ export const Conference = ({
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}
const handleEncryptionError = () => {
console.log('error')
}
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen header={false} footer={false}>
@@ -109,13 +181,14 @@ export const Conference = ({
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
connect={e2eeSetupComplete}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
connectOptions={connectOptions}
className={css({
backgroundColor: 'primaryDark.50 !important',
})}
onEncryptionError={handleEncryptionError}
>
<VideoConference />
{showInviteDialog && (
@@ -15,8 +15,9 @@ import {
VideoTrack,
TrackRefContext,
ParticipantContextIfNeeded,
useIsSpeaking,
} from '@livekit/components-react'
import React from 'react'
import React, { useEffect } from 'react'
import {
isTrackReference,
isTrackReferencePinned,
@@ -17,6 +17,10 @@ livekit:
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
webhook:
api_key: devkey
urls:
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/livekit-webhook/
loadBalancer:
@@ -61,6 +61,7 @@ backend:
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
PASSPHRASE_ENCRYPTION_KEY: lT3cX5dzFhCe-9xNjXUiTCX00r2ZgHgGUJKO66x-QIo=
migrate: