Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine 1e78cc3dab (frontend) add configurable ministries domains to add-in CSP
Introduce a variable to define allowed ministries domains in the
CSP configuration for the add-in.

This improves flexibility and avoids hardcoding domain lists.
2026-05-06 16:47:51 +02:00
lebaudantoine 1af58b889e 🩹(frontend) add Microsoft 365 domains to CSP configuration
Include M365 cloud domains in CSP directives to allow the
Outlook add-in to load properly in Microsoft 365 SaaS clients
2026-05-06 16:44:28 +02:00
430 changed files with 8722 additions and 20165 deletions
+15 -22
View File
@@ -19,8 +19,6 @@ env:
DOCKER_USER: 1001:127 DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
jobs: jobs:
build-and-push-backend: build-and-push-backend:
@@ -33,7 +31,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -46,7 +43,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -63,9 +60,9 @@ jobs:
with: with:
context: . context: .
target: backend-production target: backend-production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -79,7 +76,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -92,7 +88,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -110,9 +106,9 @@ jobs:
context: . context: .
file: ./src/frontend/Dockerfile file: ./src/frontend/Dockerfile
target: frontend-production target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -126,7 +122,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -139,7 +134,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -157,9 +152,9 @@ jobs:
context: . context: .
file: ./docker/dinum-frontend/Dockerfile file: ./docker/dinum-frontend/Dockerfile
target: frontend-production target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -173,7 +168,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -186,7 +180,7 @@ jobs:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary' images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -206,9 +200,9 @@ jobs:
context: ./src/summary context: ./src/summary
file: ./src/summary/Dockerfile file: ./src/summary/Dockerfile
target: production target: production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -222,7 +216,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- -
name: Set up QEMU name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
@@ -235,7 +228,7 @@ jobs:
images: lasuite/meet-agents images: lasuite/meet-agents
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
@@ -255,9 +248,9 @@ jobs:
context: ./src/agents context: ./src/agents
file: ./src/agents/Dockerfile file: ./src/agents/Dockerfile
target: production target: production
platforms: ${{ env.BUILD_PLATFORMS }} platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+5 -11
View File
@@ -150,14 +150,13 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install uv cache: "pip"
uses: astral-sh/setup-uv@v7 - name: Install development dependencies
- name: Install the project run: pip install --user .[dev]
run: uv sync --locked --all-extras
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: uv run ruff format . --diff run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff - name: Lint code with ruff
run: uv run ruff check . run: ~/.local/bin/ruff check .
lint-summary: lint-summary:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -323,11 +322,6 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Install Python - name: Install Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
-3
View File
@@ -83,6 +83,3 @@ docker/livekit/out
# LiveKit CA configuration # LiveKit CA configuration
docker/livekit/rootCA.pem docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
-143
View File
@@ -8,158 +8,16 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ⬆️(agents) upgrade to python 3.14 slim
- ⬆️(dependencies) update python dependencies
### Fixed
- 🚀(front) fix frontend build failure
- 🐛(makefile) fix args in make test
- 🩹(backend) fix case-insensitive email deduplication in merge command
- 🐛(summary) support media files with bad streams #1478
## [1.22.0] - 2026-07-03
### Added
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
- 📝(docs) document rebranding the favicon via a volume mount #1443
- ✨(backend) add command to clean pending and deleted files
- 🧱(helm) run clean files command as cronjob
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
### Changed
- ✨(summary) generalized stt api call #1420
- ♻️(env) refactor env variables handling
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
### Fixed
- 🛂(backend) reject user access tokens on the API
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
## [1.21.0] - 2026-06-15
### Added
- ✨(frontend) allow disabling silent login via a URL parameter
- ✨(frontend) allow hiding the login button via a URL parameter
- ✨(summary) add optional satisfaction survey footer
### Changed
- ✨(frontend) enhance noise reduction with BBBA audio processing pipeline
- 🚸(frontend) mute join notification sound in larger rooms
- 🚸(frontend) mute participants by default when joining a large meeting
### Fixed
- 🐛(frontend) fix metadata agent collector enabled check
### Fixed
- ♿️(frontend) improve accessibilty of the Effects panel #1401
## [1.20.0] - 2026-06-12
### Changed
- ♻️(addon) improve Outlook add-on: i18n support, feedback link, smarter link
- ⬆️(frontend) upgrade react-i18next from 15.1.1 to 17.0.8
### Fixed
- 🐛(frontend) fix noise reduction left-channel-only audio
## [1.19.0] - 2026-06-04
### Added
- ✨(backend) add file specific admin #1387
### Changed
- 🐛(agents) fix bug when closing metadata-collector
- ⬆️(dependencies) update python dependencies
- ⬆️(frontend) update js dependencies
- ♻️(agents) replace deprecated room options API
### Fixed
- 🔇(summary) make ffmpeg quiet #1404
- 🔒️(backend) prevent accessing files if they are not ready #1395
- # ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
## [1.18.0] - 2026-06-03
### Added
- 🔧(backend) backport logging configuration from docs
- 🧑‍💻(backend) add management command to merge duplicate users
- 👷(helm) add Kubernetes job for duplicate user merge command
### Fixed
- 🐛(backend) prevent duplicate pending users on concurrent requests
- 🔒️(backend) prevent file change post checks #1377
## [1.17.0] - 2026-05-31
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
- ✨(backend) add core.recording.event.parsers.S3Parser
- ✨(summary) extended support for all video / audio files #1358
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
- 🚀 (paas) remove buildpack requirements.txt to use the new uv.lock #1349
- ✨(backend) allow room configuration and access level via external api #1260
- ♻️(backend) prefix Swagger routes with /api
### Fixed
- 🩹(backend) fix swagger and redoc documentation URLs
## [1.16.0] - 2026-05-13
### Added ### Added
- 🔒️(backend) add validation of Room.configuration - 🔒️(backend) add validation of Room.configuration
- ✨(helm) add support multiple transcribe worker / endpoint #1247 - ✨(helm) add support multiple transcribe worker / endpoint #1247
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
- ✨(summary) add speaker-to-participant assignment
### Changed
- ♻️(summary) change tasks endpoint signature
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
- 🧑‍💻(agents) use `uv` for package management
- ✨(summary) improve speaker-to-participant assignment
### Fixed ### Fixed
- ♻(frontend) standardize role terminology across localizations - ♻(frontend) standardize role terminology across localizations
- 🐛(backend) make start-recording atomic and fault-tolerant - 🐛(backend) make start-recording atomic and fault-tolerant
- 🔒️(frontend) room ids are generated with non-cryptographic rand - 🔒️(frontend) room ids are generated with non-cryptographic rand
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
- 🐛(summary) complete webm support #1328
- 🐛(backend) add link to "Open" text in recording email
- 🩹(frontend) fix spacing regression in mobile control bar
## [1.15.0] - 2026-04-30 ## [1.15.0] - 2026-04-30
@@ -198,7 +56,6 @@ and this project adheres to
- ✨(summary) allow more file extensions #1265 - ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262 - ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261 - ♿️(frontend) set an explicit document title on recording download page #1261
- ♿️(frontend) add customizable accessibility fonts #1270
### Fixed ### Fixed
+15 -20
View File
@@ -75,8 +75,7 @@ create-env-files: \
env.d/development/kc_postgresql \ env.d/development/kc_postgresql \
env.d/development/summary \ env.d/development/summary \
env.d/development/kube-secret \ env.d/development/kube-secret \
env.d/development/multi_user_transcriber \ env.d/development/multi_user_transcriber
env.d/development/metadata_collector
.PHONY: create-env-files .PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project bootstrap: ## Prepare Docker images for the project
@@ -110,7 +109,7 @@ build-frontend: ## build the frontend container
.PHONY: build-frontend .PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber-dev @$(COMPOSE) build multi-user-transcriber
.PHONY: build-agents .PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes down: ## stop and remove containers, networks, images, and volumes
@@ -139,7 +138,7 @@ run-agents: ## start the multi-user-transcriber agent
.PHONY: run-agents .PHONY: run-agents
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber) run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev @$(COMPOSE) up --force-recreate -d multi-user-transcriber
.PHONY: run-agent-multi-user-transcriber .PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector) run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
@@ -211,25 +210,24 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
@$(COMPOSE_RUN_APP) pylint meet demo core @$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint .PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"` test: ## run project tests
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @$(MAKE) test-back-parallel
$(MAKE) test-back-parallel ARGS="$${args}" && \ @$(MAKE) test-summary
$(MAKE) test-summary ARGS="$${args}"
.PHONY: test .PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS) test-back: ## run back-end tests
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args} bin/pytest $${args:-${1}}
.PHONY: test-back .PHONY: test-back
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS) test-back-parallel: ## run all back-end tests in parallel
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args} bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel .PHONY: test-back-parallel
test-summary: ## run summary tests (pass extra pytest args via ARGS) test-summary: ## run summary tests
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \ @args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args} bin/pytest-summary $${args:-${1}}
.PHONY: test-summary .PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project. makemigrations: ## run django makemigrations for the Meet project.
@@ -294,9 +292,6 @@ env.d/development/kube-secret:
env.d/development/multi_user_transcriber: env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
env.d/development/metadata_collector:
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
# -- Internationalization # -- Internationalization
env.d/development/crowdin: env.d/development/crowdin:
+12 -58
View File
@@ -13,9 +13,6 @@
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE"> <a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/> <img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a> </a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
</a>
</p> </p>
<p align="center"> <p align="center">
@@ -31,14 +28,6 @@
## La Suite Meet: Simple Video Conferencing ## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/). Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
> [!TIP]
> New here? Start by introducing yourself in our Matrix channel:
> **https://matrix.to/#/#meet-official:matrix.org**
>
> Were happy to discuss ideas, answer questions, and help to deploy LaSuite Meet.
### Features ### Features
- Optimized for stability in large meetings (+100 p.) - Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams - Support for multiple screen sharing streams
@@ -63,7 +52,7 @@ Were continuously adding new features to enhance your experience, with the la
### 🚀 Major roll out to all French public servants ### 🚀 Major roll out to all French public servants
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html)) On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents ## Table of Contents
@@ -95,57 +84,22 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
#### Known instances #### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏 We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access | | Url | Org | Access |
|---------------------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------| |---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [Clever Cloud](https://www.clever.cloud/product/visio/) | clever cloud | Openvisio is a sovereign video conferencing solution based on LaSuite Meet offered by [Clever Cloud](https://www.clever.cloud/). |
| [Email.eu](https://email.eu/) | Email.eu | Sovereign business workspace. |
# Contributing ## Contributing
We <3 contributions of all kinds **big or small** and were genuinely glad youre here. 🌱 We <3 contributions of any kind, big and small:
### Start by saying hi - Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
**The best first contribution is simply to come say hi.**
Before opening a PR, especially a larger one, or one written with the help of AI, we encourage you to reach out to a maintainer on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org) (@antoine.lebaud:matrix.org).
Getting in touch early helps us align on goals, avoid duplicated or wasted effort, and build a community that stays active, welcoming, and fun to be part of. There are no silly questions here: whether youve shipped hundreds of PRs or youre just getting started, youre welcome.
### AI contributions
AI-assisted contributions are welcome. But code is never the end goal. What matters most is building relationships, sharing knowledge, and growing a sustainable community over time.
If your contribution has been heavily generated with AI, please be transparent about it. This helps maintainers review it with the right context and respects the time they invest in the project.
Using AI does not transfer ownership of the contribution: you should still fully understand the code, the problem it solves, and the reasoning behind the approach you propose. In short, even if AI helped write it, the why should still be yours.
### Contributions beyond code
**Not technical? We need you too.**
Open source is much more than code. Writing documentation, improving onboarding, translating content, answering questions, reporting bugs, or simply helping others feel welcome all make a huge difference.
### Ways to contribute
When youre ready, here are a few ways to get involved:
* 👋 **Say hello** and share your ideas with the community and maintainers on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org)
* 🛠️ **Open a PR** by following our guide to [develop La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)
* 💡 **Suggest an idea** by opening a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md)
* 🐛 **Report a bug** by opening a [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
Thank you for helping build something open, useful, and human. 💙
### Community call
We host a community call on the first Friday of every month to share updates, discuss ideas, and connect with contributors.
Whether youre actively contributing or just curious about the project, youre welcome to join. More details are shared on the [Matrix channel](https://matrix.to/#/#meet-official:matrix.org).
## Philosophy ## Philosophy
+4 -13
View File
@@ -23,8 +23,8 @@ docker_build(
live_update=[ live_update=[
sync('../src/backend', '/app'), sync('../src/backend', '/app'),
run( run(
'uv sync --locked --no-dev', 'pip install -r /app/requirements.txt',
trigger=['../src/backend/uv.lock', '../src/backend/pyproject.toml'] trigger=['./api/requirements.txt']
) )
] ]
) )
@@ -110,20 +110,11 @@ k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis']) k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis']) k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis']) k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis']) k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server']) k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql']) k8s_resource('keycloak', resource_deps=['kc-postgresql'])
# Trigger once on launch k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
k8s_resource(
'meet-backend-createsuperuser',
resource_deps=['meet-backend-migrate'],
trigger_mode=TRIGGER_MODE_MANUAL,
)
k8s_resource(
'meet-backend-migrate',
resource_deps=['meet-backend'],
trigger_mode=TRIGGER_MODE_MANUAL,
)
migration = ''' migration = '''
set -eu set -eu
+1
View File
@@ -47,3 +47,4 @@ mv src/backend/* ./
mv deploy/paas/* ./ mv deploy/paas/* ./
echo "3.13" > .python-version echo "3.13" > .python-version
echo "." > requirements.txt
+25 -22
View File
@@ -237,25 +237,30 @@ services:
- livekit-egress - livekit-egress
livekit-egress: livekit-egress:
image: livekit/egress:v1.11.0 image: livekit/egress:v1.11.0
environment: environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes: volumes:
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml - ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
- ./docker/livekit/out:/out - ./docker/livekit/out:/out
depends_on: depends_on:
- redis - redis
metadata-collector-dev: metadata-collector-dev:
build: build:
context: ./src/agents context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"] command: ["python", "metadata_collector.py", "dev"]
env_file: environment:
- env.d/development/metadata_collector - LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=secret
- AWS_S3_ENDPOINT_URL=minio:9000
- AWS_S3_ACCESS_KEY_ID=meet
- AWS_S3_SECRET_ACCESS_KEY=password
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
- AWS_S3_SECURE_ACCESS=False
volumes: volumes:
- ./src/agents:/app - ./src/agents:/app
- /app/.venv
depends_on: depends_on:
- livekit - livekit
- minio - minio
@@ -264,16 +269,6 @@ services:
- action: rebuild - action: rebuild
path: ./src/agents path: ./src/agents
multi-user-transcriber-dev:
build:
context: ./src/agents
target: development
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
- /app/.venv
redis-summary: redis-summary:
image: redis image: redis
ports: ports:
@@ -335,6 +330,14 @@ services:
- action: rebuild - action: rebuild
path: ./src/summary path: ./src/summary
multi-user-transcriber:
build:
context: ./src/agents
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
networks: networks:
default: default:
resource-server: resource-server:
-2
View File
@@ -60,8 +60,6 @@ USER root
# Security patches for known CVEs # Security patches for known CVEs
RUN apk update && apk upgrade \ RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
+5 -7
View File
@@ -44,17 +44,15 @@ server {
add_header Pragma "no-cache" always; add_header Pragma "no-cache" always;
add_header Expires 0 always; add_header Expires 0 always;
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com"; set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com https://*.cloud.microsoft";
set $ministries_domains "https://courrieljf.ccomptes.fr";
set $nonce $request_id; set $nonce $request_id;
set $csp "default-src 'self'; upgrade-insecure-requests; "; set $csp "upgrade-insecure-requests; ";
set $csp "${csp}frame-ancestors ${ms_domains}; "; set $csp "${csp}frame-ancestors ${ms_domains} ${ministries_domains}; ";
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; "; set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
set $csp "${csp}style-src 'self' 'unsafe-inline'; "; set $csp "${csp}connect-src 'self' ${ms_domains} ${ministries_domains}; ";
set $csp "${csp}img-src 'self' data:; ";
set $csp "${csp}font-src 'self' data:; ";
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
set $csp "${csp}frame-src 'none'; "; set $csp "${csp}frame-src 'none'; ";
set $csp "${csp}object-src 'none'; "; set $csp "${csp}object-src 'none'; ";
set $csp "${csp}base-uri 'none'; "; set $csp "${csp}base-uri 'none'; ";
+1 -59
View File
@@ -96,17 +96,10 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. | | **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. | | **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. | | **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. | | **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. | | **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. | | **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. | | **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook ### Manual Storage Webhook
@@ -148,54 +141,3 @@ Using default project meet
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly. This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage.
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+1
View File
@@ -80,6 +80,7 @@ sequenceDiagram
| whisperx_api_key | Secret | — | API key for accessing WhisperX. | | whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. | | whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. | | whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. | | webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. | | webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. | | webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
+1 -64
View File
@@ -244,69 +244,6 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet. You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## Rebranding the favicon
The favicon is bundled into the frontend image and served as a set of static
files from `/usr/share/nginx/html` (`favicon.ico`, `favicon-16x16.png`,
`favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`,
`android-chrome-512x512.png`, `icon.png`). To rebrand without forking and
rebuilding the image, overlay your own icons onto those paths with a volume —
this serves the right icon from the first byte (no rebuild, no flash) and
covers every variant, including the iOS home-screen and Android/PWA icons.
Put your icons in a `ConfigMap` (`binaryData` keeps the PNGs intact)…
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: meet-favicon
binaryData:
# base64 of each replacement icon
favicon.ico: <base64…>
favicon-16x16.png: <base64…>
favicon-32x32.png: <base64…>
apple-touch-icon.png: <base64…>
android-chrome-192x192.png: <base64…>
android-chrome-512x512.png: <base64…>
```
```bash
# e.g. build the ConfigMap straight from a directory of icons
$ kubectl create configmap meet-favicon --from-file=./my-icons/
```
…then mount each file over the bundled one via the chart's
`frontend.extraVolumes` / `frontend.extraVolumeMounts` (the `subPath` mounts
the single file without hiding the rest of `html/`):
```yaml
frontend:
extraVolumes:
- name: favicon
configMap:
name: meet-favicon
extraVolumeMounts:
- name: favicon
mountPath: /usr/share/nginx/html/favicon.ico
subPath: favicon.ico
- name: favicon
mountPath: /usr/share/nginx/html/favicon-16x16.png
subPath: favicon-16x16.png
- name: favicon
mountPath: /usr/share/nginx/html/favicon-32x32.png
subPath: favicon-32x32.png
- name: favicon
mountPath: /usr/share/nginx/html/apple-touch-icon.png
subPath: apple-touch-icon.png
- name: favicon
mountPath: /usr/share/nginx/html/android-chrome-192x192.png
subPath: android-chrome-192x192.png
- name: favicon
mountPath: /usr/share/nginx/html/android-chrome-512x512.png
subPath: android-chrome-512x512.png
```
## All options ## All options
These are the environmental options available on meet backend. These are the environmental options available on meet backend.
@@ -407,7 +344,7 @@ These are the environmental options available on meet backend.
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} | | RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser | | RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true | | RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false | | RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | | | RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | | | RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | | | RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
+12 -66
View File
@@ -185,7 +185,6 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: {}
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
@@ -199,6 +198,10 @@ paths:
description: | description: |
Creates a new room with secure defaults for external API usage. Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:** **Defaults:**
- Delegated user is set as owner - Delegated user is set as owner
- Room slug auto-generated for uniqueness - Room slug auto-generated for uniqueness
@@ -215,17 +218,8 @@ paths:
$ref: '#/components/schemas/RoomCreate' $ref: '#/components/schemas/RoomCreate'
examples: examples:
emptyBody: emptyBody:
summary: No parameters (use all defaults) summary: No parameters (default)
value: { } value: {}
withAccessLevel:
summary: Specify access level
value:
access_level: "trusted"
withConfiguration:
summary: Provide room configuration
value:
configuration:
everyone_can_mute: true
responses: responses:
'201': '201':
description: Room created successfully description: Room created successfully
@@ -275,7 +269,6 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: {}
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
@@ -351,56 +344,8 @@ components:
RoomCreate: RoomCreate:
type: object type: object
description: | description: Empty object - all room properties are auto-generated
Optional fields for room creation. All fields have secure defaults if omitted. properties: {}
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
Optional room behaviour settings. Unknown fields are rejected.
All fields are optional and default to `null` (server-side defaults apply).
properties:
can_publish_sources:
type: array
nullable: true
description: |
Restricts which media tracks participants are allowed to publish.
If `null`, all sources are permitted.
items:
type: string
enum:
- camera
- microphone
- screen_share
- screen_share_audio
example: [ "camera", "microphone" ]
everyone_can_mute:
type: boolean
nullable: true
description: |
Whether any participant can mute others, or only the room owner/moderator.
If `null`, the server default applies.
example: true
additionalProperties: false
RoomAccessLevel:
type: string
enum:
- public
- trusted
- restricted
description: |
Controls who can join the room without going through the lobby.
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
example: "trusted"
Room: Room:
type: object type: object
@@ -417,7 +362,10 @@ components:
description: URL-friendly room identifier (auto-generated) description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer" example: "aze-eere-zer"
access_level: access_level:
$ref: '#/components/schemas/RoomAccessLevel' type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url: url:
type: string type: string
format: uri format: uri
@@ -445,8 +393,6 @@ components:
type: string type: string
description: Default country code description: Default country code
example: "US" example: "US"
configuration:
$ref: '#/components/schemas/RoomConfiguration'
OAuthError: OAuthError:
type: object type: object
+69 -68
View File
@@ -48,7 +48,7 @@ paths:
summary: List rooms summary: List rooms
description: | description: |
Returns a list of rooms accessible to the authenticated user. Returns a list of rooms accessible to the authenticated user.
Only rooms where the user has access will be returned. Only rooms where the delegated user has access will be returned.
operationId: listRooms operationId: listRooms
security: security:
- BearerAuth: [rooms:list] - BearerAuth: [rooms:list]
@@ -108,7 +108,6 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: { }
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
@@ -122,8 +121,12 @@ paths:
description: | description: |
Creates a new room with secure defaults for external API usage. Creates a new room with secure defaults for external API usage.
**Restrictions:**
- Rooms are always created with `trusted` access (no public rooms via API)
- Room access_level can be updated from the webapp interface.
**Defaults:** **Defaults:**
- user is set as owner - Delegated user is set as owner
- Room slug auto-generated for uniqueness - Room slug auto-generated for uniqueness
- Telephony PIN auto-generated when enabled - Telephony PIN auto-generated when enabled
- Creation tracked with application client_id for auditing - Creation tracked with application client_id for auditing
@@ -138,17 +141,8 @@ paths:
$ref: '#/components/schemas/RoomCreate' $ref: '#/components/schemas/RoomCreate'
examples: examples:
emptyBody: emptyBody:
summary: No parameters (use all defaults) summary: No parameters (default)
value: { } value: {}
withAccessLevel:
summary: Specify access level
value:
access_level: "trusted"
withConfiguration:
summary: Provide room configuration
value:
configuration:
everyone_can_mute: true
responses: responses:
'201': '201':
description: Room created successfully description: Room created successfully
@@ -198,7 +192,6 @@ paths:
pin_code: "123456" pin_code: "123456"
phone_number: "+1-555-0100" phone_number: "+1-555-0100"
default_country: "US" default_country: "US"
configuration: { }
'401': '401':
$ref: '#/components/responses/UnauthorizedError' $ref: '#/components/responses/UnauthorizedError'
'403': '403':
@@ -217,58 +210,65 @@ components:
Include in requests as: `Authorization: Bearer <token>` Include in requests as: `Authorization: Bearer <token>`
schemas: schemas:
TokenRequest:
type: object
required:
- client_id
- client_secret
- grant_type
- scope
properties:
client_id:
type: string
description: Application client identifier
example: "550e8400-e29b-41d4-a716-446655440000"
client_secret:
type: string
format: password
writeOnly: true
description: Application secret key
example: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type:
type: string
enum:
- client_credentials
description: OAuth2 grant type (must be 'client_credentials')
example: "client_credentials"
scope:
type: string
format: email
description: |
Email address of the user to delegate.
The application will act on behalf of this user.
Note: This parameter is named 'scope' to align with OAuth2 conventions,
but accepts an email address to identify the user. This design allows
for future extensibility.
example: "user@example.com"
TokenResponse:
type: object
properties:
access_token:
type: string
description: JWT access token
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
token_type:
type: string
description: Token type (always 'Bearer')
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds
example: 3600
scope:
type: string
description: Space-separated list of granted permission scopes
example: "rooms:list rooms:retrieve rooms:create"
RoomCreate: RoomCreate:
type: object type: object
description: | description: Empty object - all room properties are auto-generated
Optional fields for room creation. All fields have secure defaults if omitted. properties: {}
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
Optional room behaviour settings. Unknown fields are rejected.
All fields are optional and default to `null` (server-side defaults apply).
properties:
can_publish_sources:
type: array
nullable: true
description: |
Restricts which media tracks participants are allowed to publish.
If `null`, all sources are permitted.
items:
type: string
enum:
- camera
- microphone
- screen_share
- screen_share_audio
example: [ "camera", "microphone" ]
everyone_can_mute:
type: boolean
nullable: true
description: |
Whether any participant can mute others, or only the room owner/moderator.
If `null`, the server default applies.
example: true
additionalProperties: false
RoomAccessLevel:
type: string
enum:
- public
- trusted
- restricted
description: |
Controls who can join the room without going through the lobby.
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
example: "trusted"
Room: Room:
type: object type: object
@@ -285,7 +285,10 @@ components:
description: URL-friendly room identifier (auto-generated) description: URL-friendly room identifier (auto-generated)
example: "aze-eere-zer" example: "aze-eere-zer"
access_level: access_level:
$ref: '#/components/schemas/RoomAccessLevel' type: string
readOnly: true
description: Room access level (always 'trusted' for API-created rooms)
example: "trusted"
url: url:
type: string type: string
format: uri format: uri
@@ -313,8 +316,6 @@ components:
type: string type: string
description: Default country code description: Default country code
example: "US" example: "US"
configuration:
$ref: '#/components/schemas/RoomConfiguration'
OAuthError: OAuthError:
type: object type: object
-17
View File
@@ -57,7 +57,6 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
LIVEKIT_API_SECRET=secret LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880 LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_INTERNAL_URL=http://livekit:7880
LIVEKIT_VERIFY_SSL=False LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False ALLOW_UNREGISTERED_ROOMS=False
@@ -69,27 +68,12 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Telephony # Telephony
ROOM_TELEPHONY_ENABLED=True ROOM_TELEPHONY_ENABLED=True
# Metadata # Metadata
METADATA_COLLECTOR_ENABLED=True METADATA_COLLECTOR_ENABLED=True
# Subtitle
ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False FRONTEND_USE_PROCONNECT_BUTTON=False
@@ -98,4 +82,3 @@ EXTERNAL_API_ENABLED=True
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000 APPLICATION_BASE_URL=http://localhost:3000
@@ -1,9 +0,0 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -2,10 +2,8 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret LIVEKIT_API_SECRET=secret
STT_PROVIDER=kyutai # kyutai, deepgram STT_PROVIDER=kyutai
ENABLE_SILERO_VAD=False ENABLE_SILERO_VAD=False
DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL= KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY= KYUTAI_API_KEY=
+1 -9
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev" APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password" APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage" AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000" AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false AWS_S3_SECURE_ACCESS=false
@@ -20,13 +20,5 @@ LLM_MODEL="albert-large"
WEBHOOK_API_TOKEN="secret" WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com" WEBHOOK_URL="https://configure-your-url.com"
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED=true
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP=0.5
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS=true
RESOLVE_SPEAKER_MAX_WORD_DURATION=1
POSTHOG_API_KEY="your-posthog-key" POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False" POSTHOG_ENABLED="False"
# Transcription
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
+13 -32
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp"> <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id> <Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.2.0</Version> <Version>0.0.1.0</Version>
<ProviderName>__APP_NAME__</ProviderName> <ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale> <DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/> <DisplayName DefaultValue="__APP_NAME__"/>
@@ -87,9 +87,9 @@
<Description resid="GenerateLink.Tooltip"/> <Description resid="GenerateLink.Tooltip"/>
</Supertip> </Supertip>
<Icon> <Icon>
<bt:Image size="16" resid="Icon.16x16"/> <bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/> <bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/> <bt:Image size="80" resid="Add.80x80"/>
</Icon> </Icon>
<Action xsi:type="ExecuteFunction"> <Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromMail</FunctionName> <FunctionName>generateMeetingLinkFromMail</FunctionName>
@@ -126,9 +126,9 @@
<Description resid="GenerateLink.Tooltip"/> <Description resid="GenerateLink.Tooltip"/>
</Supertip> </Supertip>
<Icon> <Icon>
<bt:Image size="16" resid="Icon.16x16"/> <bt:Image size="16" resid="Add.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/> <bt:Image size="32" resid="Add.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/> <bt:Image size="80" resid="Add.80x80"/>
</Icon> </Icon>
<Action xsi:type="ExecuteFunction"> <Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName> <FunctionName>generateMeetingLinkFromCalendar</FunctionName>
@@ -175,34 +175,15 @@
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/> <bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls> </bt:Urls>
<bt:ShortStrings> <bt:ShortStrings>
<!-- Default (French) -->
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/> <bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"> <bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/> <bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/> <bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:String>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
<bt:Override Locale="en-US" Value="Open settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
</bt:String>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
<bt:Override Locale="en-US" Value="Settings"/>
<bt:Override Locale="de-DE" Value="Einstellungen"/>
</bt:String>
</bt:ShortStrings> </bt:ShortStrings>
<bt:LongStrings> <bt:LongStrings>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."> <bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/> <bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/> <bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
</bt:String>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
</bt:String>
</bt:LongStrings> </bt:LongStrings>
</Resources> </Resources>
</VersionOverrides> </VersionOverrides>
+299 -360
View File
@@ -9,36 +9,34 @@
"version": "0.0.1", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "^3.36.0",
"i18next": "26.3.1", "regenerator-runtime": "^0.14.1"
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "7.29.0", "@babel/preset-env": "^7.25.4",
"@types/office-js": "1.0.582", "@types/office-js": "^1.0.377",
"@types/office-runtime": "1.0.36", "@types/office-runtime": "^1.0.35",
"acorn": "8.16.0", "acorn": "^8.11.3",
"babel-loader": "9.2.1", "babel-loader": "^9.1.3",
"copy-webpack-plugin": "14.0.0", "copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "4.0.6", "eslint-plugin-office-addins": "^4.0.3",
"file-loader": "6.2.0", "file-loader": "^6.2.0",
"html-loader": "5.1.0", "html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "1.0.6", "html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "5.6.6", "html-webpack-plugin": "^5.6.0",
"office-addin-cli": "2.0.6", "office-addin-cli": "^2.0.3",
"office-addin-debugging": "6.0.6", "office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "2.0.6", "office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "3.0.6", "office-addin-lint": "^3.0.3",
"office-addin-manifest": "2.1.2", "office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "2.0.1", "office-addin-prettier-config": "^2.0.1",
"os-browserify": "0.3.0", "os-browserify": "^0.3.0",
"process": "0.11.10", "process": "^0.11.10",
"source-map-loader": "5.0.0", "source-map-loader": "^5.0.0",
"webpack": "5.105.4", "webpack": "^5.95.0",
"webpack-cli": "5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4" "webpack-dev-server": "5.2.1"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": { "node_modules/@apidevtools/json-schema-ref-parser": {
@@ -2113,7 +2111,9 @@
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -4319,195 +4319,44 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@nodelib/fs.scandir": {
"version": "1.4.0", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 8"
},
"funding": {
"url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@peculiar/asn1-cms": { "node_modules/@nodelib/fs.walk": {
"version": "2.7.0", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/asn1-schema": "^2.7.0", "@nodelib/fs.scandir": "2.1.5",
"@peculiar/asn1-x509": "^2.7.0", "fastq": "^1.6.0"
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-rsa": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pfx": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">= 8"
} }
}, },
"node_modules/@peculiar/x509/node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@pkgr/core": { "node_modules/@pkgr/core": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4521,6 +4370,19 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4722,6 +4584,16 @@
"form-data": "^4.0.4" "form-data": "^4.0.4"
} }
}, },
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/office-js": { "node_modules/@types/office-js": {
"version": "1.0.582", "version": "1.0.582",
"resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz", "resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz",
@@ -5778,21 +5650,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -6264,16 +6121,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/bytestreamjs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6839,20 +6686,21 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/copy-webpack-plugin": { "node_modules/copy-webpack-plugin": {
"version": "14.0.0", "version": "12.0.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.1", "glob-parent": "^6.0.1",
"globby": "^14.0.0",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"schema-utils": "^4.2.0", "schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3", "serialize-javascript": "^6.0.2"
"tinyglobby": "^0.2.12"
}, },
"engines": { "engines": {
"node": ">= 20.9.0" "node": ">= 18.12.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -6863,9 +6711,9 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.49.0", "version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -8214,6 +8062,36 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8302,6 +8180,16 @@
"node": ">= 4.9.1" "node": ">= 4.9.1"
} }
}, },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/faye-websocket": { "node_modules/faye-websocket": {
"version": "0.11.4", "version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@@ -8870,6 +8758,37 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/globby": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.3",
"path-type": "^6.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -9363,43 +9282,6 @@
"node": ">=10.18" "node": ">=10.18"
} }
}, },
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -11162,6 +11044,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": { "node_modules/methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -12746,6 +12638,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pathval": { "node_modules/pathval": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
@@ -12881,24 +12786,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pkijs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
"asn1js": "^3.0.6",
"bytestreamjs": "^2.0.1",
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -13082,26 +12969,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -13118,6 +12985,37 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -13604,6 +13502,17 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -13636,6 +13545,30 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -13830,17 +13763,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/selfsigned": { "node_modules/selfsigned": {
"version": "5.5.0", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@peculiar/x509": "^1.14.2", "@types/node-forge": "^1.3.0",
"pkijs": "^3.3.3" "node-forge": "^1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=10"
} }
}, },
"node_modules/semver": { "node_modules/semver": {
@@ -13909,13 +13842,13 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.0.5", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "dependencies": {
"node": ">=20.0.0" "randombytes": "^2.1.0"
} }
}, },
"node_modules/serve-index": { "node_modules/serve-index": {
@@ -14327,6 +14260,19 @@
"simple-concat": "^1.0.0" "simple-concat": "^1.0.0"
} }
}, },
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sockjs": { "node_modules/sockjs": {
"version": "0.3.24", "version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -15074,26 +15020,6 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -15250,7 +15176,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -15385,6 +15311,19 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -15715,15 +15654,15 @@
} }
}, },
"node_modules/webpack-dev-server": { "node_modules/webpack-dev-server": {
"version": "5.2.4", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", "integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/bonjour": "^3.5.13", "@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4", "@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.25", "@types/express": "^4.17.21",
"@types/express-serve-static-core": "^4.17.21", "@types/express-serve-static-core": "^4.17.21",
"@types/serve-index": "^1.9.4", "@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5", "@types/serve-static": "^1.15.5",
@@ -15733,17 +15672,17 @@
"bonjour-service": "^1.2.1", "bonjour-service": "^1.2.1",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"colorette": "^2.0.10", "colorette": "^2.0.10",
"compression": "^1.8.1", "compression": "^1.7.4",
"connect-history-api-fallback": "^2.0.0", "connect-history-api-fallback": "^2.0.0",
"express": "^4.22.1", "express": "^4.21.2",
"graceful-fs": "^4.2.6", "graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.9", "http-proxy-middleware": "^2.0.7",
"ipaddr.js": "^2.1.0", "ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1", "launch-editor": "^2.6.1",
"open": "^10.0.3", "open": "^10.0.3",
"p-retry": "^6.2.0", "p-retry": "^6.2.0",
"schema-utils": "^4.2.0", "schema-utils": "^4.2.0",
"selfsigned": "^5.5.0", "selfsigned": "^2.4.1",
"serve-index": "^1.9.1", "serve-index": "^1.9.1",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"spdy": "^4.0.2", "spdy": "^4.0.2",
@@ -15799,9 +15738,9 @@
} }
}, },
"node_modules/webpack-dev-server/node_modules/ipaddr.js": { "node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.4.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -15828,9 +15767,9 @@
} }
}, },
"node_modules/webpack-dev-server/node_modules/ws": { "node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.20.1", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+26 -28
View File
@@ -26,36 +26,34 @@
"watch": "webpack --mode development --watch" "watch": "webpack --mode development --watch"
}, },
"dependencies": { "dependencies": {
"core-js": "3.49.0", "core-js": "^3.36.0",
"i18next": "26.3.1", "regenerator-runtime": "^0.14.1"
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "7.29.0", "@babel/preset-env": "^7.25.4",
"@types/office-js": "1.0.582", "@types/office-js": "^1.0.377",
"@types/office-runtime": "1.0.36", "@types/office-runtime": "^1.0.35",
"acorn": "8.16.0", "acorn": "^8.11.3",
"babel-loader": "9.2.1", "babel-loader": "^9.1.3",
"copy-webpack-plugin": "14.0.0", "copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "4.0.6", "eslint-plugin-office-addins": "^4.0.3",
"file-loader": "6.2.0", "file-loader": "^6.2.0",
"html-loader": "5.1.0", "html-loader": "^5.0.0",
"html-webpack-inject-attributes-plugin": "1.0.6", "html-webpack-inject-attributes-plugin": "^1.0.6",
"html-webpack-plugin": "5.6.6", "html-webpack-plugin": "^5.6.0",
"office-addin-cli": "2.0.6", "office-addin-cli": "^2.0.3",
"office-addin-debugging": "6.0.6", "office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "2.0.6", "office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "3.0.6", "office-addin-lint": "^3.0.3",
"office-addin-manifest": "2.1.2", "office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "2.0.1", "office-addin-prettier-config": "^2.0.1",
"os-browserify": "0.3.0", "os-browserify": "^0.3.0",
"process": "0.11.10", "process": "^0.11.10",
"source-map-loader": "5.0.0", "source-map-loader": "^5.0.0",
"webpack": "5.105.4", "webpack": "^5.95.0",
"webpack-cli": "5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4" "webpack-dev-server": "5.2.1"
}, },
"prettier": "office-addin-prettier-config", "prettier": "office-addin-prettier-config",
"browserslist": [ "browserslist": [
+29 -46
View File
@@ -1,18 +1,12 @@
/* global Office */ /* global Office */
const { APP_NAME } = require("../common/index");
const { createRoom, initSession } = require("../common/api"); const { createRoom, initSession } = require("../common/api");
const { startPolling } = require("../common/polling"); const { startPolling } = require("../common/polling");
const { saveSession, loadSession } = require("../common/session"); const { saveSession, loadSession } = require("../common/session");
const { openTransitDialog } = require("../common/transitDialog"); const { openTransitDialog } = require("../common/transitDialog");
const { buildMeetingMessage } = require("../common/messageBuilder"); const { buildMeetingMessage } = require("../common/messageBuilder");
const { applyAppName } = require("../common/helpers"); const { applyAppName } = require("../common/helpers");
const { initI18n, t } = require("../common/i18n");
const { isMeetingAlreadyAdded } = require("../common/meetingDetector");
Office.onReady(async function (info) {
await initI18n()
Office.onReady(function (info) {
if (info.host === Office.HostType.Outlook) { if (info.host === Office.HostType.Outlook) {
applyAppName(); applyAppName();
} }
@@ -28,52 +22,41 @@ function notify(message) {
} }
function insertMeetingLink(event, session) { function insertMeetingLink(event, session) {
const item = Office.context.mailbox.item;
isMeetingAlreadyAdded(item)
.then((alreadyAdded) => {
if (alreadyAdded) {
notify(t("meeting.already_added", { app_name: APP_NAME }));
event.completed();
return;
}
return _doInsertMeetingLink(event, session);
})
.catch((err) => {
notify(t("meeting.error.details", { message: err.message }));
event.completed();
});
}
function _doInsertMeetingLink(event, session) {
createRoom(session) createRoom(session)
.then((data) => { .then((data) => {
const isWeb = Office.context.diagnostics.platform === "OfficeOnline"; const { url, message } = buildMeetingMessage(data);
const { url, text } = buildMeetingMessage(data, isWeb);
const item = Office.context.mailbox.item; const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => { item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) { if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: setResult.error.message })); notify(`Erreur de lecture : ${getResult.error.message}`);
resolve(); resolve();
return; return;
} }
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) { const newBody = getResult.value + message;
notify(t("meeting.link_inserted")); item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
resolve(); if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
return; notify(`Erreur d'insertion : ${setResult.error.message}`);
} resolve();
return;
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(t("meeting.error.details", { message: locationResult.error.message }));
} else {
notify(t("meeting.link_inserted"));
} }
resolve();
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
notify("Lien de réunion inséré !");
resolve();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
notify(`Erreur de localisation : ${locationResult.error.message}`);
} else {
notify("Lien de réunion inséré !");
}
resolve();
});
}); });
}); });
}); });
@@ -96,11 +79,11 @@ function connect(event) {
}); });
}, },
onTimeout: () => { onTimeout: () => {
notify(t("meeting.error.auth")); notify("Connexion expirée, veuillez réessayer.");
event.completed(); event.completed();
}, },
onError: (err) => { onError: (err) => {
notify(t("meeting.error.retry")); notify("Une erreur est survenue, veuillez ré-essayer");
event.completed(); event.completed();
}, },
}); });
@@ -116,7 +99,7 @@ function connect(event) {
}); });
}) })
.catch((err) => { .catch((err) => {
notify(t("meeting.error.details", { message: err.message })); notify(`Erreur : ${err.message}`);
event.completed(); event.completed();
}); });
} }
-48
View File
@@ -1,48 +0,0 @@
const { APP_NAME } = require("../common");
const i18nextModule = require("i18next");
const i18next = i18nextModule.default || i18nextModule;
const fr = require("../locales/fr/translation.json");
const en = require("../locales/en/translation.json");
const de = require("../locales/de/translation.json");
async function initI18n() {
const lng = typeof Office !== "undefined" ? Office.context.displayLanguage : navigator.language;
await i18next.init({
lng,
fallbackLng: "fr",
interpolation: { escapeValue: false },
resources: {
fr: { translation: fr },
en: { translation: en },
de: { translation: de },
},
});
}
function t(key, vars) {
return i18next.t(key, vars);
}
function translateUI() {
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
el.textContent = t(key, { app_name: APP_NAME });
});
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
const pairs = el.getAttribute("data-i18n-attr").split(",");
pairs.forEach((pair) => {
const [attr, key] = pair.split(":");
el.setAttribute(attr, t(key, { app_name: APP_NAME }));
});
});
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
el.setAttribute("aria-label", t(el.getAttribute("data-i18n-aria")));
});
}
module.exports = { initI18n, t, translateUI };
-4
View File
@@ -1,11 +1,7 @@
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io"; const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet"; const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
const ENABLE_SOURCE_TRACKING = window.__APP_CONFIG__?.ENABLE_SOURCE_TRACKING === "true";
const FEEDBACK_FORM = window.__APP_CONFIG__?.FEEDBACK_FORM || null;
module.exports = { module.exports = {
BASE_URL, BASE_URL,
APP_NAME, APP_NAME,
ENABLE_SOURCE_TRACKING,
FEEDBACK_FORM
}; };
@@ -1,107 +0,0 @@
const { BASE_URL } = require("./index");
/**
* Returns a promise that resolves to true if a meeting link is already present
*/
function isMeetingAlreadyAdded(item) {
return Promise.all([_checkBody(item), _checkLocation(item)]).then(
([inBody, inLocation]) => inBody || inLocation
);
}
function _checkBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Text, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _checkLocation(item) {
// Location only exists on appointments
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve(false);
return;
}
resolve(_containsMeetingUrl(result.value));
});
});
}
function _containsMeetingUrl(text) {
if (!text) return false;
return text.includes(BASE_URL);
}
function removeMeetingLink(item) {
return Promise.all([_removeFromBody(item), _removeFromLocation(item)]);
}
function _removeFromBody(item) {
return new Promise((resolve) => {
item.body.getAsync(Office.CoercionType.Html, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
resolve();
return;
}
const cleaned = _cleanBody(result.value || "");
if (cleaned === null) {
resolve();
return;
}
item.body.setAsync(cleaned, { coercionType: Office.CoercionType.Html }, () => resolve());
});
});
}
function _removeFromLocation(item) {
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
return Promise.resolve();
}
return new Promise((resolve) => {
item.location.getAsync((result) => {
if (
result.status === Office.AsyncResultStatus.Succeeded &&
_containsMeetingUrl(result.value)
) {
item.location.setAsync("", () => resolve());
} else {
resolve();
}
});
});
}
const SEPARATOR = /─{10,}/;
/**
* Returns cleaned HTML, or null if no meeting block was found.
*/
function _cleanBody(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const hits = [];
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (SEPARATOR.test(walker.currentNode.nodeValue)) hits.push(walker.currentNode);
}
if (hits.length < 2) return null;
const range = doc.createRange();
range.setStartBefore(hits[0]);
range.setEndAfter(hits[hits.length - 1]);
range.deleteContents();
return doc.documentElement.outerHTML;
}
module.exports = { isMeetingAlreadyAdded, removeMeetingLink };
+16 -43
View File
@@ -1,5 +1,4 @@
const { APP_NAME, ENABLE_SOURCE_TRACKING } = require("./index"); const { APP_NAME } = require("./index");
const { t } = require("./i18n");
function _formatPin(pin) { function _formatPin(pin) {
if (!pin) return ""; if (!pin) return "";
@@ -21,59 +20,33 @@ function _formatPhone(phone) {
return clean; return clean;
} }
function _appendTrackingParams(url) {
if (!ENABLE_SOURCE_TRACKING) return url;
const u = new URL(url);
u.searchParams.set("from", "outlook-addin");
return u.toString();
}
// todo - escape html / link // todo - escape html / link
function buildMeetingMessage(data, isWeb) { function buildMeetingMessage(data) {
if (!data?.url) { if (!data?.url) {
throw new Error("buildMeetingMessage: missing url in data"); throw new Error("buildMeetingMessage: missing url in data");
} }
const url = _appendTrackingParams(data.url); const url = data.url;
const phone = _formatPhone(data.telephony?.phone_number); const phone = _formatPhone(data.telephony?.phone_number);
const pin = _formatPin(data.telephony?.pin_code); const pin = _formatPin(data.telephony?.pin_code);
let textLines = ""; const telephonyBlock =
let phoneLines = []; phone && pin
? `
const join = t("meeting_message.join", { app_name: APP_NAME }); Ou appelez (audio uniquement)
const phoneOnly = t("meeting_message.phone_only"); (FR) ${phone}
const phoneFr = t("meeting_message.phone_fr", { phone }); Code : ${pin}`
const pinCode = t("meeting_message.pin_code", { pin }); : "";
if (isWeb) { const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
phoneLines = phone && pin ? [`<br><br>${phoneOnly}`, `<br>${phoneFr}`, `<br>${pinCode}`] : []; ────────────────────────────────────────
Rejoindre la réunion ${APP_NAME}
textLines = [ <a href="${url}">${url}</a>${telephonyBlock}
"<br><br>────────────────────────────────────────", ────────────────────────────────────────</pre>`;
`<br>${join}`,
`<br><br><a href="${url}" target="_blank">${url}</a>`,
...phoneLines,
"<br>────────────────────────────────────────<br>",
];
} else { return { url, message };
phoneLines = phone && pin ? [`\n\n${phoneOnly}`, `\n${phoneFr}`, `\n${pinCode}`] : [];
textLines = [
"\n\n────────────────────────────────────────",
`\n${join}`,
`\n\n${url}`,
...phoneLines,
"\n────────────────────────────────────────\n",
];
}
const text = textLines.join("");
return { url, text };
} }
module.exports = { buildMeetingMessage }; module.exports = { buildMeetingMessage };
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Laden Sie das Add-In.",
"loading": "Wird geladen..."
},
"unauth": {
"intro": "Fügen Sie Ihren Outlook-Terminen ganz einfach einen {{app_name}}-Besprechungslink hinzu.",
"proconnect_btn": "Aanmelden met ProConnect",
"proconnect_link": "Wat is ProConnect?",
"proconnect_link_title": "Wat is ProConnect? - nieuw venster"
},
"success": {
"close_window": "Falls sich dieses Fenster nicht automatisch schließt, schließen Sie es bitte manuell."
},
"auth": {
"disconnect": "Abmelden"
},
"meeting": {
"already_added": "Es wurde bereits ein {{app_name}}-Meeting hinzugefügt.",
"link_inserted": "Besprechungslink erfolgreich eingefügt",
"generating": "Wird erstellt...",
"add_meeting": "{{app_name}}-Besprechung hinzufügen",
"remove_meeting": "{{app_name}}-Besprechung entfernen",
"removing": "Wird entfernt...",
"error": {
"auth": "Ihre Sitzung ist abgelaufen. Bitte versuchen Sie es erneut.",
"retry": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"details": "Fehler: {{message}}"
}
},
"meeting_message": {
"join": "An der {{app_name}}-Besprechung teilnehmen",
"phone_only": "Oder per Telefon teilnehmen (nur Audio)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Teilen Sie uns Ihr Feedback mit"
}
}
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Please load the add-in.",
"loading": "Loading..."
},
"unauth": {
"intro": "Easily add a {{app_name}} meeting link to your Outlook events.",
"proconnect_btn": "Sign in with ProConnect",
"proconnect_link": "What is ProConnect?",
"proconnect_link_title": "What is ProConnect? - new window"
},
"success": {
"close_window": "If this window does not close automatically, please close it manually."
},
"auth": {
"disconnect": "Sign out"
},
"meeting": {
"already_added": "A {{app_name}} meeting has already been added.",
"link_inserted": "Meeting link inserted successfully",
"generating": "Generating...",
"add_meeting": "Add a {{app_name}} meeting",
"remove_meeting": "Remove the {{app_name}} meeting",
"removing": "Removing...",
"error": {
"auth": "Your session has expired. Please try again.",
"retry": "An error occurred. Please try again.",
"details": "Error: {{message}}"
}
},
"meeting_message": {
"join": "Join the {{app_name}} meeting",
"phone_only": "Or call in (audio only)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Share your feedback"
}
}
@@ -1,40 +0,0 @@
{
"app": {
"sideload": "Veuillez charger le complément.",
"loading": "Chargement..."
},
"unauth": {
"intro": "Ajoutez facilement un lien de réunion {{app_name}} à vos événements Outlook.",
"proconnect_btn": "S'identifier avec ProConnect",
"proconnect_link": "Qu'est-ce que ProConnect ?",
"proconnect_link_title": "Qu'est-ce que ProConnect ? - nouvelle fenêtre"
},
"success": {
"close_window": "Si cette fenêtre ne se ferme pas toute seule, veuillez la fermer manuellement."
},
"auth": {
"disconnect": "Se déconnecter"
},
"meeting": {
"already_added": "Une réunion {{app_name}} a déjà été ajoutée.",
"link_inserted": "Lien de réunion inséré avec succès",
"generating": "Génération...",
"add_meeting": "Ajouter une réunion {{app_name}}",
"remove_meeting": "Supprimer la réunion {{app_name}}",
"removing": "Suppression en cours...",
"error": {
"auth": "Connexion expirée, veuillez réessayer.",
"retry": "Une erreur est survenue, veuillez ré-essayer",
"details": "Erreur : {{message}}"
}
},
"meeting_message": {
"join": "Rejoindre la réunion {{app_name}}",
"phone_only": "Ou appelez (audio uniquement)",
"phone_fr": "(FR) {{phone}}",
"pin_code": "Code {{pin}}"
},
"footer": {
"feedback": "Partagez-nous vos retours"
}
}
+2 -3
View File
@@ -9,10 +9,10 @@
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script> <script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
</head> </head>
<body> <body>
<div id="sideload-msg" data-i18n="app.sideload"></div> <div id="sideload-msg">Veuillez charger le complément.</div>
<div class="spinner-container" <div class="spinner-container"
role="progressbar" role="progressbar"
data-i18n-aria="app.loading" aria-label="Chargement..."
> >
<svg class="spinner-svg" <svg class="spinner-svg"
viewBox="0 0 28 28" viewBox="0 0 28 28"
@@ -40,6 +40,5 @@
</svg> </svg>
</span> </span>
</div> </div>
<p id="close-msg" style="display: none; text-align: center; font-size: 13px; color: #666; margin-top: 16px;" data-i18n="success.close_window"></p>
</body> </body>
</html> </html>
+14 -29
View File
@@ -1,35 +1,20 @@
const { applyAppName } = require("../common/helpers"); const { applyAppName } = require("../common/helpers");
const { exchangeSession } = require("../common/api"); const { exchangeSession } = require("../common/api");
const { consume } = require("../common/transitToken"); const { consume } = require("../common/transitToken");
const { initI18n, translateUI } = require("../common/i18n");
(async () => { applyAppName();
await initI18n();
applyAppName(); const transitToken = consume();
translateUI();
const transitToken = consume(); if (!transitToken) {
console.error("Transit token not found in sessionStorage");
if (!transitToken) { window.close();
console.error("Transit token not found in sessionStorage"); } else {
window.close(); exchangeSession(transitToken)
} else { .catch((e) => {
exchangeSession(transitToken) console.error(`Error occured: ${e}`);
.then(() => { })
document.querySelector(".spinner-container").style.display = "none"; .finally(() => {
document.querySelector("#close-msg").style.display = "block"; window.close();
}) });
.catch((e) => { }
console.error(`Error occured: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
// this window wasn't opened by this script (it was opened externally),
// so it blocks window.close() for security reasons. The "#close-msg"
// shown above is the fallback for that case.g
window.close();
});
}
})();
+4 -48
View File
@@ -115,39 +115,15 @@ button {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
/* ── Danger button (remove meeting) ── */
#btn-remove {
background-color: #CA3632; /* error.400 */
color: #FFFFFF;
border: none;
}
#btn-remove:hover {
background-color: #EE6A66; /* error.600 */
}
#btn-remove:active {
background-color: #F28D8A; /* error.700 */
color: #F6AFAD; /* error.200 */
}
#btn-remove:disabled {
background-color: #F6AFAD; /* error.800 */
color: #FAD2D1; /* error.900 */
cursor: not-allowed;
}
/* ── Version ── */ /* ── Version ── */
#version-tag { #version-tag {
position: fixed; position: fixed;
bottom: 8px; bottom: 8px;
left: 8px;
right: 8px; right: 8px;
display: flex; display: inline-flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 4px;
font-size: 11px; font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280; color: #6b7280;
@@ -155,29 +131,9 @@ button {
pointer-events: none; pointer-events: none;
} }
#feedback-link {
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #6b7280;
text-decoration: underline;
pointer-events: all; /* override parent's pointer-events: none */
cursor: pointer;
}
#feedback-link:hover {
color: #374151;
}
#footer-right {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.version-badge { .version-badge {
background: #EEF1F4; background: #fef3c7;
color: #2845C1; color: #92400e;
padding: 1px 6px; padding: 1px 6px;
border-radius: 3px; border-radius: 3px;
font-weight: 600; font-weight: 600;
+16 -25
View File
@@ -10,56 +10,47 @@
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script> <script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head> </head>
<body> <body>
<div id="sideload-msg" data-i18n="app.sideload"></div> <div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body"> <div id="app-body">
<!-- Loading --> <!-- Loading -->
<div id="view-loading"> <div id="view-loading">
<p class="intro-text" data-i18n="app.loading"></p> <p class="intro-text">Chargement...</p>
</div> </div>
<!-- Unauthenticated --> <!-- Unauthenticated -->
<div id="view-unauth" style="display:none;"> <div id="view-unauth" style="display:none;">
<p class="intro-text"> <p class="intro-text">
<span data-i18n="unauth.intro"></span> <span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
</p> </p>
<hr class="divider" /> <hr class="divider" />
<button class="proconnect-button" id="btn-connect"> <button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only" data-i18n="unauth.proconnect_btn"></span> <span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button> </button>
<p> <p>
<a href="https://www.proconnect.gouv.fr/" <a
target="_blank" href="https://www.proconnect.gouv.fr/"
rel="noopener noreferrer" target="_blank"
data-i18n-attr="title:unauth.proconnect_link_title" rel="noopener noreferrer"
data-i18n="unauth.proconnect_link" title="Quest-ce que ProConnect ? - nouvelle fenêtre"
></a> >
Quest-ce que ProConnect ?
</a>
</p> </p>
</div> </div>
<!-- Authenticated --> <!-- Authenticated -->
<div id="view-auth" style="display:none;"> <div id="view-auth" style="display:none;">
<div id="btn-container"> <div id="btn-container">
<!-- shown when no meeting is present --> <button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
<button id="btn-generate" data-i18n="meeting.add_meeting"></button> <button id="btn-disconnect">Se déconnecter</button>
<!-- shown when a meeting is already present -->
<button id="btn-remove" style="display:none;" data-i18n="meeting.remove_meeting"></button>
<button id="btn-disconnect" data-i18n="auth.disconnect"></button>
</div> </div>
</div> </div>
</div> </div>
<footer id="version-tag"> <footer id="version-tag">
<a id="feedback-link" <span class="version-badge">alpha</span>
style="display:none;" <span class="version-number">0.0.1</span>
target="_blank"
rel="noopener noreferrer"
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-badge">beta</span>
<span class="version-number">0.0.2</span>
</div>
</footer> </footer>
</body> </body>
</html> </html>
+42 -113
View File
@@ -1,77 +1,22 @@
/* global Office */ const { APP_NAME } = require("../common");
const { APP_NAME, FEEDBACK_FORM } = require("../common");
const { applyAppName } = require("../common/helpers"); const { applyAppName } = require("../common/helpers");
const { initSession, createRoom } = require("../common/api"); const { initSession, createRoom } = require("../common/api");
const { startPolling } = require("../common/polling"); const { startPolling } = require("../common/polling");
const { openTransitDialog } = require("../common/transitDialog"); const { openTransitDialog } = require("../common/transitDialog");
const { loadSession, saveSession, clearSession } = require("../common/session"); const { loadSession, saveSession, clearSession } = require("../common/session");
const { buildMeetingMessage } = require("../common/messageBuilder"); const { buildMeetingMessage } = require("../common/messageBuilder");
const { initI18n, t, translateUI } = require("../common/i18n");
const { isMeetingAlreadyAdded, removeMeetingLink } = require("../common/meetingDetector");
// ── Views ────────────────────────────────────────────────────
// todo - support loading view while polling
// todo - support error view
function showView(name) { function showView(name) {
document.getElementById("view-loading").style.display = "none"; document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none"; document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none"; document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block"; document.getElementById(`view-${name}`).style.display = "block";
if (name === "auth") {
_refreshMeetingButtonState();
}
} }
// ── Button state ─────────────────────────────────────────────
function _showAddButton() {
document.getElementById("btn-generate").style.display = "block";
document.getElementById("btn-remove").style.display = "none";
}
function _showRemoveButton() {
document.getElementById("btn-generate").style.display = "none";
document.getElementById("btn-remove").style.display = "block";
}
function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = t("meeting.generating");
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = t("meeting.add_meeting", { app_name: APP_NAME });
}
function _setRemoveLoading() {
const btn = document.getElementById("btn-remove");
btn.disabled = true;
btn.textContent = t("meeting.removing");
}
function _setRemoveIdle() {
const btn = document.getElementById("btn-remove");
btn.disabled = false;
btn.textContent = t("meeting.remove_meeting", { app_name: APP_NAME });
}
function _refreshMeetingButtonState() {
const item = Office.context.mailbox.item;
if (!item) return;
isMeetingAlreadyAdded(item).then((alreadyAdded) => {
if (alreadyAdded) {
_showRemoveButton();
} else {
_showAddButton();
}
});
}
// ── Auth ─────────────────────────────────────────────────────
function connect() { function connect() {
initSession() initSession()
.then((data) => { .then((data) => {
@@ -102,11 +47,22 @@ function disconnect() {
clearSession().finally(() => showView("unauth")); clearSession().finally(() => showView("unauth"));
} }
// ── Meeting ────────────────────────────────────────────────── function _setButtonLoading() {
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
}
function _setButtonIdle() {
const btn = document.getElementById("btn-generate");
btn.disabled = false;
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
}
function generateMeetingLink() { function generateMeetingLink() {
const session = loadSession(); const session = loadSession();
if (!session?.access_token) { if (!session?.access_token) {
console.error("Session introuvable. Veuillez vous reconnecter.");
showView("unauth"); showView("unauth");
return; return;
} }
@@ -115,28 +71,36 @@ function generateMeetingLink() {
createRoom(session) createRoom(session)
.then((data) => { .then((data) => {
const isWeb = Office.context.diagnostics.platform === "OfficeOnline"; const { url, message } = buildMeetingMessage(data);
const { url, text } = buildMeetingMessage(data, isWeb);
const item = Office.context.mailbox.item; const item = Office.context.mailbox.item;
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => { item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) { if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error); reject(getResult.error);
return; return;
} }
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve()); item.body.setAsync(
return; getResult.value + message,
} { coercionType: Office.CoercionType.Html },
resolve(); (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
reject(setResult.error);
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, () => resolve());
return;
}
resolve();
}
);
}); });
}); });
}) })
.then(() => {
_showRemoveButton();
})
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
}) })
@@ -145,41 +109,7 @@ function generateMeetingLink() {
}); });
} }
function removeMeetingLinkFromItem() { Office.onReady((info) => {
const session = loadSession();
if (!session?.access_token) {
showView("unauth");
return;
}
_setRemoveLoading();
const item = Office.context.mailbox.item;
removeMeetingLink(item)
.then(() => {
_showAddButton();
})
.catch((err) => {
console.error(err);
})
.finally(() => {
_setRemoveIdle();
});
}
// ── Init ─────────────────────────────────────────────────────
Office.onReady(async (info) => {
await initI18n();
translateUI();
if (FEEDBACK_FORM) {
const link = document.getElementById("feedback-link");
link.href = FEEDBACK_FORM;
link.style.display = "inline";
}
if (info.host === Office.HostType.Outlook) { if (info.host === Office.HostType.Outlook) {
applyAppName(); applyAppName();
document.getElementById("sideload-msg").style.display = "none"; document.getElementById("sideload-msg").style.display = "none";
@@ -187,11 +117,10 @@ Office.onReady(async (info) => {
document.getElementById("btn-connect").onclick = connect; document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect; document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink; document.getElementById("btn-generate").onclick = generateMeetingLink;
document.getElementById("btn-remove").onclick = removeMeetingLinkFromItem;
const session = loadSession(); const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) { if (session?.state === "authenticated" && session?.access_token) {
showView("auth"); // this already calls _refreshMeetingButtonState internally showView("auth");
} else { } else {
showView("unauth"); showView("unauth");
} }
+2 -2
View File
@@ -10,11 +10,11 @@
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script> <script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head> </head>
<body> <body>
<div id="sideload-msg" data-i18n="app.sideload"></div> <div id="sideload-msg">Veuillez charger le complément.</div>
<div <div
class="spinner-container" class="spinner-container"
role="progressbar" role="progressbar"
data-i18n-aria="app.loading" aria-label="Chargement..."
> >
<svg <svg
class="spinner-svg" class="spinner-svg"
+1 -6
View File
@@ -2,7 +2,6 @@ const { applyAppName } = require("../common/helpers");
const { URLS } = require("../common/urls"); const { URLS } = require("../common/urls");
const { save } = require("../common/transitToken"); const { save } = require("../common/transitToken");
const { DIALOG_SIGNALS } = require("../common/transitDialog"); const { DIALOG_SIGNALS } = require("../common/transitDialog");
const { initI18n, translateUI } = require("../common/i18n");
// Initiate the authentication flow, then return to the success page // Initiate the authentication flow, then return to the success page
function getAuthenticateUrl() { function getAuthenticateUrl() {
@@ -11,11 +10,7 @@ function getAuthenticateUrl() {
return url.toString(); return url.toString();
} }
Office.onReady(async function (info) { Office.onReady(function (info) {
await initI18n();
translateUI();
if (info.host === Office.HostType.Outlook) { if (info.host === Office.HostType.Outlook) {
applyAppName(); applyAppName();
} }
-38
View File
@@ -1,38 +0,0 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
**/.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.iml
db.sqlite3
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Env
.env
+12 -40
View File
@@ -1,4 +1,4 @@
FROM python:3.14.6-slim AS base FROM python:3.13.13-slim AS base
# Install system dependencies required by LiveKit # Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -6,61 +6,31 @@ RUN apt-get update && apt-get install -y \
libgobject-2.0-0 \ libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder FROM base AS builder
ENV UV_COMPILE_BYTECODE=1 \ WORKDIR /builder
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
# Install uv COPY pyproject.toml .
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app RUN mkdir /install && \
pip install --prefix=/install .
# Install production dependencies without the project itself (cacheable layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- Development image ----
FROM base AS development FROM base AS development
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app WORKDIR /app
COPY . /app COPY pyproject.toml .
RUN pip install --no-cache-dir ".[dev]"
RUN --mount=type=cache,target=/root/.cache/uv \ COPY . .
uv sync --locked --all-extras
ENV PATH="/app/.venv/bin:$PATH" CMD ["python", "metadata_collector.py", "dev"]
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production FROM base AS production
WORKDIR /app WORKDIR /app
# Copy the pre-built virtualenv and application source COPY --from=builder /install /usr/local
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# Remove pip to reduce attack surface in production # Remove pip to reduce attack surface in production
RUN pip uninstall -y pip RUN pip uninstall -y pip
@@ -69,4 +39,6 @@ RUN pip uninstall -y pip
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}
COPY ./*.py /app/
CMD ["python", "multi_user_transcriber.py", "start"] CMD ["python", "multi_user_transcriber.py", "start"]
+12 -10
View File
@@ -19,14 +19,13 @@ from livekit.agents import (
JobContext, JobContext,
JobProcess, JobProcess,
JobRequest, JobRequest,
RoomInputOptions,
RoomIO, RoomIO,
RoomOutputOptions,
WorkerPermissions, WorkerPermissions,
cli, cli,
utils, utils,
) )
from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import silero from livekit.plugins import silero
from minio import Minio from minio import Minio
from minio.error import S3Error from minio.error import S3Error
@@ -178,7 +177,7 @@ class MetadataCollector:
def save(self): def save(self):
"""Serialize collected events and upload as JSON to S3.""" """Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...") logger.info("Persisting metadata")
participants = [] participants = []
for k, v in self.participants.items(): for k, v in self.participants.items():
@@ -303,11 +302,13 @@ class MetadataCollector:
agent_session=session, agent_session=session,
room=self.ctx.room, room=self.ctx.room,
participant=participant, participant=participant,
options=lk_room_io.RoomOptions( input_options=RoomInputOptions(
audio_input=lk_room_io.AudioInputOptions(), audio_enabled=True,
text_input=False, text_enabled=False,
audio_output=False, ),
text_output=False, output_options=RoomOutputOptions(
audio_enabled=False,
transcription_enabled=False,
), ),
) )
@@ -323,6 +324,7 @@ class MetadataCollector:
async def _close_session(self, session: AgentSession) -> None: async def _close_session(self, session: AgentSession) -> None:
"""Close and cleanup VAD monitoring session.""" """Close and cleanup VAD monitoring session."""
try: try:
await session.drain()
await session.aclose() await session.aclose()
except Exception: except Exception:
logger.exception("Error closing session") logger.exception("Error closing session")
@@ -370,7 +372,7 @@ async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY) await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup(): async def cleanup():
logger.info("Shutting down metadata collector...") logger.info("Shutting down metadata collector")
await metadata_collector.aclose() await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup) ctx.add_shutdown_callback(cleanup)
+13 -9
View File
@@ -1,25 +1,29 @@
[project] [project]
name = "agents" name = "agents"
version = "1.22.0" version = "1.15.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.4", "livekit-agents==1.4.5",
"livekit-plugins-deepgram==1.6.4", "livekit-plugins-deepgram==1.4.5",
"livekit-plugins-silero==1.6.4", "livekit-plugins-silero==1.4.5",
"livekit-plugins-kyutai-lasuite==0.0.6", "livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2", "python-dotenv==1.2.2",
"protobuf>=6.33.5", "protobuf==6.33.5",
"minio==7.2.20" "minio==7.2.15"
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff==0.15.19", "ruff==0.15.6",
] ]
[tool.uv] [tool.setuptools]
package = false py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff] [tool.ruff]
target-version = "py313" target-version = "py313"
-2036
View File
File diff suppressed because it is too large Load Diff
-168
View File
@@ -3,55 +3,17 @@
from django import forms from django import forms
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin from django.contrib.auth import admin as auth_admin
from django.db import transaction
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.recording.event import notification from core.recording.event import notification
from . import models from . import models
from .tasks.file import process_file_deletion
from .utils import generate_download_file_url
def hard_delete_file(file):
"""Hard delete a file, soft deleting it first when needed."""
if file.deleted_at is None:
file.soft_delete()
file.hard_delete()
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
class FileInlineFormSet(forms.BaseInlineFormSet):
"""Inline formset overriding delete behavior for files."""
def delete_existing(self, obj, commit=True):
"""Hard delete files instead of calling model.delete()."""
hard_delete_file(obj)
class FileInline(admin.TabularInline):
"""Inline class for the File model."""
model = models.File
formset = FileInlineFormSet
fk_name = "creator"
extra = 0
fields = ("id", "title", "type", "upload_state", "created_at")
readonly_fields = ("id", "created_at", "upload_state", "type")
show_change_link = True
def get_queryset(self, request):
"""Hide hard deleted files in the inline."""
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
@admin.register(models.User) @admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin): class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model""" """Admin class for the User model"""
inlines = (FileInline,)
fieldsets = ( fieldsets = (
( (
None, None,
@@ -135,136 +97,6 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name") search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.File)
class FileAdmin(admin.ModelAdmin):
"""Admin class for the File model."""
list_display = (
"id",
"title",
"type",
"creator",
"upload_state",
"deleted_at",
"hard_deleted_at",
"created_at",
"updated_at",
)
list_filter = (
"type",
"upload_state",
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
)
search_fields = (
"id",
"title",
"filename",
"mimetype",
"description",
"creator__email",
"creator__admin_email",
"creator__full_name",
)
ordering = ("-created_at",)
readonly_fields = (
"id",
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
"description",
"malware_detection_info",
"is_ready",
"preview_url",
"extension",
"key_base",
"file_key",
"upload_state",
"type",
"mimetype",
"size",
)
autocomplete_fields = ("creator",)
fieldsets = (
(
None,
{
"fields": (
"id",
"title",
"type",
"creator",
"filename",
"upload_state",
)
},
),
(
_("Content"),
{
"fields": (
"mimetype",
"size",
"description",
"malware_detection_info",
)
},
),
(
_("Deletion"),
{
"fields": (
"deleted_at",
"hard_deleted_at",
)
},
),
(
_("Derived info"),
{
"fields": (
"is_ready",
"extension",
"key_base",
"file_key",
"preview_url",
)
},
),
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
)
@admin.display(description=_("File preview"))
def preview_url(self, obj):
"""Return a clickable preview URL for the file."""
if not obj.is_ready:
return "-"
url = generate_download_file_url(obj, expires_in=60 * 60)
return format_html(
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
)
def get_queryset(self, request):
"""Hide hard deleted files in admin listing and lookups."""
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
def delete_model(self, request, obj):
"""Hard delete instead of calling model.delete()."""
hard_delete_file(obj)
def delete_queryset(self, request, queryset):
"""Hard delete all selected files."""
for file in queryset:
hard_delete_file(file)
def has_add_permission(self, request):
return False
class ResourceAccessInline(admin.TabularInline): class ResourceAccessInline(admin.TabularInline):
"""Admin class for the room user access model""" """Admin class for the room user access model"""
-59
View File
@@ -1,59 +0,0 @@
"""
Pluggable analytics.
Usage anywhere in the codebase:
from core import analytics
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
The concrete backend is resolved lazily from Django settings, so swapping
PostHog for anything else is a configuration change, not a code change.
"""
from functools import lru_cache
from typing import Any
from django.conf import settings
from django.utils.module_loading import import_string
from .base import AnalyticsBackend, NoOpAnalytics
from .events import AnalyticsEvent
__all__ = [
"get_analytics",
"identify",
"capture",
"AnalyticsBackend",
"AnalyticsEvent",
]
@lru_cache(maxsize=1)
def get_analytics() -> AnalyticsBackend:
"""Instantiate the configured backend once per process."""
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
backend_class = import_string(dotted_path)
return backend_class(**options)
# Convenience module-level shortcuts
analytics_instance = get_analytics()
def identify(user, properties: dict[str, Any] | None = None) -> None:
"""Associate traits with an identified user."""
analytics_instance.identify(user, properties)
def capture(
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
) -> None:
"""Record an event performed by an identified user."""
analytics_instance.capture(user, event, properties)
-50
View File
@@ -1,50 +0,0 @@
"""Analytics backend protocol and default no-op implementation."""
from typing import Any, Protocol
from ..models import User
from .events import AnalyticsEvent
class AnalyticsBackend(Protocol):
"""
Interface every analytics backend must implement.
Backends are instantiated once (singleton) with the kwargs declared in
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
"""
def __init__(self, **kwargs: Any) -> None: ...
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
def capture(
self,
user: User,
event: AnalyticsEvent,
properties: dict[str, Any] | None = None,
) -> None:
"""Record an event performed by an identified user."""
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
class NoOpAnalytics:
"""Default backend: silently discards everything."""
def __init__(self, **kwargs: Any) -> None:
"""No-op: accepts and ignores any backend settings kwargs."""
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
def capture(self, user, event, properties=None) -> None:
"""No-op: discards captured events."""
def shutdown(self) -> None:
"""No-op: nothing to flush."""
-10
View File
@@ -1,10 +0,0 @@
"""Catalog of all analytics events emitted by the backend."""
from enum import StrEnum
class AnalyticsEvent(StrEnum):
"""All trackable events. Values are the wire names sent to the provider."""
# Rooms
ROOM_CREATED = "room_created"
-74
View File
@@ -1,74 +0,0 @@
"""PostHog implementation of the analytics backend protocol."""
import logging
from typing import Any
from posthog import Posthog
from ..models import User
from .events import AnalyticsEvent
logger = logging.getLogger(__name__)
class PostHogAnalytics:
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
def __init__(
self,
*,
api_key: str,
host: str = "https://eu.i.posthog.com",
**kwargs: Any,
) -> None:
# The SDK batches and sends in a background thread by default,
# so calls below never block the request/response cycle.
self._client = Posthog(
project_api_key=api_key,
host=host,
**kwargs,
)
@staticmethod
def _distinct_id(user: User) -> str | None:
"""Return the PostHog distinct_id for a user, or None if anonymous."""
if user is None or not getattr(user, "is_authenticated", False):
return None
return str(user.pk)
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return
try:
self._client.set(
distinct_id=distinct_id,
properties=properties or {},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("PostHog identify failed")
def capture(
self,
user: User,
event: AnalyticsEvent,
properties: dict[str, Any] | None = None,
) -> None:
"""Record an event performed by an identified user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return
try:
self._client.capture(
distinct_id=distinct_id,
event=str(event),
properties=properties or {},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("PostHog capture failed for event %s", event)
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
self._client.shutdown()
+7 -3
View File
@@ -8,8 +8,6 @@ from rest_framework import views as drf_views
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
from core.utils import build_telephony_config
def exception_handler(exc, context): def exception_handler(exc, context):
"""Handle Django ValidationError as an accepted exception. """Handle Django ValidationError as an accepted exception.
@@ -60,7 +58,13 @@ def get_frontend_configuration(request):
"allowed_mimetypes" "allowed_mimetypes"
], ],
}, },
"telephony": build_telephony_config(), "telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED}, "subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": { "livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"], "url": settings.LIVEKIT_CONFIGURATION["url"],
-30
View File
@@ -136,33 +136,3 @@ class FilePermission(IsAuthenticated):
raise Http404 raise Http404
return obj.get_abilities(request.user).get(view.action, False) return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
"""
Grant muting rights based on role or room configuration.
- Admins and owners can always mute.
- When `everyone_can_mute` is enabled on the room, any participant
currently in the room (proven by a valid LiveKit token for that room)
can mute.
"""
def has_object_permission(self, request, view, obj):
"""Check if the requesting user is allowed to mute a participant in the given room."""
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
# Always allow admins/owners when authenticated with session cookie
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
return True
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
if not everyone_can_mute:
return False
if not is_livekit_token_auth:
return False
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
+15 -10
View File
@@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer from pydantic import BaseModel, Field
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -166,6 +166,11 @@ class RoomSerializer(serializers.ModelSerializer):
) )
output["accesses"] = access_serializer.data output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
should_access_room = ( should_access_room = (
( (
instance.access_level == models.RoomAccessLevel.TRUSTED instance.access_level == models.RoomAccessLevel.TRUSTED
@@ -182,7 +187,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
username=username, username=username,
configuration=output["configuration"], configuration=configuration,
is_admin_or_owner=is_admin_or_owner, is_admin_or_owner=is_admin_or_owner,
) )
else: else:
@@ -312,7 +317,9 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
) )
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"] RoomConfigurationTrackSource = Literal[
"camera", "microphone", "screen_share", "screen_share_audio"
]
class RoomConfiguration(BaseModel): class RoomConfiguration(BaseModel):
@@ -321,12 +328,14 @@ class RoomConfiguration(BaseModel):
Unknown fields are rejected. Unknown fields are rejected.
""" """
can_publish_sources: list[TrackSource] | None = None can_publish_sources: list[RoomConfigurationTrackSource] | None = None
everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel): class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf. """Mirror the LiveKit ParticipantPermission protobuf.
@@ -346,10 +355,6 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
@field_serializer("can_publish_sources")
def _serialize_sources(self, sources: list[str]) -> list[str]:
return [s.upper() for s in sources]
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer): class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data.""" """Validate participant update data."""
@@ -456,7 +461,7 @@ class ListFileSerializer(serializers.ModelSerializer):
def get_url(self, obj): def get_url(self, obj):
"""Return the URL of the file.""" """Return the URL of the file."""
if not obj.is_ready: if obj.is_pending_upload:
return None return None
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}" return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
+94 -195
View File
@@ -33,9 +33,8 @@ from rest_framework import (
from rest_framework import ( from rest_framework import (
status as drf_status, status as drf_status,
) )
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils from core import enums, models, utils
from core.api.filters import ListFileFilter from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension from core.recording.enums import FileExtension
@@ -46,15 +45,12 @@ from core.recording.event.exceptions import (
InvalidFileTypeError, InvalidFileTypeError,
ParsingEventDataError, ParsingEventDataError,
) )
from core.recording.event.notification import notification_service
from core.recording.event.parsers import get_parser from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import ( from core.recording.services.metadata_collector import (
MetadataCollectorException, MetadataCollectorException,
MetadataCollectorService, MetadataCollectorService,
) )
from core.recording.services.recording_events import (
RecordingEventsService,
RecordingNotSavableError,
)
from core.recording.worker.exceptions import ( from core.recording.worker.exceptions import (
RecordingStartError, RecordingStartError,
RecordingStopError, RecordingStopError,
@@ -80,11 +76,6 @@ from core.services.participants_management import (
ParticipantsManagementException, ParticipantsManagementException,
) )
from core.services.room_creation import RoomCreation from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.subtitle import SubtitleException, SubtitleService from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
@@ -308,51 +299,6 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"): if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room) RoomCreation().persist_callback_state(callback_id, room)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"from_callback": bool(self.request.data.get("callback_id")),
},
)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
room = serializer.save()
if (
room.configuration == old_configuration
and room.access_level == old_access_level
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action( @decorators.action(
detail=True, detail=True,
methods=["post"], methods=["post"],
@@ -417,7 +363,6 @@ class RoomViewSet(
): ):
try: try:
MetadataCollectorService().start(recording) MetadataCollectorService().start(recording)
logger.debug("Started MetadataCollectorService")
except MetadataCollectorException: except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService") logger.warning("Failed to start MetadataCollectorService")
@@ -668,11 +613,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="mute-participant", url_path="mute-participant",
url_name="mute-participant", url_name="mute-participant",
permission_classes=[permissions.CanMuteParticipant], permission_classes=[permissions.HasPrivilegesOnRoom],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
) )
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room.""" """Mute a specific track for a participant in the room."""
@@ -681,26 +622,6 @@ class RoomViewSet(
serializer = serializers.MuteParticipantSerializer(data=request.data) serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
# joined. Cross-check identity against the live participant list until auth
# is hardened. Skipped for non-LiveKit auth backends.
caller_identity = getattr(request.auth, "identity", None)
if caller_identity is not None:
try:
ParticipantsManagement().check_if_in_meeting(
room_name=str(room.pk),
identity=caller_identity,
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Failed to verify caller presence for mute in room %s; denying",
room.pk,
)
return drf_response.Response(
{"error": "Could not verify caller presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try: try:
ParticipantsManagement().mute( ParticipantsManagement().mute(
room_name=str(room.pk), room_name=str(room.pk),
@@ -985,15 +906,24 @@ class RecordingViewSet(
except models.Recording.DoesNotExist as e: except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e raise drf_exceptions.NotFound("No recording found for this event.") from e
# Save recording if not recording.is_savable():
recording_events_service = RecordingEventsService()
try:
recording_events_service.handle_complete(recording)
except RecordingNotSavableError:
raise drf_exceptions.PermissionDenied( raise drf_exceptions.PermissionDenied(
f"Recording with ID {recording_id} cannot be saved because it is either," f"Recording with ID {recording_id} cannot be saved because it is either,"
" in an error state or has already been saved." " in an error state or has already been saved."
) from None )
# Attempt to notify external services about the recording
# This is a non-blocking operation - failures are logged but don't interrupt the flow
notification_succeeded = notification_service.notify_external_services(
recording
)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
return drf_response.Response( return drf_response.Response(
{"message": "Event processed."}, {"message": "Event processed."},
@@ -1191,10 +1121,7 @@ class FileViewSet(
serializer.save(creator=self.request.user) serializer.save(creator=self.request.user)
def perform_destroy(self, instance): def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database. """Override to implement a soft delete instead of dumping the record in database."""
Files are actually purged by commands that should run periodically.
"""
instance.soft_delete() instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended") @decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@@ -1203,124 +1130,96 @@ class FileViewSet(
""" """
Check the actual uploaded file and mark it as ready. Check the actual uploaded file and mark it as ready.
""" """
# Ensures we go through authorization checks
file = self.get_object() file = self.get_object()
# Try to update the file with the new state. If the file is already in this state if not file.is_pending_upload:
# we are in a concurrent request, and we should reject that request
updated_rows = models.File.objects.filter(
upload_state=models.FileUploadStateChoices.PENDING,
pk=kwargs["pk"],
).update(upload_state=models.FileUploadStateChoices.ANALYZING)
if updated_rows != 1:
raise drf_exceptions.ValidationError( raise drf_exceptions.ValidationError(
{"file": "This action is only available for files in PENDING state."}, {"file": "This action is only available for files in PENDING state."},
code="file_upload_state_not_pending", code="file_upload_state_not_pending",
) )
file.refresh_from_db()
s3_client = default_storage.connection.meta.client s3_client = default_storage.connection.meta.client
validation_error = None
try: head_response = s3_client.head_object(
# We copy the file to its final destination, we will run the checks on that Bucket=default_storage.bucket_name, Key=file.file_key
# final file and ignore any updates to the temporary file. (We cannot revoke the policy, )
# so the temporary file might still be updated after that.) file_size = head_response["ContentLength"]
# The temporary folders will need to be cleaned periodically
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
self._complete_file_deletion(file)
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
# Use improved MIME type detection combining magic bytes and file extension
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
self._complete_file_deletion(file)
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object( s3_client.copy_object(
Bucket=default_storage.bucket_name, Bucket=default_storage.bucket_name,
Key=file.file_key, Key=file.file_key,
CopySource={ CopySource={
"Bucket": default_storage.bucket_name, "Bucket": default_storage.bucket_name,
"Key": file.temporary_file_key, "Key": file.file_key,
}, },
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
) )
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and
# the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
validation_error = drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
else:
# Use improved MIME type detection combining magic bytes and file extension
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
validation_error = drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
if validation_error is not None:
self._complete_file_deletion(file)
else:
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
except Exception as e:
logger.exception("Failed to analyze file, reverting to pending state")
file.upload_state = models.FileUploadStateChoices.PENDING
file.save()
raise e
if validation_error:
raise validation_error
# Not yet implemented # Not yet implemented
# Change the file.upload_state when this will be done # Change the file.upload_state when this will be done
# malware_detection.analyse_file(file.file_key, file_id=file.id) # malware_detection.analyse_file(file.file_key, file_id=file.id)
@@ -1333,7 +1232,7 @@ class FileViewSet(
"""Delete a file completely.""" """Delete a file completely."""
file.soft_delete() file.soft_delete()
file.hard_delete() file.hard_delete()
transaction.on_commit(lambda: process_file_deletion.delay(file.id)) process_file_deletion.delay(file.id)
def _authorize_subrequest(self, request, pattern): def _authorize_subrequest(self, request, pattern):
""" """
@@ -1424,7 +1323,7 @@ class FileViewSet(
request, MEDIA_STORAGE_URL_PATTERN request, MEDIA_STORAGE_URL_PATTERN
) )
if not file.is_ready: if file.is_pending_upload:
logger.warning("File '%s' is not ready", file.id) logger.warning("File '%s' is not ready", file.id)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
@@ -9,7 +9,6 @@ from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
from rest_framework.authentication import SessionAuthentication
from core.models import User from core.models import User
from core.services.marketing import ( from core.services.marketing import (
@@ -97,17 +96,3 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"Multiple user accounts share a common email." "Multiple user accounts share a common email."
) from e ) from e
return None return None
class SessionAuthenticationWith401(SessionAuthentication):
"""
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
header so unauthenticated requests get a 401 instead of a 403.
The scheme is deliberately NOT 'Basic' — that would trigger the browser's
native login popup. 'Session' is ignored by the browser's auth UI but is
still truthy, so DRF keeps the status at 401.
"""
def authenticate_header(self, request):
return "Session"
+4 -35
View File
@@ -4,11 +4,10 @@
from django.conf import settings from django.conf import settings
from pydantic import ValidationError
from rest_framework import serializers from rest_framework import serializers
from core import models, utils from core import models, utils
from core.api.serializers import BaseValidationOnlySerializer, RoomConfiguration from core.api.serializers import BaseValidationOnlySerializer
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
@@ -35,37 +34,10 @@ class RoomSerializer(serializers.ModelSerializer):
following the principle of least privilege. following the principle of least privilege.
""" """
configuration = serializers.JSONField(required=False)
class Meta: class Meta:
model = models.Room model = models.Room
fields = ["id", "name", "slug", "pin_code", "access_level", "configuration"] fields = ["id", "name", "slug", "pin_code", "access_level"]
read_only_fields = ["id", "name", "slug", "pin_code"] read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
def validate_configuration(self, value):
"""Validate room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except ValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
def validate_access_level(self, access_level):
"""Reject public access_level unless explicitly allowed or the default is already public."""
if settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL == models.RoomAccessLevel.PUBLIC:
return access_level
if (
access_level == models.RoomAccessLevel.PUBLIC
and not settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS
):
raise serializers.ValidationError(
"Public rooms are disabled for the external API."
)
return access_level
def to_representation(self, instance): def to_representation(self, instance):
"""Enrich response with application-specific computed fields.""" """Enrich response with application-specific computed fields."""
@@ -96,9 +68,6 @@ class RoomSerializer(serializers.ModelSerializer):
# Set secure defaults # Set secure defaults
validated_data["name"] = utils.generate_room_slug() validated_data["name"] = utils.generate_room_slug()
validated_data.setdefault( validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
"access_level", settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL
)
validated_data.setdefault("configuration", {})
return super().create(validated_data) return super().create(validated_data)
+38 -33
View File
@@ -4,7 +4,7 @@ from logging import getLogger
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
@@ -19,15 +19,10 @@ from rest_framework import (
status as drf_status, status as drf_status,
) )
from core import analytics, api, models from core import api, models
from core.api.feature_flag import FeatureFlag from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
ProvisionalUserIntegrityError,
ProvisionalUserService,
)
from . import authentication, permissions, serializers from . import authentication, permissions, serializers
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -99,14 +94,40 @@ class ApplicationViewSet(viewsets.ViewSet):
) )
try: try:
user, _ = ProvisionalUserService().get_or_create(email, client_id) user = models.User.objects.get(email__iexact=email)
except ProvisionalUserCreationDisabledError as not_found_error: except models.User.DoesNotExist as e:
raise drf_exceptions.NotFound("User not found.") from not_found_error if (
except ProvisionalUserIntegrityError: settings.APPLICATION_ALLOW_USER_CREATION
return drf_response.Response( and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
{"error": "Failed to create or retrieve provisional user."}, and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
status=drf_status.HTTP_409_CONFLICT, ):
) # Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
scope = " ".join(application.scopes or []) scope = " ".join(application.scopes or [])
@@ -194,26 +215,10 @@ class RoomViewSet(
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
) )
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing # Log for auditing
logger.info( logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s", "Room created via application: room_id=%s, user_id=%s, client_id=%s",
room.id, room.id,
self.request.user.id, self.request.user.id,
client_id, getattr(self.request.auth, "client_id", "unknown"),
auth_method,
)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
},
) )
@@ -1,47 +0,0 @@
"""Clean stale pending files that were never fully uploaded."""
from datetime import timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from core.models import File, FileUploadStateChoices
from core.tasks.file import process_file_deletion
class Command(BaseCommand):
"""Remove pending files older than a given threshold."""
help = "Delete pending files that have been stuck for too long"
def add_arguments(self, parser):
parser.add_argument(
"--hours",
type=int,
default=24,
help="Age threshold in hours (default: 24)",
)
def handle(self, *args, **options):
hours = options["hours"]
if hours < 0:
raise CommandError("Hours must be greater than 0")
threshold = timezone.now() - timedelta(hours=hours)
files = File.objects.filter(
upload_state=FileUploadStateChoices.PENDING,
created_at__lt=threshold,
hard_deleted_at__isnull=True,
)
count = 0
for file in files.iterator():
# This check shouldn't happen, but just in case we do it to avoid an error
if not file.deleted_at:
file.soft_delete()
file.hard_delete()
process_file_deletion(file.id)
count += 1
self.stdout.write(f"Cleaned {count} stale pending file(s).")
@@ -1,182 +0,0 @@
"""Management command to merge duplicate users based on their email address."""
# pylint: disable=too-many-locals
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from django.db.models.functions import Lower
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
User = get_user_model()
ROLE_PRIORITY = {
RoleChoices.OWNER: 3,
RoleChoices.ADMIN: 2,
RoleChoices.MEMBER: 1,
}
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email (case-insensitive) into the
most recently created one.
Emails are compared case-insensitively, so 'John@Example.com' and
'john@example.com' are treated as duplicates. The KEPT user is the most
recently created. All room memberships, recording accesses and files are
transferred to it. When a conflict exists, the higher-privilege role wins.
Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
help = __doc__
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate the merge without writing any changes to the database.",
)
parser.add_argument(
"--email-filter",
type=str,
default=None,
help="Only merge users whose email contains this string (e.g. '@example.com').",
)
def handle(self, *args, **options):
"""Execute the management command."""
dry_run = options["dry_run"]
email_filter = options["email_filter"]
if dry_run:
self.stdout.write("[DRY-RUN] No changes will be written.\n")
users_qs = User.objects.all()
if email_filter:
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
# are detected as duplicates of each other.
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.annotate(email_lower=Lower("email"))
.values("email_lower")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email_lower", flat=True)
)
if not duplicate_emails:
self.stdout.write("[INFO] No duplicate users found. Nothing to do.")
return
self.stdout.write(
f"[INFO] Found {len(duplicate_emails)} email(s) with duplicate users."
)
total_merged = 0
total_deleted = 0
failed_emails = []
for email in duplicate_emails:
# Case-insensitive lookup to fetch every casing variant of the email.
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(
User.objects.filter(email__iexact=email).order_by("created_at", "id")
)
kept_user = users[-1]
stale_users = users[:-1]
self.stdout.write(
f"\n[INFO] Email '{email}': {len(users)} users — "
f"keeping {kept_user.id} (created {kept_user.created_at.date()})."
)
for u in stale_users:
self.stdout.write(
f" stale: {u.id} (created {u.created_at.date()})"
)
if dry_run:
ra_count = ResourceAccess.objects.filter(user__in=stale_users).count()
rca_count = RecordingAccess.objects.filter(user__in=stale_users).count()
f_count = File.objects.filter(creator__in=stale_users).count()
self.stdout.write(
f" [DRY-RUN] Would migrate: {ra_count} ResourceAccess, "
f"{rca_count} RecordingAccess, {f_count} File(s)."
)
continue
try:
group_deleted = 0
with transaction.atomic():
for stale_user in stale_users:
self._merge_resource_accesses(stale_user, kept_user)
self._merge_recording_accesses(stale_user, kept_user)
self._merge_files(stale_user, kept_user)
stale_user.delete()
group_deleted += 1
total_deleted += group_deleted
total_merged += 1
except Exception as exc: # noqa: BLE001 #pylint: disable=broad-exception-caught
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if not kept_user.email.islower():
kept_user.email = kept_user.email.lower()
kept_user.save(update_fields=["email"])
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
)
self.stdout.write(
self.style.SUCCESS(
f"\n[DONE] Merged {total_merged} group(s), deleted {total_deleted} user(s)."
)
)
def _merge_resource_accesses(self, stale_user, kept_user):
"""Transfer room memberships from stale_user to kept_user."""
for ra in ResourceAccess.objects.filter(user=stale_user):
existing = ResourceAccess.objects.filter(
user=kept_user, resource=ra.resource
).first()
if existing is None:
ra.user = kept_user
ra.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(ra.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = ra.role
existing.save(update_fields=["role"])
ra.delete()
def _merge_recording_accesses(self, stale_user, kept_user):
"""Transfer recording accesses from stale_user to kept_user."""
for rca in RecordingAccess.objects.filter(user=stale_user):
existing = RecordingAccess.objects.filter(
user=kept_user, recording=rca.recording
).first()
if existing is None:
rca.user = kept_user
rca.save(update_fields=["user"])
else:
if ROLE_PRIORITY.get(rca.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
existing.role = rca.role
existing.save(update_fields=["role"])
rca.delete()
def _merge_files(self, stale_user, kept_user):
"""Re-assign files created by stale_user to kept_user."""
File.objects.filter(creator=stale_user).update(creator=kept_user)
@@ -1,40 +0,0 @@
"""Purge deleted files."""
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from core.models import File
from core.tasks.file import process_file_deletion
class Command(BaseCommand):
"""
Purge deleted files (object storage and database object):
- files marked as hard deleted in database
- files marked as soft deleted and for which the trashbin retention period has expired
"""
help = "Purge deleted files"
def handle(self, *args, **options):
"""Browse purgeable files and queue them through the file deletion task."""
is_hard_deleted = Q(hard_deleted_at__isnull=False)
is_purgeable = Q(
deleted_at__lte=timezone.now()
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
)
count = 0
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
if file.hard_deleted_at is None:
file.hard_delete()
process_file_deletion.delay(file.id)
count += 1
self.stdout.write(f"Purged {count} deleted file(s).")
@@ -1,19 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-02 17:31
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('core', '0018_rename_active_application_is_active'),
]
operations = [
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('email'), condition=models.Q(('sub__isnull', True)), name='unique_email_when_sub_is_null'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-03 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_user_unique_email_when_sub_is_null'),
]
operations = [
migrations.AlterField(
model_name='file',
name='upload_state',
field=models.CharField(choices=[('pending', 'Pending'), ('analyzing', 'Analyzing'), ('ready', 'Ready')], max_length=25),
),
]
+4 -28
View File
@@ -211,13 +211,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
ordering = ("-created_at",) ordering = ("-created_at",)
verbose_name = _("user") verbose_name = _("user")
verbose_name_plural = _("users") verbose_name_plural = _("users")
constraints = [
models.UniqueConstraint(
models.functions.Lower("email"),
condition=models.Q(sub__isnull=True),
name="unique_email_when_sub_is_null",
)
]
def __str__(self): def __str__(self):
return self.email or self.admin_email or str(self.id) return self.email or self.admin_email or str(self.id)
@@ -395,7 +388,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices, choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
) )
# Public configuration exposed to any room participant via the API
configuration = models.JSONField( configuration = models.JSONField(
blank=True, blank=True,
default=dict, default=dict,
@@ -846,8 +838,8 @@ class FileUploadStateChoices(models.TextChoices):
"""Possible states of a file.""" """Possible states of a file."""
PENDING = "pending", _("Pending") PENDING = "pending", _("Pending")
ANALYZING = "analyzing", _("Analyzing")
# Commented out for now, as we may need this when we implement the malware detection logic. # Commented out for now, as we may need this when we implement the malware detection logic.
# ANALYZING = "analyzing", _("Analyzing")
# SUSPICIOUS = "suspicious", _("Suspicious") # SUSPICIOUS = "suspicious", _("Suspicious")
# FILE_TOO_LARGE_TO_ANALYZE = ( # FILE_TOO_LARGE_TO_ANALYZE = (
# "file_too_large_to_analyze", # "file_too_large_to_analyze",
@@ -925,9 +917,9 @@ class File(BaseModel):
return super().delete(using, keep_parents) return super().delete(using, keep_parents)
@property @property
def is_ready(self): def is_pending_upload(self):
"""Return whether the file is in a ready upload state""" """Return whether the file is in a pending upload state"""
return self.upload_state == FileUploadStateChoices.READY return self.upload_state == FileUploadStateChoices.PENDING
@property @property
def extension(self): def extension(self):
@@ -954,16 +946,6 @@ class File(BaseModel):
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}" return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
@property
def temporary_key_base(self):
"""Temporary key base used while upload is still pending."""
if not self.pk:
raise RuntimeError(
"The file instance must be saved before requesting a storage key."
)
return f"{settings.FILE_UPLOAD_TMP_PATH}/{self.pk!s}"
@property @property
def file_key(self): def file_key(self):
"""Key used to store the file in object storage.""" """Key used to store the file in object storage."""
@@ -972,12 +954,6 @@ class File(BaseModel):
# leaking Personal Information in logs, etc. # leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}" return f"{self.key_base}{extension!s}"
@property
def temporary_file_key(self):
"""Temporary key used to upload the file before it is finalized."""
_, extension = splitext(self.filename)
return f"{self.temporary_key_base}{extension!s}"
def get_abilities(self, user): def get_abilities(self, user):
""" """
Compute and return abilities for a given user on the file. Compute and return abilities for a given user on the file.
@@ -1,9 +1,7 @@
"""Service to notify external services when a new recording is ready.""" """Service to notify external services when a new recording is ready."""
import asyncio
import logging import logging
import smtplib import smtplib
from datetime import datetime, timezone
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail from django.core.mail import send_mail
@@ -11,12 +9,9 @@ from django.template.loader import render_to_string
from django.utils.translation import get_language, override from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import aiohttp
import requests import requests
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils from core import models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -136,50 +131,7 @@ class NotificationService:
return not has_failures return not has_failures
@staticmethod @staticmethod
async def _get_recording_timestamps(worker_id): def _notify_summary_service(recording):
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
FileInfo.started_at is more accurate than EgressInfo.started_at because
it reflects when file recording actually began. The started_at value exposed
in the manifest file, as well as in the EgressInfo returned by the API,
corresponds to when the egress service received the request, not the moment
the egress worker effectively joined the room.
Returns:
Tuple of (started_at, ended_at) datetimes, either may be None.
"""
if not worker_id:
return None, None
custom_configuration = {
**settings.LIVEKIT_CONFIGURATION,
"timeout": aiohttp.ClientTimeout(total=10),
}
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
try:
egress_list = await lkapi.egress.list_egress(
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
)
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
logger.exception("Could not fetch egress info for worker %s", worker_id)
return None, None
finally:
await lkapi.aclose()
if not egress_list.items or not egress_list.items[0].file_results:
logger.debug("No file_results for worker %s", worker_id)
return None, None
file_result = egress_list.items[0].file_results[0]
def _ns_to_utc(ns):
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _notify_summary_service(recording: models.Recording):
"""Notify summary service about a new recording.""" """Notify summary service about a new recording."""
if ( if (
@@ -198,35 +150,24 @@ class NotificationService:
.first() .first()
) )
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
else:
metadata_filename = None
if not owner_access: if not owner_access:
logger.error("No owner found for recording %s", recording.id) logger.error("No owner found for recording %s", recording.id)
return False return False
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
payload = { payload = {
"owner_id": str(owner_access.user.id), "owner_id": str(owner_access.user.id),
"recording_filename": recording.key, "filename": recording.key,
"metadata_filename": metadata_filename,
"email": owner_access.user.email, "email": owner_access.user.email,
"sub": owner_access.user.sub, "sub": owner_access.user.sub,
"room": recording.room.name, "room": recording.room.name,
"language": recording.options.get("language"), "language": recording.options.get("language"),
"owner_timezone": str(owner_access.user.timezone), "recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}", "download_link": f"{get_recording_download_base_url()}/{recording.id}",
"context_language": owner_access.user.language, "context_language": owner_access.user.language,
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
} }
headers = { headers = {
+32 -61
View File
@@ -1,12 +1,10 @@
"""Meet storage event parser classes.""" """Meet storage event parser classes."""
import logging import logging
import mimetypes
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any, Dict, Optional, Protocol from typing import Any, Dict, Optional, Protocol
from urllib.parse import quote
from django.conf import settings from django.conf import settings
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
@@ -20,9 +18,6 @@ from .exceptions import (
ParsingEventDataError, ParsingEventDataError,
) )
# Additional MIME type mapping
mimetypes.add_type("audio/ogg", ".ogg")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,7 +54,7 @@ class EventParser(Protocol):
def parse(self, data: Dict) -> StorageEvent: def parse(self, data: Dict) -> StorageEvent:
"""Extract storage event data from raw dictionary input.""" """Extract storage event data from raw dictionary input."""
def validate(self, data: StorageEvent) -> str: def validate(self, data: StorageEvent) -> None:
"""Verify storage event data meets all requirements.""" """Verify storage event data meets all requirements."""
def get_recording_id(self, data: Dict) -> str: def get_recording_id(self, data: Dict) -> str:
@@ -79,8 +74,8 @@ def get_parser() -> EventParser:
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME) return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
class BaseS3Parser: class MinioParser:
"""Base class for handling parsing and validation of S3-compatible storage events.""" """Handle parsing and validation of Minio storage events."""
def __init__(self, bucket_name: str, allowed_filetypes=None): def __init__(self, bucket_name: str, allowed_filetypes=None):
"""Initialize parser with target bucket name and accepted filetypes.""" """Initialize parser with target bucket name and accepted filetypes."""
@@ -96,6 +91,32 @@ class BaseS3Parser:
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})" rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
) )
@staticmethod
def parse(data):
"""Convert raw Minio event dictionary to StorageEvent object."""
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
bucket_name = s3["bucket"]["name"]
file_object = s3["object"]
filepath = file_object["key"]
filetype = file_object["contentType"]
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
try:
return StorageEvent(
filepath=filepath,
filetype=filetype,
bucket_name=bucket_name,
metadata=None,
)
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
def validate(self, event_data: StorageEvent) -> str: def validate(self, event_data: StorageEvent) -> str:
"""Verify StorageEvent matches bucket, filetype and filepath requirements.""" """Verify StorageEvent matches bucket, filetype and filepath requirements."""
@@ -120,59 +141,9 @@ class BaseS3Parser:
return recording_id return recording_id
def get_recording_id(self, data): def get_recording_id(self, data):
"""Extract recording ID from S3 event through parsing and validation.""" """Extract recording ID from Minio event through parsing and validation."""
event_data = self.parse(data) event_data = self.parse(data)
return self.validate(event_data) recording_id = self.validate(event_data)
def parse(self, data: Dict) -> StorageEvent: return recording_id
"""To be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement parse()")
class MinioParser(BaseS3Parser):
"""Minio specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
record = data["Records"][0]
s3 = record["s3"]
return StorageEvent(
filepath=s3["object"]["key"],
filetype=s3["object"]["contentType"], # Minio-specific field
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
except TypeError as e:
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
class S3Parser(BaseS3Parser):
"""AWS S3 specific event parsing."""
def parse(self, data: Dict) -> StorageEvent:
if not data:
raise ParsingEventDataError("Received empty data.")
try:
# AWS S3 structure can slightly differ from Minio implementation
record = data["Records"][0]
s3 = record["s3"]
filepath = s3["object"]["key"]
if not filepath:
raise ParsingEventDataError("Missing object key name")
filetype, _ = mimetypes.guess_type(filepath)
# Normalize raw S3-compatible object keys without re-encoding
# already encoded AWS S3 notification keys.
filepath = quote(filepath, safe="%+")
return StorageEvent(
filepath=filepath,
filetype=filetype,
bucket_name=s3["bucket"]["name"],
metadata=None,
)
except (KeyError, IndexError) as e:
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
@@ -8,7 +8,6 @@ from livekit import api
from core import models, utils from core import models, utils
from core.models import Recording from core.models import Recording
from core.recording.event.notification import notification_service
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -17,10 +16,6 @@ class RecordingEventsError(Exception):
"""Recording event handling fails.""" """Recording event handling fails."""
class RecordingNotSavableError(Exception):
"""Recording cannot be saved because it is either in an error state or has already been saved"""
class RecordingEventsService: class RecordingEventsService:
"""Handles recording-related LiveKit webhook events.""" """Handles recording-related LiveKit webhook events."""
@@ -78,23 +73,3 @@ class RecordingEventsService:
f"Failed to notify participants in room '{recording.room.id}' about " f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})" f"recording limit reached (recording_id={recording.id})"
) from e ) from e
@staticmethod
def handle_complete(recording: Recording):
"""Notify external services and save recording."""
if not recording.is_savable():
raise RecordingNotSavableError
# Attempt to notify external services about the recording
# This is a non-blocking operation - failures are logged but don't interrupt the flow
notification_succeeded = notification_service.notify_external_services(
recording
)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
@@ -1,7 +1,5 @@
"""Factory, configurations and Protocol to create worker services""" """Factory, configurations and Protocol to create worker services"""
# pylint: disable=no-member
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
@@ -10,17 +8,8 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
from django.conf import settings from django.conf import settings
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from livekit import api as livekit_api
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
# Kept fixed because changing them would shift the goal-post away from the
# "safe drop-in replacement for the default preset" contract of this feature.
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
@dataclass(frozen=True) @dataclass(frozen=True)
class WorkerServiceConfig: class WorkerServiceConfig:
@@ -29,7 +18,6 @@ class WorkerServiceConfig:
output_folder: str output_folder: str
server_configurations: Dict[str, Any] server_configurations: Dict[str, Any]
bucket_args: Optional[dict] bucket_args: Optional[dict]
encoding_options: Optional[Dict[str, Any]] = None
@classmethod @classmethod
@lru_cache @lru_cache
@@ -37,24 +25,6 @@ class WorkerServiceConfig:
"""Load configuration from Django settings with caching for efficiency.""" """Load configuration from Django settings with caching for efficiency."""
logger.debug("Loading WorkerServiceConfig from settings.") logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls( return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER, output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION, server_configurations=settings.LIVEKIT_CONFIGURATION,
@@ -66,7 +36,6 @@ class WorkerServiceConfig:
"bucket": settings.AWS_STORAGE_BUCKET_NAME, "bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True, "force_path_style": True,
}, },
encoding_options=encoding_options,
) )
+3 -27
View File
@@ -83,22 +83,6 @@ class BaseEgressService:
""" """
raise NotImplementedError("Subclass must implement this method.") raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**opts)
class VideoCompositeEgressService(BaseEgressService): class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file.""" """Record multiple participant video and audio tracks into a single output '.mp4' file."""
@@ -120,17 +104,9 @@ class VideoCompositeEgressService(BaseEgressService):
s3=self._s3, s3=self._s3,
) )
request_kwargs = { request = livekit_api.RoomCompositeEgressRequest(
"room_name": room_name, room_name=room_name, file_outputs=[file_output], layout="speaker-light"
"file_outputs": [file_output], )
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
if advanced is not None:
request_kwargs["advanced"] = advanced
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
response = self._handle_request(request, "start_room_composite_egress") response = self._handle_request(request, "start_room_composite_egress")
+7 -30
View File
@@ -19,7 +19,6 @@ from core.recording.services.metadata_collector import (
from core.recording.services.recording_events import ( from core.recording.services.recording_events import (
RecordingEventsError, RecordingEventsError,
RecordingEventsService, RecordingEventsService,
RecordingNotSavableError,
) )
from .lobby import LobbyService from .lobby import LobbyService
@@ -89,13 +88,6 @@ class LiveKitEventsService:
def __init__(self): def __init__(self):
"""Initialize with required services.""" """Initialize with required services."""
self._webhook_handlers = {
"egress_updated": self._handle_egress_updated,
"egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started,
"room_finished": self._handle_room_finished,
}
token_verifier = api.TokenVerifier( token_verifier = api.TokenVerifier(
settings.LIVEKIT_CONFIGURATION["api_key"], settings.LIVEKIT_CONFIGURATION["api_key"],
settings.LIVEKIT_CONFIGURATION["api_secret"], settings.LIVEKIT_CONFIGURATION["api_secret"],
@@ -143,11 +135,14 @@ class LiveKitEventsService:
f"Unknown webhook type: {data.event}" f"Unknown webhook type: {data.event}"
) from e ) from e
# Handle according to received webhook type handler_name = f"_handle_{webhook_type.value}"
handler = self._webhook_handlers.get(webhook_type.value) handler = getattr(self, handler_name, None)
if handler is not None: if not handler or not callable(handler):
handler(data) return
# pylint: disable=not-callable
handler(data)
def _handle_egress_updated(self, data): def _handle_egress_updated(self, data):
"""Handle 'egress_updated' event.""" """Handle 'egress_updated' event."""
@@ -200,24 +195,6 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}" f"Failed to process limit reached event for recording {recording}"
) from e ) from e
# Fallback for completion when no MinIO/S3 webhooks are configured
if (
not settings.RECORDING_STORAGE_EVENT_ENABLE
) and data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED,
]:
try:
self.recording_events.handle_complete(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on egress complete "
"(already saved or in an error state); ignoring.",
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
def _handle_room_started(self, data): def _handle_room_started(self, data):
"""Handle 'room_started' event.""" """Handle 'room_started' event."""
@@ -15,7 +15,6 @@ from livekit.api import (
TwirpError, TwirpError,
UpdateParticipantRequest, UpdateParticipantRequest,
) )
from livekit.protocol.models import ParticipantInfo
from core import utils from core import utils
@@ -155,44 +154,3 @@ class ParticipantsManagement:
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
Raises ParticipantsManagementException for unexpected LiveKit errors
so callers can fail closed rather than silently allowing the action.
"""
if not room_name or not identity:
return False
lkapi = utils.create_livekit_client()
try:
participant = await lkapi.room.get_participant(
RoomParticipantIdentity(
room=room_name,
identity=identity,
)
)
except TwirpError as e:
if e.code == "not_found":
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error checking participant %s in room %s",
identity,
room_name,
)
raise ParticipantsManagementException(
"Could not verify participant presence"
) from e
finally:
await lkapi.aclose()
return (
participant is not None
and participant.state != ParticipantInfo.State.DISCONNECTED
)
@@ -1,110 +0,0 @@
"""Service for provisional user creation."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import IntegrityError
from core import models
logger = logging.getLogger(__name__)
class ProvisionalUserError(Exception):
"""Base exception for provisional user service errors."""
class ProvisionalUserCreationDisabledError(ProvisionalUserError):
"""Raised when provisional user creation is disabled by configuration."""
class ProvisionalUserIntegrityError(ProvisionalUserError):
"""Raised when a provisional user cannot be created or retrieved after a race condition."""
class ProvisionalUserService:
"""Handles creation and retrieval of provisional users.
A provisional user is created without a `sub`, identified by email only.
The `sub` is set on first successful OIDC authentication via Django LaSuite.
"""
def __init__(self):
"""Initialize the service."""
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
self._is_creation_enabled = (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
)
def _get_by_email(self, email: str) -> models.User | None:
"""Return the user with this email, or None if not found."""
try:
return models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist:
return None
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
def get_or_create(
self, email: str, client_id: str
) -> tuple[models.User | None, bool]:
"""Get or create a provisional user identified by email.
Args:
email: The email address to identify the user.
client_id: The application client_id, used for audit logging only.
Returns:
A (user, created) tuple mirrors get_or_create conventions.
Raises:
ProvisionalUserError: If creation and retrieval both fail.
"""
user = self._get_by_email(email)
if user:
return user, False
if not self._is_creation_enabled:
raise ProvisionalUserCreationDisabledError(
"Provisional user creation is disabled by configuration."
)
# Create a provisional user without `sub`, identified by email only.
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
try:
user = models.User(sub=None, email=email)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
client_id,
)
return user, True
except (IntegrityError, ValidationError) as e:
logger.warning(
"Race condition on provisional user creation, fetching existing: "
"email=%s, client_id=%s",
email,
client_id,
)
user = self._get_by_email(email)
if user:
return user, False
raise ProvisionalUserIntegrityError(
"Failed to create or retrieve provisional user."
) from e
@@ -1,64 +0,0 @@
"""Room management service for LiveKit rooms."""
# pylint: disable=no-name-in-module
import json
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
TwirpError,
UpdateRoomMetadataRequest,
)
from core import utils
logger = getLogger(__name__)
class RoomManagementException(Exception):
"""Exception raised when a room management operation fails."""
class RoomNotFoundException(RoomManagementException):
"""Raised when the target room does not exist in LiveKit."""
class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
"Unexpected error updating metadata for room %s",
room_name,
)
raise RoomManagementException("Could not update room metadata") from e
finally:
await lkapi.aclose()
@@ -1,263 +0,0 @@
"""
Unit tests for PostHogAnalytics.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
import pytest
from core.analytics.events import AnalyticsEvent
from core.analytics.posthog import PostHogAnalytics
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# ==============================
# __init__
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
"""Should forward api_key and host to the Posthog SDK constructor."""
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
mock_posthog_cls.assert_called_once_with(
project_api_key="my-key",
host="https://custom.i.posthog.com",
)
@patch("core.analytics.posthog.Posthog")
def test_init_defaults_to_eu_host(mock_posthog_cls):
"""Should default host to the EU PostHog cloud when not specified."""
PostHogAnalytics(api_key="my-key")
_, kwargs = mock_posthog_cls.call_args
assert kwargs["host"] == "https://eu.i.posthog.com"
@patch("core.analytics.posthog.Posthog")
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
_, kwargs = mock_posthog_cls.call_args
assert kwargs["debug"] is True
assert kwargs["disabled"] is False
# ==============================
# _distinct_id
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
"""Should return None when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(None) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
"""Should return None when user.is_authenticated is falsy."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(AnonymousUser()) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
"""Should return None when the user object has no is_authenticated attribute at all."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(object()) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
"""Should return str(user.pk) for an authenticated user."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
assert backend._distinct_id(user) == str(user.pk)
# ==============================
# identify
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
"""Should not call the SDK when the user is anonymous."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.identify(AnonymousUser(), {"email": "a@example.com"})
mock_posthog_cls.return_value.set.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_identify_noop_for_none_user(mock_posthog_cls):
"""Should not call the SDK when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.identify(None, {"email": "a@example.com"})
mock_posthog_cls.return_value.set.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
"""Should call capture with event=$identify and properties wrapped in $set."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.identify(user, {"email": "a@example.com", "name": "A"})
mock_posthog_cls.return_value.set.assert_called_once_with(
distinct_id=str(user.pk),
properties={"email": "a@example.com", "name": "A"},
)
@patch("core.analytics.posthog.Posthog")
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
"""Should send an empty $set payload when properties is None."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.identify(user, None)
mock_posthog_cls.return_value.set.assert_called_once_with(
distinct_id=str(user.pk),
properties={},
)
@patch("core.analytics.posthog.Posthog")
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
"""Should log and not raise when the SDK call fails."""
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
# Must not propagate.
backend.identify(user, {"email": "a@example.com"})
# ==============================
# capture
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
"""Should not call the SDK when the user is anonymous."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
mock_posthog_cls.return_value.capture.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_capture_noop_for_none_user(mock_posthog_cls):
"""Should not call the SDK when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
mock_posthog_cls.return_value.capture.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
"""Should call capture with the distinct_id, event name, and properties."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
mock_posthog_cls.return_value.capture.assert_called_once_with(
distinct_id=str(user.pk),
event="room_created",
properties={"room_id": "room-1"},
)
@patch("core.analytics.posthog.Posthog")
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
_, kwargs = mock_posthog_cls.return_value.capture.call_args
assert kwargs["event"] == "room_created"
assert isinstance(
kwargs["event"], str
) # not AnalyticsEvent, not StrEnum subclass leaking through
@patch("core.analytics.posthog.Posthog")
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
"""Should send an empty properties dict when properties is None."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
mock_posthog_cls.return_value.capture.assert_called_once_with(
distinct_id=str(user.pk),
event="room_created",
properties={},
)
@patch("core.analytics.posthog.Posthog")
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
"""Should log and not raise when the SDK call fails."""
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
# Must not propagate.
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
@patch("core.analytics.posthog.Posthog")
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
"""Should log which event failed, to aid debugging without crashing the caller."""
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with caplog.at_level("ERROR"):
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
assert any("PostHog capture failed" in record.message for record in caplog.records)
# ==============================
# shutdown
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_shutdown_flushes_the_client(mock_posthog_cls):
"""Should delegate to the SDK's shutdown to flush pending events."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.shutdown()
mock_posthog_cls.return_value.shutdown.assert_called_once()
@@ -346,11 +346,8 @@ def test_authentication_getter_existing_user_change_fields(
# One and only one additional update query when a field has changed # One and only one additional update query when a field has changed
# Note: .save() triggers uniqueness validation queries for unique fields, # Note: .save() triggers uniqueness validation queries for unique fields,
# adding extra SELECT queries before the UPDATE: # adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
# - unique=True on 'sub' with django_assert_num_queries(3):
# - unique=True on 'admin_email'
# - partial unique index 'unique_email_when_sub_is_null'
with django_assert_num_queries(5):
authenticated_user = klass.get_or_create_user( authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )
@@ -1,90 +0,0 @@
"""Tests for the clean_pending_files management command."""
from datetime import timedelta
from django.core.files.storage import default_storage
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_clean_pending_files_no_stale_files():
"""Nothing happens when there are no stale pending files."""
call_command("clean_pending_files")
def test_clean_pending_files_recent_pending_not_deleted():
"""Recent pending files (within threshold) should not be deleted."""
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.file_key)
def test_clean_pending_files_old_pending_deleted():
"""Pending files older than the threshold should be deleted."""
old_date = timezone.now() - timedelta(hours=49)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
assert default_storage.exists(file.file_key)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
call_command("clean_pending_files")
assert not models.File.objects.filter(pk=file.pk).exists()
assert not default_storage.exists(file.file_key)
def test_clean_pending_files_old_non_pending_not_deleted():
"""Old files that are not pending should not be deleted."""
old_date = timezone.now() - timedelta(hours=49)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert file.hard_deleted_at is None
def test_clean_pending_files_custom_hours():
"""The --hours argument controls the age threshold."""
old_date = timezone.now() - timedelta(hours=10)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
# Default 24h threshold -> file not deleted
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.file_key)
# 8h threshold -> file deleted
call_command("clean_pending_files", "--hours=8")
assert not models.File.objects.filter(pk=file.pk).exists()
assert not default_storage.exists(file.file_key)
@@ -1,85 +0,0 @@
"""Tests for the purge_deleted_files management command."""
from datetime import timedelta
from io import StringIO
from random import randint
from unittest.mock import patch
from django.core.files.storage import default_storage
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
from core.tasks.file import process_file_deletion
pytestmark = pytest.mark.django_db
def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
"""Nothing happens when there are no purgeable files."""
with django_assert_num_queries(1):
call_command("purge_deleted_files")
@pytest.mark.django_db(transaction=True)
def test_purge_deleted_files_success(settings):
"""
Queue deletion for:
- hard-deleted files
- soft-deleted files past retention period + grace period.
"""
out = StringIO()
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
now = timezone.now()
purge_now = now - timedelta(days=grace)
not_deleted_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
with patch("django.utils.timezone.now", return_value=now):
not_purgeable_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
not_purgeable_file.soft_delete()
with patch("django.utils.timezone.now", return_value=purge_now):
purgeable_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
purgeable_file.soft_delete()
hard_deleted_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
hard_deleted_file.soft_delete()
hard_deleted_file.hard_delete()
with patch(
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
side_effect=process_file_deletion,
) as mock_delay:
call_command("purge_deleted_files", stdout=out)
assert "Purged 2 deleted file(s)." in out.getvalue()
assert mock_delay.call_count == 2
called_ids = {call.args[0] for call in mock_delay.call_args_list}
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
assert models.File.objects.filter(id=not_deleted_file.id).exists()
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
assert not models.File.objects.filter(id=purgeable_file.id).exists()
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
assert default_storage.exists(not_deleted_file.file_key)
assert default_storage.exists(not_purgeable_file.file_key)
assert not default_storage.exists(purgeable_file.file_key)
assert not default_storage.exists(hard_deleted_file.file_key)
@@ -118,7 +118,7 @@ def test_api_files_create_file_authenticated_success():
assert policy_parsed.scheme == "http" assert policy_parsed.scheme == "http"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"] assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png" assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query) query_params = parse_qs(policy_parsed.query)
@@ -86,11 +86,7 @@ def test_api_files_media_get_own():
assert response.content.decode("utf-8") == "my prose" assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize( def test_api_files_media_auth_file_pending():
"rejecting_status",
[models.FileUploadStateChoices.PENDING, models.FileUploadStateChoices.ANALYZING],
)
def test_api_files_media_auth_rejects(rejecting_status):
""" """
Users who have a specific access to an file, whatever the role, should not be able to Users who have a specific access to an file, whatever the role, should not be able to
retrieve related attachments if the file is not ready. retrieve related attachments if the file is not ready.
@@ -101,7 +97,7 @@ def test_api_files_media_auth_rejects(rejecting_status):
file = factories.FileFactory( file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE, type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_state=rejecting_status, upload_state=models.FileUploadStateChoices.PENDING,
creator=user, creator=user,
) )
@@ -1,7 +1,6 @@
"""Test related to item upload ended API.""" """Test related to item upload ended API."""
import logging import logging
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO from io import BytesIO
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
@@ -82,7 +81,7 @@ def test_api_file_upload_ended_success(settings):
) )
default_storage.save( default_storage.save(
file.temporary_file_key, file.file_key,
BytesIO(b"my prose"), BytesIO(b"my prose"),
) )
@@ -98,7 +97,6 @@ def test_api_file_upload_ended_success(settings):
assert response.json()["mimetype"] == "text/plain" assert response.json()["mimetype"] == "text/plain"
@pytest.mark.django_db(transaction=True)
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog): def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
""" """
Test that the API returns a 400 when the mimetype is not allowed. Test that the API returns a 400 when the mimetype is not allowed.
@@ -121,7 +119,7 @@ def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
) )
default_storage.save( default_storage.save(
file.temporary_file_key, file.file_key,
BytesIO(b"my prose"), BytesIO(b"my prose"),
) )
@@ -158,7 +156,7 @@ def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settin
) )
default_storage.save( default_storage.save(
file.temporary_file_key, file.file_key,
BytesIO(b"my prose"), BytesIO(b"my prose"),
) )
@@ -202,7 +200,7 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
s3_client.put_object( s3_client.put_object(
Bucket=default_storage.bucket_name, Bucket=default_storage.bucket_name,
Key=file.temporary_file_key, Key=file.file_key,
ContentType="text/html", ContentType="text/html",
Body=BytesIO( Body=BytesIO(
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">' b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
@@ -213,7 +211,7 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
) )
head_object = s3_client.head_object( head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.temporary_file_key Bucket=default_storage.bucket_name, Key=file.file_key
) )
assert head_object["ContentType"] == "text/html" assert head_object["ContentType"] == "text/html"
@@ -236,10 +234,9 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
assert head_object["Metadata"] == {"foo": "bar"} assert head_object["Metadata"] == {"foo": "bar"}
@pytest.mark.django_db(transaction=True)
def test_api_upload_ended_file_size_exceeded(settings, caplog): def test_api_upload_ended_file_size_exceeded(settings, caplog):
""" """
Test when the file size exceeds the allowed max upload file size Test when the file size exceed the allowed max upload file size
should return a 400 and delete the file. should return a 400 and delete the file.
""" """
@@ -259,7 +256,7 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
) )
default_storage.save( default_storage.save(
file.temporary_file_key, file.file_key,
BytesIO(b"my prose"), BytesIO(b"my prose"),
) )
@@ -273,48 +270,3 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
assert not models.File.objects.filter(id=file.id).exists() assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key) assert not default_storage.exists(file.file_key)
@pytest.mark.django_db(transaction=True)
def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
"""Only one concurrent upload-ended call can finalize a pending upload."""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/plain"],
},
}
user = factories.UserFactory()
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.txt",
creator=user,
)
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
def call_upload_ended():
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(call_upload_ended),
executor.submit(call_upload_ended),
]
responses = [future.result() for future in futures]
status_codes = sorted(response.status_code for response in responses)
assert status_codes == [200, 400]
failed_response = next(
response for response in responses if response.status_code == 400
)
assert failed_response.json() == {
"file": "This action is only available for files in PENDING state."
}
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
@@ -1,480 +0,0 @@
"""Tests for the merge_duplicate_users management command."""
from unittest import mock
from django.core.management import base, call_command
import pytest
from core.factories import (
FileFactory,
UserFactory,
UserRecordingAccessFactory,
UserResourceAccessFactory,
)
from core.models import RecordingAccess, ResourceAccess, RoleChoices, User
pytestmark = pytest.mark.django_db
# pylint: disable=W0613
def test_merge_no_duplicates_does_nothing():
"""Command should do nothing when no duplicate users exist."""
user = UserFactory(email="unique@example.com")
call_command("merge_duplicate_users")
assert User.objects.count() == 1
assert User.objects.filter(id=user.id).exists()
def test_merge_keeps_most_recently_created_user():
"""Command should keep the most recently created user when duplicates exist."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
def test_merge_user_case_insensitive():
"""Emails differing only by case should be treated as duplicates and merged,
keeping the most recently created user."""
user1 = UserFactory(email="Dup@example.com")
user2 = UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
user3 = UserFactory(email="joe@example.com")
user4 = UserFactory(email="Joe@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user3.id).exists()
assert User.objects.filter(id=user4.id).exists()
user4.refresh_from_db()
assert user4.email.islower()
def test_merge_deletes_all_stale_users():
"""Command should delete all stale users and keep only the most recently created one."""
email = "many@example.com"
UserFactory(email=email)
UserFactory(email=email)
user_kept = UserFactory(email=email)
call_command("merge_duplicate_users")
assert User.objects.filter(email=email).count() == 1
assert User.objects.filter(id=user_kept.id).exists()
# ── ResourceAccess ─────────────────────────────────────────────────────────────
def test_merge_transfers_resource_access_to_kept_user():
"""ResourceAccess should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra = UserResourceAccessFactory(user=user1)
call_command("merge_duplicate_users")
ra.refresh_from_db()
assert ra.user == user2
def test_merge_transfers_multiple_room_accesses():
"""All ResourceAccesses should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserResourceAccessFactory.create_batch(3, user=user1)
call_command("merge_duplicate_users")
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in accesses:
assert ResourceAccess.objects.filter(user=user2, resource=ra.resource).exists()
def test_merge_all_resource_accesses_owned_by_kept_user_nothing_changes():
"""ResourceAccesses should remain unchanged when all are already owned by the kept user."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserResourceAccessFactory.create_batch(3, user=user2)
call_command("merge_duplicate_users")
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in accesses:
ra.refresh_from_db()
assert ra.user == user2
def test_merge_resource_access_conflict_upgrades_to_owner():
"""ResourceAccess role should be upgraded to owner when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.OWNER)
ra2 = UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
)
other_accesses = UserResourceAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
ra2.refresh_from_db()
assert ra2.role == RoleChoices.OWNER
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in other_accesses:
assert ResourceAccess.objects.filter(
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
).exists()
def test_merge_resource_access_conflict_upgrades_to_admin():
"""ResourceAccess role should be upgraded to admin when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.ADMIN)
ra2 = UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
)
other_accesses = UserResourceAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
ra2.refresh_from_db()
assert ra2.role == RoleChoices.ADMIN
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in other_accesses:
assert ResourceAccess.objects.filter(
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
).exists()
def test_merge_resource_access_conflict_does_not_downgrade_role():
"""ResourceAccess role should not be downgraded when stale user has a lower role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.MEMBER)
ra2 = UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.OWNER
)
other_accesses = UserResourceAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
ra2.refresh_from_db()
assert ra2.role == RoleChoices.OWNER
assert not ResourceAccess.objects.filter(user=user1).exists()
for ra in other_accesses:
assert ResourceAccess.objects.filter(
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
).exists()
def test_merge_resource_access_conflict_equal_role_keeps_single_access():
"""ResourceAccess should keep one entry for the kept user when both ones have the same role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.MEMBER)
UserResourceAccessFactory(
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
accesses = ResourceAccess.objects.filter(resource=ra1.resource)
assert accesses.count() == 1
assert accesses.first().user == user2
assert accesses.first().role == RoleChoices.MEMBER
# ── RecordingAccess ────────────────────────────────────────────────────────────
def test_merge_transfers_recording_access_to_kept_user():
"""RecordingAccess should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca = UserRecordingAccessFactory(user=user1)
call_command("merge_duplicate_users")
rca.refresh_from_db()
assert rca.user == user2
def test_merge_transfers_multiple_recording_accesses():
"""All RecordingAccesses should be transferred to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserRecordingAccessFactory.create_batch(3, user=user1)
call_command("merge_duplicate_users")
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording
).exists()
def test_merge_all_recording_accesses_owned_by_kept_user_nothing_changes():
"""RecordingAccesses should remain unchanged when all are already owned by the kept user."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
accesses = UserRecordingAccessFactory.create_batch(3, user=user2)
call_command("merge_duplicate_users")
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in accesses:
rca.refresh_from_db()
assert rca.user == user2
def test_merge_recording_access_conflict_upgrades_to_owner():
"""RecordingAccess role should be upgraded to owner when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.OWNER)
rca2 = UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
)
other_accesses = UserRecordingAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
rca2.refresh_from_db()
assert rca2.role == RoleChoices.OWNER
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in other_accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
).exists()
def test_merge_recording_access_conflict_upgrades_to_admin():
"""RecordingAccess role should be upgraded to admin when stale user has a higher role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.ADMIN)
rca2 = UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
)
other_accesses = UserRecordingAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
rca2.refresh_from_db()
assert rca2.role == RoleChoices.ADMIN
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in other_accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
).exists()
def test_merge_recording_access_conflict_does_not_downgrade_role():
"""RecordingAccess role should not be downgraded when stale user has a lower role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.MEMBER)
rca2 = UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.OWNER
)
other_accesses = UserRecordingAccessFactory.create_batch(
3, user=user1, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
rca2.refresh_from_db()
assert rca2.role == RoleChoices.OWNER
assert not RecordingAccess.objects.filter(user=user1).exists()
for rca in other_accesses:
assert RecordingAccess.objects.filter(
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
).exists()
def test_merge_recording_access_conflict_equal_role_keeps_single_access():
"""RecordingAccess should keep one entry for the user when both users have the same role."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.MEMBER)
UserRecordingAccessFactory(
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
)
call_command("merge_duplicate_users")
accesses = RecordingAccess.objects.filter(recording=rca1.recording)
assert accesses.count() == 1
assert accesses.first().user == user2
assert accesses.first().role == RoleChoices.MEMBER
# ── Files ──────────────────────────────────────────────────────────────────────
def test_merge_reassigns_files_to_kept_user():
"""Files should be reassigned to the kept user when stale user is merged."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
files = FileFactory.create_batch(3, creator=user1)
call_command("merge_duplicate_users")
for f in files:
f.refresh_from_db()
assert f.creator == user2
def test_merge_kept_user_own_files_untouched():
"""Files already owned by the kept user should remain unchanged after merge."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
FileFactory(creator=user1)
kept_file = FileFactory(creator=user2)
call_command("merge_duplicate_users")
kept_file.refresh_from_db()
assert kept_file.creator == user2
# ── Dry-run ────────────────────────────────────────────────────────────────────
def test_merge_dry_run_does_not_delete_users():
"""Command should not delete any users when dry-run is enabled."""
UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
call_command("merge_duplicate_users", dry_run=True)
assert User.objects.filter(email="dup@example.com").count() == 2
def test_merge_dry_run_does_not_move_resource_access():
"""Command should not move resource accesses when dry-run is enabled."""
user1 = UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
ra = UserResourceAccessFactory(user=user1)
call_command("merge_duplicate_users", dry_run=True)
ra.refresh_from_db()
assert ra.user == user1
def test_merge_dry_run_does_not_move_recording_access():
"""Command should not move recording accesses when dry-run is enabled."""
user1 = UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
ra = UserRecordingAccessFactory(user=user1)
call_command("merge_duplicate_users", dry_run=True)
ra.refresh_from_db()
assert ra.user == user1
def test_merge_dry_run_does_not_move_files():
"""Command should not reassign files when dry-run is enabled."""
user1 = UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
f = FileFactory(creator=user1)
call_command("merge_duplicate_users", dry_run=True)
f.refresh_from_db()
assert f.creator == user1
# ── Isolation ──────────────────────────────────────────────────────────────────
def test_merge_non_duplicate_users_untouched():
"""Non-duplicate users should remain untouched when other duplicates are merged."""
unique = UserFactory(email="unique@example.com")
UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert User.objects.filter(id=unique.id).exists()
def test_merge_non_duplicate_resource_access_untouched():
"""ResourceAccess of non-duplicate users should remain untouched."""
unique = UserFactory(email="unique@example.com")
ra = UserResourceAccessFactory(user=unique)
UserFactory(email="dup@example.com")
UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
ra.refresh_from_db()
assert ra.user == unique
def test_merge_multiple_email_groups_all_merged():
"""Command should merge all duplicate email groups in a single run."""
for i in range(3):
UserFactory(email=f"group{i}@example.com")
UserFactory(email=f"group{i}@example.com")
call_command("merge_duplicate_users")
for i in range(3):
assert User.objects.filter(email=f"group{i}@example.com").count() == 1
assert User.objects.count() == 3
# ── NULL / blank email guard ───────────────────────────────────────────────────
def test_merge_does_not_merge_users_with_null_email():
"""Users with NULL email must never be merged together, even if multiple exist."""
user1 = UserFactory(email=None)
user2 = UserFactory(email=None)
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
def test_merge_does_not_merge_users_with_blank_email():
"""Users with empty-string email must never be merged together, even if multiple exist."""
user1 = UserFactory(email="")
user2 = UserFactory(email="")
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
# ── Atomicity ──────────────────────────────────────────────────────────────────
@mock.patch(
"core.management.commands.merge_duplicate_users.Command._merge_recording_accesses",
side_effect=Exception("forced failure"),
)
def test_merge_is_atomic_rolls_back_all_on_any_failure(mock_reassign_files):
"""Merge should be fully rolled back when any step fails."""
user1 = UserFactory(email="dup@example.com")
user2 = UserFactory(email="dup@example.com")
resource_accesses = UserResourceAccessFactory.create_batch(3, user=user1)
recording_accesses = UserRecordingAccessFactory.create_batch(3, user=user1)
files = FileFactory.create_batch(3, creator=user1)
with pytest.raises(base.CommandError):
call_command("merge_duplicate_users")
assert User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
for ra in resource_accesses:
ra.refresh_from_db()
assert ra.user == user1
for rca in recording_accesses:
rca.refresh_from_db()
assert rca.user == user1
for f in files:
f.refresh_from_db()
assert f.creator == user1
# ── Email filter ───────────────────────────────────────────────────────────────
def test_merge_email_filter_only_merges_matching_emails():
"""Command should only merge users whose email matches the filter."""
UserFactory(email="user1@example.com")
UserFactory(email="user1@example.com")
other1 = UserFactory(email="user1@other.com")
other2 = UserFactory(email="user1@other.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@example.com").count() == 1
assert User.objects.filter(id=other1.id).exists()
assert User.objects.filter(id=other2.id).exists()
def test_merge_email_filter_no_match_does_nothing():
"""Command should do nothing when the email filter matches no users."""
UserFactory(email="user1@example.com")
UserFactory(email="user1@example.com")
call_command("merge_duplicate_users", email_filter="@nomatch.com")
assert User.objects.filter(email="user1@example.com").count() == 2
def test_merge_email_filter_is_case_insensitive():
"""Command should match emails case-insensitively when filtering."""
UserFactory(email="user1@Example.com")
UserFactory(email="user1@Example.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@example.com").count() == 1
@@ -18,13 +18,10 @@ from core.recording.event.exceptions import (
) )
from core.recording.event.parsers import ( from core.recording.event.parsers import (
MinioParser, MinioParser,
S3Parser,
StorageEvent, StorageEvent,
get_parser, get_parser,
) )
# MinioParser
@pytest.fixture @pytest.fixture
def valid_minio_event(): def valid_minio_event():
@@ -50,7 +47,7 @@ def minio_parser():
return MinioParser(bucket_name="test-bucket") return MinioParser(bucket_name="test-bucket")
def test_minio_parse_valid_event(minio_parser, valid_minio_event): def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event.""" """Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event) event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent) assert isinstance(event, StorageEvent)
@@ -60,33 +57,13 @@ def test_minio_parse_valid_event(minio_parser, valid_minio_event):
assert event.metadata is None assert event.metadata is None
def test_minio_parse_with_video_type(minio_parser): def test_parse_empty_data(minio_parser):
"""Test parsing event with video file type."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
"contentType": "video/mp4",
},
}
}
]
}
event = minio_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_minio_parse_empty_data(minio_parser):
"""Test parsing empty event data raises error.""" """Test parsing empty event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."): with pytest.raises(ParsingEventDataError, match="Received empty data."):
minio_parser.parse({}) minio_parser.parse({})
def test_minio_parse_missing_keys(minio_parser): def test_parse_missing_keys(minio_parser):
"""Test parsing event with missing key.""" """Test parsing event with missing key."""
invalid_minio_event = { invalid_minio_event = {
@@ -100,11 +77,11 @@ def test_minio_parse_missing_keys(minio_parser):
] ]
} }
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"): with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
minio_parser.parse(invalid_minio_event) minio_parser.parse(invalid_minio_event)
def test_minio_parse_none_key(minio_parser): def test_parse_none_key(minio_parser):
"""Test parsing event with None field.""" """Test parsing event with None field."""
invalid_minio_event = { invalid_minio_event = {
@@ -125,7 +102,7 @@ def test_minio_parse_none_key(minio_parser):
minio_parser.parse(invalid_minio_event) minio_parser.parse(invalid_minio_event)
def test_minio_validate_invalid_bucket(minio_parser): def test_validate_invalid_bucket(minio_parser):
"""Test validation with wrong bucket name.""" """Test validation with wrong bucket name."""
event = StorageEvent( event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
@@ -137,7 +114,7 @@ def test_minio_validate_invalid_bucket(minio_parser):
minio_parser.validate(event) minio_parser.validate(event)
def test_minio_validate_invalid_filetype(minio_parser): def test_validate_invalid_filetype(minio_parser):
"""Test validation with unsupported file type.""" """Test validation with unsupported file type."""
event = StorageEvent( event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt", filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
@@ -162,7 +139,7 @@ def test_minio_validate_invalid_filetype(minio_parser):
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/ "folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
], ],
) )
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser): def test_validate_invalid_filepath(invalid_filepath, minio_parser):
"""Test validation with malformed filepath.""" """Test validation with malformed filepath."""
event = StorageEvent( event = StorageEvent(
filepath=invalid_filepath, filepath=invalid_filepath,
@@ -174,7 +151,7 @@ def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
minio_parser.validate(event) minio_parser.validate(event)
def test_minio_validate_valid_event(minio_parser): def test_validate_valid_event(minio_parser):
"""Test validation with valid event data.""" """Test validation with valid event data."""
event = StorageEvent( event = StorageEvent(
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
@@ -186,13 +163,13 @@ def test_minio_validate_valid_event(minio_parser):
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8" assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_get_recording_id_success(minio_parser, valid_minio_event): def test_get_recording_id_success(minio_parser, valid_minio_event):
"""Test successful extraction of recording ID.""" """Test successful extraction of recording ID."""
recording_id = minio_parser.get_recording_id(valid_minio_event) recording_id = minio_parser.get_recording_id(valid_minio_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8" assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_validate_filepath_with_folder(minio_parser): def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure.""" """Test validation of filepath with folder structure."""
event = StorageEvent( event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
@@ -204,21 +181,41 @@ def test_minio_validate_filepath_with_folder(minio_parser):
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8" assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_empty_allowed_filetypes(): def test_parse_with_video_type(minio_parser):
"""Test parsing event with video file type."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
"contentType": "video/mp4",
},
}
}
]
}
event = minio_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_empty_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes.""" """Test MinioParser with empty allowed_filetypes."""
empty_types = set() empty_types = set()
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types) parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"} assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
def test_minio_custom_allowed_filetypes(): def test_custom_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes.""" """Test MinioParser with empty allowed_filetypes."""
custom_types = {"audio/mp3", "video/mov"} custom_types = {"audio/mp3", "video/mov"}
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types) parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"} assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
def test_minio_validate_custom_filetypes(): def test_validate_custom_filetypes():
"""Test validation of filepath with folder structure.""" """Test validation of filepath with folder structure."""
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"}) parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
@@ -232,212 +229,18 @@ def test_minio_validate_custom_filetypes():
parser.validate(event) parser.validate(event)
def test_minio_constructor_none_bucket(): def test_constructor_none_bucket():
"""Test MinioParser constructor with None bucket name.""" """Test MinioParser constructor with None bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"): with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name=None) MinioParser(bucket_name=None)
def test_minio_constructor_empty_bucket(): def test_constructor_empty_bucket():
"""Test MinioParser constructor with empty bucket name.""" """Test MinioParser constructor with empty bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"): with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name="") MinioParser(bucket_name="")
# S3Parser
@pytest.fixture
def valid_s3_event():
"""Mock a valid S3 event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
},
}
}
]
}
@pytest.fixture
def s3_parser():
"""Mock an S3 parser."""
return S3Parser(bucket_name="test-bucket")
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
"""Test parsing a valid S3 event."""
event = s3_parser.parse(valid_s3_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
def test_s3_parse_empty_data(s3_parser):
"""Test parsing empty S3 event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
s3_parser.parse({})
def test_s3_parse_missing_keys(s3_parser):
"""Test parsing S3 event with missing key."""
invalid_s3_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
s3_parser.parse(invalid_s3_event)
def test_s3_parse_none_key(s3_parser):
"""Test parsing S3 event with None field."""
invalid_s3_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": None,
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
s3_parser.parse(invalid_s3_event)
def test_s3_parse_with_video_type(s3_parser):
"""Test parsing S3 event with mp4 file extension."""
video_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
},
}
}
]
}
event = s3_parser.parse(video_event)
assert event.filetype == "video/mp4"
assert event.filepath.endswith(".mp4")
def test_s3_parse_unrecognized_extension(s3_parser):
"""Test parsing S3 event with unrecognized file extension."""
event_with_unknown_ext = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
},
}
}
]
}
with pytest.raises(TypeError, match="filetype cannot be None"):
s3_parser.parse(event_with_unknown_ext)
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
"""Test S3 parser keeps already encoded object keys compatible."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_accepts_unencoded_filepath(settings):
"""Test S3 parser accepts raw object keys with slash separators."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings/{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
"""Test S3 parser preserves plus signs in already encoded object keys."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
"""Test successful extraction of recording ID from S3 event."""
recording_id = s3_parser.get_recording_id(valid_s3_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
# get_parser
@pytest.fixture @pytest.fixture
def clear_lru_cache(): def clear_lru_cache():
"""Fixture to clear the LRU cache between tests.""" """Fixture to clear the LRU cache between tests."""
@@ -224,44 +224,3 @@ def test_save_recording_success(recording_settings, mock_get_parser, client, sta
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED assert recording.status == RecordingStatusChoices.SAVED
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@pytest.mark.parametrize("notification_succeeded", [True, False])
def test_save_recording_notifies_external_services(
mock_notify_external_services,
recording_settings,
mock_get_parser,
client,
notification_succeeded,
):
"""External services should be notified when a recording is saved."""
recording = RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
mock_notify_external_services.return_value = notification_succeeded
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == (
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else RecordingStatusChoices.SAVED
)
@@ -2,7 +2,7 @@
Test worker service factories. Test worker service factories.
""" """
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member # pylint: disable=protected-access,redefined-outer-name,unused-argument
from dataclasses import FrozenInstanceError from dataclasses import FrozenInstanceError
from unittest.mock import Mock from unittest.mock import Mock
@@ -10,9 +10,6 @@ from unittest.mock import Mock
from django.test import override_settings from django.test import override_settings
import pytest import pytest
from livekit import (
api as livekit_api_codec,
)
from core.recording.worker.factories import ( from core.recording.worker.factories import (
WorkerService, WorkerService,
@@ -66,8 +63,6 @@ def test_config_initialization(default_config):
"bucket": "test-bucket", "bucket": "test-bucket",
"force_path_style": True, "force_path_style": True,
} }
# Encoding override is opt-in; disabled by default.
assert default_config.encoding_options is None
def test_config_immutability(default_config): def test_config_immutability(default_config):
@@ -76,45 +71,6 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path" default_config.output_folder = "new/path"
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280,
RECORDING_ENCODING_HEIGHT=720,
RECORDING_ENCODING_FRAMERATE=15,
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
)
def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
The dict mixes operator-tunable values from settings with pinned codec /
frequency constants, so the services layer can simply unpack it.
"""
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == {
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 600,
"audio_bitrate": 64,
"key_frame_interval": 10.0,
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
"audio_codec": livekit_api_codec.AudioCodec.AAC,
"audio_frequency": 48000,
}
@override_settings( @override_settings(
RECORDING_OUTPUT_FOLDER="/test/output", RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"}, LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -39,31 +39,6 @@ def config():
) )
@pytest.fixture
def config_with_encoding(config):
"""Fixture for a config carrying custom encoding options.
Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()`
(operator-tunable values + pinned codec / frequency constants).
"""
return WorkerServiceConfig(
output_folder=config.output_folder,
server_configurations=config.server_configurations,
bucket_args=config.bucket_args,
encoding_options={
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 600,
"audio_bitrate": 64,
"key_frame_interval": 10.0,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
},
)
@pytest.fixture @pytest.fixture
def mock_s3_upload(): def mock_s3_upload():
"""Fixture for mocked S3Upload""" """Fixture for mocked S3Upload"""
@@ -249,41 +224,6 @@ def test_video_composite_egress_start_missing_egress_id(video_service):
assert "Egress ID not found" in str(exc_info.value) assert "Egress ID not found" in str(exc_info.value)
def test_video_composite_egress_start_without_encoding_options(video_service):
"""When no encoding options are configured, no `advanced` field is set.
LiveKit then falls back to its built-in preset (H264_720P_30).
"""
video_service._handle_request.return_value = Mock(egress_id="eg-1")
video_service.start("test-room", "rec-1")
request = video_service._handle_request.call_args[0][0]
# Proto oneof `options` must be unset when no advanced encoding is provided.
assert request.WhichOneof("options") is None
def test_video_composite_egress_start_with_encoding_options(config_with_encoding):
"""Custom encoding options are forwarded as `advanced` EncodingOptions."""
service = VideoCompositeEgressService(config_with_encoding)
service._handle_request = Mock(return_value=Mock(egress_id="eg-2"))
service.start("test-room", "rec-2")
request = service._handle_request.call_args[0][0]
assert request.WhichOneof("options") == "advanced"
advanced = request.advanced
assert advanced.width == 1280
assert advanced.height == 720
assert advanced.framerate == 15
assert advanced.video_bitrate == 600
assert advanced.audio_bitrate == 64
assert advanced.key_frame_interval == pytest.approx(10.0)
assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN
assert advanced.audio_codec == livekit_api.AudioCodec.AAC
assert advanced.audio_frequency == 48000
def test_audio_composite_egress_hrid(audio_service): def test_audio_composite_egress_hrid(audio_service):
"""Test HRID is correct""" """Test HRID is correct"""
assert audio_service.hrid == "audio-recording-composite-livekit-egress" assert audio_service.hrid == "audio-recording-composite-livekit-egress"
@@ -2,23 +2,20 @@
Test rooms API endpoints in the Meet core app: participants management. Test rooms API endpoints in the Meet core app: participants management.
""" """
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines # pylint: disable=redefined-outer-name,unused-argument,protected-access
import random import random
from unittest import mock from unittest import mock
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.urls import reverse from django.urls import reverse
import pytest import pytest
from livekit.api import TwirpError, UpdateParticipantRequest from livekit.api import TwirpError
from livekit.protocol.models import ParticipantInfo
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
@@ -34,8 +31,8 @@ def mock_livekit_client():
yield mock_client yield mock_client
def test_mute_participant_success_as_admin(mock_livekit_client): def test_mute_participant_success(mock_livekit_client):
"""Admins and owners should be able to mute without a LiveKit token.""" """Test successful participant muting."""
client = APIClient() client = APIClient()
room = RoomFactory() room = RoomFactory()
user = UserFactory() user = UserFactory()
@@ -44,12 +41,10 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(url, payload, format="json")
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"} assert response.data == {"status": "success"}
@@ -58,131 +53,23 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client): def test_mute_participant_forbidden_without_access():
"""Should forbid muting when user is anonymous and no LiveKit token.""" """Test mute participant returns 403 when user lacks room privileges."""
client = APIClient() client = APIClient()
room = RoomFactory() room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
"""Should allow muting when the LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_with_livekit_token_for_another_room_forbidden(
mock_livekit_client,
):
"""Should forbid muting when the LiveKit token is scoped to a different room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user has no room role and no LiveKit token."""
client = APIClient()
room = RoomFactory() # everyone_can_mute defaults to True
user = UserFactory() # no UserResourceAccess for this room
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(url, payload, format="json")
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
mock_livekit_client,
):
"""Should forbid muting when everyone_can_mute is False, even with a LiveKit token."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
"""Should allow admins and owners to mute when everyone_can_mute is False."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_invalid_payload(): def test_mute_participant_invalid_payload():
"""Should reject muting when the payload is invalid.""" """Test mute participant with invalid payload."""
client = APIClient() client = APIClient()
room = RoomFactory() room = RoomFactory()
user = UserFactory() user = UserFactory()
@@ -191,16 +78,16 @@ def test_mute_participant_invalid_payload():
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(url, payload, format="json")
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client): def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Should return 500 when the LiveKit API raises a TwirpError.""" """Test mute participant when LiveKit API raises TwirpError."""
client = APIClient() client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError( mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
@@ -214,12 +101,10 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
) )
client.force_authenticate(user=user) client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post( response = client.post(url, payload, format="json")
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"} assert response.data == {"error": "Failed to mute participant"}
@@ -227,282 +112,6 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_participant_not_found(mock_livekit_client):
"""Should return 404 when the participant does not exist in the room."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_management_exception(mock_livekit_client):
"""Should return 500 when ParticipantsManagement raises an unexpected error."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="boom", code="internal", status=503
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
"""Should allow muting when user is admin and LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
# Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client):
"""Should not allow muting when user is admin and the LiveKit token is for another room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=target_room,
user=user,
role=random.choice(["administrator", "owner"]),
)
# Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit
# token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "You do not have permission to perform this action."
}
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_admin_token_replayed_does_not_grant_admin(
mock_livekit_client,
):
"""Should forbid muting when a LiveKit token issued for an admin is passed without a session."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room,
user=admin_user,
role=random.choice(["administrator", "owner"]),
)
# The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client):
"""Should check participant presence when authenticated via LiveKit token only."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
# Presence is verified against LiveKit before the mute is issued.
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_returns_participant(
mock_livekit_client,
):
"""Should mute when the authentified participant is currently in the room."""
client = APIClient()
room = RoomFactory()
# Simulate LiveKit confirming the caller is currently in the room.
# State != DISCONNECTED (3) means present.
mock_livekit_client.room.get_participant.return_value = ParticipantInfo(
identity="caller-identity",
state=ParticipantInfo.State.ACTIVE,
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_participant_not_found(
mock_livekit_client,
):
"""Should not mute when the authentified participant is not found."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
mock_livekit_client,
):
"""Should not mute when the presence check fail."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="an error occured", code="not_found", status=500
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client):
"""Should not check presence of the participant when authentified with a session cookie."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
# Session auth has no LiveKit identity to verify against, so the
# stop-gap presence check is skipped.
mock_livekit_client.room.get_participant.assert_not_called()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_update_participant_success(mock_livekit_client): def test_update_participant_success(mock_livekit_client):
"""Test successful participant update.""" """Test successful participant update."""
client = APIClient() client = APIClient()
@@ -521,8 +130,8 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True, "can_publish": True,
"can_publish_data": True, "can_publish_data": True,
"can_publish_sources": [ "can_publish_sources": [
"camera", "CAMERA",
"microphone", "MICROPHONE",
], ],
"can_update_metadata": True, "can_update_metadata": True,
"can_subscribe_metrics": True, "can_subscribe_metrics": True,
@@ -549,8 +158,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True}, {"can_publish_data": True},
{ {
"can_publish_sources": [ "can_publish_sources": [
"camera", "CAMERA",
"microphone", "MICROPHONE",
] ]
}, },
{"can_update_metadata": True}, {"can_update_metadata": True},
@@ -581,41 +190,9 @@ def test_update_participant_permission_fields_are_optional(
assert response.data == {"status": "success"} assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once() mock_livekit_client.room.update_participant.assert_called_once()
(request_arg,), _ = mock_livekit_client.room.update_participant.call_args
assert isinstance(request_arg, UpdateParticipantRequest)
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
def test_update_participant_permission_fields_invalid_case(mock_livekit_client):
"""Should raise bad request when can_publish_sources is uppercase."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_publish_sources": [
"CAMERA",
"microphone",
]
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_livekit_client.room.update_participant.assert_not_called()
mock_livekit_client.aclose.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"value,permission_key", "value,permission_key",
[ [
@@ -776,7 +353,7 @@ def test_update_participant_invalid_permission():
"loc": ["invalid-attributes"], "loc": ["invalid-attributes"],
"msg": "Extra inputs are not permitted", "msg": "Extra inputs are not permitted",
"input": "True", "input": "True",
"url": "https://errors.pydantic.dev/2.13/v/extra_forbidden", "url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
}, },
] ]
} }
@@ -28,7 +28,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -48,7 +47,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": "trusted", "access_level": "trusted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -67,7 +65,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -84,7 +81,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -101,7 +97,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -205,7 +200,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
assert response.status_code == 200 assert response.status_code == 200
expected_name = f"{room.id!s}" expected_name = f"{room.id!s}"
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -252,7 +246,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
expected_name = f"{room.id!s}" expected_name = f"{room.id!s}"
assert response.json() == { assert response.json() == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -304,7 +297,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
expected_name = f"{room.id!s}" expected_name = f"{room.id!s}"
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -346,7 +338,6 @@ def test_api_rooms_retrieve_authenticated():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"configuration": {},
"access_level": "restricted", "access_level": "restricted",
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -392,7 +383,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
expected_name = str(room.id) expected_name = str(room.id)
assert content_dict == { assert content_dict == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level), "access_level": str(room.access_level),
"id": str(room.id), "id": str(room.id),
"is_administrable": False, "is_administrable": False,
@@ -3,18 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
""" """
import random import random
from unittest.mock import patch
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel from ...models import RoomAccessLevel
from ...services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -85,14 +79,12 @@ def test_api_rooms_update_members():
assert room.configuration == {} assert room.configuration == {}
@patch.object(RoomManagement, "update_metadata") def test_api_rooms_update_administrators():
def test_api_rooms_update_administrators(mock_update_metadata): """Administrators or owners of a room should be allowed to update it."""
"""Should sync LiveKit metadata when both configuration and access level change."""
user = UserFactory() user = UserFactory()
room = RoomFactory( room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))], users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
) )
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -114,120 +106,11 @@ def test_api_rooms_update_administrators(mock_update_metadata):
assert room.access_level == RoomAccessLevel.PUBLIC assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]} assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_configuration_only(mock_update_metadata):
"""Should sync LiveKit metadata when only configuration changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "restricted",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_access_level_only(mock_update_metadata):
"""Should sync LiveKit metadata when only access level changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"access_level": RoomAccessLevel.PUBLIC,
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_name_only(mock_update_metadata):
"""Should not sync LiveKit metadata when neither configuration nor access level changes."""
user = UserFactory()
room = RoomFactory(
name="Old name",
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name"},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
# Unrelated fields untouched
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"configuration", "configuration",
[ [
{},
{"can_publish_sources": ["camera", "microphone"]}, {"can_publish_sources": ["camera", "microphone"]},
{ {
"can_publish_sources": [ "can_publish_sources": [
@@ -239,17 +122,12 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
}, },
{"can_publish_sources": []}, {"can_publish_sources": []},
{"can_publish_sources": None}, {"can_publish_sources": None},
{"can_publish_sources": None, "everyone_can_mute": True},
{"can_publish_sources": None, "everyone_can_mute": False},
{"can_publish_sources": None, "everyone_can_mute": "yes"},
{"can_publish_sources": None, "everyone_can_mute": "1"},
], ],
) )
@patch.object(RoomManagement, "update_metadata") def test_api_rooms_update_configuration_valid(configuration):
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
"""Administrators should be allowed to set valid configurations.""" """Administrators should be allowed to set valid configurations."""
user = UserFactory() user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={}) room = RoomFactory(users=[(user, "owner")])
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -262,28 +140,6 @@ def test_api_rooms_update_configuration_valid(mock_update_metadata, configuratio
room.refresh_from_db() room.refresh_from_db()
assert room.configuration == configuration assert room.configuration == configuration
mock_update_metadata.assert_called_once()
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata):
"""Should not sync LiveKit metadata when patching an already empty configuration."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
def test_api_rooms_update_configuration_extra_keys_rejected(): def test_api_rooms_update_configuration_extra_keys_rejected():
"""Extra keys in configuration should be rejected.""" """Extra keys in configuration should be rejected."""
@@ -342,24 +198,6 @@ def test_api_rooms_update_configuration_wrong_type():
assert room.configuration == {} assert room.configuration == {}
@pytest.mark.parametrize("invalid_value", ["test", [], {}])
def test_api_rooms_update_configuration_everyone_can_mute_wrong_type(invalid_value):
"""everyone_can_mute values with wrong types should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"everyone_can_mute": invalid_value}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
def test_api_rooms_update_administrators_of_another(): def test_api_rooms_update_administrators_of_another():
""" """
Being administrator or owner of a room should not grant authorization to update Being administrator or owner of a room should not grant authorization to update
@@ -379,61 +217,3 @@ def test_api_rooms_update_administrators_of_another():
other_room.refresh_from_db() other_room.refresh_from_db()
assert other_room.name == "Old name" assert other_room.name == "Old name"
assert other_room.slug == "old-name" assert other_room.slug == "old-name"
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
"""Should not fail the API request when the LiveKit room does not exist yet."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@@ -91,9 +91,7 @@ def test_handle_egress_ended_success(
) )
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == "stopped"
# NB: notify_external_services will return False, so status is "saved"
assert recording.status == "saved"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -157,7 +155,7 @@ def test_handle_egress_updated_non_handled(
def test_handle_egress_ended_metadata_update_fails( def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service mock_update_room_metadata, mock_notify, mode, notification_type, service
): ):
"""Should successfully stop and save recording when metadata's update fails.""" """Should successfully stop recording when metadata's update fails."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active") recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -172,9 +170,7 @@ def test_handle_egress_ended_metadata_update_fails(
room_name=str(recording.room.id), notification_data={"type": notification_type} room_name=str(recording.room.id), notification_data={"type": notification_type}
) )
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == "stopped"
# NB: notify_external_services will return False, so status is "saved"
assert recording.status == "saved"
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@@ -330,143 +326,6 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
mock_collector.stop.assert_not_called() mock_collector.stop.assert_not_called()
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
)
@pytest.mark.parametrize(
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
recording_status,
egress_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == recording_status
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@pytest.mark.parametrize(
"egress_status, expected_status",
[
(EgressStatus.EGRESS_COMPLETE, "active"),
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata,
mock_notify,
mock_notify_external_services,
egress_status,
expected_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""When storage event webhooks are enabled, egress_ended must not finalize the
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
the recording, EGRESS_COMPLETE leaves it active.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = True
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"egress_status",
[
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings
):
"""Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "active"
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings
):
"""Should handle non-savable recordings idempotently without raising.
'egress_ended' may be redelivered (e.g. for an already-saved recording);
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
"""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status=status)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == status
@mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule") @mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule( def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@@ -4,7 +4,6 @@ Test suite for generated openapi schema.
import json import json
from io import StringIO from io import StringIO
from unittest.mock import patch
from django.core.management import call_command from django.core.management import call_command
from django.test import Client from django.test import Client
@@ -34,26 +33,10 @@ def test_openapi_client_schema():
) )
assert output.getvalue() == "" assert output.getvalue() == ""
response = Client().get("/api/v1.0/swagger.json") response = Client().get("/v1.0/swagger.json")
assert response.status_code == 200 assert response.status_code == 200
with open( with open(
"core/tests/swagger/swagger.json", "r", encoding="utf-8" "core/tests/swagger/swagger.json", "r", encoding="utf-8"
) as expected_schema: ) as expected_schema:
assert response.json() == json.load(expected_schema) assert response.json() == json.load(expected_schema)
@patch(
"django.contrib.staticfiles.storage.staticfiles_storage.url",
side_effect=lambda name: f"/static/{name}",
)
# pylint: disable=unused-argument
def test_openapi_documentation_routes(mock_staticfiles):
"""Swagger and ReDoc documentation should be served on canonical URLs."""
client = Client()
swagger_response = client.get("/api/v1.0/swagger/")
redoc_response = client.get("/api/v1.0/redoc/")
assert swagger_response.status_code == 200
assert redoc_response.status_code == 200
+19 -263
View File
@@ -250,112 +250,6 @@ def test_api_rooms_list_filters_by_user():
assert str(room2.id) not in returned_ids assert str(room2.id) not in returned_ids
def test_api_rooms_list_access_level_in_results():
"""Rooms should include the correct access_level for each room."""
user = UserFactory()
room_trusted = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
)
room_restricted = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.RESTRICTED
)
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
results = {r["id"]: r for r in response.data["results"]}
assert results[str(room_trusted.id)]["access_level"] == RoomAccessLevel.TRUSTED
assert (
results[str(room_restricted.id)]["access_level"] == RoomAccessLevel.RESTRICTED
)
def test_api_rooms_list_does_not_expose_sensitive_fields():
"""Rooms should not expose pin_code or accesses."""
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
result = response.data["results"][0]
assert "pin_code" not in result
assert "accesses" not in result
assert "livekit" not in result
def test_api_rooms_list_expected_fields(settings):
"""Rooms should expose exactly the expected fields."""
settings.APPLICATION_BASE_URL = "https://example.com"
settings.ROOM_TELEPHONY_ENABLED = True
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert set(response.data["results"][0].keys()) == {
"id",
"name",
"slug",
"access_level",
"configuration",
"telephony",
"url",
}
def test_api_rooms_list_expected_fields_without_telephony(settings):
"""Rooms shouldn't expose telephony related fields when disabled."""
settings.APPLICATION_BASE_URL = "https://example.com"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert "telephony" not in set(response.data["results"][0].keys())
def test_api_rooms_list_expected_fields_missing_base_url(settings):
"""Rooms shouldn't expose URL field when the application base url is missing."""
settings.APPLICATION_BASE_URL = None
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert "url" not in set(response.data["results"][0].keys())
def test_api_rooms_retrieve_requires_authentication(): def test_api_rooms_retrieve_requires_authentication():
"""Retrieving rooms without authentication should return 401.""" """Retrieving rooms without authentication should return 401."""
@@ -489,7 +383,6 @@ def test_api_rooms_retrieve_success(settings):
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"access_level": str(room.access_level), "access_level": str(room.access_level),
"configuration": room.configuration,
"url": f"http://your-application.com/{room.slug}", "url": f"http://your-application.com/{room.slug}",
"telephony": { "telephony": {
"enabled": True, "enabled": True,
@@ -672,40 +565,11 @@ def test_api_rooms_create_success():
assert "slug" in response.data assert "slug" in response.data
assert "name" in response.data assert "name" in response.data
assert response.data["name"] == response.data["slug"] assert response.data["name"] == response.data["slug"]
assert response.data["configuration"] == {}
# Verify room was created with user as owner # Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"]) room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted" assert room.access_level == "trusted"
assert room.configuration == {}
def test_api_rooms_create_with_configuration_success():
"""Creating a room with a validated configuration should succeed."""
user = UserFactory()
token = generate_test_token(
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"can_publish_sources": ["camera"]},
},
format="json",
)
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"can_publish_sources": ["camera"]}
assert response.data["configuration"] == {"can_publish_sources": ["camera"]}
def test_api_rooms_create_readonly_enforcement(): def test_api_rooms_create_readonly_enforcement():
@@ -723,6 +587,7 @@ def test_api_rooms_create_readonly_enforcement():
"id": "fake-id", "id": "fake-id",
"slug": "fake-slug", "slug": "fake-slug",
"name": "fake-name", "name": "fake-name",
"access_level": "public",
}, },
format="json", format="json",
) )
@@ -734,150 +599,41 @@ def test_api_rooms_create_readonly_enforcement():
assert response.data["slug"] != "fake-slug" assert response.data["slug"] != "fake-slug"
assert "id" in response.data assert "id" in response.data
assert response.data["name"] != "fake-name" assert response.data["name"] != "fake-name"
assert response.data["configuration"] == {}
# Verify room was created with user as owner # Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"]) room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted" assert room.access_level == "trusted"
assert room.configuration == {}
def test_api_rooms_create_rejects_invalid_configuration(): def test_api_rooms_unknown_actions():
"""Creating a room with unsupported configuration keys should fail.""" """Updating or deleting a room are not supported yet."""
user = UserFactory() user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(
user,
[
ApplicationScope.ROOMS_RETRIEVE,
ApplicationScope.ROOMS_DELETE,
ApplicationScope.ROOMS_UPDATE,
],
)
client = APIClient() client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post( response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
"/external-api/v1.0/rooms/",
{
"configuration": {
"unsupported_flag": True,
}
},
format="json",
)
assert response.status_code == 400 assert response.status_code == 405
assert "extra inputs are not permitted" in str(response.data).lower() assert 'method "delete" not allowed.' in str(response.data).lower()
@pytest.mark.parametrize(
"invalid_configuration",
[
{"can_publish_sources": ["invalid-source"]},
{"everyone_can_mute": "invalid-value"},
],
)
def test_api_rooms_create_rejects_invalid_configuration_values(invalid_configuration):
"""Creating a room with invalid configuration values should fail."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient() client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post( response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
"/external-api/v1.0/rooms/",
{"configuration": invalid_configuration},
format="json",
)
assert response.status_code == 400 assert response.status_code == 405
assert 'method "patch" not allowed.' in str(response.data).lower()
def test_api_rooms_create_public_access_disabled_by_default():
"""Public rooms should be disabled for the external API by default."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 400
assert "public rooms are disabled" in str(response.data).lower()
def test_api_rooms_create_public_access_enabled_with_settings(settings):
"""Public rooms should be creatable when explicitly enabled."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.access_level == RoomAccessLevel.PUBLIC
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
def test_api_rooms_create_default_access_level_respects_settings(settings):
"""Room creation should reflect the EXTERNAL_API_DEFAULT_ACCESS_LEVEL setting."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
format="json",
)
assert response.status_code == 201
assert response.data["access_level"] == RoomAccessLevel.TRUSTED
settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public"
response = client.post(
"/external-api/v1.0/rooms/",
format="json",
)
assert response.status_code == 201
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
def test_api_rooms_create_public_access_level_when_default_is_public(settings):
"""Explicit public access_level is accepted when the default is already public."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = False
settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# No access_level in body — default kicks in, public room is created.
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 201
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
# Explicit access_level=public in body — still rejected.
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 201
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
def test_api_rooms_response_no_url(settings): def test_api_rooms_response_no_url(settings):
@@ -4,8 +4,6 @@ Tests for external API /token endpoint
# pylint: disable=W0621 # pylint: disable=W0621
from unittest import mock
import jwt import jwt
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
@@ -17,7 +15,6 @@ from core.factories import (
UserFactory, UserFactory,
) )
from core.models import ApplicationScope, User from core.models import ApplicationScope, User
from core.services import provisional_user_service
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -439,88 +436,3 @@ def test_api_applications_token_existing_user(settings):
"delegated": True, "delegated": True,
"scope": "rooms:list rooms:create", "scope": "rooms:list rooms:create",
} }
@mock.patch.object(provisional_user_service.ProvisionalUserService, "_get_by_email")
def test_api_applications_token_new_user_race_condition(mock_get_by_email, settings):
"""Should handle race condition where two concurrent requests create the same user."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
application = ApplicationFactory(
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
email = "john.doe@example.com"
# First call: lie and say user doesn't exist, simulating the race window
# Second call (recovery path): return the real user
existing_user = UserFactory(sub=None, email=email)
mock_get_by_email.side_effect = [None, existing_user]
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": email,
},
format="json",
)
assert response.status_code == 200
assert mock_get_by_email.call_count == 2
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload["user_id"] == str(existing_user.id)
assert User.objects.filter(email=email).count() == 1
@mock.patch.object(
provisional_user_service.ProvisionalUserService,
"get_or_create",
side_effect=provisional_user_service.ProvisionalUserIntegrityError,
)
def test_api_applications_token_new_user_race_condition_unrecoverable(
mock_get_or_create, settings
):
"""Should return 500 when ProvisionalUserIntegrityError is raised."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
application = ApplicationFactory(
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "john.doe@example.com",
},
format="json",
)
assert response.status_code == 409
assert mock_get_or_create.call_count == 1
@@ -44,73 +44,3 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message") user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address." assert str(excinfo.value) == "User has no email address."
def test_models_users_email_unique_when_sub_is_null():
"""Email should be unique among users with no sub (pending users)."""
user = factories.UserFactory(sub=None, email="test@example.com")
with pytest.raises(
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
):
factories.UserFactory(sub=None, email=user.email)
def test_models_users_email_unique_case_insensitive_when_sub_is_null():
"""Email uniqueness should be case-insensitive among users with no sub (pending users)."""
factories.UserFactory(sub=None, email="Test@example.com")
with pytest.raises(
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
):
factories.UserFactory(sub=None, email="test@example.com")
def test_models_users_email_not_unique_when_sub_is_set():
"""Email uniqueness should not be enforced when users have a sub."""
user = factories.UserFactory(sub="sub-1", email="test@example.com")
user2 = factories.UserFactory(sub="sub-2", email=user.email)
assert user2.email == user.email
def test_models_users_email_not_unique_between_sub_null_and_sub_set():
"""A user with a sub and a pending user (sub=None) can share the same email."""
user = factories.UserFactory(sub="sub-1", email="test@example.com")
user2 = factories.UserFactory(sub=None, email=user.email)
assert user2.email == user.email
def test_models_users_email_unique_constraint_allows_multiple_null_emails():
"""Multiple users with sub=None and email=None should be allowed."""
factories.UserFactory(sub=None, email=None)
factories.UserFactory(sub=None, email=None)
def test_models_users_sub_null_email_null_does_not_prevent_creation():
"""Multiple pending users (sub=None, email=None) can be created without conflict.
sub=None is not unique-constrained. email uniqueness is only enforced among
sub=None users with a non-null email, so email=None bypasses it (NULL != NULL in SQL).
"""
# Ghost row can still appear from bad code path
u1 = factories.UserFactory(sub=None, email=None)
u2 = factories.UserFactory(sub=None, email=None)
assert u1.pk != u2.pk
def test_models_users_sub_can_be_null():
"""sub is nullable: pending users exist before OIDC activation."""
user = factories.UserFactory(sub=None)
user.refresh_from_db()
assert user.sub is None
def test_models_users_sub_null_does_not_prevent_creation():
"""Multiple users can be created with sub=None (pending state)."""
u1 = factories.UserFactory(sub=None)
u2 = factories.UserFactory(sub=None)
assert u1.pk != u2.pk
def test_models_users_sub_blank_is_accepted():
"""sub='' passes validation because blank=True; null is preferred but not enforced."""
user = factories.UserFactory.build(sub="")
user.full_clean()
@@ -1,58 +0,0 @@
"""
Test utils.build_telephony_config
"""
import logging
from core.utils import build_telephony_config
def test_build_telephony_config_disabled(settings):
"""Returns {"enabled": False} when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
config = build_telephony_config()
assert config == {"enabled": False}
def test_build_telephony_config_enabled_with_valid_number(settings):
"""Returns full config with country and international number when telephony is enabled."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "0123456789"
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
config = build_telephony_config()
assert config == {
"enabled": True,
"default_country": "FR",
"international_phone_number": "+33 1 23 45 67 89",
}
def test_build_telephony_config_enabled_with_invalid_number(settings):
"""Returns {"enabled": False} when phone number cannot be parsed."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "not-a-number"
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
config = build_telephony_config()
assert config == {"enabled": False}
def test_build_telephony_config_enabled_with_missing_number(settings):
"""Returns {"enabled": False} when phone number is not configured."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = ""
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
config = build_telephony_config()
assert config == {"enabled": False}
def test_build_telephony_config_enabled_with_missing_number_warns(settings, caplog):
"""Logs a warning when telephony is enabled but phone number is not configured."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = ""
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "FR"
with caplog.at_level(logging.WARNING):
build_telephony_config()
assert "ROOM_TELEPHONY_PHONE_NUMBER" in caplog.text
@@ -1,102 +0,0 @@
"""
Test utils._format_telephony_phone_number
"""
import logging
import pytest
from core.utils import _format_telephony_phone_number
@pytest.fixture(autouse=True)
def clear_lru_cache():
"""Clear the lru_cache before each test to ensure isolation."""
_format_telephony_phone_number.cache_clear()
yield
_format_telephony_phone_number.cache_clear()
def test_format_telephony_phone_number_missing_raw_number():
"""Returns (None, None) when raw_number is empty."""
country, international = _format_telephony_phone_number("", "FR")
assert country is None
assert international is None
def test_format_telephony_phone_number_none_raw_number():
"""Returns (None, None) when raw_number is None."""
country, international = _format_telephony_phone_number(None, "FR")
assert country is None
assert international is None
def test_format_telephony_phone_number_missing_default_country():
"""Returns (None, None) when default_country is empty."""
country, international = _format_telephony_phone_number("+33123456789", "")
assert country is None
assert international is None
def test_format_telephony_phone_number_none_default_country():
"""Returns (None, None) when default_country is None."""
country, international = _format_telephony_phone_number("+33123456789", None)
assert country is None
assert international is None
def test_format_telephony_phone_number_both_missing():
"""Returns (None, None) when both inputs are missing."""
country, international = _format_telephony_phone_number(None, None)
assert country is None
assert international is None
def test_format_telephony_phone_number_invalid_number(caplog):
"""Returns (None, None) and logs a warning when the number cannot be parsed."""
with caplog.at_level(logging.WARNING):
country, international = _format_telephony_phone_number("not-a-number", "FR")
assert country is None
assert international is None
assert "not-a-number" in caplog.text
assert "FR" in caplog.text
def test_format_telephony_phone_number_valid_french_number():
"""Returns correct country and international format for a valid French number."""
country, international = _format_telephony_phone_number("0123456789", "FR")
assert country == "FR"
assert international == "+33 1 23 45 67 89"
def test_format_telephony_phone_number_valid_e164_number():
"""Returns correct result for an E.164-formatted number (no default country needed)."""
country, international = _format_telephony_phone_number("+33123456789", "US")
assert country == "FR"
assert international == "+33 1 23 45 67 89"
def test_format_telephony_phone_number_valid_us_number():
"""Returns correct country and international format for a valid US number."""
country, international = _format_telephony_phone_number("2025550123", "US")
assert country == "US"
assert international == "+1 202-555-0123"
def test_format_telephony_phone_number_valid_german_number():
"""Returns correct country and international format for a valid German number."""
country, international = _format_telephony_phone_number("03012345678", "DE")
assert country == "DE"
assert international == "+49 30 12345678"
def test_format_telephony_phone_number_lru_cache():
"""Results are cached: the same inputs return the same object."""
result1 = _format_telephony_phone_number("0123456789", "FR")
result2 = _format_telephony_phone_number("0123456789", "FR")
assert result1 is result2
# pylint: disable=no-value-for-parameter
cache_info = _format_telephony_phone_number.cache_info()
assert cache_info.hits >= 1
+1 -93
View File
@@ -12,7 +12,6 @@ import mimetypes
import random import random
import secrets import secrets
import string import string
from functools import lru_cache
from typing import List, Optional from typing import List, Optional
from uuid import uuid4 from uuid import uuid4
@@ -23,7 +22,6 @@ import aiohttp
import boto3 import boto3
import botocore import botocore
import magic import magic
import phonenumbers
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611 from livekit.api import ( # pylint: disable=E0611
AccessToken, AccessToken,
@@ -426,7 +424,7 @@ def generate_upload_policy(file):
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long
""" """
key = file.temporary_file_key key = file.file_key
# This settings should be used if the backend application and the frontend application # This settings should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the # can't connect to the object storage with the same domain. This is the case in the
@@ -457,93 +455,3 @@ def generate_upload_policy(file):
) )
return policy return policy
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
"""
Generate a S3 signed download url for a given file.
"""
key = file.file_key
# This setting should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
return s3_client.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": default_storage.bucket_name, "Key": key},
ExpiresIn=expires_in,
)
@lru_cache(maxsize=1)
def _format_telephony_phone_number(raw_number, default_country):
"""Parse a configured phone number and return (country, international_format).
Returns (None, None) if the inputs are missing or the number cannot be
parsed. Logs a warning on parse failure so operators see the misconfiguration.
"""
if not raw_number or not default_country:
return None, None
try:
parsed = phonenumbers.parse(raw_number, default_country)
except phonenumbers.NumberParseException:
logger.warning(
"ROOM_TELEPHONY_PHONE_NUMBER %r is not a valid phone number for "
"default country %r; telephony block will be returned without "
"formatted number.",
raw_number,
default_country,
)
return None, None
country = phonenumbers.region_code_for_number(parsed)
international = phonenumbers.format_number(
parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL
)
return country, international
def build_telephony_config():
"""Build the telephony block of the frontend configuration."""
if not settings.ROOM_TELEPHONY_ENABLED:
return {"enabled": False}
country, international = _format_telephony_phone_number(
settings.ROOM_TELEPHONY_PHONE_NUMBER,
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
)
if international is None:
logger.warning(
"Telephony is enabled but ROOM_TELEPHONY_PHONE_NUMBER %r with "
"default country %r could not be formatted; telephony will be disabled.",
settings.ROOM_TELEPHONY_PHONE_NUMBER,
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
)
return {"enabled": False}
return {
"enabled": True,
"default_country": country,
"international_phone_number": international,
}
@@ -576,8 +576,8 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13 #: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten " msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:209 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14 #: core/templates/mail/text/screen_recording.txt:14
@@ -572,8 +572,8 @@ msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13 #: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"Open\" button below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:209 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14 #: core/templates/mail/text/screen_recording.txt:14

Some files were not shown because too many files have changed in this diff Show More