Compare commits

..

36 Commits

Author SHA1 Message Date
lebaudantoine a324c3680b 🐛(frontend) fix missing user label in prejoin screen
Add missing hint to guide user entering their name while
joining a room.

I introduced this bug while merging @manuhabitela works on
the newly prejoin screen.
2024-11-11 19:14:32 +01:00
lebaudantoine cb4acc32e5 🗑️(backend) remove useless analytics settings
Remove useless analytics key settings as we removed tracking
data from the backend code.
2024-11-08 10:45:58 +01:00
lebaudantoine 60f5c8486b 🩹(backend) add missing Django settings for cold storage access
After configuring the cold storage infrastructure, I forgot to add Django
settings needed to access the configuration.
2024-11-08 10:45:58 +01:00
lebaudantoine ed3a26d449 (backend) enable Django Admin on Recording
Manage Recording in the Django Admin. As of today, I have not enforced
a strict policy to avoid edit on recording rows or even creating new
data point directly from the admin. Will do in the future.
2024-11-08 10:36:38 +01:00
lebaudantoine cb4c058c5d (backend) add minimal Recording viewset for room recordings
Implements routes to manage recordings within rooms, following the patterns
established in Impress. The viewset exposes targeted endpoints rather than
full CRUD operations, with recordings being created (soon) through
room-specific routes (e.g. room/123/start-recording).

The implementation draws from @sampaccoud's initial work and advices.

Review focus areas:
- Permission implementation choices
- Serializer design and structure

Credit: Initial work by @sampaccoud
2024-11-08 10:36:31 +01:00
lebaudantoine c504b5262b (backend) introduce Recording model with independent access control
The Recording model is introduced to track recording lifecycle within rooms,
while maintaining strict separation of access controls between rooms and
recordings.

Recordings follow the BaseAccess pattern (similar to Documents in Impress),
providing independent access control from room permissions. This ensures that
joining a room doesn't automatically grant access to previous recordings,
allowing for more flexible permission management.

The implementation was driven by TDD, particularly for the get_abilities
function, resulting in reduced nesting levels and improved readability.

The Recording model is deliberately kept minimal to serve as a foundation for
upcoming AI features while maintaining flexibility for future extensions.

I have avoided LiveKit-specific terminology for better abstraction.

Note: Room access control remains unchanged in this commit, pending future
refactor to use BaseAccess pattern (discussed IRL with @sampaccoud).
2024-11-07 18:06:26 +01:00
lebaudantoine 3b3816b333 ♻️(backend) rename room-specific setting in Resource class
Renames a Django setting in the Resource base class to use resource-agnostic
terminology. While rooms are currently the only resource type, keeping base
class naming generic improves clarity.
2024-11-07 18:06:26 +01:00
lebaudantoine 15e922f9df 🔥(backend) vendor analytics code
Analytics code is now useless, we mostly use
frontend tracking.
2024-11-04 17:49:15 +01:00
lebaudantoine b69f777e5a 🔧(helm) expose MinIO console via ingress
Add ingress configuration for MinIO web console access. Include Nginx proxy
buffer annotations to support large file uploads through web interface.
2024-11-04 15:24:58 +01:00
lebaudantoine 67d004fbda ♻️(backend) refactor try/except when getting a room
Be more Pythonist simplifying try except while tracking when
user is getting a room.
2024-11-04 15:21:24 +01:00
lebaudantoine 4449b578bd 🐛(config) rename hardcoded docker-compose filename
Previously, the Docker Compose filename was hardcoded in _config.sh when used
through utility scripts. In recent commits, I've renamed the filename without
updating this configuration.

Oopsie, running make commands was fine, but running bin scripts
requiring compose failed.
2024-11-04 14:17:31 +01:00
lebaudantoine 1f0d2ce335 🚨(backend) fix Pylint warning
Python module models.py raises too-many-ancestors warning, but linter
still pass, code is rated 10/10.

Inspired from @sampaccoud's commit #eee20032 on impress.
2024-11-04 14:17:31 +01:00
lebaudantoine 0d1d0662ee ♻️(compose) rename docker-compose to compose
Docker Compose now by default search for a 'compose' file.
(according to @jonathanperret).
2024-11-03 22:31:30 +01:00
lebaudantoine 0056b4cc29 🔥(compose) remove docker compose version
The version is now automatically guessed by Docker Compose. This
commit will fix the warning raised about the uselessness of this
setting.

from @sampaccoud
2024-11-03 22:31:30 +01:00
lebaudantoine d7892cde9f (backend) move freezegun to dev dependencies
Freezegun is for testing and should not be installed in the
production image. (from @sampaccoud)
2024-11-03 22:31:30 +01:00
lebaudantoine 372db49e94 🎨(docker) convert to uppercase 'as' keyword
Match 'FROM' casing, to remove a warning.
2024-11-03 22:31:30 +01:00
lebaudantoine f7ed70dc9c (livekit) add Livekit Egress
Egress is already deployed in staging. But, while
working locally on feature relying on Egress, it's not
suitable to test your development or iterate.

Especially I'll need to test the connection between the Egress
and the minio bucket in my next PR.

We faced quite a few issue while starting the whole stack.
Egress didn't want to start. Its connection with the livekit server
while the egress participant was joining the room was not successful.

The Turn part of the livekit server helm chart was activated. We needed
to update few values to in the helm configuration to enabled this turn.

Updated CoreDNS to expose Egress pod. Egress tries connecting to MinIO at
127.0.0.1, where no instance exists. Using minio.127.0.0.1.nip.io resolves
to 127.0.0.1, causing Egress to connect to itself for uploads. The CoreDNS
rewrite directs this to the Ingress IP, correctly routing to MinIO.
2024-10-28 10:26:51 +01:00
lebaudantoine 427b23ca80 (backend) add S3 objects
Inspired by Impress and @sampaccoud's work.

We use Indie Hoster’s Kubernetes objects in staging and production.
In the "dev" environment, we install the `bitnami/minio` chart to mimic
Indie Hoster’s MinIO setup.

To access the MinIO admin interface in dev, use port forwarding;
the interface runs on port 9001.
2024-10-28 10:26:51 +01:00
lebaudantoine 99a1efc538 🐛(helm) deploy livekit in 'meet' namespace
LiveKit Helm chart doesn't support namespace parameterization
like MinIO templates. Egress and server deploy to default namespace,
but frontend and backend need all components in the same namespace.

Workaround: force kubectl context to 'meet' before starting Tilt.
Future improvement needed in chart.

Issue #105 have been opened in livekit-helm repo.
2024-10-28 10:26:51 +01:00
lebaudantoine ec22abf82b 💄(frontend) adjust few minor details
Temporary styles, will be fixed when redesigning the UI.
Inspired by GMeet.
2024-10-15 09:27:02 +02:00
lebaudantoine cde4b10794 ♻️(frontend) update icon for feedback option
This new names feels clearer that 'feedback' is an option for users
to express themselves.
2024-10-15 09:27:02 +02:00
lebaudantoine a47f1f92c4 ♻️(frontend) enhance useSidePanel
Encapsulate all interactions with the layout store concerning the
side panel into a hook. This hook exposes a clear interface.
Each variable has a single responsability, with a clear naming.
2024-10-15 09:27:02 +02:00
lebaudantoine 0db36c788b ♻️(frontend) rename useWidget to useSidePanel
Rename useWidget interaction to useSidePanel. Remove LiveKit-specific naming
as we no longer use LiveKit elements in the layout context. This change
improves clarity and reflects the current functionality of the hook.
2024-10-15 09:27:02 +02:00
lebaudantoine a84b76170d 💩(frontend) integrate chat into sidepanel and revamp UI
Properly integrate chat into the sidepanel to improve UX and avoid disruptions.
Implement initial styling based on Google Meet's design, with plans for future
enhancements. Some details remain to be refined, such as preserving newline
characters in the message formatter.

This substantial commit refactors and cleans up a significant legacy component.
Chat notifications will be addressed in a separate PR.

Note: While this is a large commit, it represents a major improvement in user
experience (in my opinion).
2024-10-15 09:27:02 +02:00
lebaudantoine 998382020d ♻️(frontend) refactor backported LiveKit sources
Backported changes to LiveKit sources, eliminating the need to listen for
specific Participant events.
2024-10-15 09:27:02 +02:00
lebaudantoine 2a12715673 🚸(frontend) enhance side panel UX
Implement smooth animations and DOM persistence for sidepanel.
Side panel state should be kept alive, to match initial LiveKit team
intent.

Temporarily, remove chat component. Chat functionality will be absent
until next commits. It will be reintegrated in the side panel.
2024-10-15 09:27:02 +02:00
lebaudantoine 54d4330a97 🩹(frontend) clean up notifications when participant quits
When a participant leaves a videoconference, remove all notifications they
authored. It's generally unnecessary to keep notifications about actions from
users who are no longer present, with few exceptions.

As we currently support only two notification types (Joined and Raised Hand),
it's appropriate to close all of them when the author leaves the room.
2024-10-15 09:27:02 +02:00
lebaudantoine 5dcbe56e56 ♻️(frontend) rename hook useRaisedHand filename
Was using a wrong extension .tsx, renamed it to .ts.
Trivial change.
2024-10-15 09:27:02 +02:00
lebaudantoine 1a52221ef2 ♻️(frontend) remove deprecated chat options
Changes introduced by LiveKit which deprecated some chat's options.
As we duplicated their code, let's just removed them.
They are not useful, and not in use anywhere.
2024-10-15 09:27:02 +02:00
lebaudantoine 2f3e64b389 ♻️(frontend) use newly created chat's input
Refactor and adapt LiveKit original component to use the newly
introduced text area, which is more accessible and
also internationalized.
2024-10-15 09:27:02 +02:00
lebaudantoine 2dcaf814e1 (frontend) introduce chat/input component
Introduce a draft chat input component inspired by Google Meet design.
This component is not yet in use and requires further enhancements.

To be improved:
- Avoid sizing in pixels
- Replace hardcoded colors with theme variables

This lays the groundwork for a more interactive chat experience in the future.
2024-10-15 09:27:02 +02:00
lebaudantoine 583f5b8e70 💄(frontend) add style for disabled icon button
Necessary when the submit message button is disabled.
Bad color management again …
2024-10-15 09:27:02 +02:00
lebaudantoine fe8fd36467 (frontend) introduce a TextArea primitive
Needed for the Chat text message input.
Basic styled RAC text area.
2024-10-15 09:27:02 +02:00
lebaudantoine 0370d9cad0 ♻️(frontend) delegate scroll to side panel content
Allow more flexibility in side panel behavior. Some panels, such as the chat,
require specific scrolling functionality:
- Header and footer should remain fixed
- Only chat messages should be scrollable

This change enables customization of scroll behavior for different panel types,
improving component reusability.
2024-10-15 09:27:02 +02:00
lebaudantoine 0da8aa846a ♻️(frontend) encapsulate side panel values
Refactor values in an enum, enhance code's typing.
2024-10-15 09:27:02 +02:00
lebaudantoine 70ed99b6c9 💩(frontend) duplicate chat prefabs component
Duplicate LiveKit component to start customizing it.
2024-10-15 09:27:02 +02:00
74 changed files with 1859 additions and 1296 deletions
-3
View File
@@ -122,9 +122,6 @@ jobs:
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
+7 -7
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 as base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -11,7 +11,7 @@ RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
WORKDIR /builder
@@ -23,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -34,7 +34,7 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG MEET_STATIC_ROOT=/data/static
RUN apk add \
@@ -58,7 +58,7 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
FROM base AS core
ENV PYTHONUNBUFFERED=1
@@ -93,7 +93,7 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as backend-development
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
@@ -119,7 +119,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production
ARG MEET_STATIC_ROOT=/data/static
+1
View File
@@ -302,6 +302,7 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
kubectl config set-context --current --namespace=meet
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+1 -1
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_FILE="${REPO_DIR}/compose.yml"
COMPOSE_PROJECT="meet"
+35
View File
@@ -97,6 +97,41 @@ data:
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
rewrite stop {
name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto
}
cache 30
loop
reload
loadbalance
}
EOF
kubectl -n kube-system rollout restart deployments/coredns
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
-27
View File
@@ -1,4 +1,3 @@
version: '3.8'
services:
postgresql:
@@ -16,30 +15,6 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -64,7 +39,6 @@ services:
- mailcatcher
- redis
- nginx
- createbuckets
- livekit
celery-dev:
@@ -98,7 +72,6 @@ services:
depends_on:
- postgresql
- redis
- createbuckets
- livekit
celery:
-3
View File
@@ -18,9 +18,6 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+1 -1
View File
@@ -447,7 +447,7 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=15
max-locals=20
# Maximum number of parents for a class (see R0901).
max-parents=10
+15
View File
@@ -77,3 +77,18 @@ class RoomAdmin(admin.ModelAdmin):
"""Room admin interface declaration."""
inlines = (ResourceAccessInline,)
class RecordingAccessInline(admin.TabularInline):
"""Inline admin class for recording accesses."""
model = models.RecordingAccess
extra = 0
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
list_display = ("id", "status", "room", "created_at", "worker_id")
-58
View File
@@ -1,58 +0,0 @@
"""
Meet analytics class.
"""
import uuid
from django.conf import settings
from june import analytics as jAnalytics
class Analytics:
"""Analytics integration
This class wraps the June analytics code to avoid coupling our code directly
with this third-party library. By doing so, we create a generic interface
for analytics that can be easily modified or replaced in the future.
"""
def __init__(self):
key = getattr(settings, "ANALYTICS_KEY", None)
if key is not None:
jAnalytics.write_key = key
self._enabled = key is not None
def _is_anonymous_user(self, user):
"""Check if the user is anonymous."""
return user is None or user.is_anonymous
def identify(self, user, **kwargs):
"""Identify a user"""
if self._is_anonymous_user(user) or not self._enabled:
return
traits = kwargs.pop("traits", {})
traits.update({"email": user.email_anonymized})
jAnalytics.identify(user_id=user.sub, traits=traits, **kwargs)
def track(self, user, **kwargs):
"""Track an event"""
if not self._enabled:
return
event_data = {}
if self._is_anonymous_user(user):
event_data["anonymous_id"] = str(uuid.uuid4())
else:
event_data["user_id"] = user.sub
jAnalytics.track(**event_data, **kwargs)
analytics = Analytics()
+27 -34
View File
@@ -39,41 +39,37 @@ class IsSelf(IsAuthenticated):
return obj == request.user
# class RoomPermissions(permissions.BasePermission):
# """
# Permissions applying to the room API endpoint.
# """
# def has_permission(self, request, view):
# """Only allow authenticated users for unsafe methods."""
# if request.method in permissions.SAFE_METHODS:
# return True
# return request.user.is_authenticated
# def has_object_permission(self, request, view, obj):
# """Object permissions are only given to administrators of the room."""
# if request.method in permissions.SAFE_METHODS:
# return True
# user = request.user
# if request.method == "DELETE":
# return obj.is_owner(user)
# return obj.is_administrator(user)
class ResourceAccessPermission(permissions.BasePermission):
class RoomPermissions(permissions.BasePermission):
"""
Permissions for a room that can only be updated by room administrators.
Permissions applying to the room API endpoint.
"""
def has_permission(self, request, view):
"""Only allow authenticated users."""
"""Only allow authenticated users for unsafe methods."""
if request.method in permissions.SAFE_METHODS:
return True
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room."""
if request.method in permissions.SAFE_METHODS:
return True
user = request.user
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator(user)
class ResourceAccessPermission(IsAuthenticated):
"""
Permissions for a room that can only be updated by room administrators.
"""
def has_object_permission(self, request, view, obj):
"""
Check that the logged-in user is administrator of the linked room.
@@ -82,15 +78,12 @@ class ResourceAccessPermission(permissions.BasePermission):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return RoleChoices.is_administrator(obj.resource.get_roles(user))
return obj.resource.is_administrator(user)
class AccessPermission(permissions.BasePermission):
class HasAbilityPermission(IsAuthenticated):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+7 -7
View File
@@ -115,10 +115,10 @@ class RoomSerializer(serializers.ModelSerializer):
if not request:
return output
roles = instance.get_roles(request.user)
is_administrator = models.RoleChoices.is_administrator(roles)
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
if roles:
if role is not None:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -126,10 +126,10 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
if not is_administrator:
if not is_admin:
del output["configuration"]
if roles or instance.is_public:
if role is not None or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.query_params.get("username", None)
@@ -142,7 +142,7 @@ class RoomSerializer(serializers.ModelSerializer):
),
}
output["is_administrable"] = is_administrator
output["is_administrable"] = is_admin
return output
@@ -154,5 +154,5 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "stopped_at", "status"]
fields = ["id", "room", "created_at", "updated_at", "status"]
read_only_fields = fields
-33
View File
@@ -1,33 +0,0 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.core.files.storage import default_storage
import botocore
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
+9 -116
View File
@@ -1,22 +1,17 @@
"""API endpoints"""
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
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 rest_framework import (
decorators,
exceptions,
mixins,
pagination,
status,
viewsets,
)
from rest_framework import (
@@ -25,18 +20,10 @@ from rest_framework import (
from core import models, utils
from ..analytics import analytics
from . import permissions, serializers
# pylint: disable=too-many-ancestors
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
RECORDING_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}recordings/({UUID_REGEX:s})/file.mp4/$"
)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -174,7 +161,7 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -201,7 +188,6 @@ class RoomViewSet(
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
slug = slugify(self.kwargs["pk"])
username = request.query_params.get("username", None)
data = {
@@ -215,11 +201,6 @@ class RoomViewSet(
},
}
else:
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
data = self.get_serializer(instance).data
return drf_response.Response(data)
@@ -252,25 +233,6 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
analytics.track(
user=self.request.user,
event="Create Room",
properties={
"slug": room.slug,
},
)
@decorators.action(detail=True, methods=["post"], url_path="start-recording")
def start_recording(self, request, *args, **kwargs):
"""This view is used to start a recording for a room."""
# Check permission first
room = self.get_object()
recording = room.start_recording()
return drf_response.Response(
{"recording": recording.id}, status=status.HTTP_201_CREATED
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -327,84 +289,15 @@ class RecordingViewSet(
"""
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.HasAbilityPermission]
queryset = models.Recording.objects.all()
serializer_class = serializers.RecordingSerializer
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint to a user's room."""
queryset = self.filter_queryset(self.get_queryset())
def get_queryset(self):
"""Restrict recordings to the user's ones."""
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(room__accesses__user=user)
else:
queryset = queryset.none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
@decorators.action(detail=True, methods=["post"], url_path="stop")
def stop(self, request, *args, **kwargs):
"""
This view is used to stop a recording. It can be called anonymously as the
recording ID will not have been communicated anywhere at the time of recording.
"""
recording = self.get_object()
if recording.stopped_at is not None:
raise exceptions.PermissionDenied()
recording.stopped_at = timezone.now()
recording.save()
# TODO: Generate summary and send to note taking app
serializer = self.get_serializer(recording)
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording file.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
if not request.user.is_authenticated:
raise exceptions.AuthenticationFailed()
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = RECORDING_URL_PATTERN.search(original_url.path)
try:
(pk,) = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
if not models.Recording.objects.filter(
pk=pk, room__accesses__user=request.user
).exists():
raise exceptions.PermissionDenied()
# Generate authorization headers and return an authorization to proceed with the request
key = models.Recording(pk=pk).key
request = utils.generate_s3_authorization_headers(key)
return drf_response.Response("authorized", headers=request.headers, status=200)
return (
super()
.get_queryset()
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
)
@@ -10,8 +10,6 @@ from mozilla_django_oidc.auth import (
from core.models import User
from ..analytics import analytics
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -81,7 +79,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
else:
user = None
analytics.identify(user=user)
return user
def create_user(self, claims):
-6
View File
@@ -22,8 +22,6 @@ from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
from ..analytics import analytics
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
@@ -100,10 +98,6 @@ class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
logout_url = self.redirect_url
analytics.track(
user=request.user,
event="Signed Out",
)
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
+37 -1
View File
@@ -68,9 +68,45 @@ class RoomFactory(ResourceFactory):
class RecordingFactory(factory.django.DjangoModelFactory):
"""Create fake recordings for testing."""
"""Create fake recording for testing."""
class Meta:
model = models.Recording
room = factory.SubFactory(RoomFactory)
status = models.RecordingStatusChoices.INITIATED
worker_id = None
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to recording from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserRecordingAccessFactory(recording=self, user=item)
else:
UserRecordingAccessFactory(
recording=self, user=item[0], role=item[1]
)
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording user accesses for testing."""
class Meta:
model = models.RecordingAccess
recording = factory.SubFactory(RecordingFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording team accesses for testing."""
class Meta:
model = models.RecordingAccess
recording = factory.SubFactory(RecordingFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
@@ -1,37 +0,0 @@
# Generated by Django 5.1.1 on 2024-10-12 11:52
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_user_language'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='Recording',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('stopped_at', models.DateTimeField(blank=True, null=True, verbose_name='End Time')),
('status', models.CharField(choices=[('recording', 'Recording'), ('done', 'Done')], default='recording')),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='core.room', verbose_name='Room')),
],
options={
'verbose_name': 'Recording',
'verbose_name_plural': 'Recordings',
'db_table': 'meet_recording',
'ordering': ('created_at',),
},
),
]
@@ -0,0 +1,67 @@
# Generated by Django 5.1.1 on 2024-11-06 14:31
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_user_language'),
]
operations = [
migrations.CreateModel(
name='Recording',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('status', models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20)),
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording.This ID is retained even when the worker stops, allowing for easy tracking.', max_length=255, null=True, verbose_name='Worker ID')),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='core.room', verbose_name='Room')),
],
options={
'verbose_name': 'Recording',
'verbose_name_plural': 'Recordings',
'db_table': 'meet_recording',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='RecordingAccess',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.recording')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Recording/user relation',
'verbose_name_plural': 'Recording/user relations',
'db_table': 'meet_recording_access',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name='recording',
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['active', 'initiated'])), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
),
migrations.AddConstraint(
model_name='recordingaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'recording'), name='unique_recording_user', violation_error_message='This user is already in this recording.'),
),
migrations.AddConstraint(
model_name='recordingaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'recording'), name='unique_recording_team', violation_error_message='This team is already in this recording.'),
),
migrations.AddConstraint(
model_name='recordingaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_recording_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]
+265 -56
View File
@@ -4,6 +4,7 @@ Declare and configure the models for the Meet core application
import uuid
from logging import getLogger
from typing import List
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -28,16 +29,47 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
@classmethod
def is_administrator(cls, roles):
def check_administrator_role(cls, role):
"""Check if a role is administrator."""
return bool(set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}))
return role in [cls.ADMIN, cls.OWNER]
@classmethod
def check_owner_role(cls, role):
"""Check if a role is owner."""
return role == cls.OWNER
class RecordingStatusChoices(models.TextChoices):
"""Recording status choices."""
"""Enumeration of possible states for a recording operation."""
RECORDING = "recording", _("Recording")
DONE = "done", _("Done")
INITIATED = "initiated", _("Initiated")
ACTIVE = "active", _("Active")
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
@classmethod
def is_final(cls, status):
"""Determine if the recording status represents a final state.
A final status indicates the recording flow has completed, either
successfully or unsuccessfully.
"""
return status in {
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
class BaseModel(models.Model):
@@ -173,10 +205,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
return f"***@{self.email.split('@')[1]}"
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
"""
Get all roles assigned to a user for a specific resource, including team-based roles.
Args:
resource: The resource to check permissions for
user: The user to get roles for
Returns:
List of role strings assigned to the user
"""
if not user.is_authenticated:
return []
# Use pre-annotated roles if available from viewset optimization
if hasattr(resource, "user_roles"):
return resource.user_roles or []
try:
return list(
resource.accesses.filter_user(user)
.values_list("role", flat=True)
.distinct()
)
except (IndexError, models.ObjectDoesNotExist):
return []
class Resource(BaseModel):
"""Model to define access control"""
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
users = models.ManyToManyField(
User,
through="ResourceAccess",
@@ -195,44 +255,34 @@ class Resource(BaseModel):
except AttributeError:
return f"Resource {self.id!s}"
def get_role(self, user):
"""
Determine the role of a given user in this resource.
"""
if not user or not user.is_authenticated:
return None
role = None
for access in self.accesses.filter(user=user):
if access.role == RoleChoices.OWNER:
return RoleChoices.OWNER
if access.role == RoleChoices.ADMIN:
role = RoleChoices.ADMIN
if access.role == RoleChoices.MEMBER and role != RoleChoices.ADMIN:
role = RoleChoices.MEMBER
return role
def is_administrator(self, user):
"""
Check if a user is administrator of the resource.
Users carrying the "owner" role are considered as administrators a fortiori.
"""
return RoleChoices.is_administrator(self.get_roles(user))
return RoleChoices.check_administrator_role(self.get_role(user))
def is_owner(self, user):
"""Check if a user is owner of the resource."""
return RoleChoices.OWNER in self.get_roles(user)
def get_roles(self, user):
"""Compute the roles a user has in a room."""
if not user or not user.is_authenticated:
return self.accesses.none()
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(user=user).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = self.accesses.none()
return roles
def get_abilities(self, user):
"""Compute and return abilities for a given user on the room."""
roles = self.get_roles(user)
is_owner_or_admin = RoleChoices.is_administrator(roles)
return {
"start_recording": is_owner_or_admin,
"destroy": RoleChoices.OWNER in roles,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin,
"retrieve": True,
"update": is_owner_or_admin,
}
return RoleChoices.check_owner_role(self.get_role(user))
class ResourceAccess(BaseModel):
@@ -338,46 +388,205 @@ class Room(Resource):
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
def start_recording(self):
"""Create a new related recording object to which Livekit will be able to save a file."""
return Recording.objects.create(room=self)
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
def filter_user(self, user):
"""Filter accesses for a given user, including both direct and team-based access."""
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
)
team = models.CharField(max_length=100, blank=True)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
objects = BaseAccessManager()
class Meta:
abstract = True
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = get_resource_roles(resource, user)
is_owner = RoleChoices.OWNER in roles
has_privileges = is_owner or RoleChoices.ADMIN in roles
# Default values for unprivileged users
set_role_to = set()
can_delete = False
# Special handling when modifying an owner's access
if self.role == RoleChoices.OWNER:
# Prevent orphaning the resource
can_delete = (
is_owner
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
if can_delete:
set_role_to = {RoleChoices.ADMIN, RoleChoices.OWNER, RoleChoices.MEMBER}
elif has_privileges:
can_delete = True
set_role_to = {RoleChoices.ADMIN, RoleChoices.MEMBER}
if is_owner:
set_role_to.add(RoleChoices.OWNER)
# Remove the current role as we don't want to propose it as an option
set_role_to.discard(self.role)
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": sorted(r.value for r in set_role_to),
}
class Recording(BaseModel):
"""Model for recording meetings that take place in a room"""
"""Model for recordings that take place in a room"""
room = models.ForeignKey(
Room, on_delete=models.CASCADE, related_name="meetings", verbose_name=_("Room")
Room,
on_delete=models.CASCADE,
related_name="recordings",
verbose_name=_("Room"),
)
stopped_at = models.DateTimeField(verbose_name=_("End Time"), null=True, blank=True)
status = models.CharField(
choices=RecordingStatusChoices, default=RecordingStatusChoices.RECORDING
max_length=20,
choices=RecordingStatusChoices.choices,
default=RecordingStatusChoices.INITIATED,
)
worker_id = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name=_("Worker ID"),
help_text=_(
"Enter an identifier for the worker recording."
"This ID is retained even when the worker stops, allowing for easy tracking."
),
)
class Meta:
db_table = "meet_recording"
ordering = ("created_at",)
ordering = ("-created_at",)
verbose_name = _("Recording")
verbose_name_plural = _("Recordings")
constraints = [
models.UniqueConstraint(
fields=["room"],
condition=models.Q(
status__in=[
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.INITIATED,
]
),
name="unique_initiated_or_active_recording_per_room",
)
]
def __str__(self):
return _(
f"Recording in {self.room.name:s} on {self.created_at:%B %d, %Y at %I:%M %p}"
)
@property
def key(self):
"""Return the path where the recording file will be stored in object storage."""
return f"recordings/{self.pk!s}/file.mp4/"
return f"Recording {self.id} ({self.status})"
def get_abilities(self, user):
"""Compute and return abilities for a given user on the recording."""
roles = self.room.get_roles(user)
roles = set(get_resource_roles(self, user))
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_final_status = RecordingStatusChoices.is_final(self.status)
return {
"destroy": RoleChoices.OWNER in roles,
"destroy": is_owner_or_admin and is_final_status,
"partial_update": False,
"retrieve": False,
"stop": True,
"retrieve": is_owner_or_admin,
"stop": is_owner_or_admin and not is_final_status,
"update": False,
}
def is_savable(self) -> bool:
"""Determine if the recording can be saved based on its current status."""
is_unsuccessful = RecordingStatusChoices.is_unsuccessful(self.status)
is_already_saved = self.status == RecordingStatusChoices.SAVED
return not is_unsuccessful and not is_already_saved
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role.
Recording Status Flow:
1. INITIATED: Initial state when recording is requested
2. ACTIVE: Recording is currently in progress
3. STOPPED: Recording has been stopped by user/system
4. SAVED: Recording has been successfully processed and stored
Error States:
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
Warning: Worker failures may lead to database inconsistency between the actual
recording state and its status in the database.
"""
recording = models.ForeignKey(
Recording,
on_delete=models.CASCADE,
related_name="accesses",
)
class Meta:
db_table = "meet_recording_access"
ordering = ("-created_at",)
verbose_name = _("Recording/user relation")
verbose_name_plural = _("Recording/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "recording"],
condition=models.Q(user__isnull=False), # Exclude null users
name="unique_recording_user",
violation_error_message=_("This user is already in this recording."),
),
models.UniqueConstraint(
fields=["team", "recording"],
condition=models.Q(team__gt=""), # Exclude empty string teams
name="unique_recording_team",
violation_error_message=_("This team is already in this recording."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_recording_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the recording access.
"""
return self._get_abilities(self.recording, user)
@@ -5,7 +5,7 @@ Test recordings API endpoints in the Meet core app: delete.
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
@@ -26,8 +26,8 @@ def test_api_recordings_delete_anonymous():
def test_api_recordings_delete_authenticated():
"""
Authenticated users should not be allowed to delete a recording from a room to which
they are not related.
Authenticated users should not be allowed to delete a recording
from which they are not related.
"""
recording = RecordingFactory()
user = UserFactory()
@@ -39,64 +39,76 @@ def test_api_recordings_delete_authenticated():
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 403
assert response.status_code == 404
assert Recording.objects.count() == 1
def test_api_recordings_delete_members():
"""
Authenticated users should not be allowed to delete a recording from a room of which
they are only a member.
Authenticated users should not be allowed to delete a recording
from which they are only a member.
"""
user = UserFactory()
recording = RecordingFactory(
room__users=[(user, "member")]
) # as user declared in the room but not administrator
access = UserRecordingAccessFactory(role="member", user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
f"/api/v1.0/recordings/{access.recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_administrators():
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_recordings_delete_active(role):
"""
Authenticated users should not be allowed to delete a recording from a room in which
they are administrator.
Authenticated users cannot delete active recordings, even with deletion privileges.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "administrator")])
recording = RecordingFactory(status="active")
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
f"/api/v1.0/recordings/{access.recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_owners():
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_recordings_delete_final(role):
"""
Authenticated users should be able to delete a recording from a room in which they are
directly owner.
Authenticated users should not be allowed to delete an active recording
from which they are an admin or owner.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "owner")])
recording = RecordingFactory(status="saved")
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
f"/api/v1.0/recordings/{access.recording.id}/",
)
assert response.status_code == 204
assert Recording.objects.exists() is False
assert Recording.objects.count() == 0
@@ -2,6 +2,7 @@
Test recordings API endpoints in the Meet core app: list.
"""
import operator
from unittest import mock
import pytest
@@ -15,21 +16,17 @@ pytestmark = pytest.mark.django_db
def test_api_recordings_list_anonymous():
"""Anonymous users should not be able to list recordings."""
factories.RecordingFactory(room__is_public=False)
factories.RecordingFactory(room__is_public=True)
factories.RecordingFactory()
response = APIClient().get("/api/v1.0/recordings/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
assert response.status_code == 401
def test_api_recordings_list_authenticated():
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
@@ -40,11 +37,10 @@ def test_api_recordings_list_authenticated():
other_user = factories.UserFactory()
factories.RecordingFactory(room__is_public=False)
factories.RecordingFactory(room__is_public=True)
factories.RecordingFactory(room__is_public=False, room__users=[other_user])
access = factories.UserRecordingAccessFactory(role=role, user=user)
factories.UserRecordingAccessFactory(user=other_user)
recording = factories.RecordingFactory(room__is_public=False, room__users=[user])
recording = access.recording
room = recording.room
response = client.get(
@@ -62,18 +58,51 @@ def test_api_recordings_list_authenticated():
assert results[0] == {
"id": str(recording.id),
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"stopped_at": None,
"room": {
"id": str(room.id),
"is_public": room.is_public,
"name": room.name,
"slug": room.slug,
},
"status": "recording",
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_recording_list_authenticated_via_team(mock_user_get_teams):
"""
Authenticated users should be able to list recordings they are a
owner/administrator/member of via a team.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
recordings_team1 = [
access.recording
for access in factories.TeamRecordingAccessFactory.create_batch(2, team="team1")
]
recordings_team2 = [
access.recording
for access in factories.TeamRecordingAccessFactory.create_batch(3, team="team2")
]
expected_ids = {
str(recording.id) for recording in recordings_team1 + recordings_team2
}
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_recordings_list_pagination(_mock_page_size):
"""Pagination should work as expected."""
@@ -81,8 +110,10 @@ def test_api_recordings_list_pagination(_mock_page_size):
client = APIClient()
client.force_login(user)
recordings = factories.RecordingFactory.create_batch(3, room__users=[user])
recording_ids = [str(r.id) for r in recordings]
recording_ids = [
str(access.recording_id)
for access in factories.UserRecordingAccessFactory.create_batch(3, user=user)
]
response = client.get("/api/v1.0/recordings/")
@@ -114,15 +145,13 @@ def test_api_recordings_list_pagination(_mock_page_size):
def test_api_recordings_list_authenticated_distinct():
"""A recording for a public room with several related users should only be listed once."""
"""A recording for a room with several related users should only be listed once."""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recording = factories.RecordingFactory(
room__is_public=True, room__users=[user, other_user]
)
recording = factories.RecordingFactory(users=[user, other_user])
response = client.get("/api/v1.0/recordings/")
@@ -130,3 +159,22 @@ def test_api_recordings_list_authenticated_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(recording.id)
def test_api_recordings_list_ordering_default():
"""Recordings should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.RecordingFactory.create_batch(5, users=[user])
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
@@ -1,128 +0,0 @@
"""
Test file downloads API endpoint for users in Meet's core app.
"""
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# This is a minimal MP4 file header, which creates a 1-second empty video
VIDEO_BYTES = (
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42mp41"
b"\x00\x00\x00\x08free\x00\x00\x02\xeemdat"
)
@pytest.mark.parametrize("is_public", [True, False])
def test_api_recordings_retrieve_auth_anonymous(is_public):
"""Anonymous users should not be able to retrieve recordings for a room."""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = APIClient().get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
@pytest.mark.parametrize("is_public", [True, False])
def test_api_recordings_retrieve_auth_authenticated(is_public):
"""
Authenticated users who are not related to a room should not be able to
retrieve recordings for this room.
"""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("is_public", [True, False])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_recordings_retrieve_auth_admin_or_owner(role, is_public):
"""
Users who are administrator or owner of a room, should be able to retrieve recordings.
"""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserResourceAccessFactory(resource=room, user=user, role=role)
# deposit a recording in S3
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
# verify getting object content from url with authorization headers
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content == VIDEO_BYTES
@@ -1,56 +0,0 @@
"""
Test recordings API endpoints in the Meet core app: stop.
"""
from datetime import datetime, timezone
from unittest import mock
from uuid import uuid4
from django.utils import timezone as django_timezone
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_recording_stop_recording_success():
"""Anonymous users can stop a recording just with its UUID."""
recording = factories.RecordingFactory()
now = datetime(2010, 1, 1, tzinfo=timezone.utc)
with mock.patch.object(django_timezone, "now", return_value=now):
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 200
recording.refresh_from_db()
assert recording.stopped_at == now
def test_api_recording_stop_recording_unknown():
"""Trying to stop an unknown recording should return a 404."""
response = APIClient().post(f"/api/v1.0/recordings/{uuid4()!s}/stop/")
assert response.status_code == 404
def test_api_recording_stop_recording_already_stopped():
"""
Trying to stop a recording that was already stopped should return a 403 and leave
the recording unmodified.
"""
recording = factories.RecordingFactory()
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 200
recording.refresh_from_db()
stopped_at = recording.stopped_at
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 403
recording.refresh_from_db()
assert recording.stopped_at == stopped_at
@@ -286,7 +286,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
client = APIClient()
client.force_login(user)
with django_assert_num_queries(5):
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -360,7 +360,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
client = APIClient()
client.force_login(user)
with django_assert_num_queries(5):
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -1,88 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: start-recording.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_rooms_start_recording_anonymous():
"""Anonymous users should not be allowed to start a recording for a room."""
room = factories.RoomFactory()
response = APIClient().post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 401
assert models.Recording.objects.exists() is False
def test_api_rooms_start_recording_authenticated():
"""
Authenticated users should not be allowed to start a recording for a room
to which they are not related.
"""
user = factories.UserFactory()
room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 403
assert models.Recording.objects.exists() is False
def test_api_rooms_start_recording_members():
"""
Users who are members of a room but not administrators should
not be allowed to start a recording in this room.
"""
user = factories.UserFactory()
room = factories.RoomFactory(users=[(user, "member")])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code, 403
assert models.Recording.objects.exists() is False
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_rooms_start_recording_administrators(role):
"""Administrators or owners of a room should be allowed to start a recording in this room."""
user = factories.UserFactory()
room = factories.RoomFactory(users=[(user, role)])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 201
assert models.Recording.objects.get().room == room
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_rooms_start_recording_administrators_of_another(role):
"""
Being administrator or owner of a room should not grant authorization
to update another room.
"""
user = factories.UserFactory()
factories.RoomFactory(users=[(user, role)])
other_room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{other_room.id!s}/start-recording/")
assert response.status_code, 403
assert models.Recording.objects.exists() is False
-132
View File
@@ -1,132 +0,0 @@
"""
Test for the Analytics class.
"""
# pylint: disable=W0212
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import pytest
from core.analytics import Analytics
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_june_analytics")
def _mock_june_analytics():
with patch("core.analytics.jAnalytics") as mock:
yield mock
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_init_enabled(mock_june_analytics):
"""Should enable analytics and set the write key correctly when ANALYTICS_KEY is set."""
analytics = Analytics()
assert analytics._enabled is True
assert mock_june_analytics.write_key == "test_key"
@override_settings(ANALYTICS_KEY=None)
def test_analytics_init_disabled():
"""Should disable analytics when ANALYTICS_KEY is not set."""
analytics = Analytics()
assert analytics._enabled is False
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user(mock_june_analytics):
"""Should identify a user with the correct traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com"}
)
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_user_with_traits(mock_june_analytics):
"""Should identify a user with additional traits when analytics is enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user, traits={"email": "user@example.com", "foo": "foo"})
mock_june_analytics.identify.assert_called_once_with(
user_id="12345", traits={"email": "***@example.com", "foo": "foo"}
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_identify_not_enabled(mock_june_analytics):
"""Should not call identify when analytics is not enabled."""
user = UserFactory(sub="12345", email="user@example.com")
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_no_user(mock_june_analytics):
"""Should not call identify when the user is None."""
analytics = Analytics()
analytics.identify(None)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_identify_anonymous_user(mock_june_analytics):
"""Should not call identify when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.identify(user)
mock_june_analytics.identify.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
def test_analytics_track_event(mock_june_analytics):
"""Should track an event with the correct user and event details when analytics is enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
user_id="12345", event="test_event", foo="foo"
)
@override_settings(ANALYTICS_KEY=None)
def test_analytics_track_event_not_enabled(mock_june_analytics):
"""Should not call track when analytics is not enabled."""
user = UserFactory(sub="12345")
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_not_called()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_no_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is None."""
analytics = Analytics()
analytics.track(None, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@override_settings(ANALYTICS_KEY="test_key")
@patch("uuid.uuid4", return_value="test_uuid4")
def test_analytics_track_event_anonymous_user(mock_uuid4, mock_june_analytics):
"""Should track an event with a random anonymous user ID when the user is anonymous."""
user = AnonymousUser()
analytics = Analytics()
analytics.track(user, event="test_event", foo="foo")
mock_june_analytics.track.assert_called_once_with(
anonymous_id="test_uuid4", event="test_event", foo="foo"
)
mock_uuid4.assert_called_once()
@@ -0,0 +1,212 @@
"""
Unit tests for the Recording model
"""
from django.core.exceptions import ValidationError
import pytest
from core.factories import (
RecordingFactory,
RoomFactory,
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_models_recording_str():
"""The str representation should be the recording ID."""
recording = RecordingFactory()
assert str(recording) == f"Recording {recording.id} (initiated)"
def test_models_recording_ordering():
"""Recordings should be returned ordered by created_at in descending order."""
RecordingFactory.create_batch(3)
recordings = Recording.objects.all()
assert recordings[0].created_at >= recordings[1].created_at
assert recordings[1].created_at >= recordings[2].created_at
def test_models_recording_room_relationship():
"""It should maintain proper relationship with room."""
room = RoomFactory()
recording = RecordingFactory(room=room)
assert recording.room == room
assert recording in room.recordings.all()
def test_models_recording_default_status():
"""A new recording should have INITIATED status by default."""
recording = RecordingFactory()
assert recording.status == RecordingStatusChoices.INITIATED
def test_models_recording_unique_initiated_or_active_per_room():
"""Only one initiated or active recording should be allowed per room."""
room = RoomFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
with pytest.raises(ValidationError):
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
with pytest.raises(ValidationError):
RecordingFactory(room=room, status=RecordingStatusChoices.INITIATED)
def test_models_recording_multiple_finished_allowed():
"""Multiple finished recordings should be allowed in the same room."""
room = RoomFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
assert room.recordings.count() == 2
# Test get_abilities method
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_models_recording_get_abilities_privileges_active(role):
"""Test abilities for active recording and privileged user."""
user = UserFactory()
access = UserRecordingAccessFactory(role=role, user=user)
access.recording.status = RecordingStatusChoices.ACTIVE
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": False, # Not final status
"partial_update": False,
"retrieve": True, # Privileged users can always retrieve
"stop": True, # Not final status, so can stop
"update": False,
}
def test_models_recording_get_abilities_member_active():
"""Test abilities for a user who is unprivileged."""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
access.recording.status = RecordingStatusChoices.ACTIVE
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": False,
"partial_update": False,
"retrieve": False,
"stop": False,
"update": False,
}
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_models_recording_get_abilities_privileges_final(role):
"""Test abilities for active recording and privileged user."""
user = UserFactory()
access = UserRecordingAccessFactory(role=role, user=user)
access.recording.status = RecordingStatusChoices.SAVED
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": True,
"partial_update": False,
"retrieve": True, # Privileged users can always retrieve
"stop": False, # In final status, so can not stop
"update": False,
}
def test_models_recording_get_abilities_member_final():
"""Test abilities for a user who is unprivileged."""
user = UserFactory()
access = UserRecordingAccessFactory(role="member", user=user)
access.recording.status = RecordingStatusChoices.SAVED
abilities = access.recording.get_abilities(user)
assert abilities == {
"destroy": False,
"partial_update": False,
"retrieve": False,
"stop": False,
"update": False,
}
# Test is_savable method
def test_models_recording_is_savable_normal():
"""Test is_savable for normal recording status."""
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
assert recording.is_savable() is True
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
],
)
def test_models_recording_is_savable_error(status):
"""Test is_savable for error status."""
recording = RecordingFactory(status=status)
assert recording.is_savable() is False
def test_models_recording_is_savable_already_saved():
"""Test is_savable for already saved recording."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
assert recording.is_savable() is False
# Test few corner cases
def test_models_recording_worker_id_optional():
"""Worker ID should be optional."""
recording = RecordingFactory(worker_id=None)
assert recording.worker_id is None
recording = RecordingFactory(worker_id="worker-123")
assert recording.worker_id == "worker-123"
def test_models_recording_invalid_status():
"""Test that setting an invalid status raises an error."""
recording = RecordingFactory()
recording.status = "INVALID_STATUS"
with pytest.raises(ValidationError):
recording.save()
def test_models_recording_room_deletion():
"""Test that deleting a room cascades to its recordings."""
room = RoomFactory()
recording = RecordingFactory(room=room)
room.delete()
assert not Recording.objects.filter(id=recording.id).exists()
def test_models_recording_worker_id_very_long():
"""Test worker_id with maximum length."""
long_id = "w" * 255
recording = RecordingFactory(worker_id=long_id)
assert recording.worker_id == long_id
too_long_id = "w" * 256
with pytest.raises(ValidationError):
RecordingFactory(worker_id=too_long_id)
@@ -0,0 +1,312 @@
"""
Unit tests for the RecordingAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_recording_accesses_str():
"""
The str representation should include user email, recording ID and role.
"""
user = factories.UserFactory(email="david.bowman@example.com")
access = factories.UserRecordingAccessFactory(
role="member",
user=user,
)
assert (
str(access)
== f"david.bowman@example.com is member in Recording {access.recording.id} (initiated)"
)
def test_models_recording_accesses_unique_user():
"""Recording accesses should be unique for a given couple of user and recording."""
access = factories.UserRecordingAccessFactory()
with pytest.raises(
ValidationError,
match="This user is already in this recording.",
):
factories.UserRecordingAccessFactory(
user=access.user, recording=access.recording
)
def test_models_recording_accesses_several_empty_teams():
"""A recording can have several recording accesses with an empty team."""
access = factories.UserRecordingAccessFactory()
factories.UserRecordingAccessFactory(recording=access.recording)
def test_models_recording_accesses_unique_team():
"""Recording accesses should be unique for a given couple of team and recording."""
access = factories.TeamRecordingAccessFactory()
with pytest.raises(
ValidationError,
match="This team is already in this recording.",
):
factories.TeamRecordingAccessFactory(
team=access.team, recording=access.recording
)
def test_models_recording_accesses_several_null_users():
"""A recording can have several recording accesses with a null user."""
access = factories.TeamRecordingAccessFactory()
factories.TeamRecordingAccessFactory(recording=access.recording)
def test_models_recording_accesses_user_and_team_set():
"""User and team can't both be set on a recording access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserRecordingAccessFactory(team="my-team")
def test_models_recording_accesses_user_and_team_empty():
"""User and team can't both be empty on a recording access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserRecordingAccessFactory(user=None)
# get_abilities
def test_models_recording_access_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.UserRecordingAccessFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_recording_access_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.UserRecordingAccessFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - for owner
def test_models_recording_access_get_abilities_for_owner_of_self_allowed():
"""
Check abilities of self access for the owner of a recording when
there is more than one owner left.
"""
access = factories.UserRecordingAccessFactory(role="owner")
factories.UserRecordingAccessFactory(recording=access.recording, role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "member"],
}
def test_models_recording_access_get_abilities_for_owner_of_self_last():
"""
Check abilities of self access for the owner of a recording when there is only one owner left.
"""
access = factories.UserRecordingAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_recording_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a recording."""
access = factories.UserRecordingAccessFactory(role="owner")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "member"],
}
def test_models_recording_access_get_abilities_for_owner_of_administrator():
"""Check abilities of administrator access for the owner of a recording."""
access = factories.UserRecordingAccessFactory(role="administrator")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["member", "owner"],
}
def test_models_recording_access_get_abilities_for_owner_of_member():
"""Check abilities of member access for the owner of a recording."""
access = factories.UserRecordingAccessFactory(role="member")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "owner"],
}
# - for administrator
def test_models_recording_access_get_abilities_for_administrator_of_owner():
"""Check abilities of owner access for the administrator of a recording."""
access = factories.UserRecordingAccessFactory(role="owner")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_recording_access_get_abilities_for_administrator_of_administrator():
"""Check abilities of administrator access for the administrator of a recording."""
access = factories.UserRecordingAccessFactory(role="administrator")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["member"],
}
def test_models_recording_access_get_abilities_for_administrator_of_member():
"""Check abilities of member access for the administrator of a recording."""
access = factories.UserRecordingAccessFactory(role="member")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator"],
}
# - for member
def test_models_recording_access_get_abilities_for_member_of_owner():
"""Check abilities of owner access for the member of a recording."""
access = factories.UserRecordingAccessFactory(role="owner")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="member"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_recording_access_get_abilities_for_member_of_administrator():
"""Check abilities of administrator access for the member of a recording."""
access = factories.UserRecordingAccessFactory(role="administrator")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="member"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_recording_access_get_abilities_for_member_of_member_user(
django_assert_num_queries,
):
"""Check abilities of member access for the member of a recording."""
access = factories.UserRecordingAccessFactory(role="member")
factories.UserRecordingAccessFactory(recording=access.recording) # another one
user = factories.UserRecordingAccessFactory(
recording=access.recording, role="member"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
+36 -21
View File
@@ -86,66 +86,81 @@ def test_models_rooms_is_public_default():
assert room.is_public is True
def test_models_recordings_key():
"""Check the room key format."""
room = RoomFactory()
recording = room.start_recording()
assert recording.key == f"recordings/{recording.pk!s}/file.mp4/"
# Access rights methods
def test_models_rooms_access_rights_none(django_assert_num_queries):
"""The `get_roles` method should return an empty list when called with None."""
"""Calling access rights methods with None should return None."""
room = RoomFactory()
with django_assert_num_queries(0):
assert not list(room.get_roles(None))
assert room.get_role(None) is None
with django_assert_num_queries(0):
assert room.is_administrator(None) is False
with django_assert_num_queries(0):
assert room.is_owner(None) is False
def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
"""The `get_roles` method should return an empty list for an anonymous user."""
"""Check access rights methods on the room object for an anonymous user."""
user = AnonymousUser()
room = RoomFactory()
with django_assert_num_queries(0):
assert not list(room.get_roles(user))
assert room.get_role(user) is None
with django_assert_num_queries(0):
assert room.is_administrator(user) is False
with django_assert_num_queries(0):
assert room.is_owner(user) is False
def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
"""
The `get_roles` method should return an empty list for a user not related to the room.
"""
"""Check access rights methods on the room object for an unrelated user."""
user = UserFactory()
room = RoomFactory()
with django_assert_num_queries(1):
assert not list(room.get_roles(user))
assert room.get_role(user) is None
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
"""Check `get_roles` method on the room object for a direct member."""
"""Check access rights methods on the room object for a direct member."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
with django_assert_num_queries(1):
assert list(room.get_roles(user)) == ["member"]
assert room.get_role(user) == "member"
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries):
"""Check `get_roles` method on the room object for a direct administrator."""
"""The is_administrator method should return True for a direct administrator."""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
with django_assert_num_queries(1):
assert list(room.get_roles(user)) == ["administrator"]
assert room.get_role(user) == "administrator"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is False
def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
"""Check `get_roles` method on the room object for an owner."""
"""Check access rights methods on the room object for an owner."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
with django_assert_num_queries(1):
assert list(room.get_roles(user)) == ["owner"]
assert room.get_role(user) == "owner"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
+1 -1
View File
@@ -11,8 +11,8 @@ from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
-30
View File
@@ -11,9 +11,7 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import botocore
from livekit.api import AccessToken, VideoGrants
@@ -83,31 +81,3 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
)
return token.to_jwt()
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
Binary file not shown.
Binary file not shown.
+2 -7
View File
@@ -395,15 +395,12 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue(
True, environ_name="DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", environ_prefix=None
)
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
)
ANALYTICS_KEY = values.Value(
None, environ_name="ANALYTICS_KEY", environ_prefix=None
)
# pylint: disable=invalid-name
@property
@@ -524,8 +521,6 @@ class Test(Base):
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
ANALYTICS_KEY = None
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+6 -1
View File
@@ -1,6 +1,7 @@
"""URL configuration for the Meet project"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
@@ -17,7 +18,11 @@ urlpatterns = [
]
if settings.DEBUG:
urlpatterns = urlpatterns + staticfiles_urlpatterns()
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
)
if settings.USE_SWAGGER or settings.DEBUG:
+4 -4
View File
@@ -25,14 +25,14 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.34",
"boto3==1.35.19",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.1.1",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
@@ -47,11 +47,11 @@ dependencies = [
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"psycopg[binary]==3.2.2",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.15.0",
"sentry-sdk==2.14.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
+5 -5
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine as frontend-deps
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -11,11 +11,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps as meet
FROM frontend-deps AS meet
WORKDIR /home/frontend
FROM frontend-deps as meet-dev
FROM frontend-deps AS meet-dev
WORKDIR /home/frontend
@@ -25,14 +25,14 @@ CMD [ "npm", "run", "dev"]
# Tilt will rebuild Meet target so, we dissociate meet and meet-builder
# to avoid rebuilding the app at every changes.
FROM meet as meet-builder
FROM meet AS meet-builder
WORKDIR /home/frontend
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
@@ -34,19 +34,19 @@ export const MainNotificationToast = () => {
}, [room, triggerNotificationSound])
useEffect(() => {
const removeJoinNotification = (participant: Participant) => {
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Joined
)
if (existingToast) {
toastQueue.close(existingToast.key)
}
const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => {
if (toast.content.participant === participant) {
toastQueue.close(toast.key)
}
})
}
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
return () => {
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
room.off(
RoomEvent.ParticipantDisconnected,
removeParticipantNotifications
)
}
}, [room])
@@ -105,7 +105,7 @@ export const MainNotificationToast = () => {
}, [room])
return (
<Div position="absolute" bottom={20} right={5} zIndex={1000}>
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
</Div>
)
@@ -6,7 +6,7 @@ import { HStack } from '@/styled-system/jsx'
import { Button, Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RiCloseLine, RiHand } from '@remixicon/react'
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export function ToastRaised({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
@@ -17,7 +17,7 @@ export function ToastRaised({ state, ...props }: ToastProps) {
ref
)
const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
return (
<StyledToastContainer {...toastProps} ref={ref}>
@@ -1,11 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
formatChatMessageLinks,
LiveKitRoom,
type LocalUserChoices,
} from '@livekit/components-react'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -104,7 +100,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
@@ -19,7 +19,7 @@ export const Join = ({
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
userLabel={t('join.usernameLabel')}
/>
</CenteredContent>
</Screen>
@@ -94,7 +94,7 @@ export const Effects = () => {
}
return (
<VStack padding="0 1.5rem">
<VStack padding="0 1.5rem" overflowY="scroll">
{localCameraTrack && isCameraEnabled ? (
<video
ref={videoRef}
@@ -1,4 +1,3 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
@@ -7,14 +6,16 @@ import { Box, Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
type StyledSidePanelProps = {
title: string
children: ReactNode
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
}
@@ -22,11 +23,11 @@ const StyledSidePanel = ({
title,
children,
onClose,
isClosed,
closeButtonTooltip,
}: StyledSidePanelProps) => (
<Box
size="sm"
minWidth="360px"
className={css({
overflow: 'hidden',
display: 'flex',
@@ -34,7 +35,16 @@ const StyledSidePanel = ({
margin: '1.5rem 1.5rem 1.5rem 0',
padding: 0,
gap: 0,
right: 0,
top: 0,
bottom: '80px',
width: '360px',
position: 'absolute',
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
})}
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
>
<Heading
slot="title"
@@ -43,11 +53,19 @@ const StyledSidePanel = ({
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : undefined,
}}
>
{title}
</Heading>
<Div position="absolute" top="5" right="5">
<Div
position="absolute"
top="5"
right="5"
style={{
display: isClosed ? 'none' : undefined,
}}
>
<Button
invisible
size="xs"
@@ -58,31 +76,56 @@ const StyledSidePanel = ({
<RiCloseLine />
</Button>
</Div>
<Div overflowY="scroll">{children}</Div>
{children}
</Box>
)
type PanelProps = {
isOpen: boolean
children: React.ReactNode
}
const Panel = ({ isOpen, children }: PanelProps) => (
<div
style={{
display: isOpen ? 'inherit' : 'none',
flexDirection: 'column',
overflow: 'hidden',
flexGrow: 1,
}}
>
{children}
</div>
)
export const SidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction()
const {
activePanelId,
isParticipantsOpen,
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
if (!sidePanel) {
return
}
return (
<StyledSidePanel
title={t(`heading.${sidePanel}`)}
onClose={() => (layoutStore.sidePanel = null)}
title={t(`heading.${activePanelId}`)}
onClose={() => (layoutStore.activePanelId = null)}
closeButtonTooltip={t('closeButton', {
content: t(`content.${sidePanel}`),
content: t(`content.${activePanelId}`),
})}
isClosed={!isSidePanelOpen}
>
{isParticipantsOpen && <ParticipantsList />}
{isEffectsOpen && <Effects />}
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
</StyledSidePanel>
)
}
@@ -0,0 +1,71 @@
import type { ReceivedChatMessage } from '@livekit/components-core'
import * as React from 'react'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
import { MessageFormatter } from '@livekit/components-react'
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
entry: ReceivedChatMessage
hideMetadata?: boolean
messageFormatter?: MessageFormatter
}
export const ChatEntry: (
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLLIElement,
ChatEntryProps
>(function ChatEntry(
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
ref
) {
// Fixme - Livekit messageFormatter strips '\n' char
const formattedMessage = React.useMemo(() => {
return messageFormatter ? messageFormatter(entry.message) : entry.message
}, [entry.message, messageFormatter])
const time = new Date(entry.timestamp)
const locale = navigator ? navigator.language : 'en-US'
return (
<li
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
ref={ref}
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
{...props}
>
{!hideMetadata && (
<span
className={css({
display: 'flex',
gap: '0.5rem',
paddingTop: '0.75rem',
})}
>
<Text bold={true} variant="sm">
{entry.from?.name ?? entry.from?.identity}
</Text>
<Text variant="sm" className={css({ color: 'gray.700' })}>
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</span>
)}
<Text
variant="sm"
margin={false}
className={css({
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
</li>
)
})
@@ -0,0 +1,120 @@
import { Button } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiSendPlane2Fill } from '@remixicon/react'
import { useState, useEffect } from 'react'
import { TextArea } from '@/primitives/TextArea'
import { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
const MAX_ROWS = 6
interface ChatInputProps {
inputRef: RefObject<HTMLTextAreaElement>
onSubmit: (text: string) => void
isSending: boolean
}
export const ChatInput = ({
inputRef,
onSubmit,
isSending,
}: ChatInputProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
const [text, setText] = useState('')
const [rows, setRows] = useState(1)
const handleSubmit = () => {
onSubmit(text)
setText('')
}
const isDisabled = !text.trim() || isSending
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
e.preventDefault()
if (!isDisabled) handleSubmit()
}
useEffect(() => {
const resize = () => {
if (!inputRef.current) return
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
const previousRows = inputRef.current.rows
inputRef.current.rows = 1
const currentRows = Math.floor(
inputRef.current.scrollHeight / textAreaLineHeight
)
if (currentRows === previousRows) {
inputRef.current.rows = currentRows
}
if (currentRows >= MAX_ROWS) {
inputRef.current.rows = MAX_ROWS
inputRef.current.scrollTop = inputRef.current.scrollHeight
}
if (currentRows < MAX_ROWS) {
inputRef.current.style.overflowY = 'hidden'
} else {
inputRef.current.style.overflowY = 'auto'
}
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
}
resize()
}, [text, inputRef])
return (
<HStack
style={{
margin: '0.75rem 0 1.5rem',
padding: '0.5rem',
backgroundColor: '#f3f4f6',
borderRadius: 4,
}}
>
<TextArea
ref={inputRef}
onKeyDown={(e) => {
e.stopPropagation()
submitOnEnter(e)
}}
onKeyUp={(e) => e.stopPropagation()}
placeholder={t('textArea.placeholder')}
value={text}
onChange={(e) => {
setText(e.target.value)
}}
rows={rows || 1}
style={{
border: 'none',
resize: 'none',
height: 'auto',
minHeight: `34px`,
lineHeight: 1.25,
padding: '7px 10px',
overflowY: 'hidden',
}}
placeholderStyle={'strong'}
spellCheck={false}
maxLength={500}
aria-label={t('textArea.label')}
/>
<Button
square
invisible
size="sm"
onPress={handleSubmit}
isDisabled={isDisabled}
aria-label={t('button.label')}
>
<RiSendPlane2Fill />
</Button>
</HStack>
)
}
@@ -1,13 +1,17 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
const chatSnap = useSnapshot(chatStore)
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
return (
@@ -28,7 +32,7 @@ export const ChatToggle = () => {
>
<RiChat1Line />
</ToggleButton>
{!!unreadMessages && (
{!!chatSnap.unreadMessages && (
<div
className={css({
position: 'absolute',
@@ -1,7 +1,7 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import {
RiAccountBoxLine,
RiFeedbackLine,
RiMegaphoneLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
import { useSidePanel } from '../../../hooks/useSidePanel'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
@@ -18,7 +18,7 @@ export const OptionsMenuItems = ({
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useWidgetInteraction()
const { toggleEffects } = useSidePanel()
return (
<RACMenu
style={{
@@ -42,7 +42,7 @@ export const OptionsMenuItems = ({
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiFeedbackLine size={20} />
<RiMegaphoneLine size={20} />
{t('feedbacks')}
</MenuItem>
<MenuItem
@@ -1,5 +1,4 @@
import { css } from '@/styled-system/css'
import { allParticipantRoomEvents } from '@livekit/components-core'
import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives'
@@ -8,7 +7,6 @@ import { ParticipantListItem } from '../../controls/Participants/ParticipantList
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
import { RoomEvent } from 'livekit-client'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
@@ -17,12 +15,7 @@ export const ParticipantsList = () => {
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants({
updateOnlyOn: [
RoomEvent.ParticipantNameChanged,
...allParticipantRoomEvents,
],
})
const participants = useParticipants()
const sortedRemoteParticipants = participants
.slice(1)
@@ -44,7 +37,7 @@ export const ParticipantsList = () => {
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<>
<Div overflowY="scroll">
<H
lvl={2}
className={css({
@@ -78,6 +71,6 @@ export const ParticipantsList = () => {
<ParticipantListItem participant={participant} />
)}
/>
</>
</Div>
)
}
@@ -3,7 +3,7 @@ import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
import { useSidePanel } from '../../../hooks/useSidePanel'
export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
@@ -16,7 +16,7 @@ export const ParticipantsToggle = () => {
const participants = useParticipants()
const numParticipants = participants?.length
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -0,0 +1,41 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isSidePanelOpen = !!activePanelId
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
}
return {
activePanelId,
toggleParticipants,
toggleChat,
toggleEffects,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
}
}
@@ -1,46 +0,0 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export const useWidgetInteraction = () => {
const { dispatch, state } = useLayoutContext().widget
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const isParticipantsOpen = sidePanel == 'participants'
const isEffectsOpen = sidePanel == 'effects'
const toggleParticipants = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isParticipantsOpen ? null : 'participants'
}
const toggleChat = () => {
if (isParticipantsOpen || isEffectsOpen) {
layoutStore.sidePanel = null
}
if (dispatch) {
dispatch({ msg: 'toggle_chat' })
}
}
const toggleEffects = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isEffectsOpen ? null : 'effects'
}
return {
toggleParticipants,
toggleChat,
toggleEffects,
isChatOpen: state?.showChat,
unreadMessages: state?.unreadMessages,
isParticipantsOpen,
isEffectsOpen,
}
}
@@ -0,0 +1,134 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import * as React from 'react'
import {
formatChatMessageLinks,
useChat,
useParticipants,
} from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { chatStore } from '@/stores/chat'
import { Div, Text } from '@/primitives'
import { ChatInput } from '../components/chat/Input'
import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
ChatOptions {}
/**
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
* in the room. Only users who are in the room at the time of dispatch will receive the message.
*/
export function Chat({ ...props }: ChatProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
const inputRef = React.useRef<HTMLTextAreaElement>(null)
const ulRef = React.useRef<HTMLUListElement>(null)
const { send, chatMessages, isSending } = useChat()
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
async function handleSubmit(text: string) {
if (!send || !text) return
await send(text)
inputRef?.current?.focus()
}
React.useEffect(() => {
if (chatMessages.length > 0 && ulRef.current) {
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
}
}, [ulRef, chatMessages])
React.useEffect(() => {
if (chatMessages.length === 0) {
return
}
if (
isChatOpen &&
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
) {
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
chatStore.unreadMessages = 0
return
}
const unreadMessageCount = chatMessages.filter(
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
).length
if (
unreadMessageCount > 0 &&
chatSnap.unreadMessages !== unreadMessageCount
) {
chatStore.unreadMessages = unreadMessageCount
}
}, [chatMessages, chatSnap.unreadMessages, isChatOpen])
const renderedMessages = React.useMemo(() => {
return chatMessages.map((msg, idx, allMsg) => {
const hideMetadata =
idx >= 1 &&
msg.timestamp - allMsg[idx - 1].timestamp < 60_000 &&
allMsg[idx - 1].from === msg.from
return (
<ChatEntry
key={msg.id ?? idx}
hideMetadata={hideMetadata}
entry={msg}
messageFormatter={formatChatMessageLinks}
/>
)
})
// This ensures that the chat message list is updated to reflect any changes in participant information.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatMessages, participants])
return (
<Div
display={'flex'}
padding={'0 1.5rem'}
flexGrow={1}
flexDirection={'column'}
minHeight={0}
{...props}
>
<Text
variant="sm"
style={{
padding: '0.75rem',
backgroundColor: '#f3f4f6',
borderRadius: 4,
marginBottom: '0.75rem',
}}
>
{t('disclaimer')}
</Text>
<Div
flexGrow={1}
flexDirection={'column'}
minHeight={0}
overflowY="scroll"
>
<ul className="lk-list lk-chat-messages" ref={ulRef}>
{renderedMessages}
</ul>
</Div>
<ChatInput
inputRef={inputRef}
onSubmit={(e) => handleSubmit(e)}
isSending={isSending}
/>
</Div>
)
}
@@ -8,7 +8,6 @@ import {
usePersistentUserChoices,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps.ts'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
@@ -18,6 +17,7 @@ import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { css } from '@/styled-system/css'
/** @public */
export type ControlBarControls = {
@@ -63,7 +63,6 @@ export function ControlBar({
variation,
saveUserChoices = true,
onDeviceError,
...props
}: ControlBarProps) {
const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext()
@@ -82,8 +81,6 @@ export function ControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
@@ -104,7 +101,23 @@ export function ControlBar({
)
return (
<div {...htmlProps}>
<div
className={css({
display: 'flex',
gap: '.5rem',
alignItems: 'center',
justifyContent: 'center',
padding: '.75rem',
borderTop: '1px solid var(--lk-border-color)',
maxHeight: 'var(--lk-control-bar-height)',
height: '80px',
position: 'absolute',
backgroundColor: '#d1d5db',
bottom: 0,
left: 0,
right: 0,
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
@@ -1,9 +1,4 @@
import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import {
isEqualTrackRef,
isTrackReference,
@@ -20,22 +15,19 @@ import {
GridLayout,
LayoutContextProvider,
RoomAudioRenderer,
MessageFormatter,
usePinnedTracks,
useTracks,
useCreateLayoutContext,
Chat,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { useSidePanel } from '../hooks/useSidePanel'
const LayoutWrapper = styled(
'div',
@@ -44,7 +36,7 @@ const LayoutWrapper = styled(
position: 'relative',
display: 'flex',
width: '100%',
height: 'calc(100% - var(--lk-control-bar-height))',
height: '100%',
},
})
)
@@ -54,9 +46,6 @@ const LayoutWrapper = styled(
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
chatMessageEncoder?: MessageEncoder
chatMessageDecoder?: MessageDecoder
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -79,17 +68,7 @@ export interface VideoConferenceProps
* ```
* @public
*/
export function VideoConference({
chatMessageFormatter,
chatMessageDecoder,
chatMessageEncoder,
...props
}: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
unreadMessages: 0,
showSettings: false,
})
export function VideoConference({ ...props }: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null)
@@ -101,11 +80,6 @@ export function VideoConference({
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state)
setWidgetState(state)
}
const layoutContext = useCreateLayoutContext()
const screenShareTracks = tracks
@@ -172,8 +146,7 @@ export function VideoConference({
])
/* eslint-enable react-hooks/exhaustive-deps */
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const { isSidePanelOpen } = useSidePanel()
return (
<div className="lk-video-conference" {...props}>
@@ -181,9 +154,17 @@ export function VideoConference({
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<div className="lk-video-conference-inner">
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? 'var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px'
: 'var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))',
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
}}
>
<LayoutWrapper>
<div
style={{ display: 'flex', position: 'relative', width: '100%' }}
@@ -193,7 +174,7 @@ export function VideoConference({
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
@@ -202,7 +183,7 @@ export function VideoConference({
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
@@ -215,18 +196,12 @@ export function VideoConference({
</FocusLayoutContainer>
</div>
)}
<MainNotificationToast />
</div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{sidePanel && <SidePanel />}
</LayoutWrapper>
<ControlBar />
<MainNotificationToast />
</div>
<ControlBar />
<SidePanel />
</LayoutContextProvider>
)}
<RoomAudioRenderer />
+17 -4
View File
@@ -22,7 +22,6 @@
"joinMeeting": "",
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
},
@@ -47,7 +46,16 @@
"stopScreenShare": "",
"chat": {
"open": "",
"closed": ""
"closed": "",
"input": {
"textArea": {
"label": "",
"placeholder": ""
},
"button": {
"label": ""
}
}
},
"hand": {
"raise": "",
@@ -88,14 +96,19 @@
"sidePanel": {
"heading": {
"participants": "",
"effects": ""
"effects": "",
"chat": ""
},
"content": {
"participants": "",
"effects": ""
"effects": "",
"chat": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"participants": {
"subheading": "",
"contributors": "",
+17 -4
View File
@@ -22,7 +22,6 @@
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
},
@@ -45,7 +44,16 @@
"camera": "Camera",
"chat": {
"open": "Close the chat",
"closed": "Open the chat"
"closed": "Open the chat",
"input": {
"textArea": {
"label": "Enter a message",
"placeholder": "Enter a message"
},
"button": {
"label": "Send message"
}
}
},
"hand": {
"raise": "Raise hand",
@@ -86,14 +94,19 @@
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effects"
"effects": "Effects",
"chat": "Messages in the chat"
},
"content": {
"participants": "participants",
"effects": "effects"
"effects": "effects",
"chat": "messages"
},
"closeButton": "Hide {{content}}"
},
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
},
"participants": {
"subheading": "In room",
"you": "You",
+17 -4
View File
@@ -22,7 +22,6 @@
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
},
@@ -45,7 +44,16 @@
"camera": "Camera",
"chat": {
"open": "Masquer le chat",
"closed": "Afficher le chat"
"closed": "Afficher le chat",
"input": {
"textArea": {
"label": "Ecrire un message",
"placeholder": "Ecrire un message"
},
"button": {
"label": "Envoyer un message"
}
}
},
"hand": {
"raise": "Lever la main",
@@ -86,14 +94,19 @@
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effets"
"effects": "Effets",
"chat": "Messages dans l'appel"
},
"content": {
"participants": "les participants",
"effects": "les effets"
"effects": "les effets",
"chat": "les messages"
},
"closeButton": "Masquer {{content}}"
},
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
},
"participants": {
"subheading": "Dans la réunion",
"you": "Vous",
+27
View File
@@ -0,0 +1,27 @@
import { TextArea as RACTextArea } from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
/**
* Styled RAC TextArea.
*/
export const TextArea = styled(RACTextArea, {
base: {
width: 'full',
paddingY: 0.25,
paddingX: 0.5,
border: '1px solid',
borderColor: 'control.border',
color: 'control.text',
borderRadius: 4,
transition: 'all 200ms',
},
variants: {
placeholderStyle: {
strong: {
_placeholder: {
color: 'black',
},
},
},
},
})
@@ -117,6 +117,9 @@ export const buttonRecipe = cva({
'&[data-pressed]': {
borderColor: 'currentcolor',
},
'&[data-disabled]': {
color: 'gray.300',
},
},
},
fullWidth: {
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
unreadMessages: number
}
export const chatStore = proxy<State>({
unreadMessages: 0,
})
+3 -2
View File
@@ -1,11 +1,12 @@
import { proxy } from 'valtio'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
type State = {
showHeader: boolean
sidePanel: 'participants' | 'effects' | null
activePanelId: PanelId | null
}
export const layoutStore = proxy<State>({
showHeader: false,
sidePanel: null,
activePanelId: null,
})
@@ -0,0 +1,43 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
egress:
log_level: debug
ws_url: ws://livekit-livekit-server:80
insecure: true
enable_chrome_sandbox: true
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
api_key: {{ $key }}
api_secret: {{ $value }}
{{- end }}
{{- end }}
redis:
address: redis-master:6379
password: pass
s3:
access_key: meet
secret: password
region: local
bucket: meet-media-storage
endpoint: http://minio:9000
force_path_style: true
loadBalancer:
type: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts:
- livekit-egress.127.0.0.1.nip.io
secretName: livekit-egress-dinum-cert
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
nodeSelector: {}
resources: {}
@@ -4,13 +4,20 @@ terminationGracePeriodSeconds: 18000
livekit:
log_level: debug
rtc:
use_external_ip: true
use_external_ip: false
port_range_start: 50000
port_range_end: 60000
tcp_port: 7881
redis:
address:
address: redis-master:6379
password: pass
keys:
turn:
enabled: true
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
loadBalancer:
type: nginx
+6 -20
View File
@@ -40,10 +40,6 @@ backend:
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
LIVEKIT_API_SECRET: {{ $value }}
@@ -51,10 +47,13 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ANALYTICS_KEY: xwhoIMCZ8PBRjQ2t
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
migrate:
@@ -102,23 +101,10 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/retrieve-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
enabled: false
@@ -85,6 +85,19 @@ backend:
name: redis.redis.libre.sh
key: url
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
key: LIVEKIT_API_SECRET
LIVEKIT_API_KEY:
secretKeyRef:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
@@ -102,20 +115,6 @@ backend:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
key: LIVEKIT_API_SECRET
LIVEKIT_API_KEY:
secretKeyRef:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
ANALYTICS_KEY: mwuxTKi8o2xzWH54
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
createsuperuser:
command:
@@ -147,24 +146,6 @@ ingressAdmin:
nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/start
nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/auth
ingressMedia:
enabled: true
host: visio.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://visio.numerique.gouv.fr/api/v1.0/recordings/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /impress-production-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.hedy-lamarr.indiehosters.net
serviceMedia:
host: s3.hedy-lamarr.indiehosters.net
port: 443
posthog:
ingress:
enabled: true
+12 -31
View File
@@ -84,6 +84,18 @@ backend:
name: redis.redis.libre.sh
key: url
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
key: LIVEKIT_API_SECRET
LIVEKIT_API_KEY:
secretKeyRef:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
@@ -101,19 +113,6 @@ backend:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
key: LIVEKIT_API_SECRET
LIVEKIT_API_KEY:
secretKeyRef:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}"
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
createsuperuser:
command:
@@ -157,24 +156,6 @@ ingressAdmin:
hosts:
- {{ .Values.newDomain }}
ingressMedia:
enabled: true
host: meet-staging.beta.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://meet-staging.beta.numerique.gouv.fr/api/v1.0/recordings/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /meet-staging-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.margaret-hamilton.indiehosters.net
serviceMedia:
host: s3.margaret-hamilton.indiehosters.net
port: 443
posthog:
ingress:
enabled: true
+8
View File
@@ -0,0 +1,8 @@
apiVersion: core.libre.sh/v1alpha1
kind: Bucket
metadata:
name: impress-media-storage
namespace: {{ .Release.Namespace | quote }}
spec:
provider: data
versioned: true
+18 -1
View File
@@ -50,7 +50,7 @@ releases:
namespace: {{ .Namespace }}
chart: bitnami/minio
version: 12.10.10
values:
values:
- auth:
rootUser: meet
rootPassword: password
@@ -58,6 +58,14 @@ releases:
enabled: true
buckets:
- name: meet-media-storage
versioning: true
- ingress:
enabled: true
hostname: minio-console.127.0.0.1.nip.io
servicePort: 9001
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
kubernetes.io/ingress.class: nginx
- name: redis
installed: {{ eq .Environment.Name "dev" | toYaml }}
@@ -99,3 +107,12 @@ releases:
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
- name: livekit-egress
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: livekit/egress
values:
- env.d/{{ .Environment.Name }}/values.egress.yaml.gotmpl
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
@@ -1,83 +0,0 @@
{{- if .Values.ingressMedia.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressMedia.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMedia.className }}
{{- end }}
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ $fullName }}-tls
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}
{{- range .Values.ingressMedia.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMedia.host }}
- host: {{ .Values.ingressMedia.host | quote }}
http:
paths:
- path: {{ .Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMedia.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
-14
View File
@@ -1,14 +0,0 @@
{{- $fullName := include "meet.fullname" . -}}
{{- $component := "media" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMedia.host }}
-30
View File
@@ -69,36 +69,6 @@ ingressAdmin:
enabled: true
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
## @param ingressMedia.path Path to use for the Ingress
ingressMedia:
enabled: false
className: null
host: meet.example.com
path: /media/(.*)
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Wether to enable TLS for the Ingress
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
additional: []
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000
annotations: {}
## @section backend