Compare commits
9 Commits
feat/sdk-2
...
poc-e2ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9ac96d2b | |||
| 5428aec43e | |||
| a2408aebff | |||
| 3e3ca6a87b | |||
| 9830940d61 | |||
| dd7e3a7c44 | |||
| 1cf26eec19 | |||
| 83d4028e84 | |||
| ac65404ad6 |
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user