mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
9 Commits
refactoring
...
poc-e2ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9ac96d2b | |||
| 5428aec43e | |||
| a2408aebff | |||
| 3e3ca6a87b | |||
| 9830940d61 | |||
| dd7e3a7c44 | |||
| 1cf26eec19 | |||
| 83d4028e84 | |||
| ac65404ad6 |
@@ -130,15 +130,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
del output["configuration"]
|
del output["configuration"]
|
||||||
|
|
||||||
if role is not None or instance.is_public:
|
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)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = {
|
output["livekit"] = {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": room_id,
|
||||||
"token": utils.generate_token(
|
"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
|
output["is_administrable"] = is_admin
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ from core.recording.worker.mediator import (
|
|||||||
|
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -210,6 +212,10 @@ class RoomViewSet(
|
|||||||
Allow unregistered rooms when activated.
|
Allow unregistered rooms when activated.
|
||||||
For unregistered rooms we only return a null id and the livekit room and token.
|
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:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
except Http404:
|
except Http404:
|
||||||
@@ -343,6 +349,36 @@ class RoomViewSet(
|
|||||||
{"message": f"Recording stopped for room {room.slug}."}
|
{"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:
|
class ResourceAccessListModelMixin:
|
||||||
"""List mixin for resource access API."""
|
"""List mixin for resource access API."""
|
||||||
|
|||||||
@@ -14,6 +14,47 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit.api import AccessToken, VideoGrants
|
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:
|
def generate_color(identity: str) -> str:
|
||||||
"""Generates a consistent HSL color based on a given identity string.
|
"""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})
|
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
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
def ENVIRONMENT(self):
|
def ENVIRONMENT(self):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ApiRoom = {
|
|||||||
url: string
|
url: string
|
||||||
room: string
|
room: string
|
||||||
token: string
|
token: string
|
||||||
|
passphrase: string
|
||||||
}
|
}
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean
|
[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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
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 { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -17,6 +22,9 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { css } from '@/styled-system/css'
|
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 = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
userConfig,
|
userConfig,
|
||||||
@@ -63,12 +71,49 @@ export const Conference = ({
|
|||||||
retry: false,
|
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 roomOptions = useMemo((): RoomOptions => {
|
||||||
|
const worker = getWorker()
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
|
||||||
|
// todo - explain why
|
||||||
|
const videoCodec = e2eeEnabled ? undefined : 'vp9'
|
||||||
|
const e2ee = e2eeEnabled ? { keyProvider, worker } : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
// todo - explain why
|
||||||
|
red: !e2eeEnabled,
|
||||||
|
videoCodec,
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -76,12 +121,35 @@ export const Conference = ({
|
|||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
|
e2ee,
|
||||||
}
|
}
|
||||||
// do not rely on the userConfig object directly as its reference may change on every render
|
// do not rely on the userConfig object directly as its reference may change on every render
|
||||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
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 [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
@@ -102,6 +170,10 @@ export const Conference = ({
|
|||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEncryptionError = () => {
|
||||||
|
console.log('error')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||||
<Screen header={false} footer={false}>
|
<Screen header={false} footer={false}>
|
||||||
@@ -109,13 +181,14 @@ export const Conference = ({
|
|||||||
room={room}
|
room={room}
|
||||||
serverUrl={data?.livekit?.url}
|
serverUrl={data?.livekit?.url}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={true}
|
connect={e2eeSetupComplete}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={userConfig.videoEnabled}
|
video={userConfig.videoEnabled}
|
||||||
connectOptions={connectOptions}
|
connectOptions={connectOptions}
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
|
onEncryptionError={handleEncryptionError}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
|
useIsSpeaking,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
isTrackReferencePinned,
|
isTrackReferencePinned,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ livekit:
|
|||||||
udp_port: 443
|
udp_port: 443
|
||||||
domain: livekit.127.0.0.1.nip.io
|
domain: livekit.127.0.0.1.nip.io
|
||||||
loadBalancerAnnotations: {}
|
loadBalancerAnnotations: {}
|
||||||
|
webhook:
|
||||||
|
api_key: devkey
|
||||||
|
urls:
|
||||||
|
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/livekit-webhook/
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ backend:
|
|||||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||||
SUMMARY_SERVICE_API_TOKEN: password
|
SUMMARY_SERVICE_API_TOKEN: password
|
||||||
|
PASSPHRASE_ENCRYPTION_KEY: lT3cX5dzFhCe-9xNjXUiTCX00r2ZgHgGUJKO66x-QIo=
|
||||||
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
|
|||||||
Reference in New Issue
Block a user