Compare commits

..

20 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM 51a1725c47 wip 2024-10-12 22:49:35 +02:00
lebaudantoine 1875a394c6 ♻️(frontend) simplify button primitive
In object-oriented terms, the previous implementation violated the Liskov
Substitution Principle. Props between these two components (Button and Link)
were not substitutable.

This led to TypeScript errors and increased overall complexity without
significant DX gains. To address this, the LinkButton has been extracted
into a dedicated component.
2024-10-09 16:40:59 +02:00
renovate[bot] 211ba332dc ⬆️(dependencies) update js dependencies 2024-10-09 16:40:59 +02:00
renovate[bot] 7333c21632 ⬆️(dependencies) update vite to v5.4.6 [SECURITY] 2024-10-09 15:09:59 +02:00
lebaudantoine 78ebd1a8fd 👷(ci) update build push action to v6
Update the build push action.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 682b69fc11 🔒️(backend) migrate backend image to alpine
Enhancement made by @rouja while working on the vulnerabilities
found by Trivy scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 7a73bf8fc2 💚(frontend) fix frontend image vulnerabilities
Fixed vulnerabilities found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 1e934957f5 💚(backend) fix backend image vulnerabilities
Fixed vulnerabilities with setup tools found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 5a7584a3ad 👷(ci) scan for vulnerabilities on Docker images
Configure Trivy Scan in the CI to detect vulnerabilities on our
Docker image. Enhance stack security.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL fb9bf6b08e 🔐(tilt) add Samuel's key
Add Samuel's key to handle secret.
2024-10-09 11:14:11 +02:00
Jacques ROUSSEL 11b8541005 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-27 18:46:26 +02:00
lebaudantoine 64607ae8d0 (frontend) introduce push to talk on microphone
I am not a huge fan of changing the component's behavior based on
a if, but that the only way I found to have something quickly.

Actually, the push to talk feature will always only applies to the
microphone. No other devices will be concerned.

Reuse the active speaker indicator to quickly bootstrap the feature.
Some details should be improved in terms of UI. The UX is quite
decent.
2024-09-27 16:34:32 +02:00
lebaudantoine c403bbc347 ♻️(frontend) extract toggle device component
Encapsulate toggle logic in a dedicated component.
Prepare the introduction of the push to talk feature.
2024-09-27 16:34:32 +02:00
lebaudantoine 62855fe12c (frontend) introduce active speaker pushToTalk variant
Will be used to indicate when the push to talk is open.
It gives the user a feedback wether or not her mic is active.
2024-09-27 16:34:32 +02:00
lebaudantoine f27d451eb6 ♻️(frontend) make active speaker keyframe relative to height
Keyframe can now be reused in various context, when the children
size are not fixed to 4px.
2024-09-27 16:34:32 +02:00
lebaudantoine 68792d8019 ✏️(frontend) fix typo in active speaker keyframe
Found a typo and fixed it.
2024-09-27 16:34:32 +02:00
lebaudantoine ebb8c14eeb ♻️(frontend) register shortcut using a hook
Encapsulate the logic for shortcuts registering in a proper hook.
It makes the code reusable and easier to read.
2024-09-27 16:34:32 +02:00
lebaudantoine 15d43b8d5e ♻️(frontend) handle proper shortcut object
Created a type to properly manipulate any data related to a
shortcut. Make the code more concise.
2024-09-27 16:34:32 +02:00
lebaudantoine 1faae96ccd (frontend) introduce long press keyboard shortcut
Needed to support push to talk using the spacebar.
Introduce a utility hook. Will be called by the mic toggle
in the upcoming commits.

Inspired by Jitsi code.
2024-09-27 16:34:32 +02:00
lebaudantoine 651cc0e5bd (frontend) introduce keyboard shortcut
Inspired by Gmeet for the UX and by Jitsi for the code.
Naive implementation of Keyboard shortcuts listener.

Will be enhanced in the upcoming commits.
2024-09-27 16:34:32 +02:00
57 changed files with 2785 additions and 1364 deletions
+15 -2
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -48,9 +49,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -92,9 +99,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
+3
View File
@@ -122,6 +122,9 @@ jobs:
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
+1
View File
@@ -9,3 +9,4 @@ creation_rules:
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+16 -25
View File
@@ -1,15 +1,14 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
FROM python:3.12.6-alpine3.20 as base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
@@ -38,12 +37,9 @@ RUN yarn install --frozen-lockfile && \
FROM base as link-collector
ARG MEET_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -66,17 +62,14 @@ FROM base as core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -106,9 +99,7 @@ FROM core as backend-development
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
+26
View File
@@ -16,6 +16,30 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -40,6 +64,7 @@ services:
- mailcatcher
- redis
- nginx
- createbuckets
- livekit
celery-dev:
@@ -73,6 +98,7 @@ services:
depends_on:
- postgresql
- redis
- createbuckets
- livekit
celery:
+3
View File
@@ -18,6 +18,9 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+1 -1
View File
@@ -450,7 +450,7 @@ max-branches=12
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
+29 -18
View File
@@ -39,30 +39,30 @@ class IsSelf(IsAuthenticated):
return obj == request.user
class RoomPermissions(permissions.BasePermission):
"""
Permissions applying to the room API endpoint.
"""
# class RoomPermissions(permissions.BasePermission):
# """
# Permissions applying to the room API endpoint.
# """
def has_permission(self, request, view):
"""Only allow authenticated users for unsafe methods."""
if request.method in permissions.SAFE_METHODS:
return True
# def has_permission(self, request, view):
# """Only allow authenticated users for unsafe methods."""
# if request.method in permissions.SAFE_METHODS:
# return True
return request.user.is_authenticated
# return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room."""
# def has_object_permission(self, request, view, obj):
# """Object permissions are only given to administrators of the room."""
if request.method in permissions.SAFE_METHODS:
return True
# if request.method in permissions.SAFE_METHODS:
# return True
user = request.user
# user = request.user
if request.method == "DELETE":
return obj.is_owner(user)
# if request.method == "DELETE":
# return obj.is_owner(user)
return obj.is_administrator(user)
# return obj.is_administrator(user)
class ResourceAccessPermission(permissions.BasePermission):
@@ -82,4 +82,15 @@ class ResourceAccessPermission(permissions.BasePermission):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return obj.resource.is_administrator(user)
return RoleChoices.is_administrator(obj.resource.get_roles(user))
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+26 -6
View File
@@ -87,6 +87,15 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
user = UserSerializer(read_only=True)
class ListRoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for a list API endpoint."""
class Meta:
model = models.Room
fields = ["id", "name", "slug", "is_public"]
read_only_fields = ["id", "slug"]
class RoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for the API."""
@@ -106,10 +115,10 @@ class RoomSerializer(serializers.ModelSerializer):
if not request:
return output
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
roles = instance.get_roles(request.user)
is_administrator = models.RoleChoices.is_administrator(roles)
if role is not None:
if roles:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -117,10 +126,10 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
if not is_admin:
if not is_administrator:
del output["configuration"]
if role is not None or instance.is_public:
if roles or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.query_params.get("username", None)
@@ -133,6 +142,17 @@ class RoomSerializer(serializers.ModelSerializer):
),
}
output["is_administrable"] = is_admin
output["is_administrable"] = is_administrator
return output
class RecordingSerializer(serializers.ModelSerializer):
"""Serialize Recording for the API."""
room = ListRoomSerializer(read_only=True)
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "stopped_at", "status"]
read_only_fields = fields
+33
View File
@@ -0,0 +1,33 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.core.files.storage import default_storage
import botocore
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
+123 -8
View File
@@ -1,17 +1,22 @@
"""API endpoints"""
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from rest_framework import (
decorators,
exceptions,
mixins,
pagination,
status,
viewsets,
)
from rest_framework import (
@@ -25,6 +30,13 @@ from . import permissions, serializers
# pylint: disable=too-many-ancestors
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
RECORDING_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}recordings/({UUID_REGEX:s})/file.mp4/$"
)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -162,7 +174,7 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
permission_classes = [permissions.RoomPermissions]
permission_classes = [permissions.AccessPermission]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -186,16 +198,10 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
slug = slugify(self.kwargs["pk"])
username = request.query_params.get("username", None)
data = {
@@ -209,6 +215,11 @@ class RoomViewSet(
},
}
else:
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
data = self.get_serializer(instance).data
return drf_response.Response(data)
@@ -249,6 +260,17 @@ class RoomViewSet(
},
)
@decorators.action(detail=True, methods=["post"], url_path="start-recording")
def start_recording(self, request, *args, **kwargs):
"""This view is used to start a recording for a room."""
# Check permission first
room = self.get_object()
recording = room.start_recording()
return drf_response.Response(
{"recording": recording.id}, status=status.HTTP_201_CREATED
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -293,3 +315,96 @@ class ResourceAccessViewSet(
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
API endpoints to access and perform actions on recordings.
"""
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.Recording.objects.all()
serializer_class = serializers.RecordingSerializer
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint to a user's room."""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(room__accesses__user=user)
else:
queryset = queryset.none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
@decorators.action(detail=True, methods=["post"], url_path="stop")
def stop(self, request, *args, **kwargs):
"""
This view is used to stop a recording. It can be called anonymously as the
recording ID will not have been communicated anywhere at the time of recording.
"""
recording = self.get_object()
if recording.stopped_at is not None:
raise exceptions.PermissionDenied()
recording.stopped_at = timezone.now()
recording.save()
# TODO: Generate summary and send to note taking app
serializer = self.get_serializer(recording)
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording file.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
if not request.user.is_authenticated:
raise exceptions.AuthenticationFailed()
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = RECORDING_URL_PATTERN.search(original_url.path)
try:
(pk,) = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
if not models.Recording.objects.filter(
pk=pk, room__accesses__user=request.user
).exists():
raise exceptions.PermissionDenied()
# Generate authorization headers and return an authorization to proceed with the request
key = models.Recording(pk=pk).key
request = utils.generate_s3_authorization_headers(key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+9
View File
@@ -65,3 +65,12 @@ class RoomFactory(ResourceFactory):
name = factory.Faker("catch_phrase")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
class RecordingFactory(factory.django.DjangoModelFactory):
"""Create fake recordings for testing."""
class Meta:
model = models.Recording
room = factory.SubFactory(RoomFactory)
@@ -0,0 +1,37 @@
# Generated by Django 5.1.1 on 2024-10-12 11:52
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_user_language'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='Recording',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('stopped_at', models.DateTimeField(blank=True, null=True, verbose_name='End Time')),
('status', models.CharField(choices=[('recording', 'Recording'), ('done', 'Done')], default='recording')),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='core.room', verbose_name='Room')),
],
options={
'verbose_name': 'Recording',
'verbose_name_plural': 'Recordings',
'db_table': 'meet_recording',
'ordering': ('created_at',),
},
),
]
+82 -26
View File
@@ -28,14 +28,16 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
@classmethod
def check_administrator_role(cls, role):
def is_administrator(cls, roles):
"""Check if a role is administrator."""
return role in [cls.ADMIN, cls.OWNER]
return bool(set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}))
@classmethod
def check_owner_role(cls, role):
"""Check if a role is owner."""
return role == cls.OWNER
class RecordingStatusChoices(models.TextChoices):
"""Recording status choices."""
RECORDING = "recording", _("Recording")
DONE = "done", _("Done")
class BaseModel(models.Model):
@@ -193,34 +195,44 @@ class Resource(BaseModel):
except AttributeError:
return f"Resource {self.id!s}"
def get_role(self, user):
"""
Determine the role of a given user in this resource.
"""
if not user or not user.is_authenticated:
return None
role = None
for access in self.accesses.filter(user=user):
if access.role == RoleChoices.OWNER:
return RoleChoices.OWNER
if access.role == RoleChoices.ADMIN:
role = RoleChoices.ADMIN
if access.role == RoleChoices.MEMBER and role != RoleChoices.ADMIN:
role = RoleChoices.MEMBER
return role
def is_administrator(self, user):
"""
Check if a user is administrator of the resource.
Users carrying the "owner" role are considered as administrators a fortiori.
"""
return RoleChoices.check_administrator_role(self.get_role(user))
return RoleChoices.is_administrator(self.get_roles(user))
def is_owner(self, user):
"""Check if a user is owner of the resource."""
return RoleChoices.check_owner_role(self.get_role(user))
return RoleChoices.OWNER in self.get_roles(user)
def get_roles(self, user):
"""Compute the roles a user has in a room."""
if not user or not user.is_authenticated:
return self.accesses.none()
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(user=user).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = self.accesses.none()
return roles
def get_abilities(self, user):
"""Compute and return abilities for a given user on the room."""
roles = self.get_roles(user)
is_owner_or_admin = RoleChoices.is_administrator(roles)
return {
"start_recording": is_owner_or_admin,
"destroy": RoleChoices.OWNER in roles,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin,
"retrieve": True,
"update": is_owner_or_admin,
}
class ResourceAccess(BaseModel):
@@ -325,3 +337,47 @@ class Room(Resource):
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
def start_recording(self):
"""Create a new related recording object to which Livekit will be able to save a file."""
return Recording.objects.create(room=self)
class 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,
}
@@ -0,0 +1,102 @@
"""
Test recordings API endpoints in the Meet core app: delete.
"""
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
def test_api_recordings_delete_anonymous():
"""Anonymous users should not be allowed to destroy a recording."""
recording = RecordingFactory()
client = APIClient()
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_api_recordings_delete_authenticated():
"""
Authenticated users should not be allowed to delete a recording from a room to which
they are not related.
"""
recording = RecordingFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_members():
"""
Authenticated users should not be allowed to delete a recording from a room of which
they are only a member.
"""
user = UserFactory()
recording = RecordingFactory(
room__users=[(user, "member")]
) # as user declared in the room but not administrator
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_administrators():
"""
Authenticated users should not be allowed to delete a recording from a room in which
they are administrator.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "administrator")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_owners():
"""
Authenticated users should be able to delete a recording from a room in which they are
directly owner.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 204
assert Recording.objects.exists() is False
@@ -0,0 +1,132 @@
"""
Test recordings API endpoints in the Meet core app: list.
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_recordings_list_anonymous():
"""Anonymous users should not be able to list recordings."""
factories.RecordingFactory(room__is_public=False)
factories.RecordingFactory(room__is_public=True)
response = APIClient().get("/api/v1.0/recordings/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_recordings_list_authenticated():
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.RecordingFactory(room__is_public=False)
factories.RecordingFactory(room__is_public=True)
factories.RecordingFactory(room__is_public=False, room__users=[other_user])
recording = factories.RecordingFactory(room__is_public=False, room__users=[user])
room = recording.room
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(recording.id),
}
result_ids = {result["id"] for result in results}
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"stopped_at": None,
"room": {
"id": str(room.id),
"is_public": room.is_public,
"name": room.name,
"slug": room.slug,
},
"status": "recording",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
}
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_recordings_list_pagination(_mock_page_size):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recordings = factories.RecordingFactory.create_batch(3, room__users=[user])
recording_ids = [str(r.id) for r in recordings]
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
recording_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/recordings/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"], "http://testserver/api/v1.0/recordings/"
assert len(content["results"]) == 1
recording_ids.remove(content["results"][0]["id"])
assert recording_ids == []
def test_api_recordings_list_authenticated_distinct():
"""A recording for a public room with several related users should only be listed once."""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recording = factories.RecordingFactory(
room__is_public=True, room__users=[user, other_user]
)
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(recording.id)
@@ -0,0 +1,128 @@
"""
Test file downloads API endpoint for users in Meet's core app.
"""
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# This is a minimal MP4 file header, which creates a 1-second empty video
VIDEO_BYTES = (
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42mp41"
b"\x00\x00\x00\x08free\x00\x00\x02\xeemdat"
)
@pytest.mark.parametrize("is_public", [True, False])
def test_api_recordings_retrieve_auth_anonymous(is_public):
"""Anonymous users should not be able to retrieve recordings for a room."""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = APIClient().get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
@pytest.mark.parametrize("is_public", [True, False])
def test_api_recordings_retrieve_auth_authenticated(is_public):
"""
Authenticated users who are not related to a room should not be able to
retrieve recordings for this room.
"""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("is_public", [True, False])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_recordings_retrieve_auth_admin_or_owner(role, is_public):
"""
Users who are administrator or owner of a room, should be able to retrieve recordings.
"""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserResourceAccessFactory(resource=room, user=user, role=role)
# deposit a recording in S3
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
# verify getting object content from url with authorization headers
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content == VIDEO_BYTES
@@ -0,0 +1,56 @@
"""
Test recordings API endpoints in the Meet core app: stop.
"""
from datetime import datetime, timezone
from unittest import mock
from uuid import uuid4
from django.utils import timezone as django_timezone
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_recording_stop_recording_success():
"""Anonymous users can stop a recording just with its UUID."""
recording = factories.RecordingFactory()
now = datetime(2010, 1, 1, tzinfo=timezone.utc)
with mock.patch.object(django_timezone, "now", return_value=now):
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 200
recording.refresh_from_db()
assert recording.stopped_at == now
def test_api_recording_stop_recording_unknown():
"""Trying to stop an unknown recording should return a 404."""
response = APIClient().post(f"/api/v1.0/recordings/{uuid4()!s}/stop/")
assert response.status_code == 404
def test_api_recording_stop_recording_already_stopped():
"""
Trying to stop a recording that was already stopped should return a 403 and leave
the recording unmodified.
"""
recording = factories.RecordingFactory()
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 200
recording.refresh_from_db()
stopped_at = recording.stopped_at
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 403
recording.refresh_from_db()
assert recording.stopped_at == stopped_at
@@ -286,7 +286,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -360,7 +360,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -0,0 +1,88 @@
"""
Test rooms API endpoints in the Meet core app: start-recording.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_rooms_start_recording_anonymous():
"""Anonymous users should not be allowed to start a recording for a room."""
room = factories.RoomFactory()
response = APIClient().post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 401
assert models.Recording.objects.exists() is False
def test_api_rooms_start_recording_authenticated():
"""
Authenticated users should not be allowed to start a recording for a room
to which they are not related.
"""
user = factories.UserFactory()
room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 403
assert models.Recording.objects.exists() is False
def test_api_rooms_start_recording_members():
"""
Users who are members of a room but not administrators should
not be allowed to start a recording in this room.
"""
user = factories.UserFactory()
room = factories.RoomFactory(users=[(user, "member")])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code, 403
assert models.Recording.objects.exists() is False
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_rooms_start_recording_administrators(role):
"""Administrators or owners of a room should be allowed to start a recording in this room."""
user = factories.UserFactory()
room = factories.RoomFactory(users=[(user, role)])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 201
assert models.Recording.objects.get().room == room
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_rooms_start_recording_administrators_of_another(role):
"""
Being administrator or owner of a room should not grant authorization
to update another room.
"""
user = factories.UserFactory()
factories.RoomFactory(users=[(user, role)])
other_room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{other_room.id!s}/start-recording/")
assert response.status_code, 403
assert models.Recording.objects.exists() is False
+21 -36
View File
@@ -86,81 +86,66 @@ def test_models_rooms_is_public_default():
assert room.is_public is True
def test_models_recordings_key():
"""Check the room key format."""
room = RoomFactory()
recording = room.start_recording()
assert recording.key == f"recordings/{recording.pk!s}/file.mp4/"
# Access rights methods
def test_models_rooms_access_rights_none(django_assert_num_queries):
"""Calling access rights methods with None should return None."""
"""The `get_roles` method should return an empty list when called with None."""
room = RoomFactory()
with django_assert_num_queries(0):
assert room.get_role(None) is None
with django_assert_num_queries(0):
assert room.is_administrator(None) is False
with django_assert_num_queries(0):
assert room.is_owner(None) is False
assert not list(room.get_roles(None))
def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
"""Check access rights methods on the room object for an anonymous user."""
"""The `get_roles` method should return an empty list for an anonymous user."""
user = AnonymousUser()
room = RoomFactory()
with django_assert_num_queries(0):
assert room.get_role(user) is None
with django_assert_num_queries(0):
assert room.is_administrator(user) is False
with django_assert_num_queries(0):
assert room.is_owner(user) is False
assert not list(room.get_roles(user))
def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
"""Check access rights methods on the room object for an unrelated user."""
"""
The `get_roles` method should return an empty list for a user not related to the room.
"""
user = UserFactory()
room = RoomFactory()
with django_assert_num_queries(1):
assert room.get_role(user) is None
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
assert not list(room.get_roles(user))
def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
"""Check access rights methods on the room object for a direct member."""
"""Check `get_roles` method on the room object for a direct member."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
with django_assert_num_queries(1):
assert room.get_role(user) == "member"
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
assert list(room.get_roles(user)) == ["member"]
def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries):
"""The is_administrator method should return True for a direct administrator."""
"""Check `get_roles` method on the room object for a direct administrator."""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
with django_assert_num_queries(1):
assert room.get_role(user) == "administrator"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is False
assert list(room.get_roles(user)) == ["administrator"]
def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
"""Check access rights methods on the room object for an owner."""
"""Check `get_roles` method on the room object for an owner."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
with django_assert_num_queries(1):
assert room.get_role(user) == "owner"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
assert list(room.get_roles(user)) == ["owner"]
+1
View File
@@ -11,6 +11,7 @@ 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"
+30
View File
@@ -11,7 +11,9 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import botocore
from livekit.api import AccessToken, VideoGrants
@@ -81,3 +83,31 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
)
return token.to_jwt()
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
Binary file not shown.
Binary file not shown.
+27
View File
@@ -119,6 +119,25 @@ class Base(Configuration):
},
}
# Media
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
)
AWS_STORAGE_BUCKET_NAME = values.Value(
"meet-media-storage",
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -549,6 +568,14 @@ class Production(Base):
#
# In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
+1 -6
View File
@@ -1,7 +1,6 @@
"""URL configuration for the Meet project"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
@@ -18,11 +17,7 @@ urlpatterns = [
]
if settings.DEBUG:
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
)
urlpatterns = urlpatterns + staticfiles_urlpatterns()
if settings.USE_SWAGGER or settings.DEBUG:
+5 -5
View File
@@ -25,14 +25,14 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.19",
"boto3==1.35.34",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.8",
"redis==5.1.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
@@ -42,17 +42,16 @@ dependencies = [
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.2",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.14.0",
"sentry-sdk==2.15.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
@@ -70,6 +69,7 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.27.0",
"pyfakefs==5.6.0",
+1 -1
View File
@@ -32,7 +32,7 @@ WORKDIR /home/frontend
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
+1179 -1098
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -13,23 +13,23 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.6.0",
"@livekit/components-styles": "1.1.2",
"@livekit/components-react": "2.6.5",
"@livekit/components-styles": "1.1.3",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.46.1",
"@react-aria/toast": "3.0.0-beta.15",
"@react-aria/toast": "3.0.0-beta.16",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.56.2",
"@tanstack/react-query": "5.59.4",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1",
"i18next": "23.15.1",
"i18next": "23.15.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.5.3",
"posthog-js": "1.164.1",
"livekit-client": "2.5.7",
"posthog-js": "1.167.0",
"react": "18.3.1",
"react-aria-components": "1.3.3",
"react-aria-components": "1.4.0",
"react-dom": "18.3.1",
"react-i18next": "15.0.2",
"use-sound": "4.0.3",
@@ -38,14 +38,14 @@
},
"devDependencies": {
"@pandacss/dev": "0.46.1",
"@tanstack/eslint-plugin-query": "5.57.1",
"@tanstack/react-query-devtools": "5.56.2",
"@types/node": "20.16.6",
"@types/react": "18.3.8",
"@tanstack/eslint-plugin-query": "5.59.2",
"@tanstack/react-query-devtools": "5.59.4",
"@types/node": "20.16.11",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.7.0",
"@typescript-eslint/parser": "8.7.0",
"@vitejs/plugin-react": "4.3.1",
"@typescript-eslint/eslint-plugin": "8.8.1",
"@typescript-eslint/parser": "8.8.1",
"@vitejs/plugin-react": "4.3.2",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.10.0",
@@ -53,8 +53,8 @@
"eslint-plugin-react-refresh": "0.4.12",
"postcss": "8.4.47",
"prettier": "3.3.3",
"typescript": "5.6.2",
"vite": "5.4.5",
"typescript": "5.6.3",
"vite": "5.4.8",
"vite-tsconfig-paths": "5.0.1"
}
}
+9 -9
View File
@@ -64,16 +64,16 @@ const config: Config = {
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
},
active_speaker: {
'0%': { height: '4px' },
'25%': { height: '8px' },
'50%': { height: '6px' },
'100%': { height: '16px' },
'0%': { height: '25%' },
'25%': { height: '45%' },
'50%': { height: '20%' },
'100%': { height: '55%' },
},
active_speake_small: {
'0%': { height: '4px' },
'25%': { height: '6px' },
'50%': { height: '4px' },
'100%': { height: '8px' },
active_speaker_small: {
'0%': { height: '20%' },
'25%': { height: '25%' },
'50%': { height: '18%' },
'100%': { height: '25%' },
},
wave_hand: {
'0%': { transform: 'rotate(0deg)' },
+3 -3
View File
@@ -1,12 +1,12 @@
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { LinkButton } from '@/primitives'
export const Feedback = () => {
const { t } = useTranslation()
return (
<Button
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success"
target="_blank"
@@ -20,6 +20,6 @@ export const Feedback = () => {
className={css({ marginLeft: 0.5 })}
aria-hidden="true"
/>
</Button>
</LinkButton>
)
}
@@ -12,6 +12,19 @@ const StyledContainer = styled('div', {
justifyContent: 'center',
gap: '2px',
},
variants: {
pushToTalk: {
true: {
height: '46px',
width: '58px',
borderLeftRadius: 8,
borderRightRadius: 0,
backgroundColor: '#dbeafe',
border: '1px solid #3b82f6',
gap: '3px',
},
},
},
})
const StyledChild = styled('div', {
@@ -36,16 +49,32 @@ const StyledChild = styled('div', {
},
size: {
small: {
animationName: 'active_speake_small',
animationName: 'active_speaker_small',
},
},
pushToTalk: {
true: {
backgroundColor: 'primary',
width: '6px',
height: '6px',
},
},
},
})
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
export type ActiveSpeakerProps = {
isSpeaking: boolean
pushToTalk?: boolean
}
export const ActiveSpeaker = ({
isSpeaking,
pushToTalk,
}: ActiveSpeakerProps) => {
return (
<StyledContainer>
<StyledContainer pushToTalk={pushToTalk}>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
@@ -53,12 +82,14 @@ export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
style={{
animationDelay: '100ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
@@ -5,7 +5,7 @@ import {
UseTrackToggleProps,
} from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx'
import { Button, Menu, MenuList, ToggleButton } from '@/primitives'
import { Button, Menu, MenuList } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
@@ -15,7 +15,10 @@ import {
RiVideoOnLine,
} from '@remixicon/react'
import { Track } from 'livekit-client'
import React from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
export type ToggleSource = Exclude<
Track.Source,
@@ -24,10 +27,12 @@ export type ToggleSource = Exclude<
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
type SelectToggleDeviceConfig = {
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
@@ -39,11 +44,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
@@ -61,39 +77,17 @@ export const SelectToggleDevice = <T extends ToggleSource>({
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { buttonProps, enabled } = useTrackToggle(props)
const { kind, iconOn, iconOff } = config
const trackProps = useTrackToggle(props)
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind })
useMediaDeviceSelect({ kind: config.kind })
const toggleLabel = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
const selectLabel = t('choose', { keyPrefix: `join.${kind}` })
const Icon = enabled ? iconOn : iconOff
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
return (
<HStack gap={0}>
<ToggleButton
isSelected={enabled}
variant={enabled ? undefined : 'danger'}
toggledStyles={false}
onPress={(e) =>
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement>
)
}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
>
<Icon />
</ToggleButton>
<ToggleDevice {...trackProps} config={config} />
<Menu>
<Button
tooltip={selectLabel}
@@ -0,0 +1,71 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
export type ToggleDeviceProps = {
enabled: boolean
toggle: () => void
config: SelectToggleDeviceConfig
}
export const ToggleDevice = ({
config,
enabled,
toggle,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t])
const Icon = enabled ? iconOn : iconOff
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
if (kind === 'audioinput' && pushToTalk) {
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
return (
<ToggleButton
isSelected={enabled}
variant={enabled ? undefined : 'danger'}
toggledStyles={false}
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
>
<Icon />
</ToggleButton>
)
}
@@ -8,6 +8,7 @@ import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
export const Room = () => {
const { isLoggedIn } = useUser()
@@ -19,6 +20,8 @@ export const Room = () => {
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
useKeyboardShortcuts()
const clearRouterState = () => {
if (window?.history?.state) {
window.history.replaceState({}, '')
@@ -0,0 +1,4 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
}
@@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { isMacintosh } from '@/utils/livekit'
import { formatShortcutKey } from './utils'
export const useKeyboardShortcuts = () => {
const shortcutsSnap = useSnapshot(keyboardShortcutsStore)
useEffect(() => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
shortcut()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [shortcutsSnap])
}
@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
export type useLongPressProps = {
keyCode?: string
onKeyDown: () => void
onKeyUp: () => void
longPressThreshold?: number
}
export const useLongPress = ({
keyCode,
onKeyDown,
onKeyUp,
longPressThreshold = 300,
}: useLongPressProps) => {
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code != keyCode || timeoutIdRef.current) return
timeoutIdRef.current = setTimeout(() => {
onKeyDown()
}, longPressThreshold)
}
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code != keyCode || !timeoutIdRef.current) return
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
onKeyUp()
}
if (!keyCode) return
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
return
}
export default useLongPress
@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
handler: () => void
}
export const useRegisterKeyboardShortcut = ({
shortcut,
handler,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!shortcut) return
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
}, [handler, shortcut])
}
@@ -0,0 +1,18 @@
import { isMacintosh } from '@/utils/livekit'
import { Shortcut } from '@/features/shortcuts/types'
export const CTRL = 'ctrl'
export const formatShortcutKey = (shortcut: Shortcut) => {
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
return shortcut.key.toUpperCase()
}
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
if (!shortcut.key) return
let formattedKeyLabel = shortcut.key.toLowerCase()
if (shortcut.ctrlKey) {
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
}
return `${label} (${formattedKeyLabel})`
}
+1 -16
View File
@@ -1,8 +1,6 @@
import {
Button as RACButton,
type ButtonProps as RACButtonsProps,
Link,
LinkProps,
} from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
@@ -12,25 +10,12 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
TooltipWrapperProps
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonOrLinkProps) => {
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
if ((props as LinkButtonProps).href !== undefined) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
@@ -0,0 +1,22 @@
import { Link, LinkProps } from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
export const LinkButton = ({
tooltip,
tooltipType = 'instant',
...props
}: LinkButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
+1
View File
@@ -9,6 +9,7 @@ export { Badge } from './Badge'
export { Bold } from './Bold'
export { Box } from './Box'
export { Button } from './Button'
export { LinkButton } from './LinkButton'
export { useCloseDialog } from './useCloseDialog'
export { Dialog, type DialogProps } from './Dialog'
export { Div } from './Div'
@@ -0,0 +1,11 @@
import { proxy } from 'valtio'
export type KeyboardShortcutHandler = () => void
type State = {
shortcuts: Map<string, KeyboardShortcutHandler>
}
export const keyboardShortcutsStore = proxy<State>({
shortcuts: new Map<string, KeyboardShortcutHandler>(),
})
+4
View File
@@ -25,3 +25,7 @@ export function isSafari(): boolean {
export function isLocal(p: Participant) {
return p instanceof LocalParticipant
}
export function isMacintosh() {
return navigator.platform.indexOf('Mac') > -1
}
+59 -50
View File
@@ -1,14 +1,14 @@
djangoSecretKey: ENC[AES256_GCM,data:dqeL5W5WaTir+tzUAIbivT1DE0zv1IVsq1/nTU7LRKHO2PUefwsyGAy37eYIeC89q9I=,iv:ODTaWFvvYLHNYRkIX//wY2knKD2uIwf7Mo4UhUBEX9c=,tag:MnE3wLdLcK79XUwXV99w4w==,type:str]
djangoSecretKey: ENC[AES256_GCM,data:p+9m8eNB/dKMXAdfL0cVCg1uKhAv+YLrM+jjajvRYmOZZ9qbiikuFv0dyDp32va/M9w=,iv:ijUztg7ta6BBTsKs+IIfJMFdN0DfzyAKoxlfY8lisPg=,tag:B+uW6akIV0iI2LdMQotrpw==,type:str]
oidc:
clientId: ENC[AES256_GCM,data:X+Qj//JXn9kfVszbe9BNbL8rQ53AJq0Cluq3nG58OyXlv5/y,iv:/Na7zvcsIu5OxJw1uFwiksh8tXfzG9It26jW41E0edQ=,tag:oph41uyMwj3uZMnZPI64ZQ==,type:str]
clientSecret: ENC[AES256_GCM,data:2WV+i0pd4wBXiwG8ak+yVROGsmwSaM0X0rEJjFeNeGYtUsHozd5XrurQu6FAB1tQnzTRgRgAU0ob0MqbsMUYhg==,iv:OJZRG+OUD0Fi2HZ0w3Rn+SMFkebMrsQtKvymJkicF28=,tag:PJYzhVvM0gH0xAGrSSPAIQ==,type:str]
#ENC[AES256_GCM,data:iZjWgYcHtDeK/c1TrfVVsZaJRmuc1+2XEJnLEOYw1wXMsUUvgswEc5PP/4UmirhbSulwtuMRhFt74sIogMxPl2etMAoCqo3ziKRuu3FrmgSUD/bdXQRl+VQ0nJ3NnWNcKqf+GS8=,iv:5hUaA4hkABLvXoKVE4TNZevOdUCaCy3zYkGajHpmZ5k=,tag:ikiSAyIly8NAgVA0olASRw==,type:comment]
clientId: ENC[AES256_GCM,data:rHzKkQwFQ7hV6kOBBP60RK41NBKVMUs4dMcZavMQ8gCu9ust,iv:8vviSb+XIKS/zjBIScfmWu0VJ8lXCQZ8p7BxuvJtA2w=,tag:k8vn8I/qxKLE/+JNTDj4Jw==,type:str]
clientSecret: ENC[AES256_GCM,data:dOYJoG2PStlOMIJPi2exPzsqlxis73iTkcBMvjr8DBr2isWzstpbexscsog7Tuyelw4tpzrJKzC5BTTwJ+xioQ==,iv:oqkLRTPB8+qR0AHvjyNVfHRmoeGrkUvZjrTsWBjIeBc=,tag:hryfmSeqkdWCN9U38jxXlA==,type:str]
#ENC[AES256_GCM,data:ua1td/VBXGIHDgAw/bm8XnWIRLmgeJKX9dP7g/rNv3jVsXHw6T+iDXxMWpLXNicAZ/RTymdntlwLwsH47r70Z4icEPsjps0yOZ+X734vaL9wVH9IsyFwCihtyck94kgY4CyC7DI=,iv:iGHYu+2aPaI28PQWFheVVuge8BPWLw1VB7Afsz7eLtI=,tag:pfkXsS+/QmHb3kHS/ONHCA==,type:comment]
livekit:
keys:
devkey: ENC[AES256_GCM,data:deDMTTO9,iv:ZCloCUPRJ7PG2KcGsnhVj3lRDfCmJowwp8PVg4fdTmg=,tag:zCh8lQT29kQbY7LiXHNEiA==,type:str]
devkey: ENC[AES256_GCM,data:5RnAMGm3,iv:bY4n8op2KFlXRqzV9h3QwoC3Bws2aEoN1GFxPlrrVBw=,tag:lA+b/6poVRzeJW6Bu8V29A==,type:str]
livekitApi:
key: ENC[AES256_GCM,data:fh0zJSUE,iv:yR5siulW5vlO/Ew8qtYFNNtyODOPm3d3NL4SVCzzVcM=,tag:hLtEuonLJoi0eFJmY0RYEg==,type:str]
secret: ENC[AES256_GCM,data:81SOHzA6,iv:is7QdB/T58IP41SrPyDdFHtyX57tphb9Gpf/aQ7NnTY=,tag:1E03R+EFYmLtxFoIDcVEHg==,type:str]
key: ENC[AES256_GCM,data:JP7KkPms,iv:LlIJ62IRyGf8fByl6abSZv1to2FUc90laC0oL5HFJK4=,tag:2aLMQ79GlDOaiurh8unO0Q==,type:str]
secret: ENC[AES256_GCM,data:kGDJo1lh,iv:dnI1OuvZGOJZEKZwzoigXqViqYCw/6H7Y0sVXH/p5RA=,tag:G1IB0mc8zuKEmkxrfyImrQ==,type:str]
sops:
kms: []
gcp_kms: []
@@ -18,77 +18,86 @@ sops:
- recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCQnRJY0xWNFVRYkR0QTND
Rkc5dDRITUpRMW1EVlhEUk9xN0ttbElrdEJvClBMVnVKMXhGMThTcTVaN0s3OG1v
S0U2UUNYZ3FzcXUwVXRyS1hPZkpxcmsKLS0tIHhWei9KVmxFaXgzRUxmMWU2RmhG
Y1ZQekpQS3poSWxHZXUvbitlc2lIczAKjLZlgUSz51W4GHirUn352eFPSxIK1/Wf
N+kzoUvMLmwwfHDztFFYLOEE9x1zx7GoPGG7fN9bTG7GWgcRdd7NUw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByR3IybDN3eGx4amYzZkFt
OW5VV3FQN3dkSmZBL0JwUE1qSzNLYmRTc1RjCkVCQ2ZmaHk2SFRJaXdMd0VMZUlP
b0VQeDVUTDBEZzhBQnhrS2RybzYvL1UKLS0tIG1CbllhWGpsOWx4WEkya0NLeUlC
WmRScW9MVkxQLzRxdk85WTZ4U2E0aUUKTpOPYQXutU0xYLih7SNYoQgO+PSEIERL
HLz+C7iV+Fj1/M7JrgiGxTB8wJoKMo7IhJ8AjxaAdxR4Q1TgUpQkPw==
-----END AGE ENCRYPTED FILE-----
- recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3aTVDMDNUUFFVQlE3Ykpl
ZlFQUHQ0UUdlTU54WlEvanBWeGVlYnd1aEZFClQ3Y1ZSOHp6QUt1UXBLV3NWaGJ4
T2ZwKzZXcXh1Z2llQk9SN1RRLzNOdGcKLS0tIHN0YTFpL05SQUc1WTRsQ2Z0ZGtu
ZjhoUHhFbkMvRUhscy9LY05xbXFZVlEKXrt6UPLOprcnsTFX1WCF/pIWXmjiPGEx
7NNbnAl+A9yfheCeiJdWcj5ZBCa6Nx2udVMNjQ7ITMzhIY4BBSicuQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0aE15QkRsNmg2UTkxaWNF
T3NZY2RqSDd0WlRKOHYxWFE2R3J5SGJhRjJNClNIcEFwOEtoSmRWQjdaSm1ZSnlj
amhNci9tRDl2Qlp4dlBGZFYzTGxYdm8KLS0tIDZZWTYxQmVqOEZQaTNOODFGWUhn
cXpJL3poT3dpYjZKWTN6dGpOV3kxT2sKozsOz+cSYJdZ0C2L6QCf/VSU9DnOz6ae
lqV5MMzSl1Jf8ETpqt+PhvvWz+MLCAkIriT9yf6R29DQifCacB7XOA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1Y3JyU3JPOWdiamd0cEFV
UWRUdUQ5V3ZnQXQ0YURLZ3RiY0toN29vNkhJCnIzYWdkV3puVWQ5Nm5SVGpobllB
OW5WWkVLaE55eUs2OWNDWkZiOGNuZW8KLS0tIHN3eGVST1BRMU1JRWNRZGNJeGdi
SWJXQ2VMZ3kyWElBSjBKc0tQWmIwMjgKLDakU3iHwVTQBYGR0d2TFFdLdsct49y1
/S8vydlcx08L0yWHbfamhiYJE3BZbRXZue6z5irBPKEVjGD42aSREw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBySkpOYWxjQVZRbGtkNXlt
OTRKTDlrNjNMenU3V0hPeXYyRnhGVU1mMmhNCmhJTi9ZQzB3ekpSR0k1VDFiNExu
dW9TQkI3Vy9LOXhQaEExZHMyM25xZlEKLS0tIGRYTkpzbjIvL1FMS2lYYXl4dDVZ
U040akh0Z1ZYVmdjS3k2ZjFRK2VRNGMKqSCnviWARWTkZXeht+sdOYKAxylYYyZK
uXYE3nBaXGosIqmTf6deVqCIY+m0mH/J4UMcbH+faMV4pWmVr2JAxg==
-----END AGE ENCRYPTED FILE-----
- recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKUklKTzRzdHZiZjdHSmY2
WCs3ZFVodFBGcDdvZWZaaHNnbG9mdFk2WVJjCjUwZjA2Qi9CR1JYdjJ3VFlhZDZR
REEyZStrTjIwbWdCdVU5NWo2Y2hBemsKLS0tIFZmWVh0Z1BFUGRjZExMNlNJSFVJ
VnlWY3U4dW4ycXJCOVVUUlJxYzVONGsKy47vHe+awhJyI/pSUOhvUiOt+jtn+Vwg
Rlm93bJr0GNVWYm521r/I409s3TMitQerweGnl3u7t1FaKX7oI1qBQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJbUhzZStoUVBHUkZLWlE3
NWNiRkJMdXhUVXRNZTFCMUljVzIxY3BVMFQ4ClpmOGhqeUZiaG1HcU5zdndmWE5y
Ym5OTmoyVVVsb2Ywa3loRTVNZzdlVjQKLS0tIHNEWVV3Mkk2VGVzR3diQW5Ccm1a
MVNUYjZCME9rQWFUaWNycEh5THQyTTAKTBnoF76mJ/GoCIq4TsmV+luYbiWnx0+I
BEISvqsr9gbT0z8kfdo/htPoKHZmnyevZhRhd2AMZdKixYvQMX9sjA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVUE9nVk1SMUp5WW9zYnZs
OFBYWmpwZW56S0J0Qk43dmlCWG5RV0RIKzM0CkVNbzI5VSs3VVdaT2xnVEhGdW9h
QW02K0ZmeGxsSDZMTGdETjkwd2xVd2MKLS0tIHp2WWQyUFphMmlZaDBZR0JQZ095
UTVaYXl6SkZUNElUdUlZcU8rditFd3cK0CqMiDzEItfB/T0K8YEncp9HuKWVTy7q
2LgHBQJi35sdBviEpsHZt7BlHTKanbmB5S9oUexNq+3wUP9e1n5CGA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDWEZkODBNOGw2WFdncjJ0
TTVzRHlEa1AzaTF4V2hYR3hFRGg2cnBzYmowCmp3WDJ3bEZoTlFYL2hoZ3hhTVU1
WnQyYk03K2xmSk00dS92OHNNZnRIL2cKLS0tIEVrbjY4enJBZzdQMjRCRmwwVlRI
OHVOMm9NTGdJbnZ2aXYxdi9OdWpkVE0K4b1Hu6rOHVtfH601aXb/uTGYjNMh6yW/
LetO+HKk+VEzXHntObK2k/4mTl5I0+OP5H8+PR0jdIUZDpr79iEbgQ==
-----END AGE ENCRYPTED FILE-----
- recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0VDdRaEVCemd5T0ozSmtp
NllJa0tURVhxTXlZQS8vNUZxbGFQVnhRMDJjClhlaE53WTRpeEtVSGErYUdyZk1h
SloxZGNUdFRDZ1J4bzdneFUyNXdXdkUKLS0tIGdxU3RCUEVmcTdnZnFidkROdXJS
dVdQUUdVUUhpVVh6SytRV1V0RHV6cDQKagJA6w43n8yh119PrJltPPh/EbHIKjfH
G8RfgXBrf5iWdWsyD8haDL6WsjdbVQsbPCz+ucULfnZ1Dn8A/3yQUQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqVE9iMmUwTXE2SHZNdG5P
Vi9XQ1Jkc1VDamFlakpkZk45ODZ2YnkwYkVBCnNrbktIdkV4UGltcHBUUHlXbjdx
Z0QwM3ZKbGI1cDBjL2g2cjdKdElOQjAKLS0tIGxrcTJDa1BWVWcxUS80MmxIMWZH
YjBRMDZJZWlmN1FNaXV5c04yVWtleE0K+nGNyFzqSotFP7My/kUnAgxXGu/ji50K
OGVLYgNvU48rCGck3r9ZrKY1HpQdAY8UMQXECsuO4HgdirNjiZ97Zg==
-----END AGE ENCRYPTED FILE-----
- recipient: age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIbGgxRG5sZHFPZm5OTnZw
L21JUnFDOTNZUitYNHJad3FnYkk2aDlQRzM0CnI1NXZDcEtQdjBWaVhJMElIOEZU
KzFjT0p4OVpzUFlnR1Q5cm5SVnhGaEUKLS0tIFliVVQ4VFg4QVJxazdyZG9PbE9M
M3NzZFRKMFkxZHZ1Rmh0VWFWMDZTQlEKxKVEvnnV56t/RSvP94TgjW1Do68Tn2N2
tJMI8VIp7rs5vxaToois6CMuhVONsl1CBozAEV8pqS5O830RGa1eog==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZXZud0dqb0dkQ0E3NnE4
SXB0ZENjQk1mb1BHU2R1bW0waDhTYy9OZldVCjVnRTV5d1c3Q2NzcEVRQ3BoL09I
T1RPQ3hHT3Y2NFNzWG9EdGM2STR2STgKLS0tIHBvL3RhREFNTVdwUGk3S1B4NWJL
TnZpblF1SDdGRlVXM0dEdFAzT1FEMUUK6L8gTv5gt6++A3B7PHyWl+xtBUc8bC6G
53xoJvyyBpaov3HgUAdrN9VHubfEJmrBGgN7DngGgwYPtlhV87M7/w==
-----END AGE ENCRYPTED FILE-----
- recipient: age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBta3VwOGQrSHdENGZLZUpq
SGw0Z2YwYm8vZ0h4Y01UclIrQlRVNWVJaHlRCmFMQkp1aGc5R1JrMVdPNmwvbkYv
NkxOc3pYY2E5dVVFejZJaitSb0hRUVUKLS0tIGFzclh3VXhBc3NYQjZlUmNUSk10
S2xqMEpBcEpaOHdBNUtvOWxsVUhLLzQKGT3grtnocBkaCKXbtH7cZu/ZP9MKsAjt
ZNmS1GahpBx2lVEbfJNLfwId+IJ6kOIyHj42g9yIQFfKTPKE32Ht8A==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlaXY1VmtDejcwTmUxRVZT
YURhMkVPaHNvb0sxT0FYL0pvN3hqclNNcXdRCmxWV3FGeDZTM1VVMVRyalpkVnFJ
OGU3Wk9wVVAvejVTdjc1MENPcy9Qc1kKLS0tIGpJQXhZVzV3REc2SFlFSXg0dUo5
bjRBaGtJdUFmVUkxeGgwbGYwWjRnNEkKYwzwZ9oOo+C6XD57rkUTO6QADZKzYfSF
cFJ7fX0NyZbzxLncyofWa+dlLWLZ3KohIP0doAFngRm+RVsUEVqY5A==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-07-18T13:08:42Z"
mac: ENC[AES256_GCM,data:a/uHyw9V/SMIePV9nPf+wJgPg+YDYLJGYy7NMLBrBgCXtBWHHonSNjzdmtjix1bW2y+cU0gMqodrtqR1cJGBmXr4NRY7NJqgLWE9rEdYfG7BnfqsWmvAaTIrSs7QMZWkEic7ys/bXoA5BZoau3olhVqIO2A/iyBtoMU9Hv7hPlo=,iv:gaqSCUbN7cxWPNrFPDTl7xNxpOZL6GY/swD/MDCiRqk=,tag:Oz0f/DyD3KGV/9Rprj/1Xw==,type:str]
- recipient: age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1aFNsL2xvWmI4UTAxREc4
NFF3bC9qRTBqS3JrM3B0ZjE5bEtjR0diT0VjClhFNStFU3RydnhvcG9CSmhYM3V4
VjZ5c0JQZjRoQXh1R2UyeDMyd2NFMEEKLS0tIDNwWUNzZmlrNGZPbERTeFpoUkxO
QnZTWWFMemk5djVNWFRaekVMRkMyUjgKt4dw4BOm3J1Ig6U58NbSjzJbWi3ak/Zq
8PX5IW7tq1q5+Qd3adqv3cd9S2aVpqjHyN34fxagmuwfvYXVyQ2GDg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-10-02T07:30:09Z"
mac: ENC[AES256_GCM,data:BdEiR/7AiTz9eppAGOAarFzUJYEfCZzb0lg8LXaHiXe74B5Ob7Ai+XuBBX+x9QPIFzbLZgVveVSrqymW0wAH9Dv5R+e4spDf5KKdRCr9RADfCXNjYC0N9grZVerM70Ic51Lc1kKDnB2mon01W5Sa77Ei29Jo988yvM8AOlXFvr4=,iv:p7PCazxKNv7YcGX7Kpp2L8wXEFaJO8FajEXcVMzmmWQ=,tag:WJKZOkFZSof6IhcXqc60uQ==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.8.1
version: 3.9.0
+19 -2
View File
@@ -40,6 +40,10 @@ backend:
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
LIVEKIT_API_SECRET: {{ $value }}
@@ -98,10 +102,23 @@ ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/retrieve-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
enabled: false
@@ -85,6 +85,23 @@ backend:
name: redis.redis.libre.sh
key: url
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
@@ -130,6 +147,24 @@ ingressAdmin:
nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/start
nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/auth
ingressMedia:
enabled: true
host: visio.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://visio.numerique.gouv.fr/api/v1.0/recordings/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /impress-production-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.hedy-lamarr.indiehosters.net
serviceMedia:
host: s3.hedy-lamarr.indiehosters.net
port: 443
posthog:
ingress:
enabled: true
@@ -84,6 +84,23 @@ backend:
name: redis.redis.libre.sh
key: url
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
LIVEKIT_API_SECRET:
secretKeyRef:
name: backend
@@ -140,6 +157,24 @@ ingressAdmin:
hosts:
- {{ .Values.newDomain }}
ingressMedia:
enabled: true
host: meet-staging.beta.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://meet-staging.beta.numerique.gouv.fr/api/v1.0/recordings/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /meet-staging-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.margaret-hamilton.indiehosters.net
serviceMedia:
host: s3.margaret-hamilton.indiehosters.net
port: 443
posthog:
ingress:
enabled: true
+14
View File
@@ -45,6 +45,20 @@ releases:
enabled: true
autoGenerated: true
- name: minio
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: bitnami/minio
version: 12.10.10
values:
- auth:
rootUser: meet
rootPassword: password
- provisioning:
enabled: true
buckets:
- name: meet-media-storage
- name: redis
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
@@ -0,0 +1,83 @@
{{- if .Values.ingressMedia.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressMedia.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMedia.className }}
{{- end }}
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ $fullName }}-tls
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}
{{- range .Values.ingressMedia.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMedia.host }}
- host: {{ .Values.ingressMedia.host | quote }}
http:
paths:
- path: {{ .Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMedia.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
{{- $fullName := include "meet.fullname" . -}}
{{- $component := "media" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMedia.host }}
+30
View File
@@ -69,6 +69,36 @@ ingressAdmin:
enabled: true
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
## @param ingressMedia.path Path to use for the Ingress
ingressMedia:
enabled: false
className: null
host: meet.example.com
path: /media/(.*)
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Wether to enable TLS for the Ingress
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
additional: []
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000
annotations: {}
## @section backend
+1 -1
View File
@@ -14,7 +14,7 @@
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
},
"volta": {
"node": "20.17.0"
"node": "20.18.0"
},
"repository": "https://github.com/numerique-gouv/meet",
"author": "DINUM",