Compare commits

..

1 Commits

Author SHA1 Message Date
leo d8ab229813 wip 2026-08-04 19:49:51 +02:00
324 changed files with 6396 additions and 18611 deletions
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Download Crowdin files
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
+36 -36
View File
@@ -30,36 +30,36 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -76,36 +76,36 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
@@ -123,36 +123,36 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
@@ -170,30 +170,30 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@v5
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
@@ -201,7 +201,7 @@ jobs:
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@v6
with:
context: ./src/summary
file: ./src/summary/Dockerfile
@@ -219,30 +219,30 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@v5
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
@@ -250,7 +250,7 @@ jobs:
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@v6
with:
context: ./src/agents
file: ./src/agents/Dockerfile
@@ -273,7 +273,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
@@ -1,4 +1,4 @@
name: CI
name: meet Workflow
on:
push:
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: show
@@ -26,17 +26,17 @@ jobs:
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install uv
- name: Install gitlint
if: always()
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
@@ -47,7 +47,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -59,7 +59,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -77,15 +77,15 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -93,11 +93,11 @@ jobs:
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g --ignore-scripts yarn@1.22.22
run: npm install -g yarn
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile --ignore-scripts
run: yarn install --frozen-lockfile
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
@@ -105,7 +105,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache@v5
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
@@ -119,22 +119,22 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: uv run --no-sync --no-build pylint meet demo core
run: uv run pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
@@ -145,19 +145,19 @@ jobs:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras --no-build
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
run: uv run ruff check .
lint-summary:
runs-on: ubuntu-latest
@@ -168,19 +168,18 @@ jobs:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
run: ~/.local/bin/ruff check .
test-back:
runs-on: ubuntu-latest
@@ -236,7 +235,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Create writable /data
run: |
@@ -244,7 +243,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache@v5
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -252,20 +251,18 @@ jobs:
- name: Start MinIO
run: |
docker pull quay.io/minio/minio
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
quay.io/minio/minio server --console-address :9001 /data
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
sudo tar -C /usr/local/bin -xzv
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
@@ -280,11 +277,11 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@v7
- name: Install the dependencies
run: uv sync --locked --all-extras
@@ -294,10 +291,10 @@ jobs:
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: uv run --no-sync --no-build python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: uv run --no-sync --no-build pytest -n 2
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
@@ -323,7 +320,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install ffmpeg
run: |
@@ -331,18 +328,16 @@ jobs:
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: uv run --no-sync --no-build pytest
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
@@ -350,10 +345,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
run: cd src/frontend/ && npm ci
- name: Check linting
run: cd src/frontend/ && npm run lint
@@ -370,10 +365,10 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci --ignore-scripts
run: npm ci
- name: Check linting
run: npm run lint
@@ -391,10 +386,10 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci --ignore-scripts
run: npm ci
- name: Build SDK
run: npm run build
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -21,12 +21,12 @@ jobs:
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
+1 -199
View File
@@ -10,195 +10,6 @@ and this project adheres to
### Added
- ✨(backend) make the LiveKit default video codec configurable
### Changed
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
- ♻️(backend) delete files synchronously
### Fixed
- 🐛(backend) remove the temporary upload object when a file is deleted
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
## [1.31.0] - 2026-09-08
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
- ✨(backend) update a room's attributes from the external API
- 🔊(backend) log request duration in Gunicorn workers
- 📈(frontend) track missing lobby participant on accept/reject
- ✨(backend) sort waiting participants by their arrival time
### Changed
- ⬆️(dev) pin LiveKit server to v1.13.6
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
### Fixed
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
- ⚡️(frontend) increase lobby polling interval on both sides
- ⚡️(frontend) add trailing slash on the /me endpoint call
- ⚡️(backend) refactor lobby storage to bound key lookups per room
- ⚡️(backend) refactor presence cache to bound key lookups per room
- 💄(frontend) position the login hint dynamically next to the button
## [1.30.0] - 2026-09-01
### Added
- ✨(agent) support Voxtral realtime as inference engine
- 🌐(i18n) add Spanish language support
- ✨(frontend) expose publish permissions on the media state element #1661
### Changed
- 🔥(backend) remove the S3 storage-event webhook for recordings
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
- ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
- ♻️(backend) factorize s3 client creation in utils
- ♿️(frontend) close side panel with Escape key #1507
### Fixed
- 🐛(frontend) fix chat text-area bug
## [1.29.0] - 2026-08-25
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
- 📱(frontend) stack idle modal buttons in a column on mobile
- 📱(frontend) improve feedback screen responsiveness on mobile
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
- ✨(summary) add hostname to analytics properties
## [1.28.0] - 2026-08-24
### Added
- 📈(frontend) track errors when starting or stopping a recording
- 🚸(frontend) explain camera-in-use failures on the join screen
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
- 🐛(frontend) handle Firefox/Windows AbortError on device start
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
## [1.27.0] - 2026-08-14
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12
### Added
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
- ✨(frontend) prompt for permissions when toggling a denied device
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
- ♻️(frontend) encapsulate error tracking behind a telemetry module
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
- 🔧(frontend) sync persisted device ids with the actual selected devices
- 💄(frontend) hide the ProConnect button on narrow viewports
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
### Fixed
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
- 🐛(frontend) display the meeting id in the join screen page title
## [1.25.2] - 2026-08-06
### Fixed
- 🐛(frontend) serve MediaPipe assets under a versioned path
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
## [1.25.1] - 2026-08-06
### Fixed
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
@@ -206,9 +17,7 @@ and this project adheres to
- ✨(backend) add roomkit viewset to start a room without WebRTC join
- ✨(frontend) let users set default configuration for generated links
- ✨(frontend) expose media state to external gateways
- ✨(frontend) add connection test feature
- ✨(sdk) allow passing a background color to the calendar iframe
- ✨(sdk) add a room configuration popup from CreateMeetingButton
- ✨(backend) push recordings to the owner's Drive (POC)
### Changed
@@ -222,7 +31,6 @@ and this project adheres to
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
- 🚚(backend) rename TelephonyService to SIPManagement
- ⬆️(dependencies) update python dependencies
### Fixed
@@ -234,12 +42,6 @@ and this project adheres to
- 🐛(frontend) fall back to user.full_name on request-entry
- 🚸(frontend) show two initials in the Avatar when possible
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
- 🐛(frontend) stop the installed app reopening the room it came from
- 🐛(backend) serialize lazy title in summary payload
- 💄(frontend) show pointer cursor on interactive switches
- 🐛(frontend) fix icon centering in the Switch primitive
- 🐛(frontend) keep Unicode initials intact in avatar
- 🐛(frontend) prevent concurrent settings updates from overwriting each other
## [1.24.0] - 2026-07-21
+19 -33
View File
@@ -39,16 +39,14 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -61,10 +59,6 @@ LINT_PYLINT = pylint meet demo core
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
LINT_AGENTS = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -99,6 +93,7 @@ bootstrap: \
data/media \
data/static \
create-env-files \
create-docker-network \
build \
migrate \
demo \
@@ -132,11 +127,16 @@ down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
create-docker-network: ## create the shared lasuite-network if it doesn't exist
@docker network create lasuite-network || true
.PHONY: create-docker-network
logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up -d livekit
@@ -204,37 +204,23 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
lint: ## lint all python sources (back-end, agents, summary)
@$(MAKE) lint-back
@$(MAKE) lint-agents
@$(MAKE) lint-summary
lint: ## lint back-end python sources
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
.PHONY: lint
lint-back: ## lint back-end python sources
@$(COMPOSE_RUN_LINT_BACK) sh -c "$(LINT_BACK)"
.PHONY: lint-back
lint-agents: ## lint agents python sources
@$(COMPOSE_RUN_LINT_AGENTS) sh -c "$(LINT_AGENTS)"
.PHONY: lint-agents
lint-summary: ## lint summary python sources
@$(COMPOSE_RUN_LINT_SUMMARY) sh -c "$(LINT_SUMMARY)"
.PHONY: lint-summary
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
.PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
.PHONY: lint-ruff-check
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
.PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure.
-19
View File
@@ -16,25 +16,6 @@ the following command inside your docker container:
## [Unreleased]
## v1.30.0
### Removing S3 storage-event webhooks for recordings
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
**Required for every deployment:** LiveKit must be able to deliver webhooks to the backend at `/api/v1.0/rooms/webhooks-livekit/`. This is now the only way a recording reaches a saved state; if `egress_ended` is never delivered, recordings stay in the `active` state.
For hosters who had configured storage-event webhooks:
- Recordings reach the same final state, but they are now finalized when LiveKit reports the egress as ended rather than when the storage backend reports the upload.
- Remove the event notification from your bucket configuration: it now targets a non-existent endpoint and will fail on every delivery.
For hosters who had **not** configured storage-event webhooks:
- Nothing changes. Recordings have been finalized from the `egress_ended` webhook since v1.22.0.
In both cases, the following settings are no longer used and can be removed from your env: `RECORDING_EVENT_PARSER_CLASS`, `RECORDING_ENABLE_STORAGE_EVENT_AUTH`, `RECORDING_STORAGE_EVENT_ENABLE`, `RECORDING_STORAGE_EVENT_TOKEN`.
On completion of the egress, a recording moves to `notification_succeeded`, or to `saved` if notifying external services failed.
## v1.23.0
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
-7
View File
@@ -110,12 +110,6 @@ cd -
# Update summary pyproject.toml
update_python_version "summary"
# Run uv lock in summary
print_info "Running uv lock in summary..."
cd "src/summary"
uv lock
cd -
# Update agents pyproject.toml
update_python_version "agents"
@@ -169,7 +163,6 @@ echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/summary/uv.lock"
echo " - src/agents/pyproject.toml"
echo " - src/agents/uv.lock"
echo " - CHANGELOG.md"
+42 -17
View File
@@ -14,10 +14,13 @@ services:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
networks:
- default
- lasuite
minio:
user: ${DOCKER_USER:-1000}
image: quay.io/minio/minio
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
@@ -33,9 +36,13 @@ services:
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
networks:
default:
aliases:
- meet-minio
createbuckets:
image: quay.io/minio/mc
image: minio/mc
depends_on:
minio:
condition: service_healthy
@@ -46,6 +53,21 @@ services:
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
createwebhook:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
exit 0;"
app-dev:
build:
context: .
@@ -71,11 +93,13 @@ services:
- mailcatcher
- redis
- createbuckets
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
- resource-server
- default
- lasuite
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -92,6 +116,9 @@ services:
- /app/.venv
depends_on:
- app-dev
networks:
- default
- lasuite
app:
build:
@@ -164,7 +191,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -179,42 +206,40 @@ services:
- env.d/development/kc_postgresql
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
image: quay.io/keycloak/keycloak:26.3.2
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --proxy-headers=xforwarded
- --hostname=http://localhost:8083
- --hostname-strict=false
- --hostname-strict-https=false
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL_HOST: kc_postgresql
KC_DB_URL_DATABASE: keycloak
KC_DB_PASSWORD: pass
KC_DB_USERNAME: meet
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
depends_on:
- kc_postgresql
networks:
- default
- lasuite
livekit:
image: livekit/livekit-server:v1.13.6
image: livekit/livekit-server
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -235,7 +260,6 @@ services:
build:
context: ./src/agents
target: development
user: ${DOCKER_USER:-1000}
command: ["python", "metadata_collector.py", "dev"]
env_file:
- env.d/development/metadata_collector
@@ -254,8 +278,6 @@ services:
build:
context: ./src/agents
target: development
user: ${DOCKER_USER:-1000}
command: ["python", "multi_user_transcriber.py", "dev"]
env_file:
- env.d/development/multi_user_transcriber
volumes:
@@ -326,3 +348,6 @@ services:
networks:
default:
resource-server:
lasuite:
name: lasuite-network
external: true
+136
View File
@@ -845,6 +845,142 @@
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "drive",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": "true",
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"http://localhost:3100/*",
"http://localhost:8171/*",
"http://localhost:8085/*"
],
"webOrigins": [
"http://localhost:3100",
"http://localhost:8171",
"http://localhost:8085"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "deploycenter",
"name": "",
"description": "",
"rootUrl": "",
"adminUrl": "",
"baseUrl": "",
"surrogateAuthRequired": false,
"enabled": "true",
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
"redirectUris": [
"http://localhost:3100/*",
"http://localhost:8171/*",
"http://localhost:8085/*"
],
"webOrigins": [
"http://localhost:3100",
"http://localhost:8171",
"http://localhost:8085"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"access.token.lifespan": "-1",
"client.secret.creation.time": "1707820779",
"user.info.response.signature.alg": "RS256",
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
"oauth2.device.authorization.grant.enabled": "false",
"use.jwks.url": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"use.refresh.tokens": "true",
"tls-client-certificate-bound-access-tokens": "false",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"acr.loa.map": "{}",
"require.pushed.authorization.requests": "false",
"display.on.consent.screen": "false",
"client.session.idle.timeout": "-1",
"token.response.type.bearer.lower-case": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"clientScopes": [
+10 -3
View File
@@ -54,11 +54,18 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root
RUN apk del curl
USER nginx
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
USER nginx
-1
View File
@@ -1,6 +1,5 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
.Header-beforeLogo {
-5
View File
@@ -65,11 +65,6 @@ server {
sub_filter_once off;
}
location ^~ /assets/mediapipe/wasm/ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
@@ -14,4 +14,3 @@ accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.13.6
FROM livekit/livekit-server:v1.9.4
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
-13
View File
@@ -8,16 +8,3 @@ webhook:
api_key: devkey
urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
turn:
enabled: true
domain: turn.127.0.0.1.nip.io
udp_port: 3478
tls_port: 0
external_tls: false
relay_range_start: 30000
relay_range_end: 30100
allow_restricted_peer_cidrs:
- 192.168.0.0/16
- 172.16.0.0/12
+77 -2
View File
@@ -23,11 +23,14 @@ It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit
To use the room recording feature, the following components are required:
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
- A S3-compatible object storage where the egress uploads the recorded files.
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
- An email service to notify room owners when a recording is available for download.
- Webhook events configured between LiveKit Server and the backend.
> [!CAUTION]
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
> [!NOTE]
> Celery isn’t in use for these async tasks yet. It’s something we’d like to add, but it’s not planned at this stage.
@@ -72,7 +75,7 @@ sequenceDiagram
LiveKit->>Egress: Stop recording
Egress->>Storage: Upload recorded file
LiveKit->>Backend: POST /api/v1.0/rooms/webhooks-livekit/ (egress_ended)
Storage->>Backend: Storage event notification
Backend->>Backend: Update Recording status to SAVED
Backend->>Email: Send notification to room owner
@@ -91,6 +94,10 @@ sequenceDiagram
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **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_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_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_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). |
@@ -102,10 +109,78 @@ sequenceDiagram
| **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
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
1. Configure your S3 bucket to send file creation events to the backend webhook.
2. Enable events and token in settings:
```python
RECORDING_STORAGE_EVENT_ENABLE = True
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
RECORDING_STORAGE_EVENT_TOKEN = <token>
```
> [!NOTE]
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
## Push recordings to Drive
Once a recording is over, it can be pushed (streamed) to the user's [Drive](https://github.com/suitenumerique/drive), in addition to staying in the object storage.
Drive is called as a resource server, following its
[resource server documentation](https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md):
### Special requirements
- Drive configured as an OIDC resource server, accepting Meet's audience
(`OIDC_RS_ALLOWED_AUDIENCES` must contain Meet's client id), with the `items`
endpoint allowing the `list`, `children` and `upload_ended` actions.
- `OIDC_STORE_ACCESS_TOKEN` enabled on Meet, along with
`OIDC_STORE_REFRESH_TOKEN_KEY`, the Fernet key encrypting the stored token.
> [!CAUTION]
> This is a proof of concept: the access token is captured when the recording
> starts and assumed to still be valid when the recording ends. Long recordings
> may therefore fail to be pushed. Exchanging it for a long-lived, narrowly
> scoped token is the intended follow-up.
### Configuration options
| Option | Type | Default | Description |
| ----------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **RECORDING_PUSH_TO_DRIVE_ENABLED** | Boolean | `False` | Enable pushing recordings to the owner's Drive. |
| **DRIVE_API_BASE_URL** | String | `None` | Base URL of Drive's external API, e.g. `https://fichiers.numerique.gouv.fr/external_api/v1.0`. |
| **RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS** | Integer | `3600` | Lifetime of the signed URL the worker downloads the recording from. |
| **OIDC_STORE_ACCESS_TOKEN** | Boolean | `False` | Keep the user's access token in the session, required to call Drive on their behalf. |
| **OIDC_STORE_REFRESH_TOKEN_KEY** | Secret/File | `None` | Fernet key encrypting OIDC tokens at rest. Generate one with `Fernet.generate_key()`. |
| **DRIVE_UPLOAD_STORAGE_NETLOC** | String | `None` | Development only: `host:port` to reach Drive's object storage at, when the domain Drive signs its upload URLs with only resolves from a browser. |
### Local development
Meet and Drive run as two separate compose projects, joined by the external
`lasuite-network` (`make create-docker-network`). Meet's backend containers reach
Drive's nginx at `drive-nginx:8083` and its object storage at `drive-minio:9000`.
On the Drive side:
```bash
OIDC_RESOURCE_SERVER_ENABLED=True
OIDC_RS_CLIENT_ID=drive
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_AUDIENCE_CLAIM=client_id
OIDC_RS_ALLOWED_AUDIENCES=meet
```
`DRIVE_UPLOAD_STORAGE_NETLOC` is needed because Drive signs its upload URLs
with `localhost:9100`, which does not resolve from Meet's containers. The
presigned signature covers the `Host` header, so the backend keeps announcing the
signed host and only swaps the address it connects to.
## LiveKit Egress
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
+4
View File
@@ -406,6 +406,10 @@ These are the environmental options available on meet backend.
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| 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_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_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
+3 -105
View File
@@ -16,11 +16,11 @@ info:
* `rooms:list` – List rooms accessible to the delegated user.
* `rooms:retrieve` – Retrieve details of a specific room.
* `rooms:create` – Create new rooms.
* `rooms:update` – Update the access level and configuration of existing rooms.
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -50,24 +50,10 @@ paths:
The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
Request parameters may be sent either as "application/x-www-form-urlencoded"
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
operationId: generateToken
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
@@ -131,19 +117,6 @@ paths:
summary: Domain not authorized
value:
error: "This application is not authorized for this email domain."
'415':
description: |
Unsupported media type. The request body must be sent as
"application/x-www-form-urlencoded" or "application/json".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
unsupportedMediaType:
summary: Unsupported request content type
value:
detail: 'Unsupported media type "text/plain" in request.'
/rooms:
get:
@@ -310,67 +283,6 @@ paths:
'404':
$ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only the delegated user's rooms where they are
administrator or owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components:
securitySchemes:
BearerAuth:
@@ -447,17 +359,6 @@ components:
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
@@ -499,9 +400,6 @@ components:
- `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.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted"
Room:
+1 -76
View File
@@ -20,7 +20,7 @@ info:
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
* `lasuite_visio:rooms:create` – Create new rooms.
* `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms.
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
@@ -206,67 +206,6 @@ paths:
'404':
$ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only rooms where the user is administrator or
owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components:
securitySchemes:
BearerAuth:
@@ -288,17 +227,6 @@ components:
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
@@ -340,9 +268,6 @@ components:
- `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.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted"
Room:
-1
View File
@@ -34,7 +34,6 @@ Let's say you want to change the font of our application to a custom font. You c
:root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
```
+13 -4
View File
@@ -24,7 +24,7 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ENDPOINT_URL=http://meet-minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000
@@ -50,6 +50,10 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# The key below encrypts OIDC tokens at rest.
OIDC_STORE_ACCESS_TOKEN=True
OIDC_STORE_REFRESH_TOKEN_KEY=
OIDC_RS_CLIENT_ID=meet
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -63,11 +67,19 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=False
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Push recordings to Drive
# DRIVE_UPLOAD_STORAGE_NETLOC is a development-only workaround.
RECORDING_PUSH_TO_DRIVE_ENABLED=True
DRIVE_API_BASE_URL=http://drive-app-dev:8000/external_api/v1.0
DRIVE_UPLOAD_STORAGE_NETLOC=drive-minio:9000
# 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
@@ -102,6 +114,3 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
# Diagnostics
CONNECTION_TEST_ENABLED = True
+1 -1
View File
@@ -2,7 +2,7 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ENDPOINT_URL=meet-minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_STORAGE_BUCKET_NAME=meet-media-storage
+4 -13
View File
@@ -1,23 +1,14 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram
STT_PROVIDER=kyutai # kyutai, deepgram
ENABLE_SILERO_VAD=False
DEEPGRAM_API_KEY=your-deepgram-api-key
DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=url
KYUTAI_API_KEY=your-kyutai-api-key
VOXTRAL_VLLM_BASE_URL=wss://<host>/v1/realtime
VOXTRAL_VLLM_MODEL=voxtral-mini-4b-realtime-2602
VOXTRAL_VLLM_API_KEY=your-vllm-api-key
VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS=480
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
+1 -1
View File
@@ -2,7 +2,7 @@ APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_ENDPOINT_URL="meet-minio:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
+8 -7
View File
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
import json
from __future__ import unicode_literals
import re
import urllib.request
import requests
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
class GitmojiTitle(LineRule):
"""
@@ -28,9 +28,10 @@ class GitmojiTitle(LineRule):
Download the list possible gitmojis from the project's github repository and check that
title contains one of them.
"""
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response:
gitmojis = json.load(response)["gitmojis"]
emojis = [re.escape(item["emoji"]) for item in gitmojis]
gitmojis = requests.get(
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
).json()["gitmojis"]
emojis = [item["emoji"] for item in gitmojis]
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
if not re.search(pattern, title):
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
+8 -11
View File
@@ -9,8 +9,8 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.0",
"core-js": "3.49.0",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -6863,14 +6863,11 @@
}
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -9367,9 +9364,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
+2 -2
View File
@@ -26,8 +26,8 @@
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.0",
"core-js": "3.49.0",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+16 -53
View File
@@ -1,7 +1,6 @@
"""Multi user transcription agent."""
import asyncio
import contextlib
import logging
import os
@@ -26,7 +25,6 @@ from livekit.agents import (
)
from livekit.plugins import deepgram, silero
import voxtral_vllm_stt
from observability import configure_sentry, set_job_context
from tasks import done_callback
@@ -38,18 +36,9 @@ TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcr
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
SESSION_DRAIN_TIMEOUT_S = 15.0
def create_stt_provider(vad: silero.VAD | None = None):
"""Create STT provider based on environment configuration.
Args:
vad: Shared, prewarmed VAD instance. Required in practice for
voxtral-vllm (no server-side endpointing): if omitted, the plugin
loads its own Silero model synchronously on the event loop, once
per participant, freezing all active sessions for the duration.
"""
def create_stt_provider():
"""Create STT provider based on environment configuration."""
if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
@@ -60,9 +49,6 @@ def create_stt_provider(vad: silero.VAD | None = None):
)
elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
elif STT_PROVIDER == "voxtral-vllm":
# The plugin resolves base_url / model / api_key from the environment.
_stt_instance = voxtral_vllm_stt.STT(vad=vad)
else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
@@ -72,9 +58,9 @@ def create_stt_provider(vad: silero.VAD | None = None):
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str, vad: silero.VAD | None = None):
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
stt = create_stt_provider(vad=vad)
stt = create_stt_provider()
super().__init__(
instructions="not-needed",
@@ -90,7 +76,6 @@ class MultiUserTranscriber:
"""Init multi user transcription agent."""
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._starting: dict[str, asyncio.Task] = {}
self._tasks: set[asyncio.Task] = set()
def start(self):
@@ -111,30 +96,22 @@ class MultiUserTranscriber:
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session."""
identity = participant.identity
if identity in self._sessions or identity in self._starting:
if participant.identity in self._sessions:
return
logger.info(f"starting session for {identity}")
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._starting[identity] = task
self._tasks.add(task)
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"start transcription session for {identity}",
f"start transcription session for {participant.identity}",
)
)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (start_task := self._starting.pop(participant.identity, None)) is not None:
logger.info(f"cancelling pending session start for {participant.identity}")
start_task.cancel()
return
if (session := self._sessions.pop(participant.identity, None)) is None:
return
@@ -150,12 +127,10 @@ class MultiUserTranscriber:
)
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant.
"""Create and start transcription session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
Deduplication happens synchronously in on_participant_connected via
self._starting; by the time this coroutine runs, the identity is
already reserved.
"""
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
room_io = RoomIO(
@@ -166,30 +141,18 @@ class MultiUserTranscriber:
text_input=False, audio_output=False, text_output=True
),
)
try:
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
vad=vad,
)
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
)
except BaseException:
with contextlib.suppress(Exception):
await session.aclose()
raise
)
self._sessions[participant.identity] = session
return session
async def _close_session(self, sess: AgentSession) -> None:
"""Close and cleanup transcription session."""
try:
await asyncio.wait_for(sess.drain(), timeout=SESSION_DRAIN_TIMEOUT_S)
except (TimeoutError, asyncio.TimeoutError):
logger.warning(
"session drain timed out after %.0fs; forcing close",
SESSION_DRAIN_TIMEOUT_S,
)
await sess.drain()
await sess.aclose()
+6 -8
View File
@@ -1,24 +1,22 @@
[project]
name = "agents"
version = "1.31.0"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.6.4",
"livekit-plugins-deepgram==1.6.4",
"livekit-plugins-silero==1.6.4",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"websockets==17.1",
"httpx==0.28.1",
"sentry-sdk==2.60.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.15.19",
]
[tool.uv]
+680 -961
View File
File diff suppressed because it is too large Load Diff
-476
View File
@@ -1,476 +0,0 @@
"""LiveKit STT plugin for Voxtral Realtime served via vLLM (/v1/realtime).
vLLM exposes Voxtral Realtime over a WebSocket that follows the OpenAI Realtime
API protocol (not Mistral's proprietary realtime protocol).
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import weakref
from collections import deque
from dataclasses import dataclass, field
import websockets
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
stt,
utils,
)
from livekit.agents import (
vad as vad_module,
)
from livekit.agents.types import NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
logger = logging.getLogger("voxtral-vllm-stt")
SAMPLE_RATE = 16000
NUM_CHANNELS = 1
CHUNK_SAMPLES = 1600 # 100 ms @ 16 kHz mono
PREROLL_CHUNKS = 5 # keep 500 ms of audio before start of speech as detected by VAD
# Reconnect policy: exponential backoff capped at MAX, give up after MAX_ATTEMPTS
# consecutive failures (a successful handshake resets the counter).
RECONNECT_BACKOFF_BASE_S = 0.5
RECONNECT_BACKOFF_MAX_S = 8.0
RECONNECT_MAX_ATTEMPTS = 5
@dataclass
class _STTOptions:
base_url: str
model: str
api_key: str | None
target_streaming_delay_ms: int | None
@dataclass
class _PendingUtterance:
"""An utterance in flight on the shared websocket used for reconnect.
`sent_chunks` holds every chunk we have already enqueued for send on this
or a prior connection; on reconnect we replay them before resuming reads
from `queue`. vLLM concatenates `input_audio_buffer.append` events into a
single audio buffer per generation, so duplicates from a partial prior send
are harmless.
"""
queue: asyncio.Queue[bytes | None]
sent_chunks: list[bytes] = field(default_factory=list)
ended: bool = False
class STT(stt.STT):
"""LiveKit STT speaking the OpenAI Realtime protocol served by vLLM."""
def __init__(
self,
*,
base_url: NotGivenOr[str] = NOT_GIVEN,
model: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
target_streaming_delay_ms: NotGivenOr[int] = NOT_GIVEN,
vad: vad_module.VAD | None = None,
) -> None:
"""Build the STT.
Args:
base_url: WebSocket URL of the vLLM realtime endpoint, e.g.
ws://example:8000/v1/realtime. Falls back to $VOXTRAL_VLLM_BASE_URL.
model: Model name exposed by vLLM, default
mistralai/Voxtral-Mini-4B-Realtime-2602.
api_key: Optional bearer token. Falls back to $VOXTRAL_VLLM_API_KEY.
target_streaming_delay_ms: Target streaming delay in ms forwarded to
vLLM via session.update. Falls back to
$VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS, else server default.
vad: Voice Activity Detector. If omitted, Silero VAD is loaded.
"""
super().__init__(
capabilities=stt.STTCapabilities(streaming=True, interim_results=True)
)
resolved_url = (
base_url
if is_given(base_url)
else os.environ.get(
"VOXTRAL_VLLM_BASE_URL", "ws://127.0.0.1:8000/v1/realtime"
)
)
resolved_model = (
model
if is_given(model)
else os.environ.get(
"VOXTRAL_VLLM_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602"
)
)
resolved_key = (
api_key if is_given(api_key) else os.environ.get("VOXTRAL_VLLM_API_KEY")
)
resolved_delay = (
target_streaming_delay_ms
if is_given(target_streaming_delay_ms)
else (
int(os.environ["VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS"])
if os.environ.get("VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS")
else None
)
)
if vad is None:
try:
from livekit.plugins.silero import VAD as SileroVAD # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"livekit-plugins-silero is required for vLLM Voxtral realtime "
"(no server-side endpointing)."
) from exc
vad = SileroVAD.load()
self._vad = vad
self._opts = _STTOptions(
base_url=resolved_url,
model=resolved_model,
api_key=resolved_key,
target_streaming_delay_ms=resolved_delay,
)
self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
@property
def model(self) -> str:
"""Return the configured vLLM model name."""
return self._opts.model
@property
def provider(self) -> str:
"""Return the provider identifier."""
return "vllm-voxtral-realtime"
async def _recognize_impl(self, *_args, **_kwargs) -> stt.SpeechEvent:
raise NotImplementedError(
"vLLM Voxtral Realtime STT only supports streaming recognition."
)
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> SpeechStream:
"""Open a new streaming recognition stream."""
s = SpeechStream(
stt=self,
opts=self._opts,
vad_instance=self._vad,
conn_options=conn_options,
)
self._streams.add(s)
return s
class SpeechStream(stt.RecognizeStream):
"""Voxtral realtime handler."""
def __init__(
self,
*,
stt: STT,
opts: _STTOptions,
vad_instance: vad_module.VAD,
conn_options: APIConnectOptions,
) -> None:
"""Init the speech stream."""
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
self._opts = opts
self._vad = vad_instance
self._utterance_q: asyncio.Queue[bytes | None] | None = None
self._speaking = False
self._preroll: deque[bytes] = deque(maxlen=PREROLL_CHUNKS)
# Voxtral realtime is strictly sequential: only one generation runs at a
# time, and a new `commit` is ignored while the previous one is still
# producing. We queue per-utterance audio buffers here and let the
# pipeline process them one by one on the shared websocket.
self._utterance_chan: asyncio.Queue[asyncio.Queue[bytes | None] | None] = (
asyncio.Queue()
)
@utils.log_exceptions(logger=logger)
async def _run(self) -> None:
vad_stream = self._vad.stream()
bstream = utils.audio.AudioByteStream(
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=CHUNK_SAMPLES,
)
async def input_task() -> None:
async for data in self._input_ch:
if isinstance(data, self._FlushSentinel):
for frame in bstream.flush():
self._handle_chunk(frame.data.tobytes())
continue
vad_stream.push_frame(data)
for frame in bstream.write(data.data.tobytes()):
self._handle_chunk(frame.data.tobytes())
vad_stream.end_input()
async def vad_task() -> None:
async for ev in vad_stream:
if ev.type == vad_module.VADEventType.START_OF_SPEECH:
self._on_start_of_speech()
elif ev.type == vad_module.VADEventType.END_OF_SPEECH:
self._on_end_of_speech()
pipeline_t = asyncio.create_task(self._utterance_pipeline())
try:
await asyncio.gather(input_task(), vad_task())
# signal end-of-stream; pipeline finishes pending utterances first
self._utterance_chan.put_nowait(None)
await pipeline_t
except (APIStatusError, APIConnectionError, asyncio.CancelledError):
raise
except Exception as exc:
logger.exception("vLLM realtime stream failed")
raise APIConnectionError() from exc
finally:
if not pipeline_t.done():
pipeline_t.cancel()
try:
await pipeline_t
except asyncio.CancelledError:
# CancelledError is the expected flow on cancel()
pass
except Exception:
logger.exception("utterance pipeline failed during finalize")
await vad_stream.aclose()
def _handle_chunk(self, chunk: bytes) -> None:
self._preroll.append(chunk)
if self._speaking and self._utterance_q is not None:
self._utterance_q.put_nowait(chunk)
def _on_start_of_speech(self) -> None:
if self._speaking:
return
self._speaking = True
q: asyncio.Queue[bytes | None] = asyncio.Queue()
for chunk in self._preroll:
q.put_nowait(chunk)
self._utterance_q = q
self._utterance_chan.put_nowait(q)
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
)
def _on_end_of_speech(self) -> None:
if not self._speaking:
return
self._speaking = False
if self._utterance_q is not None:
self._utterance_q.put_nowait(None)
self._utterance_q = None
self._event_ch.send_nowait(
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
)
async def _handshake(self, ws: websockets.ClientConnection) -> str:
created = json.loads(await ws.recv())
if created.get("type") != "session.created":
raise APIStatusError(
f"expected session.created, got {created}",
status_code=500,
body=created,
)
session_update: dict = {"type": "session.update", "model": self._opts.model}
if self._opts.target_streaming_delay_ms is not None:
session_update["target_streaming_delay_ms"] = (
self._opts.target_streaming_delay_ms
)
await ws.send(json.dumps(session_update))
return created.get("id", "")
def _auth_headers(self) -> dict[str, str]:
if self._opts.api_key:
return {"Authorization": f"Bearer {self._opts.api_key}"}
return {}
async def _utterance_pipeline(self) -> None:
# Owns the websocket lifecycle. On drop, reopens and resumes the
# in-flight utterance (if any) by replaying its already-sent chunks.
pending: _PendingUtterance | None = None
attempt = 0
while True:
try:
async with websockets.connect(
self._opts.base_url,
additional_headers=self._auth_headers(),
open_timeout=self._conn_options.timeout,
) as ws:
request_id = await self._handshake(ws)
attempt = 0
while True:
if pending is None:
q = await self._utterance_chan.get()
if q is None:
return
pending = _PendingUtterance(queue=q)
await self._process_utterance(ws, pending, request_id)
pending = None
except (websockets.WebSocketException, OSError, TimeoutError) as exc:
attempt += 1
if attempt > RECONNECT_MAX_ATTEMPTS:
logger.exception(
"vLLM realtime: giving up after %d reconnect attempts",
RECONNECT_MAX_ATTEMPTS,
)
raise APIConnectionError() from exc
backoff = min(
RECONNECT_BACKOFF_BASE_S * (2 ** (attempt - 1)),
RECONNECT_BACKOFF_MAX_S,
)
if pending is None:
logger.warning(
"vLLM WS connection lost between utterances "
"(attempt %d/%d): %s; retrying in %.1fs",
attempt,
RECONNECT_MAX_ATTEMPTS,
exc,
backoff,
)
else:
logger.warning(
"vLLM WS dropped mid-utterance (%d chunks buffered, "
"ended=%s, attempt %d/%d): %s; retrying in %.1fs",
len(pending.sent_chunks),
pending.ended,
attempt,
RECONNECT_MAX_ATTEMPTS,
exc,
backoff,
)
await asyncio.sleep(backoff)
async def _process_utterance(
self,
ws: websockets.ClientConnection,
pending: _PendingUtterance,
request_id: str,
) -> None:
# Start a fresh generation. Safe to send here: the previous utterance's
# transcription.done has already been received (we await it below), so
# the server-side generation_task is done and won't ignore this commit.
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
send_t = asyncio.create_task(self._send_audio(ws, pending))
try:
await self._receive_one_transcription(ws, request_id)
finally:
if not send_t.done():
send_t.cancel()
try:
await send_t
except (asyncio.CancelledError, websockets.WebSocketException):
pass
except Exception:
logger.exception("send-audio task failed during finalize")
@staticmethod
async def _send_audio(
ws: websockets.ClientConnection, pending: _PendingUtterance
) -> None:
# Replay anything already sent on a previous (now-dead) connection.
# sent_chunks is appended before send, so a chunk that failed to send
# last time is still present and gets retried here.
for chunk in pending.sent_chunks:
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
}
)
)
if pending.ended:
await ws.send(
json.dumps({"type": "input_audio_buffer.commit", "final": True})
)
return
while True:
chunk = await pending.queue.get()
if chunk is None:
pending.ended = True
await ws.send(
json.dumps({"type": "input_audio_buffer.commit", "final": True})
)
return
pending.sent_chunks.append(chunk)
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
}
)
)
async def _receive_one_transcription(
self, ws: websockets.ClientConnection, request_id: str
) -> None:
# Use recv() rather than `async for`: the latter swallows
# ConnectionClosed on close-mid-iteration, which would let a dropped
# WS look like a clean "no transcription" return.
current_text = ""
while True:
raw = await ws.recv()
data = json.loads(raw)
event_type = data.get("type")
if event_type == "transcription.delta":
delta = data.get("delta", "")
if not delta:
continue
current_text += delta
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
request_id=request_id,
alternatives=[stt.SpeechData(text=current_text, language="")],
)
)
elif event_type == "transcription.done":
final_text = data.get("text") or current_text
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
request_id=request_id,
alternatives=[stt.SpeechData(text=final_text, language="")],
)
)
usage = data.get("usage") or {}
self._event_ch.send_nowait(
stt.SpeechEvent(
type=stt.SpeechEventType.RECOGNITION_USAGE,
request_id=request_id,
recognition_usage=stt.RecognitionUsage(
audio_duration=float(
usage.get("audio_seconds")
or usage.get("prompt_audio_seconds")
or 0
),
input_tokens=int(usage.get("prompt_tokens") or 0),
output_tokens=int(usage.get("completion_tokens") or 0),
),
)
)
return
elif event_type == "error":
err = data.get("error")
raise APIStatusError(str(err), status_code=500, body=data)
+45 -3
View File
@@ -3,25 +3,48 @@
from django import forms
from django.contrib import admin, messages
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 core.recording.event import notification
from . import models
from .tasks.file import process_file_deletion
from .utils import generate_download_s3_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)
class UserAdmin(auth_admin.UserAdmin):
@@ -123,6 +146,7 @@ class FileAdmin(admin.ModelAdmin):
"creator",
"upload_state",
"deleted_at",
"hard_deleted_at",
"created_at",
"updated_at",
)
@@ -132,6 +156,7 @@ class FileAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
)
search_fields = (
"id",
@@ -149,6 +174,7 @@ class FileAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
"description",
"malware_detection_info",
"is_ready",
@@ -187,7 +213,15 @@ class FileAdmin(admin.ModelAdmin):
)
},
),
(_("Deletion"), {"fields": ("deleted_at",)}),
(
_("Deletion"),
{
"fields": (
"deleted_at",
"hard_deleted_at",
)
},
),
(
_("Derived info"),
{
@@ -214,10 +248,18 @@ class FileAdmin(admin.ModelAdmin):
'<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):
"""Delete one by one so storage is cleaned up too."""
"""Hard delete all selected files."""
for file in queryset:
file.delete()
hard_delete_file(file)
def has_add_permission(self, request):
return False
-1
View File
@@ -8,7 +8,6 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
-2
View File
@@ -65,13 +65,11 @@ def get_frontend_configuration(request):
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
},
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
+1 -1
View File
@@ -11,12 +11,12 @@ class FeatureFlag:
FLAGS = {
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
}
@classmethod
+2 -47
View File
@@ -5,7 +5,7 @@ from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices, RoomAccessLevel
from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
@@ -134,7 +134,7 @@ class FilePermission(IsAuthenticated):
Return a 404 on deleted files or if the user is not the owner
"""
if obj.is_deleted:
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
raise Http404
if obj.creator != request.user:
@@ -198,48 +198,3 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
except ParticipantsManagementException:
return False
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to manage this room's lobby."
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
if obj.is_administrator_or_owner(user):
return True
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+2
View File
@@ -456,6 +456,7 @@ class ListFileSerializer(serializers.ModelSerializer):
"type",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
@@ -470,6 +471,7 @@ class ListFileSerializer(serializers.ModelSerializer):
"updated_at",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
-12
View File
@@ -85,15 +85,3 @@ class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
"""
scope = "roomkit_join"
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
scope = "connection_test"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
+140 -106
View File
@@ -2,10 +2,8 @@
# pylint: disable=too-many-lines
import uuid
from datetime import timedelta
from logging import getLogger
from urllib.parse import unquote, urlparse
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -29,9 +27,6 @@ from rest_framework import (
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
permissions as drf_permissions,
)
from rest_framework import (
response as drf_response,
)
@@ -41,15 +36,28 @@ from rest_framework import (
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.parsers import get_parser
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
)
from core.recording.services.recording_events import (
RecordingEventsService,
RecordingNotSavableError,
)
from core.recording.worker.exceptions import (
RecordingStartError,
RecordingStopError,
@@ -75,14 +83,17 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.utils import generate_token
from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
@@ -351,7 +362,57 @@ class RoomViewSet(
):
return
RoomManagement.sync_room_metadata(room)
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,
)
@staticmethod
def _store_drive_credentials(request, recording):
"""Keep the OIDC access token needed to push the recording to Drive later.
Pushing happens long after this request, when the egress is over and the
user may be gone, so the token has to be stored.
POC limitation: we assume the token is still valid by then. The target
design is a token exchange performed here, to get a long-lived
token narrowly scoped to that upload.
"""
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
return
if recording.mode != models.RecordingModeChoices.SCREEN_RECORDING:
# Only videos are pushed to Drive, no need for a token otherwise.
return
access_token = request.session.get("oidc_access_token")
if not access_token:
logger.warning(
"No OIDC access token in session, recording %s will not be pushed "
"to Drive. Is OIDC_STORE_ACCESS_TOKEN enabled?",
recording.id,
)
return
recording.set_owner_access_token(access_token)
@decorators.action(
detail=True,
@@ -388,6 +449,7 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
recording=recording,
)
self._store_drive_credentials(request, recording)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -498,7 +560,7 @@ class RoomViewSet(
methods=["post"],
url_path="enter",
permission_classes=[
permissions.CanManageLobby,
permissions.HasPrivilegesOnRoom,
],
)
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
@@ -536,7 +598,7 @@ class RoomViewSet(
methods=["GET"],
url_path="waiting-participants",
permission_classes=[
permissions.CanManageLobby,
permissions.HasPrivilegesOnRoom,
],
)
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
@@ -908,15 +970,6 @@ class RoomViewSet(
"""Rename the current participant in the room."""
room = self.get_object()
if (
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
and request.user.is_authenticated
):
return drf_response.Response(
{"error": "Authenticated participants cannot edit their display name"},
status=drf_status.HTTP_403_FORBIDDEN,
)
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -1005,6 +1058,56 @@ class RecordingViewSet(
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
)
@decorators.action(
detail=False,
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
parser = get_parser()
try:
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFilepathError:
return drf_response.Response(
{"message": "Notification ignored."},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
)
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
# Save recording
recording_events_service = RecordingEventsService()
try:
recording_events_service.handle_complete(recording)
except RecordingNotSavableError:
raise drf_exceptions.PermissionDenied(
f"Recording with ID {recording_id} cannot be saved because it is either,"
" in an error state or has already been saved."
) from None
return drf_response.Response(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
@@ -1061,10 +1164,9 @@ class RecordingViewSet(
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the configured header.
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1074,13 +1176,9 @@ class RecordingViewSet(
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
@@ -1198,7 +1296,7 @@ class FileViewSet(
permission_classes = [
permissions.FilePermission,
]
queryset = models.File.objects.all()
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
default_serializer_class = serializers.FileSerializer
serializer_classes = {
"list": serializers.ListFileSerializer,
@@ -1352,7 +1450,7 @@ class FileViewSet(
)
if validation_error is not None:
file.delete()
self._complete_file_deletion(file)
else:
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
@@ -1394,13 +1492,18 @@ class FileViewSet(
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
def _complete_file_deletion(self, file):
"""Delete a file completely."""
file.soft_delete()
file.hard_delete()
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
def _authorize_subrequest(self, request, pattern):
"""
Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1419,13 +1522,9 @@ class FileViewSet(
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
@@ -1496,68 +1595,3 @@ class FileViewSet(
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DiagnosticsViewSet(viewsets.ViewSet):
"""Endpoints helping users and support diagnose connectivity issues.
Diagnostics are grouped behind a single prefix so upcoming checks
(rtcstats collection, ICE candidate reports, etc.) can be added as new
actions rather than new top-level routes.
They are open to anonymous users: someone who cannot join a room is
exactly who needs to run a test, and they may well not be logged in.
Each action therefore carries its own throttle scope.
"""
permission_classes = [drf_permissions.AllowAny]
@decorators.action(
detail=False,
methods=["POST"],
url_path="connection",
url_name="connection",
throttle_classes=[
throttling.ConnectionTestUserRateThrottle,
throttling.ConnectionTestAnonRateThrottle,
],
)
@FeatureFlag.require("connection_test")
def connection(self, request):
"""Return a short-lived LiveKit token for an ephemeral test room.
Going through the room API is not an option here: it is tied to
registered meetings, lobby rules and longer-lived tokens. Each call
gets its own room so two people testing at the same time never meet.
"""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
max_age = (
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
+ settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS
)
delete_connection_test_room.apply_async(
args=[room],
countdown=max_age,
)
return drf_response.Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username="Connection Test",
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
+1 -19
View File
@@ -3,11 +3,7 @@
import contextlib
from django.conf import settings
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
@@ -21,7 +17,6 @@ from core.services.marketing import (
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -89,19 +84,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation(
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
def __init__( # noqa: PLR0917
def __init__(
self,
secret_key,
algorithm,
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
+21 -68
View File
@@ -1,6 +1,5 @@
"""External API endpoints"""
import copy
from logging import getLogger
from django.conf import settings
@@ -13,9 +12,6 @@ from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
parsers as drf_parsers,
)
from rest_framework import (
response as drf_response,
)
@@ -26,7 +22,6 @@ from rest_framework import (
from core import analytics, api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -46,7 +41,6 @@ class ApplicationViewSet(viewsets.ViewSet):
methods=["post"],
url_path="token",
url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
)
@FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs):
@@ -144,7 +138,6 @@ class RoomViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Application-delegated API for room management.
@@ -157,12 +150,8 @@ class RoomViewSet(
- list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
"""
http_method_names = ["get", "post", "patch", "head", "options"]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
@@ -196,39 +185,7 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def _track_room_event(self, room, event, **extra_properties):
"""Log a room operation for auditing and forward it to analytics."""
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
logger.info(
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
event.removeprefix("room_"),
room.id,
self.request.user.id,
client_id,
auth_method,
details,
)
analytics.capture(
self.request.user,
event,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
**extra_properties,
"$set": {"email": self.request.user.email},
},
)
def perform_create(self, serializer: serializers.RoomSerializer):
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
@@ -237,31 +194,27 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
previous_values = {
"access_level": serializer.instance.access_level,
"configuration": copy.deepcopy(serializer.instance.configuration),
}
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
field
for field, previous_value in previous_values.items()
if getattr(room, field) != previous_value
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
room.id,
self.request.user.id,
client_id,
auth_method,
)
if updated_fields:
RoomManagement.sync_room_metadata(room)
self._track_room_event(
room,
analytics.AnalyticsEvent.ROOM_UPDATED,
updated_fields=updated_fields,
previous_access_level=previous_values["access_level"],
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,
"$set": {"email": self.request.user.email},
},
)
+4
View File
@@ -48,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
"""Create fake resource user accesses for testing."""
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
recording=self, user=item[0], role=item[1]
)
self.save()
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording user accesses for testing."""
-25
View File
@@ -1,25 +0,0 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
@@ -6,6 +6,7 @@ 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):
@@ -31,19 +32,16 @@ class Command(BaseCommand):
files = File.objects.filter(
upload_state=FileUploadStateChoices.PENDING,
created_at__lt=threshold,
hard_deleted_at__isnull=True,
)
count = 0
failed = []
for file in files.iterator():
try:
file.delete()
count += 1
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
failed.append(file.pk)
self.stderr.write(f"[ERROR] Failed to clean file '{file.pk}': {exc}")
# 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).")
if failed:
raise CommandError(f"Failed to clean {len(failed)} file(s).")
@@ -3,33 +3,38 @@
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
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 files (object storage and database object) whose trash bin retention has expired."""
"""
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):
"""Delete files soft deleted for longer than the grace period."""
"""Browse purgeable files and queue them through the file deletion task."""
threshold = timezone.now() - timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
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
failed = []
for file in File.objects.filter(deleted_at__lte=threshold).iterator():
try:
file.delete()
count += 1
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
failed.append(file.pk)
self.stderr.write(f"[ERROR] Failed to purge file '{file.pk}': {exc}")
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).")
if failed:
raise CommandError(f"Failed to purge {len(failed)} file(s).")
+1 -3
View File
@@ -8,8 +8,6 @@ import uuid
from django.conf import settings
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
@@ -43,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -1,15 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0022_user_default_room_access_level_and_more"),
]
operations = [
migrations.RemoveField(
model_name="file",
name="hard_deleted_at",
),
]
+103 -24
View File
@@ -17,8 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.storage import default_storage
from django.db import models
from django.db import models, transaction
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -28,7 +27,6 @@ from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
from .validators import sub_validator
logger = getLogger(__name__)
@@ -147,11 +145,19 @@ class BaseModel(models.Model):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Printable ASCII characters only."
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -633,6 +639,17 @@ class Recording(BaseModel):
verbose_name=_("External Process ID"),
help_text=_("ID of the external process associated with the recording."),
)
owner_access_token = models.TextField(
null=True,
blank=True,
editable=False,
verbose_name=_("Owner access token"),
help_text=_(
"Encrypted OIDC access token of the user who started the recording, "
"used to push the recording to their Drive on their behalf. "
"Dropped as soon as the push has been attempted."
),
)
class Meta:
db_table = "meet_recording"
@@ -735,6 +752,39 @@ class Recording(BaseModel):
return self.expired_at < timezone.now()
def set_owner_access_token(self, access_token: str) -> None:
"""Store the OIDC access token of the user who started the recording.
It is stored encrypted, and only long enough for the worker to push the
recording to that user's Drive once the recording is over.
"""
self.owner_access_token = utils.encrypt_secret(access_token)
self.save(update_fields=["owner_access_token", "updated_at"])
def get_owner_access_token(self) -> Optional[str]:
"""Return the stored OIDC access token, or None if there is none left."""
if not self.owner_access_token:
return None
try:
return utils.decrypt_secret(self.owner_access_token)
except utils.SecretDecryptionError:
logger.exception(
"Could not decrypt the access token of recording %s", self.id
)
return None
def clear_owner_access_token(self) -> None:
"""Drop the stored access token, it is a user credential."""
if self.owner_access_token is None:
return
self.owner_access_token = None
self.save(update_fields=["owner_access_token", "updated_at"])
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
@@ -917,6 +967,7 @@ class File(BaseModel):
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
hard_deleted_at = models.DateTimeField(null=True, blank=True)
filename = models.CharField(max_length=255, null=False, blank=False)
@@ -954,10 +1005,11 @@ class File(BaseModel):
return super().save(*args, **kwargs)
@property
def is_deleted(self):
"""Return whether the file is in the trash bin."""
return self.deleted_at is not None
def delete(self, using=None, keep_parents=False):
if self.deleted_at is None:
raise RuntimeError("The file must be soft deleted before being deleted.")
return super().delete(using, keep_parents)
@property
def is_ready(self):
@@ -1017,33 +1069,60 @@ class File(BaseModel):
"""
Compute and return abilities for a given user on the file.
"""
# Characteristics that are based only on specific access
is_creator = user == self.creator
can_edit = is_creator and not self.is_deleted
retrieve = is_creator
is_deleted = self.deleted_at is not None
can_update = is_creator and not is_deleted and user.is_authenticated
can_hard_delete = is_creator and user.is_authenticated
can_destroy = can_hard_delete and not is_deleted
return {
"destroy": can_edit,
"retrieve": is_creator,
"media_auth": can_edit,
"partial_update": can_edit,
"update": can_edit,
"upload_ended": can_edit,
"destroy": can_destroy,
"hard_delete": can_hard_delete,
"retrieve": retrieve,
"media_auth": retrieve and not is_deleted,
"partial_update": can_update,
"update": can_update,
"upload_ended": can_update and user.is_authenticated,
}
@transaction.atomic
def soft_delete(self):
"""Move the file to the trash bin."""
"""
Soft delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at:
raise RuntimeError("This file is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def delete(self, using=None, keep_parents=False):
def hard_delete(self):
"""
Remove the file's objects from storage, then its row from the database.
Hard delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.hard_deleted_at:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("This file is already hard deleted."),
code="file_hard_delete_already_effective",
)
}
)
Storage is removed first so that a storage failure leaves the row in place
and the periodic purge commands retry it on their next run.
"""
default_storage.delete(self.temporary_file_key) # Pending
default_storage.delete(self.file_key) # Final
return super().delete(using, keep_parents)
if self.deleted_at is None:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("To hard delete a file, it must first be soft deleted."),
code="file_hard_delete_should_soft_delete_first",
)
}
)
self.hard_deleted_at = timezone.now()
self.save(update_fields=["hard_deleted_at"])
@@ -1,4 +1,4 @@
"""Authentication classes for server-to-server webhook token validation."""
"""Authentication class for storage event token validation."""
import logging
import secrets
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated operations."""
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self, username: str = "machine_user") -> None:
def __init__(self, username: str = "storage_event_user") -> None:
self.pk = None
self.username = username
self.is_active = True
@@ -41,17 +41,24 @@ class HeaderBasedAuthentication(BaseAuthentication):
TOKEN_TYPE = "Bearer" # noqa S105
REALM = ""
IS_ENFORCED_SETTINGS_KEY = None
EXPECTED_TOKEN_SETTINGS_KEY = None
def authenticate(self, request):
"""Validate the Bearer token from the Authorization header."""
if self.IS_ENFORCED_SETTINGS_KEY is not None:
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
return MachineUser(), None
if (
self.EXPECTED_TOKEN_SETTINGS_KEY is None
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
is None
):
raise AuthenticationFailed("Authentication token is not configured.")
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
@@ -81,6 +88,18 @@ class HeaderBasedAuthentication(BaseAuthentication):
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
class StorageEventAuthentication(HeaderBasedAuthentication):
"""Authenticate requests using a Bearer token for storage event integration.
This class validates Bearer tokens for storage events that don't map to database users.
It's designed for S3-compatible storage integrations and similar use cases.
Events are submitted when a webhook is configured on some bucket's events.
"""
REALM = "Storage event API"
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
"""
Custom authentication class for recording process webhook requests.
@@ -0,0 +1,17 @@
"""Storage parsers specific exceptions."""
class ParsingEventDataError(Exception):
"""Raised when the request data is malformed, incomplete, or missing."""
class InvalidBucketError(Exception):
"""Raised when the bucket name in the request does not match the expected one."""
class InvalidFileTypeError(Exception):
"""Raised when the file type in the request is not supported."""
class InvalidFilepathError(Exception):
"""Raised when the filepath in the request is invalid."""
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
@@ -19,6 +19,7 @@ from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.tasks.push_recording import push_recording
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -45,7 +46,15 @@ class NotificationService:
"""Service for processing recordings and notifying external services."""
def notify_external_services(self, recording):
"""Process a recording based on its mode."""
"""Process a recording, then push the video to the owner's Drive."""
try:
return self._notify_by_mode(recording)
finally:
self._push_recording_to_drive(recording)
def _notify_by_mode(self, recording):
"""Route a recording to the services its mode calls for."""
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
return self._notify_summary_service(recording)
@@ -121,7 +130,7 @@ class NotificationService:
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
@@ -192,7 +201,7 @@ class NotificationService:
"""Generate title from context or return default."""
if recording_datetime is None:
with override(locale):
return gettext("Transcription")
return _("Transcription")
dt = recording_datetime
if owner_timezone:
@@ -222,6 +231,31 @@ class NotificationService:
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
)
@staticmethod
def _push_recording_to_drive(recording: models.Recording):
"""Hand the recording over to the task pushing it to the owner's Drive."""
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
return
if recording.mode != models.RecordingModeChoices.SCREEN_RECORDING:
recording.clear_owner_access_token()
return
if not recording.owner_access_token:
logger.warning(
"No access token stored for recording %s, skipping the Drive push",
recording.id,
)
return
try:
push_recording.delay(str(recording.id))
except Exception: # pylint: disable=broad-except
logger.exception(
"Could not schedule the Drive push of recording %s", recording.id
)
@staticmethod
def _notify_summary_service_v1(recording: models.Recording):
"""Notify summary service about a new recording."""
+178
View File
@@ -0,0 +1,178 @@
"""Meet storage event parser classes."""
import logging
import mimetypes
import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Optional, Protocol
from urllib.parse import quote
from django.conf import settings
from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
# Additional MIME type mapping
mimetypes.add_type("audio/ogg", ".ogg")
logger = logging.getLogger(__name__)
@dataclass
class StorageEvent:
"""Represents a storage event with relevant metadata.
Attributes:
filepath: Identifier for the affected recording
filetype: Type of storage event
bucket_name: When the event occurred
metadata: Additional event data
"""
filepath: str
filetype: str
bucket_name: str
metadata: Optional[Dict[str, Any]]
def __post_init__(self):
if self.filepath is None:
raise TypeError("filepath cannot be None")
if self.filetype is None:
raise TypeError("filetype cannot be None")
if self.bucket_name is None:
raise TypeError("bucket_name cannot be None")
class EventParser(Protocol):
"""Interface for parsing storage events."""
def __init__(self, bucket_name, allowed_filetypes=None):
"""Initialize parser with bucket name and optional allowed filetypes."""
def parse(self, data: Dict) -> StorageEvent:
"""Extract storage event data from raw dictionary input."""
def validate(self, data: StorageEvent) -> str:
"""Verify storage event data meets all requirements."""
def get_recording_id(self, data: Dict) -> str:
"""Extract recording ID from event dictionary."""
@lru_cache(maxsize=1)
def get_parser() -> EventParser:
"""Return cached instance of configured event parser.
Uses function memoization instead of a factory class since the only
varying parameter is the parser class from settings. A factory class
would add unnecessary complexity when a cached function provides the
same singleton behavior with simpler code.
"""
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
class BaseS3Parser:
"""Base class for handling parsing and validation of S3-compatible storage events."""
def __init__(self, bucket_name: str, allowed_filetypes=None):
"""Initialize parser with target bucket name and accepted filetypes."""
if not bucket_name:
raise ValueError("Bucket name cannot be None or empty")
self._bucket_name = bucket_name
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
def validate(self, event_data: StorageEvent) -> str:
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
if event_data.bucket_name != self._bucket_name:
raise InvalidBucketError(
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if event_data.filetype not in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
)
match = self._filepath_regex.match(event_data.filepath)
if not match:
raise InvalidFilepathError(
f"Invalid filepath structure: {event_data.filepath}"
)
recording_id = match.group("recording_id")
return recording_id
def get_recording_id(self, data):
"""Extract recording ID from S3 event through parsing and validation."""
event_data = self.parse(data)
return self.validate(event_data)
def parse(self, data: Dict) -> StorageEvent:
"""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
@@ -44,7 +44,7 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
RoomManagement.update_metadata(
RoomManagement().update_metadata(
room_name, {"recording_status": recording_status}
)
except RoomNotFoundException:
@@ -107,4 +107,4 @@ class RecordingEventsService:
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.save()
recording.save(update_fields=["status", "updated_at"])
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode
try:
RoomManagement.update_metadata(
RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except RoomNotFoundException:
+181
View File
@@ -0,0 +1,181 @@
"""Client for La Suite Drive's external API (OIDC resource server).
Drive exposes `/external_api/v1.0/*` to applications holding an user's OIDC
access token.
"""
import logging
from urllib.parse import urlparse, urlunparse
from django.conf import settings
import requests
logger = logging.getLogger(__name__)
# (connect, read) timeouts, in seconds. The upload one covers a
# whole recording being relayed to Drive's object storage.
API_TIMEOUT = (10, 30)
UPLOAD_TIMEOUT = (10, 1800)
class DriveError(Exception):
"""Raised when Drive's external API cannot fulfill a request."""
class SizedStream:
"""Read-only byte stream of a known size, suitable as a `requests` body.
`requests` falls back to a chunked transfer encoding when it cannot guess the
body size upfront, which presigned S3 uploads reject. Advertising the size
through `__len__` makes it send a plain `Content-Length` instead, while the
underlying stream is still consumed chunk by chunk.
"""
def __init__(self, stream, length: int):
"""Wrap `stream`, whose full content is `length` bytes long."""
self._stream = stream
self._length = length
def __len__(self) -> int:
"""Return the total size of the stream, in bytes."""
return self._length
def __iter__(self):
"""Iterate over the stream, required for `requests` to stream the body."""
return iter(self._stream)
def read(self, amt=None) -> bytes:
"""Read up to `amt` bytes from the stream."""
return self._stream.read(amt)
class DriveClient:
"""Access Drive's external API on behalf of a user.
The client is bound to a single user access token: every call is performed
as that user, and Drive applies its own permissions accordingly.
"""
def __init__(self, access_token: str, *, base_url: str | None = None):
"""Prepare a session authenticated with the user's OIDC access token."""
self._base_url = (base_url or settings.DRIVE_API_BASE_URL or "").rstrip("/")
if not self._base_url:
raise DriveError(
"Drive API is not configured, set DRIVE_API_BASE_URL to enable it."
)
if not access_token:
raise DriveError("An access token is required to call Drive.")
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
)
def __enter__(self):
"""Allow use as a context manager, closing the session on exit."""
return self
def __exit__(self, *args):
"""Close the underlying HTTP session."""
self.close()
def close(self):
"""Release the underlying HTTP session."""
self._session.close()
def _request(self, method, path, **kwargs):
"""Perform an authenticated call to the external API and return its body."""
url = f"{self._base_url}{path}"
kwargs.setdefault("timeout", API_TIMEOUT)
try:
response = self._session.request(method, url, **kwargs)
response.raise_for_status()
except requests.RequestException as exc:
raise DriveError(f"Drive call failed: {method} {url}") from exc
if not response.content:
return None
try:
return response.json()
except ValueError as exc:
raise DriveError(f"Drive returned a non-JSON body for {url}") from exc
def create_file(self, *, filename: str) -> dict:
"""Create a file item at the root of the user's Drive and return it.
The returned item carries a `policy`: the presigned URL the content has
to be uploaded to.
"""
item = self._request(
"POST",
"/items/",
json={"type": "file", "filename": filename},
)
if not item or not item.get("policy"):
raise DriveError(
f"Drive did not return an upload policy for file '{filename}'."
)
return item
@staticmethod
def _resolve_upload_target(policy_url: str) -> tuple[str, str | None]:
"""Return the address to connect to, and the `Host` header to send.
Drive signs its upload URLs with the object storage domain meant for
browsers, which may not resolve from dev split docker compose setup.
The signature covers the `Host` header, so we swap the address we connect
to but keep announcing the original host.
"""
override = settings.DRIVE_UPLOAD_STORAGE_NETLOC
if not override:
return policy_url, None
parsed = urlparse(policy_url)
return urlunparse(parsed._replace(netloc=override)), parsed.netloc
def upload_content(self, *, policy_url: str, stream, content_length, content_type):
"""Push `stream` to the presigned URL, without buffering it as a whole."""
url, host_header = self._resolve_upload_target(policy_url)
headers = {
"Content-Type": content_type,
"Content-Length": str(content_length),
"x-amz-acl": "private",
}
if host_header:
headers["Host"] = host_header
try:
# A bare `requests.put`, not the authenticated session: the presigned
# URL carries its own credentials
response = requests.put(
url,
data=SizedStream(stream, content_length),
headers=headers,
timeout=UPLOAD_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
raise DriveError("Upload to Drive's object storage failed.") from exc
def complete_upload(self, item_id: str) -> None:
"""Inform Drive that the upload is over, making the file available."""
self._request("POST", f"/items/{item_id}/upload-ended/", json={})
+1 -1
View File
@@ -30,7 +30,7 @@ class TokenDecodeError(JWTError):
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__( # noqa: PLR0917
def __init__(
self,
secret_key: str,
algorithm: str,
+15 -48
View File
@@ -23,7 +23,6 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
from .presence import PresenceCache
from .room_management import (
RoomManagement,
RoomManagementException,
@@ -52,6 +51,12 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete."""
@@ -68,7 +73,6 @@ class LiveKitWebhookEventType(Enum):
# Participant events
PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events
TRACK_PUBLISHED = "track_published"
@@ -95,7 +99,6 @@ class LiveKitEventsService:
"egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started,
"room_finished": self._handle_room_finished,
"participant_left": self._handle_participant_left,
}
token_verifier = api.TokenVerifier(
@@ -104,7 +107,6 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.presence_cache = PresenceCache()
self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService()
@@ -135,26 +137,16 @@ class LiveKitEventsService:
room_name = data.room.name or data.egress_info.room_name
if self._is_connection_test_room(room_name):
logger.info(
"Ignoring webhook event for connection test room '%s'.",
room_name,
)
return
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError:
logger.warning(
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
data.event,
room_name,
)
return
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
# Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value)
@@ -190,7 +182,7 @@ class LiveKitEventsService:
try:
room_name = str(recording.room.id)
RoomManagement.update_metadata(
RoomManagement().update_metadata(
room_name, remove_keys=["recording_mode", "recording_status"]
)
except RoomNotFoundException:
@@ -218,8 +210,10 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
# Finalize the recording, the egress has uploaded the file to the storage
if data.egress_info.status in [
# 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,
]:
@@ -234,11 +228,6 @@ class LiveKitEventsService:
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -284,31 +273,9 @@ class LiveKitEventsService:
f"Failed to delete sip dispatch rule for room {room_id}"
) from e
self.presence_cache.clear_room(room_id)
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
def _handle_participant_left(self, data):
"""Handle 'participant_left': invalidate the presence cache.
Presence entries are created lazily (only for users who administrate
the lobby of a trusted room), so for most participants this delete is
a no-op DEL on a key that never existed. Eager invalidation shrinks
the window during which a departed participant could still act on a
trusted room's lobby (cache hit until TTL expiry). It is gated behind
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
measured and the behaviour reverted independently of the feature.
When disabled, invalidation relies on `room_finished` and the TTL.
"""
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
return
identity = data.participant.identity
if not identity:
return
self.presence_cache.clear(data.room.name, identity)
+21 -84
View File
@@ -4,12 +4,11 @@ import logging
import uuid
from dataclasses import dataclass
from enum import Enum
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from core import models, utils
@@ -47,7 +46,6 @@ class LobbyParticipant:
username: str
color: str
id: str
entered_at: str
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
@@ -56,7 +54,6 @@ class LobbyParticipant:
"username": self.username,
"id": self.id,
"color": self.color,
"entered_at": self.entered_at,
}
@classmethod
@@ -71,7 +68,6 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
entered_at=data["entered_at"],
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -90,47 +86,6 @@ class LobbyService:
"""Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_index_key(room_id: UUID) -> str:
"""Raw Redis key of the per-room participant index (a native SET)."""
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_add(self, room_id: UUID, participant_id: str) -> None:
"""Record a participant id in the room index."""
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, participant_id)
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
pipe.execute()
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
"""All participant ids currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return frozenset(
member.decode() if isinstance(member, bytes) else member
for member in members
)
def _index_touch(self, room_id: UUID) -> None:
"""Re-arm the room index backstop TTL."""
self._redis().expire(
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
)
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
"""Drop participant ids from the room index."""
if participant_ids:
self._redis().srem(self._get_index_key(room_id), *participant_ids)
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
@@ -207,7 +162,6 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -255,12 +209,15 @@ class LobbyService:
cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
)
self._index_touch(room_id)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""Add participant to waiting lobby."""
"""Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room
participants of the new entry request.
"""
color = utils.generate_color(participant_id)
@@ -269,7 +226,6 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
entered_at=timezone.now().isoformat(),
)
try:
@@ -289,7 +245,6 @@ class LobbyService:
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
self._index_add(room_id, participant_id)
return participant
@@ -311,42 +266,28 @@ class LobbyService:
cache.delete(cache_key)
return None
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
"""List all waiting participants for a room."""
member_ids = self._index_members(room_id)
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not member_ids:
return ()
if not keys:
return []
keys_by_id = {
participant_id: self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
}
data = cache.get_many(list(keys_by_id.values()))
data = cache.get_many(keys)
dead_ids = []
waiting_participants = []
for participant_id, cache_key in keys_by_id.items():
raw_participant = data.get(cache_key)
if raw_participant is None:
dead_ids.append(participant_id)
continue
for cache_key, raw_participant in data.items():
try:
participant = LobbyParticipant.from_dict(raw_participant)
except LobbyParticipantParsingError:
cache.delete(cache_key)
dead_ids.append(participant_id)
continue
if participant.status == LobbyParticipantStatus.WAITING:
waiting_participants.append(participant.to_dict())
self._index_remove(room_id, *dead_ids)
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
return tuple(waiting_participants)
return waiting_participants
def handle_participant_entry(
self,
@@ -400,24 +341,20 @@ class LobbyService:
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
self._index_touch(room_id)
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
member_ids = self._index_members(room_id)
if member_ids:
cache.delete_many(
[
self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
]
)
self._redis().delete(self._get_index_key(room_id))
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
self._index_remove(room_id, participant_id)
@@ -20,7 +20,6 @@ from livekit.protocol.models import ParticipantInfo
from core import utils
from .lobby import LobbyService
from .presence import PresenceCache
logger = getLogger(__name__)
@@ -73,9 +72,7 @@ class ParticipantsManagement:
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby/presence cache."""
PresenceCache().clear(room_name, identity)
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
@@ -115,7 +112,7 @@ class ParticipantsManagement:
await lkapi.aclose()
@async_to_sync
async def update( # noqa: PLR0917
async def update(
self,
room_name: str,
identity: str,
@@ -159,30 +156,6 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
"""Cache-first variant of `check_if_in_meeting`.
Cache hit -> True without touching LiveKit.
Cache miss -> ask LiveKit; memoize only positive answers.
Raises the same exceptions as `check_if_in_meeting` so callers keep
failing closed the same way.
"""
if not room_name or not identity:
return False
presence_cache = PresenceCache()
if presence_cache.is_marked_present(room_name, identity):
return True
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
if present:
presence_cache.mark_present(room_name, identity)
return present
@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`.
-79
View File
@@ -1,79 +0,0 @@
"""Presence cache."""
from typing import FrozenSet
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@staticmethod
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
@staticmethod
def _get_index_key(room_id: UUID | str) -> str:
"""Raw Redis key of the per-room identity index (a native SET).
Built through django-redis' make_key so it lives under the same
KEY_PREFIX/version namespace as the presence entries.
"""
return cache.client.make_key(
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
)
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
"""All identities currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return frozenset(
member.decode() if isinstance(member, bytes) else member
for member in members
)
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id` and index it for the room."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, identity)
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
pipe.execute()
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
self._redis().srem(self._get_index_key(room_id), identity)
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished).
Deletes the indexed entries and the index itself with targeted
commands instead of a full-keyspace pattern scan.
"""
identities = self._index_members(room_id)
if identities:
cache.delete_many(
[self._get_cache_key(room_id, identity) for identity in identities]
)
self._redis().delete(self._get_index_key(room_id))
+1 -60
View File
@@ -8,7 +8,6 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
@@ -30,10 +29,9 @@ class RoomNotFoundException(RoomManagementException):
class RoomManagement:
"""Service for managing LiveKit rooms."""
@classmethod
@async_to_sync
async def update_metadata(
cls,
self,
room_name: str,
metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None,
@@ -90,60 +88,3 @@ class RoomManagement:
finally:
await lkapi.aclose()
@classmethod
@async_to_sync
async def delete_room(cls, room_name: str):
"""Delete a LiveKit room and disconnect all participants.
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the deletion otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted LiveKit room %s", room_name)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping deletion",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception("Unexpected error deleting room %s", room_name)
raise RoomManagementException("Could not delete room") from e
finally:
await lkapi.aclose()
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
should never fail the request that triggered the update.
"""
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
cls.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,
)
+9 -3
View File
@@ -1,5 +1,11 @@
"""Celery tasks for the core app."""
"""Asynchronous tasks of the core application.
from core.tasks.connection_test import delete_connection_test_room
Importing the task modules here is what makes Celery's `autodiscover_tasks`
register them: it only imports the `core.tasks` package itself, never its
submodules.
"""
__all__ = ("delete_connection_test_room",)
from core.tasks.file import process_file_deletion
from core.tasks.push_recording import push_recording
__all__ = ["process_file_deletion", "push_recording"]
+1
View File
@@ -4,6 +4,7 @@ Celery task decorator that degrades to a synchronous call when Celery is off.
# The Celery app is imported lazily so that importing this module does not pull
# in Celery when CELERY_ENABLED is false.
# ruff: noqa: PLC0415
# pylint: disable=import-outside-toplevel
-39
View File
@@ -1,39 +0,0 @@
"""Tasks related to connection test rooms."""
import logging
from django.conf import settings
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def delete_connection_test_room(room_name: str):
"""Force-delete an ephemeral connection-test room.
Used as a hard cap so a participant cannot keep an auto-refreshed
LiveKit session open indefinitely after requesting a test token.
"""
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
if not room_name.startswith(prefix):
logger.error(
"Refusing to delete room '%s': expected prefix '%s'.",
room_name,
prefix,
)
return
try:
RoomManagement.delete_room(room_name)
except RoomNotFoundException:
# Room may already be gone after empty/departure timeout.
logger.info("Connection test room '%s' already gone.", room_name)
except RoomManagementException:
logger.exception("Failed to delete connection test room '%s'.", room_name)
+36
View File
@@ -0,0 +1,36 @@
"""
Tasks related to files.
"""
import logging
from django.core.files.storage import default_storage
from core.models import File
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def process_file_deletion(file_id):
"""
Process the deletion of a file.
Definitely delete it in the database.
Delete the files from the storage.
"""
logger.info("Processing item deletion for %s", file_id)
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
logger.error("Item %s does not exist", file_id)
return
if file.hard_deleted_at is None:
logger.error("To process an item deletion, it must be hard deleted first.")
return
logger.info("Deleting file %s", file.file_key)
default_storage.delete(file.file_key)
file.delete()
+110
View File
@@ -0,0 +1,110 @@
"""Task pushing a finished recording to its owner's Drive."""
import logging
from django.conf import settings
import requests
from core import models, utils
from core.services.drive import API_TIMEOUT, DriveClient, DriveError
from core.tasks._task import task
logger = logging.getLogger(__name__)
# (connect, read) timeouts for the download, in seconds. The read one has to
# accommodate a whole recording being relayed.
DOWNLOAD_TIMEOUT = (API_TIMEOUT[0], 1800)
def _build_filename(recording: models.Recording) -> str:
"""Return a filename for the Drive item."""
return (
f"{recording.room.slug}-"
f"{recording.created_at:%Y-%m-%d-%H-%M}."
f"{recording.extension}"
)
@task
def push_recording(recording_id: str) -> bool:
"""Push a recording to the Drive of the user who started it.
The recording is streamed from object storage to Drive's presigned
URL. It is NOT fully downloaded to the worker's disk or memory.
The access token stored when the recording started is consumed here and
dropped afterwards whatever the outcome.
Mostly taken from: https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md
"""
try:
recording = models.Recording.objects.select_related("room").get(pk=recording_id)
except models.Recording.DoesNotExist:
logger.error(
"Recording %s does not exist, cannot push it to Drive", recording_id
)
return False
access_token = recording.get_owner_access_token()
if not access_token:
logger.error(
"No access token stored for recording %s, cannot push it to Drive. "
"Was OIDC_STORE_ACCESS_TOKEN enabled when the recording started?",
recording_id,
)
return False
download_url = utils.generate_download_s3_url(
recording.key,
expires_in=settings.RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS,
override_domain=False,
)
filename = _build_filename(recording)
try:
with DriveClient(access_token) as drive:
item = drive.create_file(filename=filename)
# The bytes are relayed chunk by chunk: the recording is never held
# in memory as a whole.
with requests.get(
download_url, stream=True, timeout=DOWNLOAD_TIMEOUT
) as download:
download.raise_for_status()
content_length = download.headers.get("Content-Length")
if content_length is None:
raise DriveError(
"Object storage did not return the recording size, "
"cannot stream it to Drive."
)
drive.upload_content(
policy_url=item["policy"],
stream=download.raw,
content_length=int(content_length),
content_type=download.headers.get(
"Content-Type", "application/octet-stream"
),
)
drive.complete_upload(item["id"])
except (DriveError, requests.RequestException):
logger.exception("Failed to push recording %s to Drive", recording_id)
return False
finally:
recording.clear_owner_access_token()
logger.info(
"Recording %s pushed to Drive as '%s' (item %s)",
recording_id,
filename,
item["id"],
)
return True
@@ -40,111 +40,6 @@ def test_authentication_getter_existing_user(monkeypatch):
assert user == db_user
@pytest.mark.parametrize(
"sub",
[
# NUL (U+0000) passes str.isascii() but PostgreSQL text fields
# cannot store or compare it (DataError)
"auth0|abc\x00def",
# lone surrogates cannot be encoded to UTF-8 for the DB lookup
# (UnicodeEncodeError), which runs before any model validation
"bad\ud800sub",
# plainly invalid subs would otherwise escape as ValidationError
# on user creation, which mozilla-django-oidc does not catch
"\u00e9milie",
"a" * 256,
# ASCII control characters are rejected by policy
"tab\tsub",
"del\x7fsub",
],
)
def test_authentication_getter_invalid_sub_rejected_cleanly(monkeypatch, sub):
"""
Subs that can never be persisted should be rejected with
SuspiciousOperation (turned into a clean authentication failure by
mozilla-django-oidc) instead of leaking DataError, UnicodeEncodeError
or ValidationError as a server error.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": sub, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match="User info contained an invalid sub claim",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_numeric_sub(monkeypatch):
"""
Some providers serialize the sub as a JSON number. It should keep working
(CharField coerces it to a string on save) and must not crash the early
sub checks in get_existing_user.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": 12345, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "12345"
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_auth0_pipe_sub(monkeypatch):
"""
A first login with an Auth0-style sub containing a pipe ("provider|user-id")
should create the user instead of raising a ValidationError.
Regression test for https://github.com/suitenumerique/meet/issues/[XXX].
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "auth0|644c0bc8f1874ef6d339fb34", "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "auth0|644c0bc8f1874ef6d339fb34"
assert user.email == "john@example.com"
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_auth0_pipe_sub(monkeypatch):
"""
A returning user with an Auth0-style pipe sub should be matched by sub,
not duplicated or rejected.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(sub="auth0|644c0bc8f1874ef6d339fb34")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches, a user should be created.
@@ -1,11 +1,9 @@
"""Tests for the clean_pending_files management command."""
from datetime import timedelta
from io import BytesIO, StringIO
from unittest.mock import patch
from django.core.files.storage import default_storage
from django.core.management import CommandError, call_command
from django.core.management import call_command
from django.utils import timezone
import pytest
@@ -25,32 +23,31 @@ def test_clean_pending_files_recent_pending_not_deleted():
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
# A pending upload only lives under the temporary key
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.temporary_file_key)
assert default_storage.exists(file.file_key)
def test_clean_pending_files_old_pending_deleted():
"""Pending files older than the threshold should be deleted, temporary object included."""
"""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",
)
# A pending upload only lives under the temporary key
default_storage.save(file.temporary_file_key, BytesIO(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.temporary_file_key)
assert not default_storage.exists(file.file_key)
def test_clean_pending_files_old_non_pending_not_deleted():
@@ -66,6 +63,7 @@ def test_clean_pending_files_old_non_pending_not_deleted():
file.refresh_from_db()
assert file.deleted_at is None
assert file.hard_deleted_at is None
def test_clean_pending_files_custom_hours():
@@ -74,8 +72,8 @@ def test_clean_pending_files_custom_hours():
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
# Default 24h threshold -> file not deleted
@@ -83,58 +81,10 @@ def test_clean_pending_files_custom_hours():
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.temporary_file_key)
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.temporary_file_key)
def test_clean_pending_files_storage_failure_keeps_row_and_continues():
"""
A storage failure on one file leaves its row in place for the next run,
does not stop the other files from being cleaned, and exits non-zero.
"""
out = StringIO()
err = StringIO()
old_date = timezone.now() - timedelta(hours=49)
failing_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
default_storage.save(failing_file.temporary_file_key, BytesIO(b"hello"))
stale_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
default_storage.save(stale_file.temporary_file_key, BytesIO(b"hello"))
models.File.objects.filter(pk__in=[failing_file.pk, stale_file.pk]).update(
created_at=old_date
)
original_delete = default_storage.delete
def flaky_delete(name):
if name == failing_file.temporary_file_key:
raise OSError("boom")
return original_delete(name)
with (
patch.object(default_storage, "delete", side_effect=flaky_delete),
pytest.raises(CommandError, match="Failed to clean 1 file"),
):
call_command("clean_pending_files", stdout=out, stderr=err)
assert "Cleaned 1 stale pending file(s)." in out.getvalue()
assert f"Failed to clean file '{failing_file.pk}': boom" in err.getvalue()
# The failing file is retried on the next run
assert models.File.objects.filter(pk=failing_file.pk).exists()
assert default_storage.exists(failing_file.temporary_file_key)
# The other file was cleaned despite the earlier failure
assert not models.File.objects.filter(pk=stale_file.pk).exists()
assert not default_storage.exists(stale_file.temporary_file_key)
assert not default_storage.exists(file.file_key)
@@ -6,12 +6,13 @@ from random import randint
from unittest.mock import patch
from django.core.files.storage import default_storage
from django.core.management import CommandError, call_command
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
@@ -24,7 +25,11 @@ def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
@pytest.mark.django_db(transaction=True)
def test_purge_deleted_files_success(settings):
"""Only soft deleted files past the grace period are purged."""
"""
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)
@@ -51,63 +56,30 @@ def test_purge_deleted_files_success(settings):
)
purgeable_file.soft_delete()
call_command("purge_deleted_files", stdout=out)
hard_deleted_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
hard_deleted_file.soft_delete()
hard_deleted_file.hard_delete()
assert "Purged 1 deleted file(s)." in out.getvalue()
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)
@pytest.mark.django_db(transaction=True)
def test_purge_deleted_files_storage_failure_keeps_row_and_continues(settings):
"""
A storage failure on one file leaves its row in place for the next run,
does not stop the other files from being purged, and exits non-zero.
"""
out = StringIO()
err = StringIO()
settings.FILE_PURGE_GRACE_DAYS = 1
purge_now = timezone.now() - timedelta(days=2)
with patch("django.utils.timezone.now", return_value=purge_now):
failing_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
failing_file.soft_delete()
purgeable_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
purgeable_file.soft_delete()
original_delete = default_storage.delete
def flaky_delete(name):
if name == failing_file.file_key:
raise OSError("boom")
return original_delete(name)
with (
patch.object(default_storage, "delete", side_effect=flaky_delete),
pytest.raises(CommandError, match="Failed to purge 1 file"),
):
call_command("purge_deleted_files", stdout=out, stderr=err)
assert "Purged 1 deleted file(s)." in out.getvalue()
assert f"Failed to purge file '{failing_file.pk}': boom" in err.getvalue()
# The failing file is retried on the next run
assert models.File.objects.filter(id=failing_file.id).exists()
assert default_storage.exists(failing_file.file_key)
# The other file was purged despite the earlier failure
assert not models.File.objects.filter(id=purgeable_file.id).exists()
assert not default_storage.exists(purgeable_file.file_key)
assert not default_storage.exists(hard_deleted_file.file_key)
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.netloc in ["meet-minio:9000", "minio:9000", "localhost:9000"]
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
@@ -4,6 +4,8 @@ Tests for files API endpoint in meet's core app: list
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
@@ -54,6 +56,14 @@ def test_api_files_list_format():
title="item 2",
)
# hard deleted item should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
hard_deleted_at=timezone.now(),
title="hard deleted item",
creator=user,
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
@@ -84,8 +94,10 @@ def test_api_files_list_format():
"size": None,
"description": None,
"deleted_at": None,
"hard_deleted_at": None,
"abilities": {
"destroy": True,
"hard_delete": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone
import pytest
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
)
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_custom_original_url_header():
"""
Authorization should honour the configured original-url header.
Covers the attachment subrequest path, which resolves the header separately
from the recording one. Reverse proxies other than nginx-ingress use
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
emit X-Original-URL at all.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(file.file_key, BytesIO(b"my prose"))
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
"""
Only the configured header should be honoured, never a hardcoded fallback.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -3,7 +3,6 @@
import logging
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from unittest import mock
from django.core.files.storage import default_storage
@@ -137,40 +136,6 @@ def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
assert not default_storage.exists(file.temporary_file_key)
def test_api_file_upload_ended_rejected_storage_failure_keeps_row(settings):
"""
When a rejected upload cannot be removed from storage, the row must survive
(reverted to pending) so the cleanup can be retried, and no phantom file
must be re-inserted under a new id.
"""
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
with (
mock.patch.object(default_storage, "delete", side_effect=OSError("boom")),
pytest.raises(OSError, match="boom"),
):
client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert models.File.objects.count() == 1
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.PENDING
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
@@ -308,7 +273,6 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
assert not default_storage.exists(file.temporary_file_key)
@pytest.mark.django_db(transaction=True)
@@ -11,98 +11,135 @@ from rest_framework.exceptions import AuthenticationFailed
from core.recording.event.authentication import (
MachineUser,
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
def test_successful_authentication(settings):
"""Test successful authentication with valid token."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer valid-test-token"}
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
user, token = StorageEventAuthentication().authenticate(request)
assert token == "valid-test-token"
assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings):
"""Authentication should fail when no token is configured."""
def test_disabled_authentication_with_header(settings):
"""Authentication should pass when no auth is configured, and header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = None
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer some-token"}
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_disabled_authentication_without_header(settings):
"""Authentication should pass when no auth is configured, and no header is present."""
settings.RECORDING_STORAGE_EVENT_TOKEN = None
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_when_disabled(settings):
"""Authentication should pass when disabled, regardless of token configuration."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
request = RequestFactory().get("/")
user, token = StorageEventAuthentication().authenticate(request)
assert token is None
assert isinstance(user, MachineUser)
def test_authentication_fails_when_token_not_configured(settings):
"""Authentication should fail when authentication is enabled but no token is configured."""
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
settings.RECORDING_STORAGE_EVENT_TOKEN = None
request = RequestFactory().get("/")
with pytest.raises(
AuthenticationFailed,
match="Authentication token is not configured",
match="Authentication is enabled but token is not configured",
):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_missing_auth_header(settings):
"""Test failure when Authorization header is missing."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {}
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_invalid_auth_header_format(settings):
"""Test failure when Authorization header has invalid format."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "InvalidFormat"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_invalid_token_type(settings):
"""Test failure when token type is not Bearer."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Basic some-token"}
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_invalid_token(settings):
"""Test failure when token is invalid."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer wrong-token"}
with pytest.raises(AuthenticationFailed, match="Invalid token"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_malformed_auth_header(settings):
"""Test failure when Authorization header is malformed."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer"} # Missing token part
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
RecordingProcessWebhookAuthentication().authenticate(request)
StorageEventAuthentication().authenticate(request)
def test_authenticate_header():
"""Test the WWW-Authenticate header value."""
request = RequestFactory().get("/")
header = RecordingProcessWebhookAuthentication().authenticate_header(request)
assert header == "Bearer realm='External process webhook API'"
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
def test_multiple_spaces_in_auth_header(settings):
"""Extra spaces between the scheme and the token should be tolerated."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "extra-spaces-token"
"""Test success when Authorization header contains multiple spaces."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer extra-spaces-token"}
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
assert token == "extra-spaces-token"
assert isinstance(user, MachineUser)
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
@@ -5,7 +5,6 @@ Test event notification.
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
import datetime
import json
import smtplib
from unittest import mock
@@ -419,63 +418,3 @@ def test_notify_summary_service_post_args_without_metadata(
mock_is_feature_flag_enabled.assert_called_once_with(
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
settings,
):
"""Regression test for a non-JSON-serializable payload when timestamps are missing.
When the LiveKit egress can no longer be found, ``_get_recording_timestamps``
returns ``(None, None)`` and ``_generate_title`` falls back to its default
title. That default must be a real ``str``: it used to return a lazy
``gettext_lazy`` proxy, which ``json.dumps`` cannot serialize, so the real
``requests.post(json=payload)`` call crashed in production with
``TypeError: Object of type __proxy__ is not JSON serializable``.
"""
settings.SUMMARY_SERVICE_VERSION = 2
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
settings.SCREEN_RECORDING_BASE_URL = None
settings.METADATA_COLLECTOR_ENABLED = False
recording = factories.RecordingFactory(room__name="Daily")
owner = factories.UserFactory(
email="owner@test.com",
sub="owner-sub",
language="fr-fr",
timezone="Europe/Paris",
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=owner
)
# Egress timestamps unavailable -> default-title branch in _generate_title.
mock_get_recording_timestamps.return_value = (None, None)
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
mock_response = mock.Mock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"job_id": "job-77"}
mock_post.return_value = mock_response
result = NotificationService._notify_summary_service(recording)
assert result is True
payload = mock_post.call_args.kwargs["json"]
title = payload["push_to_docs_config"]["title"]
# The title must be a plain ``str``, not a lazy translation proxy...
assert isinstance(title, str)
# ...so the payload serializes exactly the way ``requests`` serializes it.
json.dumps(payload)
@@ -0,0 +1,512 @@
"""
Test event parsers.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from unittest import mock
from django.conf import settings
import pytest
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
from core.recording.event.parsers import (
MinioParser,
S3Parser,
StorageEvent,
get_parser,
)
# MinioParser
@pytest.fixture
def valid_minio_event():
"""Mock a valid Minio event."""
return {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
}
]
}
@pytest.fixture
def minio_parser():
"""Mock a Minio parser."""
return MinioParser(bucket_name="test-bucket")
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_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_minio_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_minio_parse_empty_data(minio_parser):
"""Test parsing empty event data raises error."""
with pytest.raises(ParsingEventDataError, match="Received empty data."):
minio_parser.parse({})
def test_minio_parse_missing_keys(minio_parser):
"""Test parsing event with missing key."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": None},
# Missing 'object' key
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
minio_parser.parse(invalid_minio_event)
def test_minio_parse_none_key(minio_parser):
"""Test parsing event with None field."""
invalid_minio_event = {
"Records": [
{
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": None, # 'contentType' should not be None
},
}
}
]
}
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
minio_parser.parse(invalid_minio_event)
def test_minio_validate_invalid_bucket(minio_parser):
"""Test validation with wrong bucket name."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="wrong-bucket",
metadata=None,
)
with pytest.raises(InvalidBucketError):
minio_parser.validate(event)
def test_minio_validate_invalid_filetype(minio_parser):
"""Test validation with unsupported file type."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
filetype="text/plain", # Not included in the default allowed filetypes
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFileTypeError):
minio_parser.validate(event)
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
],
)
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
"""Test validation with malformed filepath."""
event = StorageEvent(
filepath=invalid_filepath,
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
with pytest.raises(InvalidFilepathError):
minio_parser.validate(event)
def test_minio_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
"""Test successful extraction of recording ID."""
recording_id = minio_parser.get_recording_id(valid_minio_event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
)
recording_id = minio_parser.validate(event)
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
def test_minio_empty_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
empty_types = set()
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
def test_minio_custom_allowed_filetypes():
"""Test MinioParser with empty allowed_filetypes."""
custom_types = {"audio/mp3", "video/mov"}
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
def test_minio_validate_custom_filetypes():
"""Test validation of filepath with folder structure."""
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
)
parser.validate(event)
def test_minio_constructor_none_bucket():
"""Test MinioParser constructor with None bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
MinioParser(bucket_name=None)
def test_minio_constructor_empty_bucket():
"""Test MinioParser constructor with empty bucket name."""
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
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
def clear_lru_cache():
"""Fixture to clear the LRU cache between tests."""
get_parser.cache_clear()
yield
get_parser.cache_clear()
def test_returns_correct_instance(clear_lru_cache):
"""Test if get_parser returns the correct parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser = get_parser()
assert isinstance(parser, MinioParser)
assert parser._bucket_name == "test-bucket"
def test_caching_behavior(clear_lru_cache):
"""Test if the function properly caches the parser instance."""
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
parser1 = get_parser()
parser2 = get_parser()
assert parser1 is parser2 # Check object identity
def test_different_settings_new_instance():
"""Test if changing settings creates a new instance."""
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
parser = get_parser()
assert parser._bucket_name == "different-bucket"
def test_import_error_handling(clear_lru_cache):
"""Test handling of import errors for invalid parser class."""
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
with pytest.raises(ImportError):
get_parser()
@mock.patch("core.recording.event.parsers.import_string")
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
"""Test that parser class is instantiated only once due to caching."""
mock_parser_cls = type(
"MockParser",
(),
{
"__init__": lambda self, bucket_name: setattr(
self, "_bucket_name", bucket_name
)
},
)
mock_import_string.return_value = mock_parser_cls
# First call
parser1 = get_parser()
# Second call
parser2 = get_parser()
# Verify import_string was called only once
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
assert parser1 is parser2
def test_cache_clear_behavior(clear_lru_cache, settings):
"""Test that cache clearing creates new instance."""
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
parser1 = get_parser()
get_parser.cache_clear()
parser2 = get_parser()
assert parser1 is not parser2 # Should be different instances after cache clear
@@ -12,7 +12,6 @@ from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from core.utils import NotificationError
@@ -71,56 +70,3 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize("status", ["active", "stopped"])
@pytest.mark.parametrize(
("notify_return_value", "expected_status"),
((True, "notification_succeeded"), (False, "saved")),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_notify_external_services,
notify_return_value,
expected_status,
status,
service,
):
"""Test handle_complete notifies external services and saves a savable recording."""
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(status=status)
service.handle_complete(recording)
mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status",
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_complete_non_savable_recording(
mock_notify_external_services, status, service
):
"""Test handle_complete refuses recordings that are already saved or in error."""
recording = RecordingFactory(status=status)
with pytest.raises(RecordingNotSavableError):
service.handle_complete(recording)
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@@ -8,7 +8,6 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone
import pytest
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_recordings_media_auth_missing_header():
"""
Test that a subrequest without the configured original-url header is rejected.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/recordings/media-auth/")
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_custom_original_url_header():
"""
Test that the header carrying the original URL can be configured.
Reverse proxies other than nginx-ingress use different headers: Traefik's
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
# The header was read and parsed: we get as far as looking the recording up,
# rather than being rejected for a missing header.
assert response.status_code == 404
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
"""
Test that only the configured header is honoured.
Guards against the header being read from a hardcoded name in parallel with
the setting.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -0,0 +1,267 @@
"""
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
pytestmark = pytest.mark.django_db
@pytest.fixture
def recording_settings(settings):
"""Configure recording-related and storage event Django settings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = True
return settings
@pytest.fixture
def mock_get_parser():
"""Mock 'get_parser' factory function."""
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
yield mock_parser
def test_save_recording_anonymous(settings, client):
"""Anonymous users should not be allowed to save room recordings."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
RecordingFactory(status="active")
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_save_recording_wrong_bearer(settings, client):
"""Requests with incorrect bearer token should be rejected when auth is required."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
)
assert response.status_code == 401
def test_save_recording_permission_needed(settings, client):
"""Recordings should not be saved when feature is disabled."""
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
settings.RECORDING_STORAGE_EVENT_ENABLE = False
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
"""Test handling of parsing errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
"""Test handling of invalid storage bucket errors in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
"""Test handling of unsupported file types in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
"unsupported '.json'"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
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": "Notification ignored."}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
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": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
"""Test handling of events for non-existent recordings."""
RecordingFactory(status="active")
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = uuid.uuid4()
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found for this event."}
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
def test_save_recording_non_savable_recording(
recording_settings, mock_get_parser, client, status
):
"""Test that recordings in non-savable states cannot be saved."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 403
assert response.json() == {
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
" in an error state or has already been saved."
}
@pytest.mark.parametrize("status", ["active", "stopped"])
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
"""Test successful saving of recordings in valid states."""
recording = RecordingFactory(status=status)
mock_parser = mock.Mock()
mock_parser.get_recording_id.return_value = recording.id
mock_get_parser.return_value = mock_parser
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."}
recording.refresh_from_db()
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
)
@@ -9,7 +9,6 @@ from unittest import mock
from django.core.cache import cache
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ... import utils
@@ -25,7 +24,6 @@ pytestmark = pytest.mark.django_db
# Tests for request_entry endpoint
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_anonymous(settings):
"""Anonymous users should be allowed to request entry to a room."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -61,7 +59,6 @@ def test_request_entry_anonymous(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": None,
}
@@ -74,7 +71,6 @@ def test_request_entry_anonymous(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user(settings):
"""Authenticated users should be allowed to request entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -112,7 +108,6 @@ def test_request_entry_authenticated_user(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": None,
}
@@ -125,7 +120,6 @@ def test_request_entry_authenticated_user(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_with_existing_participants(settings):
"""Anonymous users should be allowed to request entry to a room with existing participants."""
# Create a restricted access room
@@ -144,7 +138,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
cache.set(
@@ -154,7 +147,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "user2",
"status": "accepted",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -186,7 +178,6 @@ def test_request_entry_with_existing_participants(settings):
assert response.json() == {
"id": participant_id,
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
@@ -201,7 +192,6 @@ def test_request_entry_with_existing_participants(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_public_room(settings):
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -240,7 +230,6 @@ def test_request_entry_public_room(settings):
assert response.json() == {
"id": "123",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
@@ -251,7 +240,6 @@ def test_request_entry_public_room(settings):
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -294,7 +282,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
@@ -305,7 +292,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_waiting_participant_public_room(settings):
"""While waiting, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -322,7 +308,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -353,7 +338,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "accepted",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": {"token": "test-token"},
}
@@ -405,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -459,7 +443,6 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
"status": "waiting",
"username": "foo",
"color": "123",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -539,7 +522,7 @@ def test_list_waiting_participants_anonymous():
def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -595,7 +578,6 @@ def test_list_waiting_participants_success(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
cache.set(
@@ -605,35 +587,28 @@ def test_list_waiting_participants_success(settings):
"username": "user2",
"status": "waiting",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
},
)
lobby_service = LobbyService()
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
assert response.json() == {
"participants": [
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
},
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
]
}
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
]
def test_list_waiting_participants_empty(settings):
@@ -1,106 +0,0 @@
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoomAccessLevel
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_list_waiting(mock_check):
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
assert client.get(url).status_code == 200
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_absent_user_forbidden(mock_check):
"""Authenticated but not connected to the meeting -> 403."""
mock_check.return_value = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_restricted_room_present_user_forbidden(mock_check):
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_presence_cleared_after_leave(mock_check):
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.get(url).status_code == 403
assert mock_check.call_count == 2
def test_trusted_room_anonymous_forbidden():
"""Anonymous users never manage the lobby."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_accept_entry(mock_check):
"""Authenticated + present in a trusted room can accept a waiting participant."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
# Permission passed; 404 because that participant isn't actually waiting.
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
@@ -372,67 +372,6 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
def test_rename_participant_forbidden_when_display_name_edit_disabled(
mock_livekit_client, settings, room, token, name
):
"""
Test rename is rejected for authenticated users when the self-hoster
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"error": "Authenticated participants cannot edit their display name"
}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_allowed_when_display_name_edit_enabled(
mock_livekit_client, settings, room, token
):
"""Test rename still works for authenticated users when the setting is enabled."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
mock_livekit_client, settings, room, anonymous_token
):
"""
Test the setting only restricts authenticated users: anonymous participants
have no account name to fall back on and can still rename themselves.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_success_anonymous(
mock_livekit_client, room, anonymous_token
):
@@ -381,11 +381,38 @@ def test_api_rooms_update_administrators_of_another():
assert other_room.slug == "old-name"
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
"""Should not fail the API request when the LiveKit metadata sync fails."""
mock_update_metadata.side_effect = exception
@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"]))],
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config):
"""Should acknowledge (200) an unknown event type rather than reject it."""
"""Should return 422 for unknown event type."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
assert response.status_code == 422
assert response.json() == {
"status": "error",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,6 +16,7 @@ from core.services.livekit_events import (
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
@@ -74,11 +75,12 @@ def test_initialization(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service
def test_handle_egress_ended_success( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service, settings
):
"""Should successfully stop recording and notifies all participant."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
@@ -157,11 +159,12 @@ def test_handle_egress_updated_non_handled(
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service, settings
):
"""Should successfully stop and save recording when metadata's update fails."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
@@ -347,7 +350,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_metadata,
mock_notify,
mock_notify_external_services,
@@ -355,10 +358,12 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
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).
(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")
@@ -374,6 +379,47 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
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.services.room_management.RoomManagement.update_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_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",
[
@@ -386,9 +432,10 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_metadata, egress_status, service
mock_update_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()
@@ -406,13 +453,14 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_metadata, status, service
mock_update_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()
@@ -664,27 +712,21 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
"""Unknown event types are logged and ignored, not rejected.
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "some_future_event"
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
service.receive(mock_request) # must not raise
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
caplog.text
)
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
@@ -781,61 +823,3 @@ def test_receive_filter_processes_matching_events(
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_ignores_connection_test_room(
mock_handle_room_started,
mock_handle_room_finished,
mock_receive,
mock_livekit_config,
settings,
):
"""Should ignore all webhook events for connection test rooms in receive()."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = "user-sub"
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_called_once()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
"""No cache operation when the webhook carries no identity."""
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = ""
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
+22 -170
View File
@@ -2,8 +2,8 @@
Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913, C0302
# ruff: noqa: PLR0913, PLR0917
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import uuid
from unittest import mock
@@ -14,7 +14,6 @@ from django.core.cache import cache
from django.http import HttpResponse
import pytest
from freezegun import freeze_time
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel
@@ -56,7 +55,6 @@ def participant_dict():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
@@ -68,7 +66,6 @@ def participant_data():
username="test-username",
id="test-participant-id",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
@@ -80,7 +77,6 @@ def test_lobby_participant_to_dict(participant_data):
assert result["username"] == "test-username"
assert result["id"] == "test-participant-id"
assert result["color"] == "#123456"
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_success(participant_dict):
@@ -91,20 +87,6 @@ def test_lobby_participant_from_dict_success(participant_dict):
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_missing_entered_at():
"""`entered_at` is mandatory; data without it is rejected."""
data = {
"status": "waiting",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(data)
def test_lobby_participant_from_dict_default_status():
@@ -113,7 +95,6 @@ def test_lobby_participant_from_dict_default_status():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant = LobbyParticipant.from_dict(data_without_status)
@@ -139,7 +120,6 @@ def test_lobby_participant_from_dict_invalid_status():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
@@ -284,7 +264,6 @@ def test_request_entry_public_room(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -323,7 +302,6 @@ def test_request_entry_trusted_room(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -366,7 +344,6 @@ def test_request_entry_new_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
mock_enter.return_value = participant_data
@@ -394,7 +371,6 @@ def test_request_entry_waiting_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -423,7 +399,6 @@ def test_request_entry_accepted_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -464,7 +439,6 @@ def test_request_entry_participant_with_role(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -491,23 +465,18 @@ def test_request_entry_participant_with_role(
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
)
lobby_service._index_touch.assert_called_once_with(room.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
@freeze_time("2025-01-01 10:00:00")
def test_enter_success(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
@@ -527,7 +496,6 @@ def test_enter_success(
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -539,16 +507,13 @@ def test_enter_success(
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
mock_index_add.assert_called_once_with(room.id, participant_id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_with_notification_error(
mock_index_add,
mock_notify,
mock_generate_color,
mock_cache,
@@ -575,7 +540,6 @@ def test_enter_with_notification_error(
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_index_add.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
@@ -614,15 +578,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._index_members = mock.Mock(return_value=[])
lobby_service._index_remove = mock.Mock()
mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id)
assert result == ()
lobby_service._index_members.assert_called_once_with(room.id)
lobby_service._index_remove.assert_not_called()
assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called()
@@ -631,8 +594,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id)
@@ -640,8 +602,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert len(result) == 1
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username"
lobby_service._index_members.assert_called_once_with(room.id)
lobby_service._index_remove.assert_called_once_with(room.id)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key])
@@ -657,7 +619,6 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user1",
"id": "participant1",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant2 = {
@@ -665,13 +626,9 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
}
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -681,15 +638,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert len(result) == 2
# Most recent entry comes first
assert [p["id"] for p in result] == ["participant2", "participant1"]
assert result[0]["username"] == "user2"
assert result[1]["username"] == "user1"
# Verify both participants are in the result
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
# Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result)
lobby_service._index_members.assert_called_once_with(room.id)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -698,13 +655,12 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
lobby_service._index_members = mock.Mock(return_value=["participant1"])
lobby_service._index_remove = mock.Mock()
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id)
assert result == ()
assert result == []
mock_cache.delete.assert_called_once_with(cache_key)
@@ -720,15 +676,11 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}
corrupted_participant = {"invalid": "data"}
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: corrupted_participant,
cache_key2: valid_participant,
@@ -746,6 +698,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -761,20 +715,15 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"username": "user1",
"id": "participant1",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant2 = {
"status": "accepted",
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}
lobby_service._index_members = mock.Mock(
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
@@ -866,12 +815,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
lobby_service._update_participant_status(
room.id,
@@ -885,12 +832,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
)
lobby_service._index_touch.assert_called_once_with(room.id)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -911,7 +856,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant1",
id="participant1",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
@@ -922,7 +866,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant2",
id="participant2",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
@@ -933,18 +876,13 @@ def test_clear_room_cache(settings, lobby_service):
username="participant3",
id="participant3",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
for participant_id in ("participant1", "participant2", "participant3"):
lobby_service._index_add(room_id, participant_id)
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
assert lobby_service._index_members(room_id) == frozenset()
def test_clear_room_empty(settings, lobby_service):
@@ -969,16 +907,12 @@ def test_clear_participant_cache(lobby_service):
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
lobby_service._index_add(room_id, participant_id)
assert cache.get(cache_key) is not None
assert participant_id in lobby_service._index_members(room_id)
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
assert participant_id not in lobby_service._index_members(room_id)
def test_clear_participant_cache_nonexistent(lobby_service):
@@ -992,85 +926,3 @@ def test_clear_participant_cache_nonexistent(lobby_service):
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
def test_index_add_members_remove_roundtrip(lobby_service):
"""The room index records, lists and forgets participant ids."""
room_id = uuid.uuid4()
assert lobby_service._index_members(room_id) == frozenset()
lobby_service._index_add(room_id, "participant1")
lobby_service._index_add(room_id, "participant2")
assert sorted(lobby_service._index_members(room_id)) == [
"participant1",
"participant2",
]
# The index carries a backstop TTL so abandoned rooms cannot leak it.
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
lobby_service._index_remove(room_id, "participant1")
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
@mock.patch("core.utils.notify_participants")
def test_enter_registers_participant_in_room_index(
mock_notify, lobby_service, participant_id, username
):
"""Entering the lobby must index the participant id for the room."""
room_id = uuid.uuid4()
lobby_service.enter(room_id, participant_id, username)
assert lobby_service._index_members(room_id) == frozenset([participant_id])
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
"""Indexed ids whose cache entry expired are pruned and not listed."""
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
room_id = uuid.uuid4()
cache.set(
f"test-lobby-prune_{room_id!s}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
timeout=100,
)
lobby_service._index_add(room_id, "participant1")
# participant2 is indexed but its cache entry has expired.
lobby_service._index_add(room_id, "participant2")
result = lobby_service.list_waiting_participants(room_id)
assert [participant["id"] for participant in result] == ["participant1"]
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
"""A lone waiter's polling must keep the room index alive.
Regression test: the index backstop TTL is only armed at enter() time,
so a participant whose rolling WAITING refreshes outlast it would keep
their entry alive while silently vanishing from the moderator list.
Refreshing the waiting status must therefore re-arm the index TTL.
"""
room_id = uuid.uuid4()
lobby_service._index_add(room_id, participant_id)
index_key = lobby_service._get_index_key(room_id)
redis_client = lobby_service._redis()
redis_client.expire(index_key, 10)
assert redis_client.ttl(index_key) <= 10
lobby_service.refresh_waiting_status(room_id, participant_id)
assert redis_client.ttl(index_key) > 10
assert lobby_service._index_members(room_id) == frozenset([participant_id])
@@ -1,107 +0,0 @@
"""Tests for the presence cache and the cached presence check."""
# pylint: disable=W0212
from unittest import mock
from uuid import uuid4
from django.core.cache import cache
import pytest
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.presence import PresenceCache
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_hit_skips_livekit(mock_check):
"""A cached positive answer must not call LiveKit."""
room_id, identity = str(uuid4()), "user-sub"
PresenceCache().mark_present(room_id, identity)
assert (
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
)
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
mock_check.return_value = True
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_negative_not_cached(mock_check):
"""Negative answers are never memoized."""
mock_check.return_value = False
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert mock_check.call_count == 2
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_errors_propagate_and_cache_nothing(mock_check):
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
room_id, identity = str(uuid4()), "user-sub"
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
mock_check.side_effect = exc
with pytest.raises(type(exc)):
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
def test_presence_clear_and_clear_room():
"""clear() removes one entry, clear_room() removes all entries of a room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
presence.mark_present(room_id, "a")
presence.mark_present(room_id, "b")
presence.mark_present(other_room, "a")
presence.clear(room_id, "a")
assert presence.is_marked_present(room_id, "a") is False
assert presence.is_marked_present(room_id, "b") is True
presence.clear_room(room_id)
assert presence.is_marked_present(room_id, "b") is False
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_removes_many_entries_and_the_index():
"""clear_room removes every entry of the room through the index — never
a keyspace scan — and leaves other rooms untouched."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
assert presence._index_members(room_id) == frozenset([])
assert presence._index_members(other_room) == frozenset(["user-0"])
@@ -1,81 +0,0 @@
"""Tests for the RoomManagement service."""
from unittest import mock
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_calls_livekit(mock_create_livekit_client):
"""DeleteRoom is forwarded to the LiveKit API."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock()
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
RoomManagement.delete_room("room-abc")
mock_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0]
assert request.room == "room-abc"
mock_api.aclose.assert_awaited_once()
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_raises_not_found(mock_create_livekit_client):
"""Missing rooms raise RoomNotFoundException."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("not_found", "room not found", status=404)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomNotFoundException):
RoomManagement.delete_room("missing-room")
mock_api.aclose.assert_awaited_once()
@mock.patch("core.services.room_management.utils.create_livekit_client")
def test_delete_room_raises_management_exception(mock_create_livekit_client):
"""Unexpected Twirp errors raise RoomManagementException."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock(
side_effect=TwirpError("internal", "boom", status=500)
)
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomManagementException):
RoomManagement.delete_room("room-abc")
mock_api.aclose.assert_awaited_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
room = RoomFactory.build(
access_level=RoomAccessLevel.RESTRICTED,
configuration={"everyone_can_mute": True},
)
RoomManagement.sync_room_metadata(room)
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@@ -1,51 +0,0 @@
"""Tests for connection test Celery tasks."""
from unittest import mock
from django.test.utils import override_settings
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.tasks.connection_test import delete_connection_test_room
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_calls_room_management(mock_delete_room, settings):
"""RoomManagement.delete_room is called for connection-test rooms."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
delete_connection_test_room("connection-test-abc")
mock_delete_room.assert_called_once_with("connection-test-abc")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room, settings):
"""Refuse to delete rooms outside the connection-test namespace."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
delete_connection_test_room("production-room")
mock_delete_room.assert_not_called()
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room, settings):
"""Missing rooms are treated as already cleaned up."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
delete_connection_test_room("connection-test-gone")
mock_delete_room.assert_called_once_with("connection-test-gone")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_logs_other_failures(mock_delete_room, settings):
"""Unexpected LiveKit failures are swallowed after logging."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
delete_connection_test_room("connection-test-fail")
mock_delete_room.assert_called_once_with("connection-test-fail")
@@ -1,166 +0,0 @@
"""Test diagnostics API endpoints."""
import uuid
from unittest import mock
from django.test.utils import override_settings
from django.urls import reverse
import jwt
import pytest
from rest_framework.test import APIClient
from core.api.throttling import (
ConnectionTestAnonRateThrottle,
ConnectionTestUserRateThrottle,
)
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_api_diagnostics_connection_url():
"""The connection check is exposed under the diagnostics namespace."""
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
def test_api_diagnostics_connection_rejects_get():
"""Only POST is exposed, the endpoint has no side effect to trigger."""
client = APIClient()
response = client.get("/api/v1.0/diagnostics/connection/")
assert response.status_code == 405
def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, client):
"""Each request gets a dedicated room and a short-lived token."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response_a = client.post("/api/v1.0/diagnostics/connection/")
response_b = client.post("/api/v1.0/diagnostics/connection/")
assert response_a.status_code == 200
assert response_b.status_code == 200
data_a = response_a.json()
data_b = response_b.json()
room_a = data_a["livekit"]["room"]
room_b = data_b["livekit"]["room"]
assert room_a.startswith("connection-test-")
assert room_b.startswith("connection-test-")
uuid.UUID(room_a.removeprefix("connection-test-"))
uuid.UUID(room_b.removeprefix("connection-test-"))
assert room_a != room_b
assert data_a["livekit"]["url"]
assert data_a["livekit"]["token"]
assert data_a["livekit"]["expires_in"] == 600
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, client):
"""Connection test tokens expire quickly for users."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
client = APIClient()
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
config = response.json()["livekit"]
payload = jwt.decode(
config["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert config["expires_in"] == 300
assert payload["video"]["room"] == config["room"]
assert payload["name"] == "Connection Test"
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@override_settings()
def test_api_diagnostics_connection_token_for_authenticated_user(settings, client):
"""Logged-in users get a token bound to their own identity."""
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
user = UserFactory()
client.force_login(user)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
payload = jwt.decode(
response.json()["livekit"]["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert payload["sub"] == str(user.sub)
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_schedules_room_deletion(
mock_apply_async, settings, client
):
"""When Celery is enabled, schedule a hard room delete after max age."""
settings.CELERY_ENABLED = True
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = 10
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
room = response.json()["livekit"]["room"]
mock_apply_async.assert_called_once_with(args=[room], countdown=310)
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_skips_room_deletion_without_celery(
mock_apply_async, settings, client
):
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
settings.CELERY_ENABLED = False
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
mock_apply_async.assert_not_called()
@pytest.mark.parametrize(
"throttle_class",
[ConnectionTestAnonRateThrottle, ConnectionTestUserRateThrottle],
)
def test_api_diagnostics_connection_is_throttled(throttle_class, client):
"""Both throttles stay wired to the action once routed through the viewset."""
with (
mock.patch.object(throttle_class, "allow_request", return_value=False),
mock.patch.object(throttle_class, "wait", return_value=42),
):
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 429
def test_api_diagnostics_connection_feature_flag(client, settings):
"""Should return a not found error when the connection diagnostics feature is disabled."""
settings.CONNECTION_TEST_ENABLED = False
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 404
@@ -1,96 +0,0 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
@@ -16,17 +16,8 @@ import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import (
Application,
ApplicationScope,
RoleChoices,
Room,
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -889,509 +880,6 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
@mock.patch("core.external_api.viewsets.analytics.capture")
def test_api_rooms_create_tracks_analytics(mock_capture):
"""Creating a room should emit a ROOM_CREATED analytics event."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 201
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_CREATED
assert properties == {
"room_id": response.data["id"],
"access_level": RoomAccessLevel.RESTRICTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
def test_api_rooms_update_requires_authentication():
"""Updating a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 401
def test_api_rooms_update_requires_scope():
"""Updating a room requires the ROOMS_UPDATE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_UPDATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:update"
in str(response.data).lower()
)
def test_api_rooms_update_no_scope():
"""Updating a room without any scope should return 403."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
"""An owner should be able to update the access level and the configuration."""
settings.APPLICATION_BASE_URL = "http://your-application.com"
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
assert response.data["configuration"] == {"everyone_can_mute": True}
assert response.data["url"] == f"http://your-application.com/{room.slug}"
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"everyone_can_mute": True}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
"""The configuration is replaced as a whole, it is not merged with the stored one."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"everyone_can_mute": False}},
format="json",
)
assert response.status_code == 200
# The keys missing from the payload are dropped, not kept.
assert response.data["configuration"] == {"everyone_can_mute": False}
room.refresh_from_db()
assert room.configuration == {"everyone_can_mute": False}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": False},
"access_level": room.access_level,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrator_success(mock_update_metadata):
"""An administrator should be able to update a room."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.ADMIN)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.put(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 405
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
"""Members and users without any role should not be able to update a room."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
expected_id, expected_name = str(room.id), room.name
expected_slug, expected_pin_code = room.slug, room.pin_code
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"id": str(uuid.uuid4()),
"name": "fake-name",
"slug": "fake-slug",
"pin_code": "000000",
"access_level": RoomAccessLevel.RESTRICTED,
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == expected_id
assert response.data["name"] == expected_name
assert response.data["slug"] == expected_slug
room.refresh_from_db()
assert str(room.id) == expected_id
assert room.name == expected_name
assert room.slug == expected_slug
assert room.pin_code == expected_pin_code
# The one writable field in the payload was applied
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
"""Updating a room with unsupported configuration keys should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"unsupported_flag": True}},
format="json",
)
assert response.status_code == 400
assert "extra inputs are not permitted" in str(response.data).lower()
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"invalid_configuration",
[
{"can_publish_sources": ["invalid-source"]},
{"everyone_can_mute": "invalid-value"},
],
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration_values(
mock_update_metadata, invalid_configuration
):
"""Updating a room with invalid configuration values should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": invalid_configuration},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
"""Switching a room to public should be disabled for the external API by default."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 400
assert "public rooms are disabled" in str(response.data).lower()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_enabled_with_settings(
mock_update_metadata, settings
):
"""Switching a room to public should be allowed when explicitly enabled."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 200
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.PUBLIC
mock_update_metadata.assert_called_once()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_unchanged_skips_livekit_sync(
mock_update_metadata, mock_capture
):
"""An update that changes nothing should not sync metadata nor report changes."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={"everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.TRUSTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_update_metadata.assert_not_called()
# The event is still emitted for auditing, but reports an empty delta.
_, _, properties = mock_capture.call_args[0]
assert properties["updated_fields"] == []
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
"""Updating a room should emit a ROOM_UPDATED analytics event."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_UPDATED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.RESTRICTED,
"updated_fields": ["access_level", "configuration"],
"previous_access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
mock_update_metadata.assert_called_once()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None
@@ -2009,106 +1497,6 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
assert response.status_code == 403
@responses.activate
@mock.patch.object(RoomManagement, "update_metadata")
def test_resource_server_updates_room_with_prefixed_scope(
mock_update_metadata, settings
):
"""A resource server token carrying the prefixed update scope should be accepted."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@responses.activate
def test_resource_server_denies_room_update_without_update_scope(settings):
"""A resource server token without the update scope should be denied."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
# ==============================
# Addons
# ==============================
@@ -2160,32 +1548,6 @@ def test_api_rooms_create_with_valid_addons_token():
assert room.get_role(user) == RoleChoices.OWNER
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
"""Updating a room with a valid addons token should succeed."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
def test_api_rooms_addons_token_inactive_user():
"""Addons token for an inactive user should return 401."""
user = UserFactory(is_active=False)
@@ -5,7 +5,6 @@ Tests for external API /token endpoint
# pylint: disable=W0621
from unittest import mock
from urllib.parse import urlencode
import jwt
import pytest
@@ -89,155 +88,6 @@ def test_api_applications_generate_token_success(settings):
}
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
(
f"client_id={application.client_id}"
f"&client_secret={plain_secret}"
"&grant_type=client_credentials"
"&scope=user%40example.com"
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
"""Invalid credentials sent as form-urlencoded should be parsed and
rejected with 401, proving the request body is properly decoded."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded_missing_fields():
"""Missing required fields in a form-urlencoded request should return
a 400 validation error, like for JSON requests."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode({"grant_type": "client_credentials"}),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
for field in ("client_id", "client_secret", "scope"):
assert field in response.data
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "authorization_code",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
assert "grant_type" in response.data
def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user@example.com",
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_unsupported_media_type():
"""Content types other than JSON and form-urlencoded should still be
rejected with 415 Unsupported Media Type."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
content_type="text/plain",
)
assert response.status_code == 415
def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401."""
user = UserFactory(email="user@example.com")
@@ -1,75 +0,0 @@
"""
Unit tests for the File model deletion flow
"""
from io import BytesIO
from unittest import mock
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
from core.factories import FileFactory
from core.models import File
pytestmark = pytest.mark.django_db
def test_models_files_soft_delete():
"""Soft deleting should only set the deletion timestamp."""
file = FileFactory()
assert file.is_deleted is False
file.soft_delete()
file.refresh_from_db()
assert file.is_deleted is True
assert FileFactory(deleted_at=timezone.now()).is_deleted is True
def test_models_files_soft_delete_twice():
"""Soft deleting an already soft deleted file should fail."""
file = FileFactory()
file.soft_delete()
with pytest.raises(RuntimeError, match="already deleted"):
file.soft_delete()
def test_models_files_delete():
"""Deleting should remove the row and both the final and temporary objects."""
file = FileFactory(upload_bytes=b"hello")
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
# Captured up front: Django nulls the pk after delete, and the keys depend on it
pk, key, temporary_key = file.pk, file.file_key, file.temporary_file_key
file.delete()
assert not File.objects.filter(pk=pk).exists()
assert not default_storage.exists(key)
assert not default_storage.exists(temporary_key)
def test_models_files_delete_without_storage_object():
"""Deleting a file that has nothing in storage should still remove the row."""
file = FileFactory()
pk = file.pk
file.delete()
assert not File.objects.filter(pk=pk).exists()
def test_models_files_delete_storage_failure_keeps_row():
"""A storage failure must leave the row in place so the deletion can be retried."""
file = FileFactory(upload_bytes=b"hello")
with (
mock.patch.object(default_storage, "delete", side_effect=OSError("boom")),
pytest.raises(OSError, match="boom"),
):
file.delete()
assert File.objects.filter(pk=file.pk).exists()
assert default_storage.exists(file.file_key)
@@ -26,64 +26,6 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
@pytest.mark.parametrize(
"sub,is_valid",
[
# cases from suitenumerique/docs PR #1295 (same validator)
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
# Auth0 emits "provider|user-id" subject identifiers
("auth0|644c0bc8f1874ef6d339fb34", True),
("google-oauth2|103547991597142817347", True),
# Keycloak-style UUID
("f:550e8400-e29b-41d4-a716-446655440000:jdoe", True),
# base64/URN-style identifiers
("dGVzdC1zdWItdmFsdWU=", True),
("urn:example:user/42", True),
# legacy format still accepted
("user@example.com", True),
# space (U+0020) is printable ASCII and remains allowed
("sub with space", True),
# non-ASCII values are rejected
("émilie", False),
# ASCII control characters (U+0000-U+001F, U+007F) are rejected:
# NUL passes isascii() but cannot be stored in PostgreSQL text
# fields, and the others invite log injection and interop issues
("nul\x00sub", False),
("\x00", False),
("tab\tsub", False),
("newline\nsub", False),
("del\x7fsub", False),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""
The "sub" field should accept any ASCII string as required by
OpenID Connect Core 1.0 §2 and RFC 7519 §4.1.2, and reject non-ASCII values.
"""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.",
):
user.full_clean()
def test_models_users_sub_max_length():
"""The "sub" field should enforce the 255 ASCII characters limit of OIDC Core 1.0 §2."""
user = factories.UserFactory(sub="a" * 255)
assert user.sub == "a" * 255
user.sub = "a" * 256
with pytest.raises(ValidationError, match="at most 255 characters"):
user.full_clean()
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
@@ -1,22 +0,0 @@
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
import pytest
from meet.settings import VideoCodecValue
@pytest.mark.parametrize(
"raw,expected",
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
)
def test_video_codec_value_normalizes(raw, expected):
"""Whitespace is trimmed and the name is lowercased before it is checked."""
# environ=False keeps __new__ from resolving the value, so the instance survives.
assert VideoCodecValue(environ=False).to_python(raw) == expected
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
def test_video_codec_value_rejects_unsupported(raw):
"""A name outside the accepted list stops the settings module loading."""
with pytest.raises(ValueError, match="Unsupported video codec"):
VideoCodecValue(environ=False).to_python(raw)
-5
View File
@@ -30,11 +30,6 @@ router.register(
addons_viewsets.SessionViewSet,
basename="addons_sessions",
)
router.register(
"diagnostics",
viewsets.DiagnosticsViewSet,
basename="diagnostics",
)
# - External API
external_router = SimpleRouter()

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