Compare commits

..

1 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM 51a1725c47 wip 2024-10-12 22:49:35 +02:00
66 changed files with 1347 additions and 1368 deletions
+3
View File
@@ -122,6 +122,9 @@ 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
+26
View File
@@ -16,6 +16,30 @@ 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: .
@@ -40,6 +64,7 @@ services:
- mailcatcher
- redis
- nginx
- createbuckets
- livekit
celery-dev:
@@ -73,6 +98,7 @@ services:
depends_on:
- postgresql
- redis
- createbuckets
- livekit
celery:
+3
View File
@@ -18,6 +18,9 @@ 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
Submodule secrets updated: 2ef2610071...8ef9f4513a
+1 -1
View File
@@ -450,7 +450,7 @@ max-branches=12
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
-316
View File
@@ -1,316 +0,0 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from minio import Minio
from django.conf import settings
import openai
import logging
from ..models import Room, RoleChoices
import tempfile
import os
import smtplib
import requests
logger = logging.getLogger(__name__)
def get_prompt(transcript, date):
return f"""
Audience: Coworkers.
**Do:**
- Detect the language of the transcript and provide your entire response in the same language.
- If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification.
- Ensure the accuracy of all information and refrain from adding unverified details.
- Format the response using proper markdown and structured sections.
- Be concise and avoid repeating yourself between the sections.
- Be super precise on nickname
- Be a nit-picker
- Auto-evaluate your response
**Don't:**
- Write something your are not sure.
- Write something that is not mention in the transcript.
- Don't make mistake while mentioning someone
**Task:**
Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details:
1. **Summary**: Write a TL;DR of the meeting.
2. **Subjects Discussed**: List the key points or issues in bullet points.
4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated.
**Transcript**:
{transcript}
**Response:**
### Summary [Translate this title based on the transcripts language]
[Provide a brief overview of the key points discussed]
### Subjects Discussed [Translate this title based on the transcripts language]
- [Summarize each topic concisely]
### Next Steps [Translate this title based on the transcripts language]
- [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.]
"""
def get_room_and_owners(slug):
"""Wip."""
try:
room = Room.objects.get(slug=slug)
owner_accesses = room.accesses.filter(role=RoleChoices.OWNER)
owners = [access.user for access in owner_accesses]
logger.info("Room %s has owners: %s", slug, owners)
except Room.DoesNotExist:
logger.error("Room with slug %s does not exist", slug)
owners = None
room = None
return room, owners
def remove_temporary_file(path):
"""Wip."""
if not path or not os.path.exists(path):
return
os.remove(path)
logger.info("Temporary file %s has been deleted.", path)
def get_blocknote_content(summary):
"""Wip."""
if not settings.BLOCKNOTE_CONVERTER_URL:
logger.error("BLOCKNOTE_CONVERTER_URL is not configured")
return None
headers = {
"Content-Type": "application/json"
}
data = {
"markdown": summary
}
logger.info("Converting summary in BlockNote.js…")
response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data)
if response.status_code != 200:
logger.error(f"Failed to convert summary. Status code: {response.status_code}")
return None
response_data = response.json()
if not 'content' in response_data:
logger.error(f"Content is missing: %s", response_data)
return None
content = response_data['content']
logger.info("Base64 content: %s", content)
return content
def get_document_link(content, email):
"""Wip."""
logger.info("Create a document for %s", email)
if not settings.DOCS_BASE_URL:
logger.error("DOCS_BASE_URL is not configured")
return None
headers = {
"Content-Type": "application/json"
}
data = {
"content": content,
"owner": email
}
logger.info("Querying docs…")
response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data)
if response.status_code != 200:
logger.error(f"Failed to get document's id. Status code: {response.status_code}")
return None
response_data = response.json()
if not 'id' in response_data:
logger.error(f"ID is missing: %s", response_data)
return None
id = response_data['id']
logger.info("Document's id: %s", id)
return f"{settings.DOCS_BASE_URL}/docs/{id}/"
def email_owner_with_summary(room, link, owner):
"""Wip."""
logger.info("Emailing owner: %s", owner)
try:
room.email_summary(owners=[owner], link=link)
except smtplib.SMTPException:
logger.error("Error while emailing owner")
def strip_room_slug(filename):
"""Wip."""
return filename.split("_")[2].split(".")[0]
def strip_room_date(filename):
"""Wip."""
return filename.split("_")[1].split(".")[0]
def get_minio_client():
"""Wip."""
try:
return Minio(
settings.MINIO_URL,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
)
except Exception as e:
logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e))
def download_temporary_file(minio_client, filename):
"""Wip."""
temp_file_path = None
logger.info('downloading file %s', filename)
try:
audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename)
with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file:
for data in audio_file_stream.stream(32 * 1024):
temp_audio_file.write(data)
temp_file_path = temp_audio_file.name
logger.info('Temporary file created at %s', temp_file_path)
audio_file_stream.close()
audio_file_stream.release_conn()
except Exception as e:
logger.error("An error occurred while accessing the object: %s", str(e))
return temp_file_path
# todo - discuss retry policy if the webhook fail
@api_view(["POST"])
def minio_webhook(request):
data = request.data
logger.info('Minio webhook sent %s', data)
record = data["Records"][0]
s3 = record['s3']
bucket = s3['bucket']
bucket_name = bucket['name']
object = s3['object']
filename = object['key']
if bucket_name != settings.MINIO_BUCKET:
logger.info('Not interested in this bucket: %s', bucket_name)
return Response("Not interested in this bucket")
if object['contentType'] != 'audio/ogg':
logger.info('Not interested in this file type: %s', object['contentType'])
return Response("Not interested in this file type")
room_slug = strip_room_slug(filename)
room_date = strip_room_date(filename)
logger.info('file received %s for room %s', filename, room_slug)
minio_client = get_minio_client()
temp_file_path = None
summary = None
try:
temp_file_path = download_temporary_file(minio_client, filename)
if settings.OPENAI_ENABLE and temp_file_path:
logger.info('Initiating OpenAI client …')
openai_client = openai.OpenAI(
api_key=settings.OPENAI_API_KEY,
)
with open(temp_file_path, "rb") as audio_file:
logger.info('Querying transcription …')
transcript = openai_client.audio.transcriptions.create(
model="whisper-1",
file=audio_file
)
logger.info('Transcript: \n %s', transcript)
prompt = get_prompt(transcript.text, room_date)
logger.info('Prompt: \n %s', prompt)
summary_response = openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."},
{"role": "user", "content": prompt}
],
)
summary = summary_response.choices[0].message.content
logger.info('Summary: \n %s', summary)
except Exception as e:
logger.error("An error occurred: %s", str(e))
raise
finally:
remove_temporary_file(temp_file_path)
if not summary:
logger.error("Empty summary.")
return Response("")
room, owners = get_room_and_owners(room_slug)
if not owners or not room:
logger.error("No owners in room %s", room_slug)
return Response("")
content = get_blocknote_content(summary)
if not content:
logger.error("Empty content.")
return Response("")
owner = owners[0]
link = get_document_link(content, owner.email)
if not link:
logger.error("Empty link.")
return Response("")
email_owner_with_summary(room, link, owner)
return Response("")
+29 -18
View File
@@ -39,30 +39,30 @@ class IsSelf(IsAuthenticated):
return obj == request.user
class RoomPermissions(permissions.BasePermission):
"""
Permissions applying to the room API endpoint.
"""
# 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
# 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
# return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room."""
# 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
# if request.method in permissions.SAFE_METHODS:
# return True
user = request.user
# user = request.user
if request.method == "DELETE":
return obj.is_owner(user)
# if request.method == "DELETE":
# return obj.is_owner(user)
return obj.is_administrator(user)
# return obj.is_administrator(user)
class ResourceAccessPermission(permissions.BasePermission):
@@ -82,4 +82,15 @@ class ResourceAccessPermission(permissions.BasePermission):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return obj.resource.is_administrator(user)
return RoleChoices.is_administrator(obj.resource.get_roles(user))
class AccessPermission(permissions.BasePermission):
"""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)
+26 -6
View File
@@ -87,6 +87,15 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
user = UserSerializer(read_only=True)
class ListRoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for a list API endpoint."""
class Meta:
model = models.Room
fields = ["id", "name", "slug", "is_public"]
read_only_fields = ["id", "slug"]
class RoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for the API."""
@@ -106,10 +115,10 @@ class RoomSerializer(serializers.ModelSerializer):
if not request:
return output
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
roles = instance.get_roles(request.user)
is_administrator = models.RoleChoices.is_administrator(roles)
if role is not None:
if roles:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -117,10 +126,10 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
if not is_admin:
if not is_administrator:
del output["configuration"]
if role is not None or instance.is_public:
if roles or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.query_params.get("username", None)
@@ -133,6 +142,17 @@ class RoomSerializer(serializers.ModelSerializer):
),
}
output["is_administrable"] = is_admin
output["is_administrable"] = is_administrator
return output
class RecordingSerializer(serializers.ModelSerializer):
"""Serialize Recording for the API."""
room = ListRoomSerializer(read_only=True)
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "stopped_at", "status"]
read_only_fields = fields
+33
View File
@@ -0,0 +1,33 @@
"""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
+123 -8
View File
@@ -1,17 +1,22 @@
"""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,6 +30,13 @@ 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):
"""
@@ -162,7 +174,7 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
permission_classes = [permissions.RoomPermissions]
permission_classes = [permissions.AccessPermission]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -186,16 +198,10 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
slug = slugify(self.kwargs["pk"])
username = request.query_params.get("username", None)
data = {
@@ -209,6 +215,11 @@ 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)
@@ -249,6 +260,17 @@ class RoomViewSet(
},
)
@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."""
@@ -293,3 +315,96 @@ class ResourceAccessViewSet(
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
API endpoints to access and perform actions on recordings.
"""
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
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())
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)
+9
View File
@@ -65,3 +65,12 @@ class RoomFactory(ResourceFactory):
name = factory.Faker("catch_phrase")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
class RecordingFactory(factory.django.DjangoModelFactory):
"""Create fake recordings for testing."""
class Meta:
model = models.Recording
room = factory.SubFactory(RoomFactory)
@@ -0,0 +1,37 @@
# 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',),
},
),
]
+78 -45
View File
@@ -15,8 +15,6 @@ from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from timezone_field import TimeZoneField
logger = getLogger(__name__)
@@ -30,14 +28,16 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
@classmethod
def check_administrator_role(cls, role):
def is_administrator(cls, roles):
"""Check if a role is administrator."""
return role in [cls.ADMIN, cls.OWNER]
return bool(set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}))
@classmethod
def check_owner_role(cls, role):
"""Check if a role is owner."""
return role == cls.OWNER
class RecordingStatusChoices(models.TextChoices):
"""Recording status choices."""
RECORDING = "recording", _("Recording")
DONE = "done", _("Done")
class BaseModel(models.Model):
@@ -195,34 +195,44 @@ 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.check_administrator_role(self.get_role(user))
return RoleChoices.is_administrator(self.get_roles(user))
def is_owner(self, user):
"""Check if a user is owner of the resource."""
return RoleChoices.check_owner_role(self.get_role(user))
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,
}
class ResourceAccess(BaseModel):
@@ -328,23 +338,46 @@ 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)
def email_summary(self, owners, link):
"""Wip"""
template_vars = {
"title": "Votre résumé est prêt",
"link": link,
"room": self.slug,
class Recording(BaseModel):
"""Model for recording meetings that take place in a room"""
room = models.ForeignKey(
Room, on_delete=models.CASCADE, related_name="meetings", verbose_name=_("Room")
)
stopped_at = models.DateTimeField(verbose_name=_("End Time"), null=True, blank=True)
status = models.CharField(
choices=RecordingStatusChoices, default=RecordingStatusChoices.RECORDING
)
class Meta:
db_table = "meet_recording"
ordering = ("created_at",)
verbose_name = _("Recording")
verbose_name_plural = _("Recordings")
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/"
def get_abilities(self, user):
"""Compute and return abilities for a given user on the recording."""
roles = self.room.get_roles(user)
return {
"destroy": RoleChoices.OWNER in roles,
"partial_update": False,
"retrieve": False,
"stop": True,
"update": False,
}
msg_html = render_to_string("mail/html/summary.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
for owner in owners:
owner.email_user(
subject="Votre résumé est prêt",
from_email=settings.EMAIL_FROM,
message=msg_plain,
html_message=msg_html,
fail_silently=False,
)
@@ -0,0 +1,102 @@
"""
Test recordings API endpoints in the Meet core app: delete.
"""
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
def test_api_recordings_delete_anonymous():
"""Anonymous users should not be allowed to destroy a recording."""
recording = RecordingFactory()
client = APIClient()
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 401
assert Recording.objects.count() == 1
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.
"""
recording = RecordingFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 403
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.
"""
user = UserFactory()
recording = RecordingFactory(
room__users=[(user, "member")]
) # as user declared in the room but not administrator
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_administrators():
"""
Authenticated users should not be allowed to delete a recording from a room in which
they are administrator.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "administrator")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_owners():
"""
Authenticated users should be able to delete a recording from a room in which they are
directly owner.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 204
assert Recording.objects.exists() is False
@@ -0,0 +1,132 @@
"""
Test recordings API endpoints in the Meet core app: list.
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories
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)
response = APIClient().get("/api/v1.0/recordings/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_recordings_list_authenticated():
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
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])
recording = factories.RecordingFactory(room__is_public=False, room__users=[user])
room = recording.room
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(recording.id),
}
result_ids = {result["id"] for result in results}
assert expected_ids == result_ids
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",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
}
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_recordings_list_pagination(_mock_page_size):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recordings = factories.RecordingFactory.create_batch(3, room__users=[user])
recording_ids = [str(r.id) for r in recordings]
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
recording_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/recordings/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"], "http://testserver/api/v1.0/recordings/"
assert len(content["results"]) == 1
recording_ids.remove(content["results"][0]["id"])
assert recording_ids == []
def test_api_recordings_list_authenticated_distinct():
"""A recording for a public 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]
)
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(recording.id)
@@ -0,0 +1,128 @@
"""
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
@@ -0,0 +1,56 @@
"""
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(4):
with django_assert_num_queries(5):
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(4):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -0,0 +1,88 @@
"""
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
+21 -36
View File
@@ -86,81 +86,66 @@ 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):
"""Calling access rights methods with None should return None."""
"""The `get_roles` method should return an empty list when called with None."""
room = RoomFactory()
with django_assert_num_queries(0):
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
assert not list(room.get_roles(None))
def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
"""Check access rights methods on the room object for an anonymous user."""
"""The `get_roles` method should return an empty list for an anonymous user."""
user = AnonymousUser()
room = RoomFactory()
with django_assert_num_queries(0):
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
assert not list(room.get_roles(user))
def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
"""Check access rights methods on the room object for an unrelated user."""
"""
The `get_roles` method should return an empty list for a user not related to the room.
"""
user = UserFactory()
room = RoomFactory()
with django_assert_num_queries(1):
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
assert not list(room.get_roles(user))
def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
"""Check access rights methods on the room object for a direct member."""
"""Check `get_roles` method on the room object for a direct member."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
with django_assert_num_queries(1):
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
assert list(room.get_roles(user)) == ["member"]
def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries):
"""The is_administrator method should return True for a direct administrator."""
"""Check `get_roles` method on the room object for a direct administrator."""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
with django_assert_num_queries(1):
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
assert list(room.get_roles(user)) == ["administrator"]
def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
"""Check access rights methods on the room object for an owner."""
"""Check `get_roles` method on the room object for an owner."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
with django_assert_num_queries(1):
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
assert list(room.get_roles(user)) == ["owner"]
+2 -2
View File
@@ -5,12 +5,13 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets, demo
from core.api import get_frontend_configuration, viewsets
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(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
@@ -24,7 +25,6 @@ urlpatterns = [
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path("minio-webhook/", demo.minio_webhook, name="demo"),
]
),
),
+30 -1
View File
@@ -11,7 +11,9 @@ 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
@@ -53,7 +55,6 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
room=room,
room_join=True,
room_admin=True,
room_record=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
@@ -82,3 +83,31 @@ 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.
+19 -61
View File
@@ -119,6 +119,25 @@ class Base(Configuration):
},
}
# Media
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
)
AWS_STORAGE_BUCKET_NAME = values.Value(
"meet-media-storage",
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -386,53 +405,6 @@ class Base(Configuration):
None, environ_name="ANALYTICS_KEY", environ_prefix=None
)
# todo - totally wip
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
)
AWS_STORAGE_BUCKET_NAME = values.Value(
"meet-media-storage",
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
OPENAI_API_KEY = values.Value(
None, environ_name="OPENAI_API_KEY", environ_prefix=None
)
OPENAI_ENABLE = values.BooleanValue(
True, environ_name="OPENAI_ENABLE", environ_prefix=None
)
# todo - totally wip
MINIO_ACCESS_KEY = values.Value(
None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None
)
MINIO_SECRET_KEY = values.Value(
None, environ_name="MINIO_SECRET_KEY", environ_prefix=None
)
MINIO_URL = values.Value(
None, environ_name="MINIO_URL", environ_prefix=None
)
MINIO_BUCKET = values.Value(
'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None
)
BLOCKNOTE_CONVERTER_URL = values.Value(
'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None
)
DOCS_BASE_URL = values.Value(
'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -576,20 +548,6 @@ class Production(Base):
ALLOWED_HOSTS=["foo.com", "foo.fr"]
"""
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
# Security
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
+1 -6
View File
@@ -1,7 +1,6 @@
"""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
@@ -18,11 +17,7 @@ urlpatterns = [
]
if settings.DEBUG:
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
)
urlpatterns = urlpatterns + staticfiles_urlpatterns()
if settings.USE_SWAGGER or settings.DEBUG:
+5 -7
View File
@@ -25,14 +25,14 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.19",
"boto3==1.35.34",
"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.0.8",
"redis==5.1.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
@@ -42,24 +42,21 @@ dependencies = [
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.2",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.14.0",
"sentry-sdk==2.15.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.7.0",
"minio==7.2.9",
"openai==1.51.2"
]
[project.urls]
@@ -72,6 +69,7 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.27.0",
"pyfakefs==5.6.0",
@@ -34,19 +34,19 @@ export const MainNotificationToast = () => {
}, [room, triggerNotificationSound])
useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => {
if (toast.content.participant === participant) {
toastQueue.close(toast.key)
}
})
}
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
return () => {
room.off(
RoomEvent.ParticipantDisconnected,
removeParticipantNotifications
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)
}
}
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
return () => {
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
}
}, [room])
@@ -105,7 +105,7 @@ export const MainNotificationToast = () => {
}, [room])
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<Div position="absolute" bottom={20} 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 { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
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 } = useSidePanel()
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
return (
<StyledToastContainer {...toastProps} ref={ref}>
@@ -1,7 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import {
formatChatMessageLinks,
LiveKitRoom,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -100,7 +104,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference />
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
@@ -1,53 +0,0 @@
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingIndicator = () => {
const room = useRoomContext()
const [isRecording, setIsRecording] = useState(room.isRecording)
const egressSnap = useSnapshot(egressStore)
const egressIsStopping = egressSnap.egressIsStopping
useEffect(() => {
const handleRecordingStatusChanges = (isRecording: boolean) => {
if (!isRecording) {
egressStore.egressIsStopping = false
}
setIsRecording(isRecording)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
}
}, [room])
const getStatus = () => {
if (egressIsStopping) {
return 'saving recording'
}
if (isRecording) {
return 'recording'
}
if (!isRecording) {
return 'available'
}
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
}}
>
Room status: {getStatus()}
</div>
)
}
@@ -1,56 +0,0 @@
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useParams } from 'wouter'
export const useRecordRoom = () => {
const data = useRoomData()
const { roomId: roomSlug } = useParams()
const recordRoom = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
if (!roomSlug) {
throw new Error('Room ID is not available')
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'/twirp/livekit.Egress/StartRoomCompositeEgress'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room_name: data.livekit.room,
audio_only: true,
file_outputs: [
{
file_extension: 'ogg',
filepath: `{room_name}_{time}_${roomSlug}`,
},
],
}),
}
)
}
const stopRecordingRoom = (egressId: string) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
return fetchServerApi(
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
egressId,
}),
}
)
}
return { recordRoom, stopRecordingRoom }
}
@@ -94,7 +94,7 @@ export const Effects = () => {
}
return (
<VStack padding="0 1.5rem" overflowY="scroll">
<VStack padding="0 1.5rem">
{localCameraTrack && isCameraEnabled ? (
<video
ref={videoRef}
@@ -1,3 +1,4 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
@@ -6,16 +7,14 @@ import { Box, Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
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
}
@@ -23,11 +22,11 @@ const StyledSidePanel = ({
title,
children,
onClose,
isClosed,
closeButtonTooltip,
}: StyledSidePanelProps) => (
<Box
size="sm"
minWidth="360px"
className={css({
overflow: 'hidden',
display: 'flex',
@@ -35,16 +34,7 @@ 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"
@@ -53,19 +43,11 @@ const StyledSidePanel = ({
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : undefined,
}}
>
{title}
</Heading>
<Div
position="absolute"
top="5"
right="5"
style={{
display: isClosed ? 'none' : undefined,
}}
>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
@@ -76,56 +58,31 @@ const StyledSidePanel = ({
<RiCloseLine />
</Button>
</Div>
{children}
<Div overflowY="scroll">{children}</Div>
</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 {
activePanelId,
isParticipantsOpen,
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
} = useSidePanel()
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
if (!sidePanel) {
return
}
return (
<StyledSidePanel
title={t(`heading.${activePanelId}`)}
onClose={() => (layoutStore.activePanelId = null)}
title={t(`heading.${sidePanel}`)}
onClose={() => (layoutStore.sidePanel = null)}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activePanelId}`),
content: t(`content.${sidePanel}`),
})}
isClosed={!isSidePanelOpen}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
{isParticipantsOpen && <ParticipantsList />}
{isEffectsOpen && <Effects />}
</StyledSidePanel>
)
}
@@ -1,71 +0,0 @@
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>
)
})
@@ -1,120 +0,0 @@
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,17 +1,13 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const chatSnap = useSnapshot(chatStore)
const { isChatOpen, toggleChat } = useSidePanel()
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
return (
@@ -32,7 +28,7 @@ export const ChatToggle = () => {
>
<RiChat1Line />
</ToggleButton>
{!!chatSnap.unreadMessages && (
{!!unreadMessages && (
<div
className={css({
position: 'absolute',
@@ -1,7 +1,7 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiFeedbackLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
@@ -19,7 +18,7 @@ export const OptionsMenuItems = ({
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { toggleEffects } = useWidgetInteraction()
return (
<RACMenu
style={{
@@ -35,7 +34,6 @@ export const OptionsMenuItems = ({
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<RecordingMenuItem />
</Section>
<Separator />
<Section>
@@ -44,7 +42,7 @@ export const OptionsMenuItems = ({
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiMegaphoneLine size={20} />
<RiFeedbackLine size={20} />
{t('feedbacks')}
</MenuItem>
<MenuItem
@@ -1,52 +0,0 @@
import { MenuItem } from 'react-aria-components'
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
import { useState } from 'react'
import { egressStore } from '@/stores/egress.ts'
import { useSnapshot } from 'valtio'
export const RecordingMenuItem = () => {
const { recordRoom, stopRecordingRoom } = useRecordRoom()
const egressSnap = useSnapshot(egressStore)
const egressId = egressSnap.egressId
const [isPending, setIsPending] = useState(false)
const handleAction = async () => {
if (egressId) {
setIsPending(true)
egressStore.egressIsStopping = true
const response = await stopRecordingRoom(egressId)
console.log(response)
egressStore.egressId = undefined
setIsPending(false)
} else {
setIsPending(true)
const response = await recordRoom()
egressStore.egressId = response['egress_id'] as string
setIsPending(false)
}
}
return (
<MenuItem
isDisabled={isPending}
className={menuItemRecipe({ icon: true })}
onAction={handleAction}
>
{egressId ? (
<>
<RiPauseCircleLine size={18} />
Arrêter l'enregistrement
</>
) : (
<>
<RiRecordCircleLine size={18} />
Enregistrer la réunion
</>
)}
</MenuItem>
)
}
@@ -1,4 +1,5 @@
import { css } from '@/styled-system/css'
import { allParticipantRoomEvents } from '@livekit/components-core'
import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives'
@@ -7,6 +8,7 @@ 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 = () => {
@@ -15,7 +17,12 @@ 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()
const participants = useParticipants({
updateOnlyOn: [
RoomEvent.ParticipantNameChanged,
...allParticipantRoomEvents,
],
})
const sortedRemoteParticipants = participants
.slice(1)
@@ -37,7 +44,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({
@@ -71,6 +78,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 { useSidePanel } from '../../../hooks/useSidePanel'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
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 } = useSidePanel()
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -1,41 +0,0 @@
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,
}
}
@@ -0,0 +1,46 @@
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,
}
}
@@ -1,134 +0,0 @@
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,6 +8,7 @@ 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'
@@ -17,7 +18,6 @@ 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,6 +63,7 @@ export function ControlBar({
variation,
saveUserChoices = true,
onDeviceError,
...props
}: ControlBarProps) {
const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext()
@@ -81,6 +82,8 @@ export function ControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
@@ -101,23 +104,7 @@ export function ControlBar({
)
return (
<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,
})}
>
<div {...htmlProps}>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
@@ -1,4 +1,9 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import {
isEqualTrackRef,
isTrackReference,
@@ -15,20 +20,22 @@ 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 { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
const LayoutWrapper = styled(
'div',
@@ -37,7 +44,7 @@ const LayoutWrapper = styled(
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
height: 'calc(100% - var(--lk-control-bar-height))',
},
})
)
@@ -47,6 +54,9 @@ const LayoutWrapper = styled(
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
chatMessageEncoder?: MessageEncoder
chatMessageDecoder?: MessageDecoder
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -69,7 +79,17 @@ export interface VideoConferenceProps
* ```
* @public
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
export function VideoConference({
chatMessageFormatter,
chatMessageDecoder,
chatMessageEncoder,
...props
}: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
unreadMessages: 0,
showSettings: false,
})
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null)
@@ -81,6 +101,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state)
setWidgetState(state)
}
const layoutContext = useCreateLayoutContext()
const screenShareTracks = tracks
@@ -147,7 +172,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
])
/* eslint-enable react-hooks/exhaustive-deps */
const { isSidePanelOpen } = useSidePanel()
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
return (
<div className="lk-video-conference" {...props}>
@@ -155,18 +181,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<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',
}}
>
<RecordingIndicator />
<div className="lk-video-conference-inner">
<LayoutWrapper>
<div
style={{ display: 'flex', position: 'relative', width: '100%' }}
@@ -176,7 +193,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
@@ -185,7 +202,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<FocusLayoutContainer>
<CarouselLayout
tracks={carouselTracks}
style={{
@@ -198,12 +215,18 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</FocusLayoutContainer>
</div>
)}
<MainNotificationToast />
</div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{sidePanel && <SidePanel />}
</LayoutWrapper>
<MainNotificationToast />
<ControlBar />
</div>
<ControlBar />
<SidePanel />
</LayoutContextProvider>
)}
<RoomAudioRenderer />
+3 -17
View File
@@ -47,16 +47,7 @@
"stopScreenShare": "",
"chat": {
"open": "",
"closed": "",
"input": {
"textArea": {
"label": "",
"placeholder": ""
},
"button": {
"label": ""
}
}
"closed": ""
},
"hand": {
"raise": "",
@@ -97,19 +88,14 @@
"sidePanel": {
"heading": {
"participants": "",
"effects": "",
"chat": ""
"effects": ""
},
"content": {
"participants": "",
"effects": "",
"chat": ""
"effects": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"participants": {
"subheading": "",
"contributors": "",
+3 -17
View File
@@ -45,16 +45,7 @@
"camera": "Camera",
"chat": {
"open": "Close the chat",
"closed": "Open the chat",
"input": {
"textArea": {
"label": "Enter a message",
"placeholder": "Enter a message"
},
"button": {
"label": "Send message"
}
}
"closed": "Open the chat"
},
"hand": {
"raise": "Raise hand",
@@ -95,19 +86,14 @@
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effects",
"chat": "Messages in the chat"
"effects": "Effects"
},
"content": {
"participants": "participants",
"effects": "effects",
"chat": "messages"
"effects": "effects"
},
"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",
+3 -17
View File
@@ -45,16 +45,7 @@
"camera": "Camera",
"chat": {
"open": "Masquer le chat",
"closed": "Afficher le chat",
"input": {
"textArea": {
"label": "Ecrire un message",
"placeholder": "Ecrire un message"
},
"button": {
"label": "Envoyer un message"
}
}
"closed": "Afficher le chat"
},
"hand": {
"raise": "Lever la main",
@@ -95,19 +86,14 @@
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effets",
"chat": "Messages dans l'appel"
"effects": "Effets"
},
"content": {
"participants": "les participants",
"effects": "les effets",
"chat": "les messages"
"effects": "les effets"
},
"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
@@ -1,27 +0,0 @@
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,9 +117,6 @@ export const buttonRecipe = cva({
'&[data-pressed]': {
borderColor: 'currentcolor',
},
'&[data-disabled]': {
color: 'gray.300',
},
},
},
fullWidth: {
-9
View File
@@ -1,9 +0,0 @@
import { proxy } from 'valtio'
type State = {
unreadMessages: number
}
export const chatStore = proxy<State>({
unreadMessages: 0,
})
-11
View File
@@ -1,11 +0,0 @@
import { proxy } from 'valtio'
type State = {
egressId: string | undefined
egressIsStopping: boolean
}
export const egressStore = proxy<State>({
egressId: undefined,
egressIsStopping: false,
})
+2 -3
View File
@@ -1,12 +1,11 @@
import { proxy } from 'valtio'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
type State = {
showHeader: boolean
activePanelId: PanelId | null
sidePanel: 'participants' | 'effects' | null
}
export const layoutStore = proxy<State>({
showHeader: false,
activePanelId: null,
sidePanel: null,
})
+19 -2
View File
@@ -40,6 +40,10 @@ 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 }}
@@ -98,10 +102,23 @@ 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,23 @@ backend:
name: redis.redis.libre.sh
key: url
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
@@ -99,23 +116,6 @@ backend:
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: impress-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: impress-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
createsuperuser:
command:
@@ -147,6 +147,24 @@ 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
+29 -29
View File
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v-hackathon"
tag: "main"
backend:
migrateJobAnnotations:
@@ -84,19 +84,6 @@ 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
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'}"
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
@@ -114,24 +101,19 @@ backend:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
key: OPENAI_API_KEY
OPENAI_ENABLE: True
MINIO_ACCESS_KEY:
key: LIVEKIT_API_SECRET
LIVEKIT_API_KEY:
secretKeyRef:
name: backend
key: MINIO_ACCESS_KEY
MINIO_SECRET_KEY:
secretKeyRef:
name: backend
key: MINIO_SECRET_KEY
MINIO_URL:
secretKeyRef:
name: backend
key: MINIO_URL
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:
@@ -145,7 +127,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v-hackathon"
tag: "main"
ingress:
enabled: true
@@ -175,6 +157,24 @@ 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
@@ -1,8 +0,0 @@
apiVersion: core.libre.sh/v1alpha1
kind: Bucket
metadata:
name: meet-media-storage
namespace: {{ .Release.Namespace | quote }}
spec:
provider: data
versioned: true
+14
View File
@@ -45,6 +45,20 @@ releases:
enabled: true
autoGenerated: true
- name: minio
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: bitnami/minio
version: 12.10.10
values:
- auth:
rootUser: meet
rootPassword: password
- provisioning:
enabled: true
buckets:
- name: meet-media-storage
- name: redis
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
@@ -0,0 +1,83 @@
{{- 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
@@ -0,0 +1,14 @@
{{- $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 }}
-13
View File
@@ -15,16 +15,3 @@ stringData:
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }}
LIVEKIT_API_KEY: {{ .Values.livekitApi.key }}
{{- if .Values.openaiApiKey }}
OPENAI_API_KEY: {{ .Values.openaiApiKey }}
{{- end }}
{{- if .Values.minioAccessKey }}
MINIO_ACCESS_KEY: {{ .Values.minioAccessKey }}
{{- end }}
{{- if .Values.minioSecretKey }}
MINIO_SECRET_KEY: {{ .Values.minioSecretKey }}
{{- end }}
{{- if .Values.minioUrl }}
MINIO_URL: {{ .Values.minioUrl }}
{{- end }}
+30
View File
@@ -69,6 +69,36 @@ 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
-19
View File
@@ -1,19 +0,0 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text font-size="14px">
<p>Cher utilisateur,</p>
<p>La réunion <strong>{{room}}</strong> a été transcrite et résumée avec succès.</p>
</mj-text>
<mj-button href="{{link}}" font-size="14px" background-color="#346DB7" color="white">
Obtenez votre résumé
</mj-button>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>