Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 808fc7a00f | |||
| c8b4592d88 | |||
| db071f0dcc | |||
| b1b28fe330 | |||
| 98950f43af | |||
| b43f9922b6 | |||
| d49dc72849 | |||
| 82f0251101 | |||
| 2f675c2a3f | |||
| 7105c7cbae | |||
| 376ff8982c | |||
| a4820ae867 | |||
| 2a56cda55c | |||
| 817cf25b37 | |||
| fdaf567f5d | |||
| 0cf5ab1825 | |||
| c9c5d3b452 | |||
| f0b250739c | |||
| 41c9693d10 | |||
| 83cfcacc0e | |||
| e8618099ac | |||
| ac183c9eb9 | |||
| 3dcc93b630 | |||
| 3614b1e803 | |||
| 5e57647b34 | |||
| e271c87a20 | |||
| 40d1f01277 | |||
| 8477296471 | |||
| d200eeb6bd | |||
| 7a51b09664 | |||
| ec22abf82b | |||
| cde4b10794 | |||
| a47f1f92c4 | |||
| 0db36c788b | |||
| a84b76170d | |||
| 998382020d | |||
| 2a12715673 | |||
| 54d4330a97 | |||
| 5dcbe56e56 | |||
| 1a52221ef2 | |||
| 2f3e64b389 | |||
| 2dcaf814e1 | |||
| 583f5b8e70 | |||
| fe8fd36467 | |||
| 0370d9cad0 | |||
| 0da8aa846a | |||
| 70ed99b6c9 |
@@ -122,9 +122,6 @@ jobs:
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
|
||||
|
||||
@@ -16,30 +16,6 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -64,7 +40,6 @@ services:
|
||||
- mailcatcher
|
||||
- redis
|
||||
- nginx
|
||||
- createbuckets
|
||||
- livekit
|
||||
|
||||
celery-dev:
|
||||
@@ -98,7 +73,6 @@ services:
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- createbuckets
|
||||
- livekit
|
||||
|
||||
celery:
|
||||
|
||||
@@ -18,9 +18,6 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
|
||||
+1
-1
Submodule secrets updated: 8ef9f4513a...2ef2610071
@@ -450,7 +450,7 @@ max-branches=12
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=10
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
|
||||
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 transcript’s language]
|
||||
[Provide a brief overview of the key points discussed]
|
||||
|
||||
### Subjects Discussed [Translate this title based on the transcript’s language]
|
||||
- [Summarize each topic concisely]
|
||||
|
||||
### Next Steps [Translate this title based on the transcript’s 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("")
|
||||
@@ -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,15 +82,4 @@ class ResourceAccessPermission(permissions.BasePermission):
|
||||
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
|
||||
return obj.user == user
|
||||
|
||||
return RoleChoices.is_administrator(obj.resource.get_roles(user))
|
||||
|
||||
|
||||
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)
|
||||
return obj.resource.is_administrator(user)
|
||||
|
||||
@@ -87,15 +87,6 @@ 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."""
|
||||
|
||||
@@ -115,10 +106,10 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if not request:
|
||||
return output
|
||||
|
||||
roles = instance.get_roles(request.user)
|
||||
is_administrator = models.RoleChoices.is_administrator(roles)
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
|
||||
if roles:
|
||||
if role is not None:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
@@ -126,10 +117,10 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
if not is_administrator:
|
||||
if not is_admin:
|
||||
del output["configuration"]
|
||||
|
||||
if roles or instance.is_public:
|
||||
if role is not None or instance.is_public:
|
||||
slug = f"{instance.id!s}".replace("-", "")
|
||||
|
||||
username = request.query_params.get("username", None)
|
||||
@@ -142,17 +133,6 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
),
|
||||
}
|
||||
|
||||
output["is_administrable"] = is_administrator
|
||||
output["is_administrable"] = is_admin
|
||||
|
||||
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
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Util to generate S3 authorization headers for object storage access control"""
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our documents' content
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
@@ -1,22 +1,17 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
mixins,
|
||||
pagination,
|
||||
status,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
@@ -30,13 +25,6 @@ from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
RECORDING_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}recordings/({UUID_REGEX:s})/file.mp4/$"
|
||||
)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
@@ -174,7 +162,7 @@ class RoomViewSet(
|
||||
API endpoints to access and perform actions on rooms.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AccessPermission]
|
||||
permission_classes = [permissions.RoomPermissions]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
@@ -198,10 +186,16 @@ 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 = {
|
||||
@@ -215,11 +209,6 @@ class RoomViewSet(
|
||||
},
|
||||
}
|
||||
else:
|
||||
analytics.track(
|
||||
user=self.request.user,
|
||||
event="Get Room",
|
||||
properties={"slug": instance.slug},
|
||||
)
|
||||
data = self.get_serializer(instance).data
|
||||
|
||||
return drf_response.Response(data)
|
||||
@@ -260,17 +249,6 @@ 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."""
|
||||
@@ -315,96 +293,3 @@ 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)
|
||||
|
||||
@@ -65,12 +65,3 @@ 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)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-12 11:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_alter_user_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Recording',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('stopped_at', models.DateTimeField(blank=True, null=True, verbose_name='End Time')),
|
||||
('status', models.CharField(choices=[('recording', 'Recording'), ('done', 'Done')], default='recording')),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='core.room', verbose_name='Room')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recording',
|
||||
'verbose_name_plural': 'Recordings',
|
||||
'db_table': 'meet_recording',
|
||||
'ordering': ('created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
+45
-78
@@ -15,6 +15,8 @@ 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__)
|
||||
@@ -28,16 +30,14 @@ class RoleChoices(models.TextChoices):
|
||||
OWNER = "owner", _("Owner")
|
||||
|
||||
@classmethod
|
||||
def is_administrator(cls, roles):
|
||||
def check_administrator_role(cls, role):
|
||||
"""Check if a role is administrator."""
|
||||
return bool(set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}))
|
||||
return role in [cls.ADMIN, cls.OWNER]
|
||||
|
||||
|
||||
class RecordingStatusChoices(models.TextChoices):
|
||||
"""Recording status choices."""
|
||||
|
||||
RECORDING = "recording", _("Recording")
|
||||
DONE = "done", _("Done")
|
||||
@classmethod
|
||||
def check_owner_role(cls, role):
|
||||
"""Check if a role is owner."""
|
||||
return role == cls.OWNER
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
@@ -195,44 +195,34 @@ class Resource(BaseModel):
|
||||
except AttributeError:
|
||||
return f"Resource {self.id!s}"
|
||||
|
||||
def get_role(self, user):
|
||||
"""
|
||||
Determine the role of a given user in this resource.
|
||||
"""
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
|
||||
role = None
|
||||
for access in self.accesses.filter(user=user):
|
||||
if access.role == RoleChoices.OWNER:
|
||||
return RoleChoices.OWNER
|
||||
if access.role == RoleChoices.ADMIN:
|
||||
role = RoleChoices.ADMIN
|
||||
if access.role == RoleChoices.MEMBER and role != RoleChoices.ADMIN:
|
||||
role = RoleChoices.MEMBER
|
||||
return role
|
||||
|
||||
def is_administrator(self, user):
|
||||
"""
|
||||
Check if a user is administrator of the resource.
|
||||
|
||||
Users carrying the "owner" role are considered as administrators a fortiori.
|
||||
"""
|
||||
return RoleChoices.is_administrator(self.get_roles(user))
|
||||
return RoleChoices.check_administrator_role(self.get_role(user))
|
||||
|
||||
def is_owner(self, user):
|
||||
"""Check if a user is owner of the resource."""
|
||||
return RoleChoices.OWNER in self.get_roles(user)
|
||||
|
||||
def get_roles(self, user):
|
||||
"""Compute the roles a user has in a room."""
|
||||
if not user or not user.is_authenticated:
|
||||
return self.accesses.none()
|
||||
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = self.accesses.filter(user=user).values_list("role", flat=True)
|
||||
except (models.ObjectDoesNotExist, IndexError):
|
||||
roles = self.accesses.none()
|
||||
return roles
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user on the room."""
|
||||
roles = self.get_roles(user)
|
||||
is_owner_or_admin = RoleChoices.is_administrator(roles)
|
||||
|
||||
return {
|
||||
"start_recording": is_owner_or_admin,
|
||||
"destroy": RoleChoices.OWNER in roles,
|
||||
"manage_accesses": is_owner_or_admin,
|
||||
"partial_update": is_owner_or_admin,
|
||||
"retrieve": True,
|
||||
"update": is_owner_or_admin,
|
||||
}
|
||||
return RoleChoices.check_owner_role(self.get_role(user))
|
||||
|
||||
|
||||
class ResourceAccess(BaseModel):
|
||||
@@ -338,46 +328,23 @@ 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"""
|
||||
|
||||
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,
|
||||
template_vars = {
|
||||
"title": "Votre résumé est prêt",
|
||||
"link": link,
|
||||
"room": self.slug,
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
Test file downloads API endpoint for users in Meet's core app.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# This is a minimal MP4 file header, which creates a 1-second empty video
|
||||
VIDEO_BYTES = (
|
||||
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42mp41"
|
||||
b"\x00\x00\x00\x08free\x00\x00\x02\xeemdat"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_public", [True, False])
|
||||
def test_api_recordings_retrieve_auth_anonymous(is_public):
|
||||
"""Anonymous users should not be able to retrieve recordings for a room."""
|
||||
room = factories.RoomFactory(is_public=is_public)
|
||||
recording = room.start_recording()
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=VIDEO_BYTES,
|
||||
ContentType="video/mp4",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_public", [True, False])
|
||||
def test_api_recordings_retrieve_auth_authenticated(is_public):
|
||||
"""
|
||||
Authenticated users who are not related to a room should not be able to
|
||||
retrieve recordings for this room.
|
||||
"""
|
||||
room = factories.RoomFactory(is_public=is_public)
|
||||
recording = room.start_recording()
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=VIDEO_BYTES,
|
||||
ContentType="video/mp4",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_public", [True, False])
|
||||
@pytest.mark.parametrize("role", models.RoleChoices.values)
|
||||
def test_api_recordings_retrieve_auth_admin_or_owner(role, is_public):
|
||||
"""
|
||||
Users who are administrator or owner of a room, should be able to retrieve recordings.
|
||||
"""
|
||||
room = factories.RoomFactory(is_public=is_public)
|
||||
recording = room.start_recording()
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
|
||||
# deposit a recording in S3
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=VIDEO_BYTES,
|
||||
ContentType="video/mp4",
|
||||
)
|
||||
|
||||
# verify getting object content from url with authorization headers
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert response.content == VIDEO_BYTES
|
||||
@@ -1,56 +0,0 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: stop.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recording_stop_recording_success():
|
||||
"""Anonymous users can stop a recording just with its UUID."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
now = datetime(2010, 1, 1, tzinfo=timezone.utc)
|
||||
with mock.patch.object(django_timezone, "now", return_value=now):
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
|
||||
|
||||
assert response.status_code == 200
|
||||
recording.refresh_from_db()
|
||||
assert recording.stopped_at == now
|
||||
|
||||
|
||||
def test_api_recording_stop_recording_unknown():
|
||||
"""Trying to stop an unknown recording should return a 404."""
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{uuid4()!s}/stop/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_recording_stop_recording_already_stopped():
|
||||
"""
|
||||
Trying to stop a recording that was already stopped should return a 403 and leave
|
||||
the recording unmodified.
|
||||
"""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
|
||||
|
||||
assert response.status_code == 200
|
||||
recording.refresh_from_db()
|
||||
stopped_at = recording.stopped_at
|
||||
|
||||
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
|
||||
|
||||
assert response.status_code == 403
|
||||
recording.refresh_from_db()
|
||||
assert recording.stopped_at == stopped_at
|
||||
@@ -286,7 +286,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -360,7 +360,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start-recording.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_rooms_start_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to start a recording for a room."""
|
||||
room = factories.RoomFactory()
|
||||
|
||||
response = APIClient().post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Recording.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_start_recording_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to start a recording for a room
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert models.Recording.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_start_recording_members():
|
||||
"""
|
||||
Users who are members of a room but not administrators should
|
||||
not be allowed to start a recording in this room.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code, 403
|
||||
assert models.Recording.objects.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_api_rooms_start_recording_administrators(role):
|
||||
"""Administrators or owners of a room should be allowed to start a recording in this room."""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory(users=[(user, role)])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.Recording.objects.get().room == room
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["administrator", "owner"])
|
||||
def test_api_rooms_start_recording_administrators_of_another(role):
|
||||
"""
|
||||
Being administrator or owner of a room should not grant authorization
|
||||
to update another room.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
factories.RoomFactory(users=[(user, role)])
|
||||
other_room = factories.RoomFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{other_room.id!s}/start-recording/")
|
||||
|
||||
assert response.status_code, 403
|
||||
assert models.Recording.objects.exists() is False
|
||||
@@ -86,66 +86,81 @@ def test_models_rooms_is_public_default():
|
||||
assert room.is_public is True
|
||||
|
||||
|
||||
def test_models_recordings_key():
|
||||
"""Check the room key format."""
|
||||
room = RoomFactory()
|
||||
recording = room.start_recording()
|
||||
assert recording.key == f"recordings/{recording.pk!s}/file.mp4/"
|
||||
|
||||
|
||||
# Access rights methods
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_none(django_assert_num_queries):
|
||||
"""The `get_roles` method should return an empty list when called with None."""
|
||||
"""Calling access rights methods with None should return None."""
|
||||
room = RoomFactory()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
assert not list(room.get_roles(None))
|
||||
assert room.get_role(None) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator(None) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(None) is False
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
|
||||
"""The `get_roles` method should return an empty list for an anonymous user."""
|
||||
"""Check access rights methods on the room object for an anonymous user."""
|
||||
user = AnonymousUser()
|
||||
room = RoomFactory()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
assert not list(room.get_roles(user))
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
|
||||
"""
|
||||
The `get_roles` method should return an empty list for a user not related to the room.
|
||||
"""
|
||||
"""Check access rights methods on the room object for an unrelated user."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory()
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert not list(room.get_roles(user))
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
|
||||
"""Check `get_roles` method on the room object for a direct member."""
|
||||
"""Check access rights methods on the room object for a direct member."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert list(room.get_roles(user)) == ["member"]
|
||||
assert room.get_role(user) == "member"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries):
|
||||
"""Check `get_roles` method on the room object for a direct administrator."""
|
||||
"""The is_administrator method should return True for a direct administrator."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "administrator")])
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert list(room.get_roles(user)) == ["administrator"]
|
||||
assert room.get_role(user) == "administrator"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
|
||||
def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
"""Check `get_roles` method on the room object for an owner."""
|
||||
"""Check access rights methods on the room object for an owner."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
assert list(room.get_roles(user)) == ["owner"]
|
||||
assert room.get_role(user) == "owner"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
|
||||
@@ -5,13 +5,12 @@ from django.urls import include, path
|
||||
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.api import get_frontend_configuration, viewsets, demo
|
||||
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"
|
||||
@@ -25,6 +24,7 @@ urlpatterns = [
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
path("minio-webhook/", demo.minio_webhook, name="demo"),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,9 +11,7 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
|
||||
@@ -55,6 +53,7 @@ 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",
|
||||
@@ -83,31 +82,3 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our documents' content
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -119,25 +119,6 @@ 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/
|
||||
|
||||
@@ -405,6 +386,53 @@ 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):
|
||||
@@ -548,6 +576,20 @@ 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 +1,7 @@
|
||||
"""URL configuration for the Meet project"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import include, path, re_path
|
||||
@@ -17,7 +18,11 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns = urlpatterns + staticfiles_urlpatterns()
|
||||
urlpatterns = (
|
||||
urlpatterns
|
||||
+ staticfiles_urlpatterns()
|
||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
)
|
||||
|
||||
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
|
||||
@@ -25,14 +25,14 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.34",
|
||||
"boto3==1.35.19",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.1.1",
|
||||
"redis==5.0.8",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
@@ -42,21 +42,24 @@ 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.3",
|
||||
"psycopg[binary]==3.2.2",
|
||||
"PyJWT==2.9.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.15.0",
|
||||
"sentry-sdk==2.14.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.7.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.7.0",
|
||||
"minio==7.2.9",
|
||||
"openai==1.51.2"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -69,7 +72,6 @@ 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 removeJoinNotification = (participant: Participant) => {
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
toast.content.participant === participant &&
|
||||
toast.content.type === NotificationType.Joined
|
||||
)
|
||||
if (existingToast) {
|
||||
toastQueue.close(existingToast.key)
|
||||
}
|
||||
const removeParticipantNotifications = (participant: Participant) => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
if (toast.content.participant === participant) {
|
||||
toastQueue.close(toast.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
|
||||
room.on(RoomEvent.ParticipantDisconnected, removeParticipantNotifications)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
|
||||
room.off(
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
removeParticipantNotifications
|
||||
)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
@@ -105,7 +105,7 @@ export const MainNotificationToast = () => {
|
||||
}, [room])
|
||||
|
||||
return (
|
||||
<Div position="absolute" bottom={20} right={5} zIndex={1000}>
|
||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine, RiHand } from '@remixicon/react'
|
||||
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export function ToastRaised({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
@@ -17,7 +17,7 @@ export function ToastRaised({ state, ...props }: ToastProps) {
|
||||
ref
|
||||
)
|
||||
const participant = props.toast.content.participant
|
||||
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
LiveKitRoom,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -104,7 +100,7 @@ export const Conference = ({
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
>
|
||||
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
|
||||
<VideoConference />
|
||||
{showInviteDialog && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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">
|
||||
<VStack padding="0 1.5rem" overflowY="scroll">
|
||||
{localCameraTrack && isCameraEnabled ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
@@ -7,14 +6,16 @@ import { Box, Button, Div } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { Effects } from './Effects'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
}
|
||||
|
||||
@@ -22,11 +23,11 @@ const StyledSidePanel = ({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
}: StyledSidePanelProps) => (
|
||||
<Box
|
||||
size="sm"
|
||||
minWidth="360px"
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
@@ -34,7 +35,16 @@ const StyledSidePanel = ({
|
||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: '80px',
|
||||
width: '360px',
|
||||
position: 'absolute',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
})}
|
||||
style={{
|
||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
@@ -43,11 +53,19 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
style={{
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
invisible
|
||||
size="xs"
|
||||
@@ -58,31 +76,56 @@ const StyledSidePanel = ({
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<Div overflowY="scroll">{children}</Div>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
<div
|
||||
style={{
|
||||
display: isOpen ? 'inherit' : 'none',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction()
|
||||
const {
|
||||
activePanelId,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
if (!sidePanel) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${sidePanel}`)}
|
||||
onClose={() => (layoutStore.sidePanel = null)}
|
||||
title={t(`heading.${activePanelId}`)}
|
||||
onClose={() => (layoutStore.activePanelId = null)}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${sidePanel}`),
|
||||
content: t(`content.${activePanelId}`),
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
>
|
||||
{isParticipantsOpen && <ParticipantsList />}
|
||||
{isEffectsOpen && <Effects />}
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
</Panel>
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReceivedChatMessage } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
import { MessageFormatter } from '@livekit/components-react'
|
||||
|
||||
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
entry: ReceivedChatMessage
|
||||
hideMetadata?: boolean
|
||||
messageFormatter?: MessageFormatter
|
||||
}
|
||||
|
||||
export const ChatEntry: (
|
||||
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
HTMLLIElement,
|
||||
ChatEntryProps
|
||||
>(function ChatEntry(
|
||||
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
||||
ref
|
||||
) {
|
||||
// Fixme - Livekit messageFormatter strips '\n' char
|
||||
const formattedMessage = React.useMemo(() => {
|
||||
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
||||
}, [entry.message, messageFormatter])
|
||||
const time = new Date(entry.timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
|
||||
return (
|
||||
<li
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})}
|
||||
ref={ref}
|
||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
||||
{...props}
|
||||
>
|
||||
{!hideMetadata && (
|
||||
<span
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<Text bold={true} variant="sm">
|
||||
{entry.from?.name ?? entry.from?.identity}
|
||||
</Text>
|
||||
<Text variant="sm" className={css({ color: 'gray.700' })}>
|
||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{formattedMessage}
|
||||
</Text>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { TextArea } from '@/primitives/TextArea'
|
||||
import { RefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MAX_ROWS = 6
|
||||
|
||||
interface ChatInputProps {
|
||||
inputRef: RefObject<HTMLTextAreaElement>
|
||||
onSubmit: (text: string) => void
|
||||
isSending: boolean
|
||||
}
|
||||
|
||||
export const ChatInput = ({
|
||||
inputRef,
|
||||
onSubmit,
|
||||
isSending,
|
||||
}: ChatInputProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
const [text, setText] = useState('')
|
||||
const [rows, setRows] = useState(1)
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const isDisabled = !text.trim() || isSending
|
||||
|
||||
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
|
||||
e.preventDefault()
|
||||
if (!isDisabled) handleSubmit()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => {
|
||||
if (!inputRef.current) return
|
||||
|
||||
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
|
||||
const previousRows = inputRef.current.rows
|
||||
inputRef.current.rows = 1
|
||||
|
||||
const currentRows = Math.floor(
|
||||
inputRef.current.scrollHeight / textAreaLineHeight
|
||||
)
|
||||
|
||||
if (currentRows === previousRows) {
|
||||
inputRef.current.rows = currentRows
|
||||
}
|
||||
|
||||
if (currentRows >= MAX_ROWS) {
|
||||
inputRef.current.rows = MAX_ROWS
|
||||
inputRef.current.scrollTop = inputRef.current.scrollHeight
|
||||
}
|
||||
|
||||
if (currentRows < MAX_ROWS) {
|
||||
inputRef.current.style.overflowY = 'hidden'
|
||||
} else {
|
||||
inputRef.current.style.overflowY = 'auto'
|
||||
}
|
||||
|
||||
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
|
||||
}
|
||||
|
||||
resize()
|
||||
}, [text, inputRef])
|
||||
|
||||
return (
|
||||
<HStack
|
||||
style={{
|
||||
margin: '0.75rem 0 1.5rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
submitOnEnter(e)
|
||||
}}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
}}
|
||||
rows={rows || 1}
|
||||
style={{
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
minHeight: `34px`,
|
||||
lineHeight: 1.25,
|
||||
padding: '7px 10px',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
placeholderStyle={'strong'}
|
||||
spellCheck={false}
|
||||
maxLength={500}
|
||||
aria-label={t('textArea.label')}
|
||||
/>
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
size="sm"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t('button.label')}
|
||||
>
|
||||
<RiSendPlane2Fill />
|
||||
</Button>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiChat1Line } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
|
||||
export const ChatToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
||||
|
||||
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
|
||||
return (
|
||||
@@ -28,7 +32,7 @@ export const ChatToggle = () => {
|
||||
>
|
||||
<RiChat1Line />
|
||||
</ToggleButton>
|
||||
{!!unreadMessages && (
|
||||
{!!chatSnap.unreadMessages && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
|
||||
+6
-4
@@ -1,7 +1,7 @@
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
|
||||
import {
|
||||
RiAccountBoxLine,
|
||||
RiFeedbackLine,
|
||||
RiMegaphoneLine,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
|
||||
@@ -9,7 +9,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { DialogState } from './OptionsButton'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = ({
|
||||
@@ -18,7 +19,7 @@ export const OptionsMenuItems = ({
|
||||
onOpenDialog: Dispatch<SetStateAction<DialogState>>
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useWidgetInteraction()
|
||||
const { toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
@@ -34,6 +35,7 @@ export const OptionsMenuItems = ({
|
||||
<RiAccountBoxLine size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
<RecordingMenuItem />
|
||||
</Section>
|
||||
<Separator />
|
||||
<Section>
|
||||
@@ -42,7 +44,7 @@ export const OptionsMenuItems = ({
|
||||
target="_blank"
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
>
|
||||
<RiFeedbackLine size={20} />
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedbacks')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+3
-10
@@ -1,5 +1,4 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { allParticipantRoomEvents } from '@livekit/components-core'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
|
||||
import { Div, H } from '@/primitives'
|
||||
@@ -8,7 +7,6 @@ import { ParticipantListItem } from '../../controls/Participants/ParticipantList
|
||||
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
|
||||
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
|
||||
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
@@ -17,12 +15,7 @@ export const ParticipantsList = () => {
|
||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
|
||||
const participants = useParticipants({
|
||||
updateOnlyOn: [
|
||||
RoomEvent.ParticipantNameChanged,
|
||||
...allParticipantRoomEvents,
|
||||
],
|
||||
})
|
||||
const participants = useParticipants()
|
||||
|
||||
const sortedRemoteParticipants = participants
|
||||
.slice(1)
|
||||
@@ -44,7 +37,7 @@ export const ParticipantsList = () => {
|
||||
|
||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||
return (
|
||||
<>
|
||||
<Div overflowY="scroll">
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
@@ -78,6 +71,6 @@ export const ParticipantsList = () => {
|
||||
<ParticipantListItem participant={participant} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
|
||||
export const ParticipantsToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
||||
@@ -16,7 +16,7 @@ export const ParticipantsToggle = () => {
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
}
|
||||
|
||||
export const useSidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const activePanelId = layoutSnap.activePanelId
|
||||
|
||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
|
||||
const toggleParticipants = () => {
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
}
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
isSidePanelOpen,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useLayoutContext } from '@livekit/components-react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export const useWidgetInteraction = () => {
|
||||
const { dispatch, state } = useLayoutContext().widget
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
const isParticipantsOpen = sidePanel == 'participants'
|
||||
const isEffectsOpen = sidePanel == 'effects'
|
||||
|
||||
const toggleParticipants = () => {
|
||||
if (dispatch && state?.showChat) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
layoutStore.sidePanel = isParticipantsOpen ? null : 'participants'
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
if (isParticipantsOpen || isEffectsOpen) {
|
||||
layoutStore.sidePanel = null
|
||||
}
|
||||
if (dispatch) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
if (dispatch && state?.showChat) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
layoutStore.sidePanel = isEffectsOpen ? null : 'effects'
|
||||
}
|
||||
|
||||
return {
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
isChatOpen: state?.showChat,
|
||||
unreadMessages: state?.unreadMessages,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
useChat,
|
||||
useParticipants,
|
||||
} from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { Div, Text } from '@/primitives'
|
||||
import { ChatInput } from '../components/chat/Input'
|
||||
import { ChatEntry } from '../components/chat/Entry'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
|
||||
export interface ChatProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
ChatOptions {}
|
||||
|
||||
/**
|
||||
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
||||
* in the room. Only users who are in the room at the time of dispatch will receive the message.
|
||||
*/
|
||||
export function Chat({ ...props }: ChatProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const ulRef = React.useRef<HTMLUListElement>(null)
|
||||
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
|
||||
const { isChatOpen } = useSidePanel()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
const participants = useParticipants()
|
||||
|
||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||
|
||||
async function handleSubmit(text: string) {
|
||||
if (!send || !text) return
|
||||
await send(text)
|
||||
inputRef?.current?.focus()
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (chatMessages.length > 0 && ulRef.current) {
|
||||
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
|
||||
}
|
||||
}, [ulRef, chatMessages])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (chatMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
isChatOpen &&
|
||||
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
|
||||
) {
|
||||
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
|
||||
chatStore.unreadMessages = 0
|
||||
return
|
||||
}
|
||||
|
||||
const unreadMessageCount = chatMessages.filter(
|
||||
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
|
||||
).length
|
||||
|
||||
if (
|
||||
unreadMessageCount > 0 &&
|
||||
chatSnap.unreadMessages !== unreadMessageCount
|
||||
) {
|
||||
chatStore.unreadMessages = unreadMessageCount
|
||||
}
|
||||
}, [chatMessages, chatSnap.unreadMessages, isChatOpen])
|
||||
|
||||
const renderedMessages = React.useMemo(() => {
|
||||
return chatMessages.map((msg, idx, allMsg) => {
|
||||
const hideMetadata =
|
||||
idx >= 1 &&
|
||||
msg.timestamp - allMsg[idx - 1].timestamp < 60_000 &&
|
||||
allMsg[idx - 1].from === msg.from
|
||||
|
||||
return (
|
||||
<ChatEntry
|
||||
key={msg.id ?? idx}
|
||||
hideMetadata={hideMetadata}
|
||||
entry={msg}
|
||||
messageFormatter={formatChatMessageLinks}
|
||||
/>
|
||||
)
|
||||
})
|
||||
// This ensures that the chat message list is updated to reflect any changes in participant information.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages, participants])
|
||||
|
||||
return (
|
||||
<Div
|
||||
display={'flex'}
|
||||
padding={'0 1.5rem'}
|
||||
flexGrow={1}
|
||||
flexDirection={'column'}
|
||||
minHeight={0}
|
||||
{...props}
|
||||
>
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: 4,
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{t('disclaimer')}
|
||||
</Text>
|
||||
<Div
|
||||
flexGrow={1}
|
||||
flexDirection={'column'}
|
||||
minHeight={0}
|
||||
overflowY="scroll"
|
||||
>
|
||||
<ul className="lk-list lk-chat-messages" ref={ulRef}>
|
||||
{renderedMessages}
|
||||
</ul>
|
||||
</Div>
|
||||
<ChatInput
|
||||
inputRef={inputRef}
|
||||
onSubmit={(e) => handleSubmit(e)}
|
||||
isSending={isSending}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
import { mergeProps } from '@/utils/mergeProps.ts'
|
||||
import { StartMediaButton } from '../components/controls/StartMediaButton'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { OptionsButton } from '../components/controls/Options/OptionsButton'
|
||||
@@ -18,6 +17,7 @@ import { HandToggle } from '../components/controls/HandToggle'
|
||||
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
|
||||
import { LeaveButton } from '../components/controls/LeaveButton'
|
||||
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
@@ -63,7 +63,6 @@ export function ControlBar({
|
||||
variation,
|
||||
saveUserChoices = true,
|
||||
onDeviceError,
|
||||
...props
|
||||
}: ControlBarProps) {
|
||||
const [isChatOpen, setIsChatOpen] = React.useState(false)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
@@ -82,8 +81,6 @@ export function ControlBar({
|
||||
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
|
||||
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
|
||||
|
||||
const {
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputEnabled,
|
||||
@@ -104,7 +101,23 @@ export function ControlBar({
|
||||
)
|
||||
|
||||
return (
|
||||
<div {...htmlProps}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '.5rem',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '.75rem',
|
||||
borderTop: '1px solid var(--lk-border-color)',
|
||||
maxHeight: 'var(--lk-control-bar-height)',
|
||||
height: '80px',
|
||||
position: 'absolute',
|
||||
backgroundColor: '#d1d5db',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
MessageDecoder,
|
||||
MessageEncoder,
|
||||
TrackReferenceOrPlaceholder,
|
||||
WidgetState,
|
||||
} from '@livekit/components-core'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
@@ -20,22 +15,20 @@ import {
|
||||
GridLayout,
|
||||
LayoutContextProvider,
|
||||
RoomAudioRenderer,
|
||||
MessageFormatter,
|
||||
usePinnedTracks,
|
||||
useTracks,
|
||||
useCreateLayoutContext,
|
||||
Chat,
|
||||
} from '@livekit/components-react'
|
||||
|
||||
import { ControlBar } from './ControlBar'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||
import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -44,7 +37,7 @@ const LayoutWrapper = styled(
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: 'calc(100% - var(--lk-control-bar-height))',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -54,9 +47,6 @@ const LayoutWrapper = styled(
|
||||
*/
|
||||
export interface VideoConferenceProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
chatMessageFormatter?: MessageFormatter
|
||||
chatMessageEncoder?: MessageEncoder
|
||||
chatMessageDecoder?: MessageDecoder
|
||||
/** @alpha */
|
||||
SettingsComponent?: React.ComponentType
|
||||
}
|
||||
@@ -79,17 +69,7 @@ export interface VideoConferenceProps
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function VideoConference({
|
||||
chatMessageFormatter,
|
||||
chatMessageDecoder,
|
||||
chatMessageEncoder,
|
||||
...props
|
||||
}: VideoConferenceProps) {
|
||||
const [widgetState, setWidgetState] = React.useState<WidgetState>({
|
||||
showChat: false,
|
||||
unreadMessages: 0,
|
||||
showSettings: false,
|
||||
})
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
@@ -101,11 +81,6 @@ export function VideoConference({
|
||||
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
|
||||
)
|
||||
|
||||
const widgetUpdate = (state: WidgetState) => {
|
||||
log.debug('updating widget state', state)
|
||||
setWidgetState(state)
|
||||
}
|
||||
|
||||
const layoutContext = useCreateLayoutContext()
|
||||
|
||||
const screenShareTracks = tracks
|
||||
@@ -172,8 +147,7 @@ export function VideoConference({
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
const { isSidePanelOpen } = useSidePanel()
|
||||
|
||||
return (
|
||||
<div className="lk-video-conference" {...props}>
|
||||
@@ -181,9 +155,18 @@ export function VideoConference({
|
||||
<LayoutContextProvider
|
||||
value={layoutContext}
|
||||
// onPinChange={handleFocusStateChange}
|
||||
onWidgetChange={widgetUpdate}
|
||||
>
|
||||
<div className="lk-video-conference-inner">
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? 'var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px'
|
||||
: 'var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))',
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
}}
|
||||
>
|
||||
<RecordingIndicator />
|
||||
<LayoutWrapper>
|
||||
<div
|
||||
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
||||
@@ -193,7 +176,7 @@ export function VideoConference({
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
@@ -202,7 +185,7 @@ export function VideoConference({
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
@@ -215,18 +198,12 @@ export function VideoConference({
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<Chat
|
||||
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
|
||||
messageFormatter={chatMessageFormatter}
|
||||
messageEncoder={chatMessageEncoder}
|
||||
messageDecoder={chatMessageDecoder}
|
||||
/>
|
||||
{sidePanel && <SidePanel />}
|
||||
</LayoutWrapper>
|
||||
<ControlBar />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar />
|
||||
<SidePanel />
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
|
||||
@@ -47,7 +47,16 @@
|
||||
"stopScreenShare": "",
|
||||
"chat": {
|
||||
"open": "",
|
||||
"closed": ""
|
||||
"closed": "",
|
||||
"input": {
|
||||
"textArea": {
|
||||
"label": "",
|
||||
"placeholder": ""
|
||||
},
|
||||
"button": {
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"hand": {
|
||||
"raise": "",
|
||||
@@ -88,14 +97,19 @@
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "",
|
||||
"effects": ""
|
||||
"effects": "",
|
||||
"chat": ""
|
||||
},
|
||||
"content": {
|
||||
"participants": "",
|
||||
"effects": ""
|
||||
"effects": "",
|
||||
"chat": ""
|
||||
},
|
||||
"closeButton": ""
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": ""
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "",
|
||||
"contributors": "",
|
||||
|
||||
@@ -45,7 +45,16 @@
|
||||
"camera": "Camera",
|
||||
"chat": {
|
||||
"open": "Close the chat",
|
||||
"closed": "Open the chat"
|
||||
"closed": "Open the chat",
|
||||
"input": {
|
||||
"textArea": {
|
||||
"label": "Enter a message",
|
||||
"placeholder": "Enter a message"
|
||||
},
|
||||
"button": {
|
||||
"label": "Send message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hand": {
|
||||
"raise": "Raise hand",
|
||||
@@ -86,14 +95,19 @@
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effects"
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects"
|
||||
"effects": "effects",
|
||||
"chat": "messages"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
|
||||
@@ -45,7 +45,16 @@
|
||||
"camera": "Camera",
|
||||
"chat": {
|
||||
"open": "Masquer le chat",
|
||||
"closed": "Afficher le chat"
|
||||
"closed": "Afficher le chat",
|
||||
"input": {
|
||||
"textArea": {
|
||||
"label": "Ecrire un message",
|
||||
"placeholder": "Ecrire un message"
|
||||
},
|
||||
"button": {
|
||||
"label": "Envoyer un message"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hand": {
|
||||
"raise": "Lever la main",
|
||||
@@ -86,14 +95,19 @@
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effets"
|
||||
"effects": "Effets",
|
||||
"chat": "Messages dans l'appel"
|
||||
},
|
||||
"content": {
|
||||
"participants": "les participants",
|
||||
"effects": "les effets"
|
||||
"effects": "les effets",
|
||||
"chat": "les messages"
|
||||
},
|
||||
"closeButton": "Masquer {{content}}"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "Dans la réunion",
|
||||
"you": "Vous",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TextArea as RACTextArea } from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
/**
|
||||
* Styled RAC TextArea.
|
||||
*/
|
||||
export const TextArea = styled(RACTextArea, {
|
||||
base: {
|
||||
width: 'full',
|
||||
paddingY: 0.25,
|
||||
paddingX: 0.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'control.border',
|
||||
color: 'control.text',
|
||||
borderRadius: 4,
|
||||
transition: 'all 200ms',
|
||||
},
|
||||
variants: {
|
||||
placeholderStyle: {
|
||||
strong: {
|
||||
_placeholder: {
|
||||
color: 'black',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -117,6 +117,9 @@ export const buttonRecipe = cva({
|
||||
'&[data-pressed]': {
|
||||
borderColor: 'currentcolor',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
color: 'gray.300',
|
||||
},
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
unreadMessages: number
|
||||
}
|
||||
|
||||
export const chatStore = proxy<State>({
|
||||
unreadMessages: 0,
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
egressId: string | undefined
|
||||
egressIsStopping: boolean
|
||||
}
|
||||
|
||||
export const egressStore = proxy<State>({
|
||||
egressId: undefined,
|
||||
egressIsStopping: false,
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
type State = {
|
||||
showHeader: boolean
|
||||
sidePanel: 'participants' | 'effects' | null
|
||||
activePanelId: PanelId | null
|
||||
}
|
||||
|
||||
export const layoutStore = proxy<State>({
|
||||
showHeader: false,
|
||||
sidePanel: null,
|
||||
activePanelId: null,
|
||||
})
|
||||
|
||||
@@ -40,10 +40,6 @@ backend:
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
{{- with .Values.livekit.keys }}
|
||||
{{- range $key, $value := . }}
|
||||
LIVEKIT_API_SECRET: {{ $value }}
|
||||
@@ -102,23 +98,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/retrieve-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
ingressAssets:
|
||||
enabled: false
|
||||
enabled: false
|
||||
|
||||
|
||||
@@ -85,23 +85,6 @@ 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
|
||||
@@ -116,6 +99,23 @@ 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,24 +147,6 @@ ingressAdmin:
|
||||
nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/start
|
||||
nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/auth
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: visio.numerique.gouv.fr
|
||||
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/auth-url: https://visio.numerique.gouv.fr/api/v1.0/recordings/retrieve-auth/
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /impress-production-media-storage/$1
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: s3.hedy-lamarr.indiehosters.net
|
||||
|
||||
serviceMedia:
|
||||
host: s3.hedy-lamarr.indiehosters.net
|
||||
port: 443
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "main"
|
||||
tag: "v-hackathon"
|
||||
|
||||
backend:
|
||||
migrateJobAnnotations:
|
||||
@@ -84,6 +84,19 @@ backend:
|
||||
name: redis.redis.libre.sh
|
||||
key: url
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: LIVEKIT_API_SECRET
|
||||
LIVEKIT_API_KEY:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: LIVEKIT_API_KEY
|
||||
LIVEKIT_API_URL: https://livekit-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
|
||||
@@ -101,19 +114,24 @@ backend:
|
||||
name: meet-media-storage.bucket.libre.sh
|
||||
key: bucket
|
||||
AWS_S3_REGION_NAME: local
|
||||
LIVEKIT_API_SECRET:
|
||||
OPENAI_API_KEY:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: LIVEKIT_API_SECRET
|
||||
LIVEKIT_API_KEY:
|
||||
key: OPENAI_API_KEY
|
||||
OPENAI_ENABLE: True
|
||||
MINIO_ACCESS_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'}"
|
||||
key: MINIO_ACCESS_KEY
|
||||
MINIO_SECRET_KEY:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: MINIO_SECRET_KEY
|
||||
MINIO_URL:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: MINIO_URL
|
||||
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
@@ -127,7 +145,7 @@ frontend:
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "main"
|
||||
tag: "v-hackathon"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -157,24 +175,6 @@ ingressAdmin:
|
||||
hosts:
|
||||
- {{ .Values.newDomain }}
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: meet-staging.beta.numerique.gouv.fr
|
||||
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet-staging.beta.numerique.gouv.fr/api/v1.0/recordings/retrieve-auth/
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-staging-media-storage/$1
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: s3.margaret-hamilton.indiehosters.net
|
||||
|
||||
serviceMedia:
|
||||
host: s3.margaret-hamilton.indiehosters.net
|
||||
port: 443
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: true
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: core.libre.sh/v1alpha1
|
||||
kind: Bucket
|
||||
metadata:
|
||||
name: meet-media-storage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
provider: data
|
||||
versioned: true
|
||||
@@ -45,20 +45,6 @@ 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 }}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
{{- if .Values.ingressMedia.enabled -}}
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}-media
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingressMedia.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingressMedia.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingressMedia.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingressMedia.host }}
|
||||
- secretName: {{ $fullName }}-tls
|
||||
hosts:
|
||||
- {{ .Values.ingressMedia.host | quote }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressMedia.tls.additional }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if .Values.ingressMedia.host }}
|
||||
- host: {{ .Values.ingressMedia.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressMedia.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}-media
|
||||
port:
|
||||
number: {{ .Values.serviceMedia.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}-media
|
||||
servicePort: {{ .Values.serviceMedia.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressMedia.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ $.Values.ingressMedia.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Prefix
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}-media
|
||||
port:
|
||||
number: {{ .Values.serviceMedia.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}-media
|
||||
servicePort: {{ .Values.serviceMedia.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,14 +0,0 @@
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- $component := "media" -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $fullName }}-media
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
annotations:
|
||||
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: {{ $.Values.serviceMedia.host }}
|
||||
@@ -15,3 +15,16 @@ 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 }}
|
||||
|
||||
|
||||
@@ -69,36 +69,6 @@ ingressAdmin:
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
## @param ingressMedia.enabled whether to enable the Ingress or not
|
||||
## @param ingressMedia.className IngressClass to use for the Ingress
|
||||
## @param ingressMedia.host Host for the Ingress
|
||||
## @param ingressMedia.path Path to use for the Ingress
|
||||
ingressMedia:
|
||||
enabled: false
|
||||
className: null
|
||||
host: meet.example.com
|
||||
path: /media/(.*)
|
||||
## @param ingressMedia.hosts Additional host to configure for the Ingress
|
||||
hosts: [ ]
|
||||
# - chart-example.local
|
||||
## @param ingressMedia.tls.enabled Wether to enable TLS for the Ingress
|
||||
## @skip ingressMedia.tls.additional
|
||||
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/retrieve-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
|
||||
|
||||
serviceMedia:
|
||||
host: minio.impress.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
|
||||
## @section backend
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user