mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-01 13:17:59 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd7c76302d | |||
| 325273e3c0 | |||
| 1378e59f75 | |||
| d55d131dc6 | |||
| c7aa3c45cf | |||
| 4dee35a0bb | |||
| 4c3ba6c0c3 | |||
| 6df7752c06 | |||
| 3ad34f176d | |||
| fcb7087f4d |
@@ -13,10 +13,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download Crowdin files
|
- name: Download Crowdin files
|
||||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
uses: crowdin/github-action@v2
|
||||||
with:
|
with:
|
||||||
upload_sources: false
|
upload_sources: false
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
|
|||||||
@@ -30,36 +30,36 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
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
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
# -
|
||||||
name: Run trivy scan
|
# name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
# uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
# with:
|
||||||
docker-build-args: '--target backend-production -f Dockerfile'
|
# 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 }}'
|
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
@@ -76,36 +76,36 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
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
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
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 }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
@@ -123,36 +123,36 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
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
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
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 }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
file: ./docker/dinum-frontend/Dockerfile
|
||||||
@@ -170,30 +170,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
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
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
file: ./src/summary/Dockerfile
|
file: ./src/summary/Dockerfile
|
||||||
@@ -219,30 +219,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
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
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: lasuite/meet-agents
|
images: lasuite/meet-agents
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
@@ -250,7 +250,7 @@ jobs:
|
|||||||
docker-context: './src/agents'
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
file: ./src/agents/Dockerfile
|
file: ./src/agents/Dockerfile
|
||||||
@@ -273,7 +273,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
id: notify
|
id: notify
|
||||||
with:
|
with:
|
||||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: CI
|
name: meet Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
@@ -26,17 +26,17 @@ jobs:
|
|||||||
- name: Enforce absence of print statements in code
|
- name: Enforce absence of print statements in code
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
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
|
- name: Check absence of fixup commits
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git log | grep 'fixup!'
|
! git log | grep 'fixup!'
|
||||||
- name: Install uv
|
- name: Install gitlint
|
||||||
if: always()
|
if: always()
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
run: pip install --user requests gitlint
|
||||||
- name: Lint commit messages added to main
|
- name: Lint commit messages added to main
|
||||||
if: always()
|
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:
|
check-changelog:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- name: Check that the CHANGELOG has been modified in the current branch
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Check CHANGELOG max line length
|
- name: Check CHANGELOG max line length
|
||||||
run: |
|
run: |
|
||||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
@@ -77,15 +77,15 @@ jobs:
|
|||||||
working-directory: src/mail
|
working-directory: src/mail
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -93,11 +93,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install yarn
|
- name: Install yarn
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
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
|
- name: Install node dependencies
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
run: yarn install --frozen-lockfile --ignore-scripts
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build mails
|
- name: Build mails
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache mail templates
|
- name: Cache mail templates
|
||||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
@@ -119,22 +119,22 @@ jobs:
|
|||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install the project
|
- name: Install the project
|
||||||
run: uv sync --locked --all-extras
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
- name: Check code formatting with ruff
|
- 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
|
- name: Lint code with ruff
|
||||||
run: uv run --no-sync --no-build ruff check .
|
run: uv run ruff check .
|
||||||
- name: Lint code with pylint
|
- 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:
|
lint-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -145,19 +145,19 @@ jobs:
|
|||||||
working-directory: src/agents
|
working-directory: src/agents
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install the project
|
- name: Install the project
|
||||||
run: uv sync --locked --all-extras --no-build
|
run: uv sync --locked --all-extras
|
||||||
- name: Check code formatting with ruff
|
- 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
|
- name: Lint code with ruff
|
||||||
run: uv run --no-sync --no-build ruff check .
|
run: uv run ruff check .
|
||||||
|
|
||||||
lint-summary:
|
lint-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -168,19 +168,18 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
cache: "pip"
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
- name: Install development dependencies
|
||||||
- name: Install the project
|
run: pip install --user .[dev]
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: uv run --no-sync --no-build ruff format . --diff
|
run: ~/.local/bin/ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: uv run --no-sync --no-build ruff check .
|
run: ~/.local/bin/ruff check .
|
||||||
|
|
||||||
test-back:
|
test-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -236,7 +235,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Create writable /data
|
- name: Create writable /data
|
||||||
run: |
|
run: |
|
||||||
@@ -244,7 +243,7 @@ jobs:
|
|||||||
sudo mkdir -p /data/static
|
sudo mkdir -p /data/static
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache@v5
|
||||||
id: mail-templates
|
id: mail-templates
|
||||||
with:
|
with:
|
||||||
path: "src/backend/core/templates/mail"
|
path: "src/backend/core/templates/mail"
|
||||||
@@ -263,9 +262,7 @@ jobs:
|
|||||||
# Tool to wait for a service to be ready
|
# Tool to wait for a service to be ready
|
||||||
- name: Install Dockerize
|
- name: Install Dockerize
|
||||||
run: |
|
run: |
|
||||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
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
|
||||||
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
|
- name: Wait for MinIO to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -280,11 +277,11 @@ jobs:
|
|||||||
mc mb meet/meet-media-storage"
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
uses: astral-sh/setup-uv@v7
|
||||||
- name: Install the dependencies
|
- name: Install the dependencies
|
||||||
run: uv sync --locked --all-extras
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
@@ -294,10 +291,10 @@ jobs:
|
|||||||
sudo apt-get install -y gettext
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
- 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
|
- name: Run tests
|
||||||
run: uv run --no-sync --no-build pytest -n 2
|
run: uv run pytest -n 2
|
||||||
|
|
||||||
test-summary:
|
test-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -323,7 +320,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install ffmpeg
|
- name: Install ffmpeg
|
||||||
run: |
|
run: |
|
||||||
@@ -331,18 +328,16 @@ jobs:
|
|||||||
sudo apt-get install -y ffmpeg
|
sudo apt-get install -y ffmpeg
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install development dependencies
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
- name: Install the project
|
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
|
|
||||||
- name: Run summary tests
|
- name: Run summary tests
|
||||||
run: uv run --no-sync --no-build pytest
|
run: ~/.local/bin/pytest
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -350,10 +345,10 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
run: cd src/frontend/ && npm ci
|
||||||
|
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: cd src/frontend/ && npm run lint
|
run: cd src/frontend/ && npm run lint
|
||||||
@@ -370,10 +365,10 @@ jobs:
|
|||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci
|
||||||
|
|
||||||
- name: Check linting
|
- name: Check linting
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
@@ -391,10 +386,10 @@ jobs:
|
|||||||
working-directory: src/sdk/library
|
working-directory: src/sdk/library
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm ci
|
||||||
|
|
||||||
- name: Build SDK
|
- name: Build SDK
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ jobs:
|
|||||||
run: rm -rf ./src/helm/extra
|
run: rm -rf ./src/helm/extra
|
||||||
|
|
||||||
- name: Install Helm
|
- name: Install Helm
|
||||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
uses: azure/setup-helm@v4
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
- name: Publish Helm charts
|
- name: Publish Helm charts
|
||||||
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
|
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||||
with:
|
with:
|
||||||
charts_dir: ./src/helm
|
charts_dir: ./src/helm
|
||||||
linting: on
|
linting: on
|
||||||
|
|||||||
@@ -8,30 +8,6 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ♿️(frontend) close side panel with Escape key #1507
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) fix chat text-area bug
|
|
||||||
|
|
||||||
## [1.29.0] - 2026-08-25
|
## [1.29.0] - 2026-08-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -39,16 +39,14 @@ DB_PORT = 5432
|
|||||||
DOCKER_UID = $(shell id -u)
|
DOCKER_UID = $(shell id -u)
|
||||||
DOCKER_GID = $(shell id -g)
|
DOCKER_GID = $(shell id -g)
|
||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
|
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||||
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
|
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
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) \
|
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
||||||
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
&& 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
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -204,37 +198,23 @@ demo: ## flush db then create a demo for load testing purpose
|
|||||||
@$(MANAGE) create_demo
|
@$(MANAGE) create_demo
|
||||||
.PHONY: demo
|
.PHONY: demo
|
||||||
|
|
||||||
lint: ## lint all python sources (back-end, agents, summary)
|
lint: ## lint back-end python sources
|
||||||
@$(MAKE) lint-back
|
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||||
@$(MAKE) lint-agents
|
|
||||||
@$(MAKE) lint-summary
|
|
||||||
.PHONY: lint
|
.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
|
lint-ruff-format: ## format back-end python sources with ruff
|
||||||
@echo 'lint:ruff-format started…'
|
@echo 'lint:ruff-format started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: lint-ruff-format
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
lint-ruff-check: ## lint back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
@echo 'lint:ruff-check started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||||
.PHONY: lint-ruff-check
|
.PHONY: lint-ruff-check
|
||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
|
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
|
|||||||
-17
@@ -16,23 +16,6 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### 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
|
## 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`.
|
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`.
|
||||||
|
|||||||
+16
-3
@@ -46,6 +46,21 @@ services:
|
|||||||
/usr/bin/mc mb meet/meet-media-storage && \
|
/usr/bin/mc mb meet/meet-media-storage && \
|
||||||
exit 0;"
|
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:
|
app-dev:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -71,6 +86,7 @@ services:
|
|||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
- createbuckets
|
- createbuckets
|
||||||
|
- createwebhook
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "127.0.0.1.nip.io:host-gateway"
|
- "127.0.0.1.nip.io:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
@@ -233,7 +249,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
command: ["python", "metadata_collector.py", "dev"]
|
command: ["python", "metadata_collector.py", "dev"]
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/metadata_collector
|
- env.d/development/metadata_collector
|
||||||
@@ -252,8 +267,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
command: ["python", "multi_user_transcriber.py", "dev"]
|
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/multi_user_transcriber
|
- env.d/development/multi_user_transcriber
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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:
|
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 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.
|
- An email service to notify room owners when a recording is available for download.
|
||||||
- Webhook events configured between LiveKit Server and the backend.
|
- 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]
|
> [!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.
|
> 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
|
LiveKit->>Egress: Stop recording
|
||||||
Egress->>Storage: Upload recorded file
|
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->>Backend: Update Recording status to SAVED
|
||||||
Backend->>Email: Send notification to room owner
|
Backend->>Email: Send notification to room owner
|
||||||
|
|
||||||
@@ -91,6 +94,10 @@ sequenceDiagram
|
|||||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
| **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_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_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_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
| **RECORDING_ENCODING_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,6 +109,19 @@ 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`. |
|
| **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]
|
> [!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).
|
> 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).
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ These are the environmental options available on meet backend.
|
|||||||
| RECORDING_ENABLE | Record meeting option | false |
|
| RECORDING_ENABLE | Record meeting option | false |
|
||||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
| 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_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_EXPIRATION_DAYS | Recording expiration in days | |
|
||||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
|
|
||||||
# Recording
|
# Recording
|
||||||
RECORDING_ENABLE=True
|
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_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
|
|||||||
@@ -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_URL=ws://livekit:7880
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram
|
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||||
ENABLE_SILERO_VAD=False
|
ENABLE_SILERO_VAD=False
|
||||||
|
|
||||||
DEEPGRAM_API_KEY=your-deepgram-api-key
|
DEEPGRAM_API_KEY=
|
||||||
|
|
||||||
KYUTAI_STT_BASE_URL=url
|
KYUTAI_STT_BASE_URL=
|
||||||
KYUTAI_API_KEY=your-kyutai-api-key
|
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
|
|
||||||
|
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
SENTRY_ENVIRONMENT=
|
SENTRY_ENVIRONMENT=
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
|
|||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||||
|
|
||||||
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
|
||||||
|
|
||||||
|
|
||||||
class GitmojiTitle(LineRule):
|
class GitmojiTitle(LineRule):
|
||||||
"""
|
"""
|
||||||
@@ -28,9 +28,10 @@ class GitmojiTitle(LineRule):
|
|||||||
Download the list possible gitmojis from the project's github repository and check that
|
Download the list possible gitmojis from the project's github repository and check that
|
||||||
title contains one of them.
|
title contains one of them.
|
||||||
"""
|
"""
|
||||||
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response:
|
gitmojis = requests.get(
|
||||||
gitmojis = json.load(response)["gitmojis"]
|
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||||
emojis = [re.escape(item["emoji"]) for item in gitmojis]
|
).json()["gitmojis"]
|
||||||
|
emojis = [item["emoji"] for item in gitmojis]
|
||||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
||||||
if not re.search(pattern, title):
|
if not re.search(pattern, title):
|
||||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||||
|
|||||||
Generated
+5
-8
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -6863,14 +6863,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.50.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"watch": "webpack --mode development --watch"
|
"watch": "webpack --mode development --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Multi user transcription agent."""
|
"""Multi user transcription agent."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ from livekit.agents import (
|
|||||||
)
|
)
|
||||||
from livekit.plugins import deepgram, silero
|
from livekit.plugins import deepgram, silero
|
||||||
|
|
||||||
import voxtral_vllm_stt
|
|
||||||
from observability import configure_sentry, set_job_context
|
from observability import configure_sentry, set_job_context
|
||||||
from tasks import done_callback
|
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")
|
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||||
|
|
||||||
SESSION_DRAIN_TIMEOUT_S = 15.0
|
|
||||||
|
|
||||||
|
def create_stt_provider():
|
||||||
def create_stt_provider(vad: silero.VAD | None = None):
|
"""Create STT provider based on environment configuration."""
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
if STT_PROVIDER == "deepgram":
|
if STT_PROVIDER == "deepgram":
|
||||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||||
# detect_language is NOT supported for real-time streaming
|
# 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":
|
elif STT_PROVIDER == "kyutai":
|
||||||
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
_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:
|
else:
|
||||||
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||||
|
|
||||||
@@ -72,9 +58,9 @@ def create_stt_provider(vad: silero.VAD | None = None):
|
|||||||
class Transcriber(Agent):
|
class Transcriber(Agent):
|
||||||
"""Create a transcription agent for a specific participant."""
|
"""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."""
|
"""Init transcription agent."""
|
||||||
stt = create_stt_provider(vad=vad)
|
stt = create_stt_provider()
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
instructions="not-needed",
|
instructions="not-needed",
|
||||||
@@ -90,7 +76,6 @@ class MultiUserTranscriber:
|
|||||||
"""Init multi user transcription agent."""
|
"""Init multi user transcription agent."""
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
self._sessions: dict[str, AgentSession] = {}
|
self._sessions: dict[str, AgentSession] = {}
|
||||||
self._starting: dict[str, asyncio.Task] = {}
|
|
||||||
self._tasks: set[asyncio.Task] = set()
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -111,30 +96,22 @@ class MultiUserTranscriber:
|
|||||||
|
|
||||||
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle new participant connection by starting transcription session."""
|
"""Handle new participant connection by starting transcription session."""
|
||||||
identity = participant.identity
|
if participant.identity in self._sessions:
|
||||||
if identity in self._sessions or identity in self._starting:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"starting session for {identity}")
|
logger.info(f"starting session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._start_session(participant))
|
task = asyncio.create_task(self._start_session(participant))
|
||||||
self._starting[identity] = task
|
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
|
|
||||||
task.add_done_callback(
|
task.add_done_callback(
|
||||||
done_callback(
|
done_callback(
|
||||||
logger,
|
logger,
|
||||||
self._tasks,
|
self._tasks,
|
||||||
f"start transcription session for {identity}",
|
f"start transcription session for {participant.identity}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle participant disconnection by closing transcription session."""
|
"""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:
|
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -150,12 +127,10 @@ class MultiUserTranscriber:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
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)
|
vad = self.ctx.proc.userdata.get("vad", None)
|
||||||
session = AgentSession(vad=vad)
|
session = AgentSession(vad=vad)
|
||||||
room_io = RoomIO(
|
room_io = RoomIO(
|
||||||
@@ -166,30 +141,18 @@ class MultiUserTranscriber:
|
|||||||
text_input=False, audio_output=False, text_output=True
|
text_input=False, audio_output=False, text_output=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
await room_io.start()
|
||||||
await room_io.start()
|
await session.start(
|
||||||
await session.start(
|
agent=Transcriber(
|
||||||
agent=Transcriber(
|
participant_identity=participant.identity,
|
||||||
participant_identity=participant.identity,
|
|
||||||
vad=vad,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except BaseException:
|
)
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await session.aclose()
|
|
||||||
raise
|
|
||||||
self._sessions[participant.identity] = session
|
self._sessions[participant.identity] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _close_session(self, sess: AgentSession) -> None:
|
async def _close_session(self, sess: AgentSession) -> None:
|
||||||
"""Close and cleanup transcription session."""
|
"""Close and cleanup transcription session."""
|
||||||
try:
|
await sess.drain()
|
||||||
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.aclose()
|
await sess.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ dependencies = [
|
|||||||
"protobuf==6.33.6",
|
"protobuf==6.33.6",
|
||||||
"minio==7.2.20",
|
"minio==7.2.20",
|
||||||
"sentry-sdk==2.66.1",
|
"sentry-sdk==2.66.1",
|
||||||
"websockets==17.1",
|
|
||||||
"httpx==0.28.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Generated
+509
-769
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
@@ -11,12 +11,14 @@ class FeatureFlag:
|
|||||||
|
|
||||||
FLAGS = {
|
FLAGS = {
|
||||||
"recording": "RECORDING_ENABLE",
|
"recording": "RECORDING_ENABLE",
|
||||||
|
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
"connection_test": "CONNECTION_TEST_ENABLED",
|
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||||
|
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
|||||||
"""Validate request entry data."""
|
"""Validate request entry data."""
|
||||||
|
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True)
|
||||||
|
participant_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
def validate_participant_id(self, value):
|
||||||
|
"""The id is a bearer credential: never trusted, only looked up."""
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
@@ -599,3 +604,20 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
|||||||
# useless bad requests
|
# useless bad requests
|
||||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||||
|
|
||||||
|
code = serializers.CharField(trim_whitespace=True)
|
||||||
|
|
||||||
|
def validate_code(self, value):
|
||||||
|
"""Reject codes whose length cannot match a generated one."""
|
||||||
|
|
||||||
|
# Calculates urlsafe_b64encode length without padding
|
||||||
|
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||||
|
|
||||||
|
if len(value) != expected_length:
|
||||||
|
raise serializers.ValidationError("Invalid transit code format.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Throttling modules for the API."""
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
def sentry_monitoring_throttle_failure(message):
|
def sentry_monitoring_throttle_failure(message):
|
||||||
"""Log when a failure occurs to detect rate limiting issues."""
|
"""Log when a failure occurs to detect rate limiting issues."""
|
||||||
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
def get_cache_key(self, request, view):
|
def get_cache_key(self, request, view):
|
||||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||||
|
|
||||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
Only throttle requests carrying a participant identifier. The
|
||||||
return None to skip throttling — the cookie will be set on the first
|
identifier is returned by the first request-entry response and
|
||||||
response, and throttling will apply from the second request onward.
|
echoed back by the client from the second request onward, which is
|
||||||
|
when throttling starts applying.
|
||||||
|
|
||||||
Keying on the cookie rather than the IP address prevents penalising
|
Keying on the identifier rather than the IP address prevents
|
||||||
multiple users behind the same NAT/proxy, and is consistent with how
|
penalising multiple users behind the same NAT/proxy, and is
|
||||||
LobbyService identifies participants.
|
consistent with how the lobby identifies participants.
|
||||||
|
|
||||||
Note: as per DRF documentation, application-level throttling is not a
|
Note: as per DRF documentation, application-level throttling is not a
|
||||||
security measure against brute-force or DoS attacks. This throttle exists
|
security measure against brute-force or DoS attacks. This throttle exists
|
||||||
@@ -58,10 +59,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
if request.user and request.user.is_authenticated:
|
if request.user and request.user.is_authenticated:
|
||||||
return None # Only throttle unauthenticated requests.
|
return None # Only throttle unauthenticated requests.
|
||||||
|
|
||||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return None
|
||||||
|
|
||||||
if participant_id is None:
|
participant_id = serializer.validated_data.get("participant_id")
|
||||||
return None # No throttling for cookieless requests
|
|
||||||
|
if not participant_id:
|
||||||
|
return None # No throttling for unidentified requests
|
||||||
|
|
||||||
return self.cache_format % {
|
return self.cache_format % {
|
||||||
"scope": self.scope,
|
"scope": self.scope,
|
||||||
@@ -97,3 +102,14 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle anonymous users requesting connection test tokens."""
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
scope = "connection_test"
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous transit code exchange attempts.
|
||||||
|
|
||||||
|
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||||
|
best-effort. The security of the exchange rests on the codes'
|
||||||
|
entropy and single use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
scope = "exchange_access_token"
|
||||||
|
|||||||
@@ -43,13 +43,28 @@ from rest_framework.settings import api_settings
|
|||||||
from core import analytics, enums, models, utils
|
from core import analytics, enums, models, utils
|
||||||
from core.api import throttling
|
from core.api import throttling
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
|
from core.authentication.user_token import USER_ACCESS_TOKEN_TYPE_CLAIM
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
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 (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
|
)
|
||||||
from core.recording.worker.exceptions import (
|
from core.recording.worker.exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
@@ -61,6 +76,7 @@ from core.recording.worker.mediator import (
|
|||||||
WorkerServiceMediator,
|
WorkerServiceMediator,
|
||||||
)
|
)
|
||||||
from core.services.invitation import InvitationService
|
from core.services.invitation import InvitationService
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
LiveKitWebhookError,
|
LiveKitWebhookError,
|
||||||
@@ -85,6 +101,7 @@ from core.services.room_roles import (
|
|||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
)
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
from core.tasks.connection_test import delete_connection_test_room
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
from core.utils import generate_token
|
from core.utils import generate_token
|
||||||
@@ -223,6 +240,96 @@ class UserViewSet(
|
|||||||
self.serializer_class(request.user, context=context).data
|
self.serializer_class(request.user, context=context).data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="exchange-access-token",
|
||||||
|
permission_classes=[],
|
||||||
|
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("user_access_token")
|
||||||
|
def exchange_access_token(self, request):
|
||||||
|
"""Exchange a single-use transit code for a user access token.
|
||||||
|
|
||||||
|
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||||
|
random string obtained through the external API and delivered to
|
||||||
|
the embedded frontend via a URL fragment, is the credential. Each
|
||||||
|
code can be exchanged exactly once (consuming it deletes it from
|
||||||
|
the cache); replaying a consumed code is denied and logged.
|
||||||
|
|
||||||
|
The issued JWT authenticates the user the code was minted for on
|
||||||
|
the whole core API, exactly like a session cookie would (similar
|
||||||
|
to lib-jitsi-meet's token authentication), and never appears in
|
||||||
|
any URL. Role-based permissions apply unchanged.
|
||||||
|
"""
|
||||||
|
if request.user and request.user.is_authenticated:
|
||||||
|
logger.warning(
|
||||||
|
"Transit code exchange refused: request is already "
|
||||||
|
"session-authenticated (user_id=%s)",
|
||||||
|
request.user.id,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied("Already authenticated.")
|
||||||
|
|
||||||
|
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||||
|
|
||||||
|
if code_data is None:
|
||||||
|
logger.warning("Invalid, expired or already used transit code")
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"Invalid, expired or already used transit code."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-check the user at exchange time so that a deactivation after
|
||||||
|
# the transit code was minted is taken into account.
|
||||||
|
try:
|
||||||
|
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
|
||||||
|
except models.User.DoesNotExist as excpt:
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"This account can no longer access the application."
|
||||||
|
) from excpt
|
||||||
|
|
||||||
|
if not models.Application.has_active_scope(
|
||||||
|
code_data.get("client_id"), models.ApplicationScope.USERS_SESSION
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Transit code exchange refused: application '%s' no longer "
|
||||||
|
"holds the '%s' grant",
|
||||||
|
code_data.get("client_id"),
|
||||||
|
models.ApplicationScope.USERS_SESSION,
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied(
|
||||||
|
"This application can no longer create user sessions."
|
||||||
|
)
|
||||||
|
|
||||||
|
token_service = JwtTokenService(
|
||||||
|
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||||
|
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = token_service.generate_jwt(
|
||||||
|
user,
|
||||||
|
"user:access",
|
||||||
|
{
|
||||||
|
"client_id": code_data.get("client_id", "unknown"),
|
||||||
|
"token_type": USER_ACCESS_TOKEN_TYPE_CLAIM,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||||
|
user.id,
|
||||||
|
code_data.get("client_id", "unknown"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(data)
|
||||||
|
|
||||||
|
|
||||||
class RoomViewSet(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
@@ -509,13 +616,10 @@ class RoomViewSet(
|
|||||||
|
|
||||||
participant, livekit = lobby_service.request_entry(
|
participant, livekit = lobby_service.request_entry(
|
||||||
room=room,
|
room=room,
|
||||||
request=request,
|
user=request.user,
|
||||||
**serializer.validated_data,
|
**serializer.validated_data,
|
||||||
)
|
)
|
||||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||||
lobby_service.prepare_response(response, participant.id)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -1020,6 +1124,56 @@ class RecordingViewSet(
|
|||||||
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
.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(
|
@decorators.action(
|
||||||
detail=False,
|
detail=False,
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
|
|||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||||
|
|
||||||
|
|
||||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||||
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
return None # No authentication attempted
|
return None # No authentication attempted
|
||||||
|
|
||||||
parts = auth_header.split()
|
parts = auth_header.split()
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
||||||
|
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
|
||||||
|
# backend may recognize it.
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(parts) != 2:
|
||||||
raise exceptions.AuthenticationFailed(
|
raise exceptions.AuthenticationFailed(
|
||||||
"Authorization header must be: Bearer <token>"
|
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||||
)
|
)
|
||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""User access JWT authentication for the Meet core API.
|
||||||
|
|
||||||
|
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||||
|
session cookies are blocked) to authenticate requests on the core API with
|
||||||
|
a JWT, obtained by exchanging a single-use transit code (see
|
||||||
|
core.services.transit_code and the users exchange-access-token endpoint)
|
||||||
|
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||||
|
|
||||||
|
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||||
|
user, not to a resource: once authenticated, the request is treated
|
||||||
|
exactly like a session-authenticated one, and the existing role-based
|
||||||
|
permissions apply unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework import exceptions
|
||||||
|
|
||||||
|
from core.external_api.authentication import BaseJWTAuthentication
|
||||||
|
from core.models import Application, ApplicationScope
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||||
|
"""JWT authentication for user access tokens.
|
||||||
|
|
||||||
|
Validates user access tokens issued by the users exchange-access-token
|
||||||
|
endpoint and authenticates the user they were issued for. A bearer
|
||||||
|
token that does not verify against the user access token secret is
|
||||||
|
deferred to the next authentication backend; a token that does verify
|
||||||
|
but carries wrong claims is rejected.
|
||||||
|
|
||||||
|
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||||
|
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||||
|
returns None before reading the Authorization header, deferring every
|
||||||
|
request to the next authentication backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the backend with user access token settings."""
|
||||||
|
super().__init__(
|
||||||
|
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||||
|
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||||
|
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_payload(self, payload):
|
||||||
|
"""Validate the token type and the issuance-audit claim.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AuthenticationFailed: If the token verified against the user
|
||||||
|
access token secret but does not carry the expected
|
||||||
|
claims, or if the issuing application lost its grant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||||
|
logger.warning("Wrong 'token_type' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
if not payload.get("client_id"):
|
||||||
|
logger.warning("Missing 'client_id' in user access token payload")
|
||||||
|
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||||
|
|
||||||
|
if not Application.has_active_scope(
|
||||||
|
payload["client_id"], ApplicationScope.USERS_SESSION
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"User access token refused: application '%s' no longer "
|
||||||
|
"holds the '%s' grant",
|
||||||
|
payload["client_id"],
|
||||||
|
ApplicationScope.USERS_SESSION,
|
||||||
|
)
|
||||||
|
raise exceptions.AuthenticationFailed("Application access revoked.")
|
||||||
@@ -20,8 +20,60 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
|
|
||||||
scope_map: Dict[str, str] = {}
|
scope_map: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def get_required_scope(self, view):
|
||||||
|
"""Return the scope required by the view's current action.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The required scope, or None for an unsupported method so
|
||||||
|
DRF's router can answer 405.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionDenied: If the action is not in scope_map (deny by
|
||||||
|
default).
|
||||||
|
"""
|
||||||
|
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||||
|
action = getattr(view, "action", None)
|
||||||
|
if not action:
|
||||||
|
# DRF routers return a 405 for unsupported methods
|
||||||
|
return None
|
||||||
|
|
||||||
|
required_scope = self.scope_map.get(action)
|
||||||
|
if not required_scope:
|
||||||
|
# Action not in scope_map, deny by default
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return required_scope
|
||||||
|
|
||||||
|
def get_token_scopes(self, request):
|
||||||
|
"""Extract and normalize the scopes claimed by the token."""
|
||||||
|
token_scopes = (request.auth or {}).get("scope")
|
||||||
|
|
||||||
|
if not token_scopes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Ensure scopes is a list (handle both list and space-separated string)
|
||||||
|
if isinstance(token_scopes, str):
|
||||||
|
token_scopes = token_scopes.split()
|
||||||
|
|
||||||
|
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
||||||
|
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
||||||
|
|
||||||
|
return self.strip_scope_prefix(token_scopes)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def strip_scope_prefix(token_scopes):
|
||||||
|
"""Strip the OIDC resource server prefix, when configured."""
|
||||||
|
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||||
|
return [
|
||||||
|
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||||
|
for scope in token_scopes
|
||||||
|
]
|
||||||
|
return token_scopes
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""Check if the JWT token contains the required scope for this action.
|
"""Check if the token claims the scope required by this action.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: DRF request object with authenticated user
|
request: DRF request object with authenticated user
|
||||||
@@ -33,38 +85,15 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
Raises:
|
Raises:
|
||||||
PermissionDenied: If required scope is missing from token
|
PermissionDenied: If required scope is missing from token
|
||||||
"""
|
"""
|
||||||
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
required_scope = self.get_required_scope(view)
|
||||||
action = getattr(view, "action", None)
|
if required_scope is None:
|
||||||
if not action:
|
|
||||||
# DRF routers return a 405 for unsupported methods
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
required_scope = self.scope_map.get(action)
|
token_scopes = self.get_token_scopes(request)
|
||||||
if not required_scope:
|
|
||||||
# Action not in scope_map, deny by default
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
|
||||||
)
|
|
||||||
|
|
||||||
token_payload = request.auth
|
|
||||||
token_scopes = token_payload.get("scope")
|
|
||||||
|
|
||||||
if not token_scopes:
|
if not token_scopes:
|
||||||
raise exceptions.PermissionDenied("Insufficient permissions.")
|
raise exceptions.PermissionDenied("Insufficient permissions.")
|
||||||
|
|
||||||
# Ensure scopes is a list (handle both list and space-separated string)
|
|
||||||
if isinstance(token_scopes, str):
|
|
||||||
token_scopes = token_scopes.split()
|
|
||||||
|
|
||||||
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
|
|
||||||
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
|
|
||||||
|
|
||||||
if settings.OIDC_RS_SCOPES_PREFIX:
|
|
||||||
token_scopes = [
|
|
||||||
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
|
||||||
for scope in token_scopes
|
|
||||||
]
|
|
||||||
|
|
||||||
if required_scope not in token_scopes:
|
if required_scope not in token_scopes:
|
||||||
raise exceptions.PermissionDenied(
|
raise exceptions.PermissionDenied(
|
||||||
f"Insufficient permissions. Required scope: {required_scope}"
|
f"Insufficient permissions. Required scope: {required_scope}"
|
||||||
@@ -73,6 +102,37 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationScopePermission(BaseScopePermission):
|
||||||
|
"""Scope-based permission for application-authenticated endpoints."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def strip_scope_prefix(token_scopes):
|
||||||
|
"""Compare application scopes verbatim."""
|
||||||
|
return token_scopes
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Check the scope claim, then the grant recorded in the database."""
|
||||||
|
granted = super().has_permission(request, view)
|
||||||
|
|
||||||
|
required_scope = self.get_required_scope(view)
|
||||||
|
|
||||||
|
if granted and required_scope:
|
||||||
|
client_id = (request.auth or {}).get("client_id")
|
||||||
|
|
||||||
|
if not models.Application.has_active_scope(client_id, required_scope):
|
||||||
|
logger.warning(
|
||||||
|
"Application '%s' presented scope '%s' without a matching "
|
||||||
|
"grant in database",
|
||||||
|
client_id,
|
||||||
|
required_scope,
|
||||||
|
)
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
f"Application is not granted the required scope: {required_scope}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return granted
|
||||||
|
|
||||||
|
|
||||||
class HasRequiredRoomScope(BaseScopePermission):
|
class HasRequiredRoomScope(BaseScopePermission):
|
||||||
"""Permission class for Room-related operations."""
|
"""Permission class for Room-related operations."""
|
||||||
|
|
||||||
@@ -86,6 +146,14 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HasRequiredUserScope(ApplicationScopePermission):
|
||||||
|
"""Scope-based permissions for the external user endpoints."""
|
||||||
|
|
||||||
|
scope_map = {
|
||||||
|
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RoomPermissions(permissions.BasePermission):
|
class RoomPermissions(permissions.BasePermission):
|
||||||
"""Permissions applying to the room API endpoint."""
|
"""Permissions applying to the room API endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -222,3 +223,59 @@ class RoomViewSet(
|
|||||||
"$set": {"email": self.request.user.email},
|
"$set": {"email": self.request.user.email},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.GenericViewSet):
|
||||||
|
"""Application-delegated API for user operations.
|
||||||
|
|
||||||
|
Provides JWT-authenticated access to user operations for external
|
||||||
|
applications acting on behalf of users. All operations are
|
||||||
|
scope-based. Meant to grow with the other user actions exposed to
|
||||||
|
third parties.
|
||||||
|
|
||||||
|
Supported operations:
|
||||||
|
- transit-code: Mint a single-use transit code for the delegated user
|
||||||
|
(requires 'users:session' scope)
|
||||||
|
"""
|
||||||
|
|
||||||
|
authentication_classes = [
|
||||||
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
]
|
||||||
|
permission_classes = [
|
||||||
|
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||||
|
]
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="transit-code",
|
||||||
|
url_name="transit-code",
|
||||||
|
)
|
||||||
|
@FeatureFlag.require("user_access_token")
|
||||||
|
def generate_transit_code(self, request):
|
||||||
|
"""Mint a transit code for the delegated user.
|
||||||
|
|
||||||
|
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||||
|
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||||
|
frontend exchanges it once on
|
||||||
|
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||||
|
equivalent to session-cookie authentication and never exposed in a URL.
|
||||||
|
"""
|
||||||
|
client_id = (request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||||
|
|
||||||
|
# Log for auditing
|
||||||
|
logger.info(
|
||||||
|
"Transit code issued: user_id=%s, client_id=%s",
|
||||||
|
request.user.id,
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{
|
||||||
|
"transit_code": code,
|
||||||
|
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||||
|
},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='application',
|
||||||
|
name='scopes',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||||
|
),
|
||||||
|
]
|
||||||
+1
-1
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
('core', '0022_alter_application_scopes'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -795,6 +795,7 @@ class ApplicationScope(models.TextChoices):
|
|||||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||||
|
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||||
|
|
||||||
|
|
||||||
class Application(BaseModel):
|
class Application(BaseModel):
|
||||||
@@ -844,6 +845,18 @@ class Application(BaseModel):
|
|||||||
domain = get_domain_from_email(email)
|
domain = get_domain_from_email(email)
|
||||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_active_scope(cls, client_id, scope) -> bool:
|
||||||
|
"""Check that an active application holds a scope."""
|
||||||
|
if not client_id or not scope:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return cls.objects.filter(
|
||||||
|
client_id=client_id,
|
||||||
|
is_active=True,
|
||||||
|
scopes__contains=[scope],
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
class ApplicationDomain(BaseModel):
|
class ApplicationDomain(BaseModel):
|
||||||
"""Domain authorized for application delegation."""
|
"""Domain authorized for application delegation."""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Authentication classes for server-to-server webhook token validation."""
|
"""Authentication class for storage event token validation."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class MachineUser:
|
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.pk = None
|
||||||
self.username = username
|
self.username = username
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
@@ -41,17 +41,24 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
|||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
REALM = ""
|
REALM = ""
|
||||||
|
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = None
|
||||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
"""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 (
|
if (
|
||||||
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||||
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||||
is None
|
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)
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
@@ -81,6 +88,18 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
|||||||
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
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):
|
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||||
"""
|
"""
|
||||||
Custom authentication class for recording process webhook requests.
|
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."""
|
||||||
@@ -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
|
||||||
@@ -220,8 +220,10 @@ class LiveKitEventsService:
|
|||||||
f"Failed to process limit reached event for recording {recording}"
|
f"Failed to process limit reached event for recording {recording}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
# Finalize the recording, the egress has uploaded the file to the storage
|
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||||
if data.egress_info.status in [
|
if (
|
||||||
|
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||||
|
) and data.egress_info.status in [
|
||||||
api.EgressStatus.EGRESS_COMPLETE,
|
api.EgressStatus.EGRESS_COMPLETE,
|
||||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
]:
|
]:
|
||||||
|
|||||||
@@ -86,23 +86,6 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_or_create_participant_id(request) -> str:
|
|
||||||
"""Extract unique participant identifier from the request."""
|
|
||||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def prepare_response(response, participant_id):
|
|
||||||
"""Set participant cookie if needed."""
|
|
||||||
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
|
||||||
response.set_cookie(
|
|
||||||
key=settings.LOBBY_COOKIE_NAME,
|
|
||||||
value=participant_id,
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="Lax",
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_bypass_lobby(room, user, role) -> bool:
|
def can_bypass_lobby(room, user, role) -> bool:
|
||||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||||
@@ -133,8 +116,9 @@ class LobbyService:
|
|||||||
def request_entry(
|
def request_entry(
|
||||||
self,
|
self,
|
||||||
room: models.Room,
|
room: models.Room,
|
||||||
request,
|
user,
|
||||||
username: str,
|
username: str,
|
||||||
|
participant_id: Optional[uuid.UUID] = None,
|
||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
"""Request entry to a room for a participant.
|
"""Request entry to a room for a participant.
|
||||||
|
|
||||||
@@ -149,51 +133,48 @@ class LobbyService:
|
|||||||
5. If denied, do nothing.
|
5. If denied, do nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
participant_id = self._get_or_create_participant_id(request)
|
participant = None
|
||||||
participant = self._get_participant(room.id, participant_id)
|
if participant_id:
|
||||||
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
|
is_new_participant = participant is None
|
||||||
|
if is_new_participant:
|
||||||
|
participant = self._create_participant(room.id, username)
|
||||||
|
|
||||||
room_id = str(room.id)
|
room_id = str(room.id)
|
||||||
user_role = room.get_role(request.user)
|
user_role = room.get_role(user)
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
if self.can_bypass_lobby(room=room, user=user, role=user_role):
|
||||||
if participant is None:
|
participant = self.handle_participant_entry(room_id, participant.id, True)
|
||||||
participant = LobbyParticipant(
|
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
|
||||||
username=username,
|
|
||||||
id=participant_id,
|
|
||||||
color=utils.generate_color(participant_id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
|
||||||
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=participant.username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant.id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
livekit_config = None
|
livekit_config = None
|
||||||
|
|
||||||
if participant is None:
|
if is_new_participant:
|
||||||
participant = self.enter(room.id, participant_id, username)
|
self._notify_entry_request(room_id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||||
self.refresh_waiting_status(room.id, participant_id)
|
self.refresh_waiting_status(room.id, participant.id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||||
# wrongly named, contains access token to join a room
|
# wrongly named, contains access token to join a room
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=participant.username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id=participant_id,
|
participant_id=participant.id,
|
||||||
role=user_role,
|
role=user_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,27 +191,36 @@ class LobbyService:
|
|||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
def enter(
|
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
"""Create and persist a new waiting participant.
|
||||||
) -> LobbyParticipant:
|
|
||||||
"""Add participant to waiting lobby.
|
|
||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
Participant identifiers are minted here, server-side, exclusively.
|
||||||
participants of the new entry request.
|
|
||||||
"""
|
"""
|
||||||
|
participant_id = str(uuid.uuid4())
|
||||||
color = utils.generate_color(participant_id)
|
|
||||||
|
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.WAITING,
|
status=LobbyParticipantStatus.WAITING,
|
||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=utils.generate_color(participant_id),
|
||||||
|
)
|
||||||
|
self._save_participant(room_id, participant)
|
||||||
|
|
||||||
|
return participant
|
||||||
|
|
||||||
|
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
|
||||||
|
"""Persist a participant in the room's lobby."""
|
||||||
|
cache.set(
|
||||||
|
self._get_cache_key(room_id, participant.id),
|
||||||
|
participant.to_dict(),
|
||||||
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_entry_request(room_id: str):
|
||||||
|
"""Notify room participants of a new entry request."""
|
||||||
try:
|
try:
|
||||||
utils.notify_participants(
|
utils.notify_participants(
|
||||||
room_name=str(room_id),
|
room_name=room_id,
|
||||||
notification_data={
|
notification_data={
|
||||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||||
},
|
},
|
||||||
@@ -239,15 +229,6 @@ class LobbyService:
|
|||||||
# If room not created yet, there is no participants to notify
|
# If room not created yet, there is no participants to notify
|
||||||
logger.exception("Failed to notify room participants")
|
logger.exception("Failed to notify room participants")
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
|
||||||
cache.set(
|
|
||||||
cache_key,
|
|
||||||
participant.to_dict(),
|
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
return participant
|
|
||||||
|
|
||||||
def _get_participant(
|
def _get_participant(
|
||||||
self, room_id: UUID, participant_id: str
|
self, room_id: UUID, participant_id: str
|
||||||
) -> Optional[LobbyParticipant]:
|
) -> Optional[LobbyParticipant]:
|
||||||
@@ -294,7 +275,7 @@ class LobbyService:
|
|||||||
room_id: UUID,
|
room_id: UUID,
|
||||||
participant_id: str,
|
participant_id: str,
|
||||||
allow_entry: bool,
|
allow_entry: bool,
|
||||||
) -> None:
|
) -> LobbyParticipant:
|
||||||
"""Handle decision on participant entry.
|
"""Handle decision on participant entry.
|
||||||
|
|
||||||
Updates participant status based on allow_entry:
|
Updates participant status based on allow_entry:
|
||||||
@@ -312,7 +293,7 @@ class LobbyService:
|
|||||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||||
}
|
}
|
||||||
|
|
||||||
self._update_participant_status(room_id, participant_id, **decision)
|
return self._update_participant_status(room_id, participant_id, **decision)
|
||||||
|
|
||||||
def _update_participant_status(
|
def _update_participant_status(
|
||||||
self,
|
self,
|
||||||
@@ -320,7 +301,7 @@ class LobbyService:
|
|||||||
participant_id: str,
|
participant_id: str,
|
||||||
status: LobbyParticipantStatus,
|
status: LobbyParticipantStatus,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
) -> None:
|
) -> LobbyParticipant:
|
||||||
"""Update participant status with appropriate timeout."""
|
"""Update participant status with appropriate timeout."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
@@ -342,6 +323,8 @@ class LobbyService:
|
|||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
|
||||||
|
return participant
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Service handling the lifecycle of transit codes.
|
||||||
|
|
||||||
|
A transit code is an opaque, cryptographically random, single-use code
|
||||||
|
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||||
|
user access token on the core API without a session cookie. The code
|
||||||
|
carries no information by itself: everything it references (user, client)
|
||||||
|
is stored server-side in the cache, and consumed atomically on exchange.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
class TransitCodeService:
|
||||||
|
"""Create and consume single-use transit codes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_key(code):
|
||||||
|
"""Build the cache key for a code.
|
||||||
|
|
||||||
|
The code is hashed so that a dump of the cache never reveals
|
||||||
|
directly usable codes.
|
||||||
|
"""
|
||||||
|
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||||
|
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||||
|
|
||||||
|
def create_code(self, user, client_id="unknown"):
|
||||||
|
"""Generate a transit code for a user, and store it.
|
||||||
|
|
||||||
|
The code expires after TRANSIT_CODE_TTL seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The opaque code to hand to the client.
|
||||||
|
"""
|
||||||
|
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||||
|
# entropy: unguessable and safe to transit through a URL fragment.
|
||||||
|
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
self._cache_key(code),
|
||||||
|
{
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": client_id,
|
||||||
|
},
|
||||||
|
timeout=settings.TRANSIT_CODE_TTL,
|
||||||
|
)
|
||||||
|
|
||||||
|
return code
|
||||||
|
|
||||||
|
def consume_code(self, code):
|
||||||
|
"""Consume a transit code, enforcing single use.
|
||||||
|
|
||||||
|
The code is deleted from the cache upon consumption. `cache.delete`
|
||||||
|
returns whether a key was actually deleted, so if two requests race
|
||||||
|
on the same code, only one of them wins.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: The data stored at creation time ('user_id',
|
||||||
|
'client_id'), or None if the code is unknown, expired or
|
||||||
|
already consumed.
|
||||||
|
"""
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = self._cache_key(code)
|
||||||
|
data = cache.get(key)
|
||||||
|
|
||||||
|
if data is None or not cache.delete(key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -11,98 +11,135 @@ from rest_framework.exceptions import AuthenticationFailed
|
|||||||
|
|
||||||
from core.recording.event.authentication import (
|
from core.recording.event.authentication import (
|
||||||
MachineUser,
|
MachineUser,
|
||||||
RecordingProcessWebhookAuthentication,
|
StorageEventAuthentication,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_successful_authentication(settings):
|
def test_successful_authentication(settings):
|
||||||
"""Test successful authentication with valid token."""
|
"""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 = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer valid-test-token"}
|
request.headers = {"Authorization": "Bearer valid-test-token"}
|
||||||
|
|
||||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
user, token = StorageEventAuthentication().authenticate(request)
|
||||||
assert token == "valid-test-token"
|
assert token == "valid-test-token"
|
||||||
assert isinstance(user, MachineUser)
|
assert isinstance(user, MachineUser)
|
||||||
|
|
||||||
|
|
||||||
def test_authentication_fails_when_token_not_configured(settings):
|
def test_disabled_authentication_with_header(settings):
|
||||||
"""Authentication should fail when no token is configured."""
|
"""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("/")
|
request = RequestFactory().get("/")
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
AuthenticationFailed,
|
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):
|
def test_missing_auth_header(settings):
|
||||||
"""Test failure when Authorization header is missing."""
|
"""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 = RequestFactory().get("/")
|
||||||
request.headers = {}
|
request.headers = {}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_auth_header_format(settings):
|
def test_invalid_auth_header_format(settings):
|
||||||
"""Test failure when Authorization header has invalid format."""
|
"""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 = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "InvalidFormat"}
|
request.headers = {"Authorization": "InvalidFormat"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token_type(settings):
|
def test_invalid_token_type(settings):
|
||||||
"""Test failure when token type is not Bearer."""
|
"""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 = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Basic some-token"}
|
request.headers = {"Authorization": "Basic some-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_token(settings):
|
def test_invalid_token(settings):
|
||||||
"""Test failure when token is invalid."""
|
"""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 = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer wrong-token"}
|
request.headers = {"Authorization": "Bearer wrong-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_auth_header(settings):
|
def test_malformed_auth_header(settings):
|
||||||
"""Test failure when Authorization header is malformed."""
|
"""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 = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer"} # Missing token part
|
request.headers = {"Authorization": "Bearer"} # Missing token part
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_header():
|
def test_authenticate_header():
|
||||||
"""Test the WWW-Authenticate header value."""
|
"""Test the WWW-Authenticate header value."""
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
header = RecordingProcessWebhookAuthentication().authenticate_header(request)
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
assert header == "Bearer realm='External process webhook API'"
|
assert header == "Bearer realm='Storage event API'"
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
def test_multiple_spaces_in_auth_header(settings):
|
||||||
"""Extra spaces between the scheme and the token should be tolerated."""
|
"""Test success when Authorization header contains multiple spaces."""
|
||||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "extra-spaces-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
assert token == "extra-spaces-token"
|
assert header == "Bearer realm='Storage event API'"
|
||||||
assert isinstance(user, MachineUser)
|
|
||||||
|
|||||||
@@ -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 (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
RecordingNotSavableError,
|
|
||||||
)
|
)
|
||||||
from core.utils import NotificationError
|
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(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
Test rooms API endpoints in the Meet core app: create.
|
Test rooms API endpoints in the Meet core app: create.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
from ...models import Room, RoomAccessLevel
|
from ...models import ApplicationScope, Room, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -312,3 +315,39 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
room = Room.objects.get()
|
room = Room.objects.get()
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should create a room exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
room = Room.objects.get()
|
||||||
|
assert room.accesses.filter(role="owner", user=user).exists()
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
Test rooms API endpoints in the Meet core app: list.
|
Test rooms API endpoints in the Meet core app: list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import ApplicationScope, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -156,3 +160,41 @@ def test_api_rooms_list_pagination_page_size():
|
|||||||
assert len(content["results"]) == 3
|
assert len(content["results"]) == 3
|
||||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||||
assert content["previous"] is None
|
assert content["previous"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should list rooms exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
RoomFactory() # another user's room, not listed
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
assert response.data["results"][0]["id"] == str(room.id)
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ from rest_framework.test import APIClient
|
|||||||
from ... import utils
|
from ... import utils
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
from ...services.lobby import (
|
|
||||||
LobbyService,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
# Configure test settings for cookies and cache
|
# Configure test settings for cookies and cache
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Add two participants already waiting in the lobby
|
# Add two participants already waiting in the lobby
|
||||||
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
# Verify successful response
|
# Verify successful response
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was properly set for the new participant
|
# The participant identifier is returned in the response body; no
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
# cookie is involved anymore
|
||||||
assert cookie is not None
|
assert not response.cookies
|
||||||
|
participant_id = response.json()["id"]
|
||||||
participant_id = cookie.value
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
|
||||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
),
|
),
|
||||||
@@ -221,11 +209,6 @@ def test_request_entry_public_room(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "123"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
@@ -235,9 +218,14 @@ def test_request_entry_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
# The accepted participant is persisted, out of the waiting list
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert not lobby_keys
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
|
ttl = cache.ttl(lobby_keys[0])
|
||||||
|
assert ttl is not None
|
||||||
|
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
@@ -247,7 +235,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Lobby cache should be empty before the request
|
# Lobby cache should be empty before the request
|
||||||
@@ -256,9 +243,8 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch(
|
||||||
LobbyService,
|
"core.services.lobby.uuid.uuid4",
|
||||||
"_get_or_create_participant_id",
|
|
||||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
),
|
),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -273,11 +259,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
@@ -287,9 +268,13 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify lobby cache is still empty after the request
|
# The accepted participant is persisted, out of the waiting list
|
||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert not lobby_keys
|
assert len(lobby_keys) == 1
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
ttl = cache.ttl(lobby_keys[0])
|
||||||
|
assert ttl is not None
|
||||||
|
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
@@ -297,7 +282,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
# Add a waiting participant to the room's lobby cache
|
# Add a waiting participant to the room's lobby cache
|
||||||
@@ -311,9 +295,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Simulate a browser with existing participant cookie
|
# Simulate a returning participant echoing its identifier
|
||||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
@@ -322,16 +304,14 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
):
|
):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "user1"},
|
{
|
||||||
|
"username": "user1",
|
||||||
|
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify the lobby cookie was set
|
|
||||||
cookie = response.cookies.get("mocked-cookie")
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
|
||||||
|
|
||||||
# Verify response content matches expected structure and values
|
# Verify response content matches expected structure and values
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
@@ -345,6 +325,11 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
assert len(lobby_keys) == 1
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
|
ttl = cache.ttl(lobby_keys[0])
|
||||||
|
assert ttl is not None
|
||||||
|
assert ttl == pytest.approx(settings.LOBBY_ACCEPTED_TIMEOUT, abs=2000)
|
||||||
|
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
def test_request_entry_invalid_data():
|
def test_request_entry_invalid_data():
|
||||||
"""Should return 400 for invalid request data."""
|
"""Should return 400 for invalid request data."""
|
||||||
@@ -637,15 +622,14 @@ def test_list_waiting_participants_empty(settings):
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
)
|
)
|
||||||
def test_request_entry_throttling_anonymous_without_cookie(
|
def test_request_entry_throttling_anonymous_unidentified(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Anonymous users without a cookie should not be throttled."""
|
"""Requests without a participant identifier should not be throttled."""
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -654,9 +638,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.cookies.get("mocked-cookie") is not None
|
|
||||||
|
|
||||||
client.cookies.clear() # Simulate a new cookieless request
|
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
@@ -670,34 +651,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||||
)
|
)
|
||||||
def test_request_entry_throttling_anonymous_with_cookie(
|
def test_request_entry_throttling_anonymous_identified(
|
||||||
mock_notify_participants, mock_generate_livekit_config, settings
|
mock_notify_participants, mock_generate_livekit_config, settings
|
||||||
):
|
):
|
||||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
"""Identified requests should be throttled after exceeding the rate limit."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||||
|
|
||||||
participant_id = str(uuid.uuid4())
|
participant_id = str(uuid.uuid4())
|
||||||
client.cookies.load({"mocked-cookie": participant_id})
|
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
{"username": "test_user"},
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
@@ -716,7 +695,6 @@ def test_request_entry_throttling_authenticated_user(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
|
||||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -737,3 +715,124 @@ def test_request_entry_throttling_authenticated_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_with_participant_id(settings):
|
||||||
|
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
participant_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Echoing the identifier must be recognized as the same
|
||||||
|
# participant: no duplicate in the lobby
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == participant_id
|
||||||
|
assert response.json()["status"] == "waiting"
|
||||||
|
|
||||||
|
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||||
|
assert len(lobby_keys) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_unknown_participant_id_not_seeded(settings):
|
||||||
|
"""An identifier unknown to the room's lobby must not be honored."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
|
||||||
|
forged_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": forged_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] != forged_id
|
||||||
|
|
||||||
|
# Nothing was stored under the forged identifier
|
||||||
|
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_participant_id_bound_to_room(settings):
|
||||||
|
"""An identifier minted for one room must not be honored in another."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
participant_id = response.json()["id"]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": participant_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] != participant_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_legacy_cookie_ignored():
|
||||||
|
"""The retired cookie channel must not be honored anymore."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
legacy_participant_id = str(uuid.uuid4())
|
||||||
|
client.cookies["lobbyParticipantId"] = legacy_participant_id
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||||
|
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
returned_id = response.json()["id"]
|
||||||
|
assert returned_id != legacy_participant_id
|
||||||
|
uuid.UUID(returned_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_entry_malformed_participant_id(settings):
|
||||||
|
"""A non-UUID identifier is rejected by the serializer with a 400."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||||
|
{"username": "test_user", "participant_id": "../../../evil-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "participant_id" in response.json()
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ Test rooms API endpoints in the Meet core app: participants management.
|
|||||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError, UpdateParticipantRequest
|
from livekit.api import TwirpError, UpdateParticipantRequest
|
||||||
from livekit.protocol.models import ParticipantInfo
|
from livekit.protocol.models import ParticipantInfo
|
||||||
@@ -19,8 +22,18 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import (
|
||||||
from core.services.lobby import LobbyService
|
ApplicationFactory,
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserResourceAccessFactory,
|
||||||
|
)
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
from core.services.lobby import (
|
||||||
|
LobbyParticipant,
|
||||||
|
LobbyParticipantStatus,
|
||||||
|
LobbyService,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -87,7 +100,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -113,7 +126,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -153,7 +166,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -300,7 +313,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -330,7 +343,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -361,7 +374,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -381,7 +394,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -412,7 +425,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -440,7 +453,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -469,7 +482,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
url,
|
url,
|
||||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -849,7 +862,15 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
|
|||||||
participant_identity = str(uuid4())
|
participant_identity = str(uuid4())
|
||||||
|
|
||||||
# Create participant in lobby cache first
|
# Create participant in lobby cache first
|
||||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
LobbyService()._save_participant(
|
||||||
|
room.id,
|
||||||
|
LobbyParticipant(
|
||||||
|
id=participant_identity,
|
||||||
|
username="John doe",
|
||||||
|
status=LobbyParticipantStatus.WAITING,
|
||||||
|
color="#123456",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Accept participant
|
# Accept participant
|
||||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||||
@@ -1020,3 +1041,142 @@ def test_remove_participant_not_found(mock_livekit_client):
|
|||||||
assert response.data == {"error": "Participant not found"}
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_bearer_scheme_defers_to_next_authentication(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should defer a "Bearer" header to the next authentication backend.
|
||||||
|
|
||||||
|
The LiveKit backend only claims the "X-LiveKit-Token" scheme. Any other
|
||||||
|
scheme must be left untouched so the backends declared after it get a
|
||||||
|
chance to authenticate the request.
|
||||||
|
"""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.get_participant.assert_not_called()
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_bearer_scheme_defers_role_permissions_still_apply(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should still enforce room privileges once another backend authenticated."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||||
|
user = UserFactory() # no UserResourceAccess for this room
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_unknown_scheme_defers_and_stays_anonymous(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should leave the request unauthenticated when no backend claims the scheme."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_livekit_scheme_is_case_insensitive(mock_livekit_client):
|
||||||
|
"""Should claim the LiveKit scheme whatever its casing, and not defer it."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
token = utils.generate_token(str(room.id), AnonymousUser())
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.get_participant.assert_called_once()
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mute_participant_livekit_scheme_malformed_header_is_rejected(
|
||||||
|
mock_livekit_client,
|
||||||
|
):
|
||||||
|
"""Should reject a malformed header once the LiveKit scheme is claimed."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
token = utils.generate_token(str(room.id), AnonymousUser())
|
||||||
|
|
||||||
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ Test rooms API endpoints: toggle hand and rename participant.
|
|||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
@@ -17,7 +20,13 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import utils
|
from core import utils
|
||||||
from core.factories import RoomFactory, UserFactory
|
from core.factories import (
|
||||||
|
ApplicationFactory,
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserResourceAccessFactory,
|
||||||
|
)
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -69,7 +78,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -84,7 +96,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": False},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -101,7 +116,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -117,7 +135,10 @@ def test_toggle_hand_identity_derived_from_token(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -128,7 +149,9 @@ def test_toggle_hand_missing_raised_field(room, token):
|
|||||||
"""Test toggle hand with missing raised field returns 400."""
|
"""Test toggle hand with missing raised field returns 400."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
response = client.post(
|
||||||
|
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "raised" in response.data
|
assert "raised" in response.data
|
||||||
@@ -142,7 +165,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
|||||||
url,
|
url,
|
||||||
{"raised": "not-a-boolean"},
|
{"raised": "not-a-boolean"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -166,7 +189,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -181,7 +207,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
@@ -200,7 +229,7 @@ def test_toggle_hand_raise_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -220,7 +249,7 @@ def test_toggle_hand_lower_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": False},
|
{"raised": False},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -240,7 +269,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -257,7 +286,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -272,7 +304,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "Jane Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -286,7 +321,10 @@ def test_rename_participant_uses_identity_from_token(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
client.post(
|
client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -298,7 +336,7 @@ def test_rename_participant_empty_name(room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -309,7 +347,9 @@ def test_rename_participant_missing_name(room, token):
|
|||||||
"""Test rename with missing name field returns 400."""
|
"""Test rename with missing name field returns 400."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
response = client.post(
|
||||||
|
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "name" in response.data
|
assert "name" in response.data
|
||||||
@@ -320,7 +360,10 @@ def test_rename_participant_name_too_long(room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "a" * 256},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
@@ -348,7 +391,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -363,7 +406,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
@@ -382,7 +428,7 @@ def test_rename_participant_success_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -402,7 +448,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -419,7 +465,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
@@ -436,7 +482,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
|||||||
url,
|
url,
|
||||||
{"name": "Guest User"},
|
{"name": "Guest User"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -462,7 +508,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -476,7 +522,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -490,7 +536,7 @@ def test_toggle_hand_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"raised": True},
|
{"raised": True},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -504,7 +550,10 @@ def test_toggle_hand_room_not_found(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -519,7 +568,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -536,7 +588,7 @@ def test_rename_participant_malformed_token(room):
|
|||||||
url,
|
url,
|
||||||
{"name": "John Doe"},
|
{"name": "John Doe"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
@@ -550,7 +602,10 @@ def test_rename_participant_room_not_found(user):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
@@ -565,10 +620,206 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
assert response.data == {"error": "Participant not found"}
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_bearer_scheme_defers_to_next_authentication(
|
||||||
|
mock_livekit_client, room, user, user_access_token
|
||||||
|
):
|
||||||
|
"""Test toggle hand defers a "Bearer" header instead of failing on it."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_bearer_scheme_defers_to_next_authentication(
|
||||||
|
mock_livekit_client, room, user, user_access_token
|
||||||
|
):
|
||||||
|
"""Test rename defers a "Bearer" header instead of failing on it."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_unknown_scheme_defers(mock_livekit_client, room):
|
||||||
|
"""Test toggle hand defers a scheme no backend recognizes."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_unknown_scheme_defers(mock_livekit_client, room):
|
||||||
|
"""Test rename defers a scheme no backend recognizes."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {"detail": "Authentication credentials were not provided."}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_session_authentication_is_not_accepted(
|
||||||
|
mock_livekit_client, room, user
|
||||||
|
):
|
||||||
|
"""Test toggle hand is not granted by a session, whatever the user's room role."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {"raised": True}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_session_authentication_is_not_accepted(
|
||||||
|
mock_livekit_client, room, user
|
||||||
|
):
|
||||||
|
"""Test rename is not granted by a session, whatever the user's room role."""
|
||||||
|
UserResourceAccessFactory(resource=room, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {"name": "John Doe"}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_livekit_scheme_is_case_insensitive(
|
||||||
|
mock_livekit_client, room, token
|
||||||
|
):
|
||||||
|
"""Test rename claims the LiveKit scheme whatever its casing."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"x-livekit-token {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_livekit_scheme_malformed_header_is_rejected(
|
||||||
|
mock_livekit_client, room, token
|
||||||
|
):
|
||||||
|
"""Test toggle hand rejects a malformed header once the LiveKit scheme is claimed."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_livekit_scheme_malformed_header_is_rejected(
|
||||||
|
mock_livekit_client, room, token
|
||||||
|
):
|
||||||
|
"""Test rename rejects a malformed header once the LiveKit scheme is claimed."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from ...factories import (
|
||||||
from ...models import RoleChoices, RoomAccessLevel
|
ApplicationFactory,
|
||||||
|
RoomFactory,
|
||||||
|
UserFactory,
|
||||||
|
UserResourceAccessFactory,
|
||||||
|
)
|
||||||
|
from ...models import ApplicationScope, RoleChoices, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -507,3 +515,41 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
role=str(user_access.role),
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||||
|
"""A user access token should retrieve a room exactly like a session would."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "owner")])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["id"] == str(room.id)
|
||||||
|
assert response.data["pin_code"] == room.pin_code
|
||||||
|
assert "accesses" in response.data
|
||||||
|
|||||||
@@ -4,15 +4,18 @@ Test rooms API endpoints in the Meet core app: start subtitle.
|
|||||||
# pylint: disable=W0621
|
# pylint: disable=W0621
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
|
from ...models import ApplicationScope
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ def test_start_subtitle_invalid_token():
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION="Bearer invalid-token",
|
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -128,7 +131,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@@ -148,7 +151,7 @@ def test_start_subtitle_valid_token(
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -178,7 +181,7 @@ def test_start_subtitle_twirp_error(
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
@@ -198,7 +201,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -219,10 +222,133 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{},
|
{},
|
||||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"detail": "Invalid LiveKit token: Signature verification failed"
|
"detail": "Invalid LiveKit token: Signature verification failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_access_token():
|
||||||
|
"""Generate a valid user access JWT, sent with the "Bearer" scheme."""
|
||||||
|
user = UserFactory()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_bearer_scheme_defers_to_next_authentication(
|
||||||
|
settings, mock_livekit_client, user_access_token
|
||||||
|
):
|
||||||
|
"""Test that a "Bearer" header is deferred instead of failing on the LiveKit backend.
|
||||||
|
|
||||||
|
The action declares LiveKitTokenAuthentication as its only backend, so a
|
||||||
|
scheme it does not own must be left to the next one. None follows, so the
|
||||||
|
request ends up unauthenticated: the body reports missing credentials
|
||||||
|
rather than an invalid LiveKit token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {user_access_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_unknown_scheme_defers(settings, mock_livekit_client):
|
||||||
|
"""Test that a scheme no backend recognizes is deferred, not rejected."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION="Basic dXNlcjpwYXNzd29yZA==",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_scheme_is_case_insensitive(
|
||||||
|
settings, mock_livekit_client, mock_livekit_token, mock_room_id
|
||||||
|
):
|
||||||
|
"""Test that the LiveKit scheme is claimed whatever its casing."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory(id=mock_room_id)
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"x-livekit-token {mock_livekit_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_subtitle_malformed_header_is_rejected(
|
||||||
|
settings, mock_livekit_client, mock_livekit_token
|
||||||
|
):
|
||||||
|
"""Test that a malformed header is rejected once the LiveKit scheme is claimed."""
|
||||||
|
|
||||||
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token} extra-part",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authorization header must be: X-LiveKit-Token <token>"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_livekit_client.agent_dispatch.create_dispatch.assert_not_called()
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ Test rooms API endpoints in the Meet core app: update.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import ApplicationScope, RoomAccessLevel
|
||||||
from ...services.room_management import (
|
from ...services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
@@ -437,3 +441,46 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_rooms_update_authenticated_with_user_access_token():
|
||||||
|
"""Role-based permissions apply unchanged with a user access token."""
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, "member")])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
# A simple member cannot update the room
|
||||||
|
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# An administrator can
|
||||||
|
room.accesses.filter(user=user).update(role="administrator")
|
||||||
|
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
room.refresh_from_db()
|
||||||
|
assert room.name == "new name"
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ def test_initialization(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_success( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -158,11 +159,12 @@ def test_handle_egress_updated_non_handled(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@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
|
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""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")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -356,10 +358,12 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|||||||
recording_status,
|
recording_status,
|
||||||
egress_status,
|
egress_status,
|
||||||
service,
|
service,
|
||||||
|
settings,
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
"""Should notify external services and save the recording on egress completion
|
"""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
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
@@ -375,6 +379,47 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|||||||
assert recording.status == recording_status
|
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, PLR0917
|
||||||
|
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(
|
@pytest.mark.parametrize(
|
||||||
"egress_status",
|
"egress_status",
|
||||||
[
|
[
|
||||||
@@ -387,9 +432,10 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
|||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
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."""
|
"""Shouldn't save on invalid status."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -407,13 +453,14 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
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.
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
'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.
|
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)
|
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ Test lobby service.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -132,63 +130,10 @@ def test_get_cache_key(lobby_service, participant_id):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
||||||
|
|
||||||
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
expected_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
||||||
assert cache_key == expected_key
|
assert cache_key == expected_key
|
||||||
|
|
||||||
|
|
||||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
|
||||||
"""Test extracting participant ID from cookie."""
|
|
||||||
request = mock.Mock()
|
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
|
||||||
|
|
||||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
|
||||||
|
|
||||||
assert participant_id == "existing-id"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
|
||||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
|
||||||
"""Test creating new participant ID when cookie is missing."""
|
|
||||||
request = mock.Mock()
|
|
||||||
request.COOKIES = {}
|
|
||||||
|
|
||||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
|
||||||
|
|
||||||
assert participant_id == "generated-id"
|
|
||||||
mock_uuid4.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
|
||||||
"""Test response preparation with existing cookie."""
|
|
||||||
response = HttpResponse()
|
|
||||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
|
||||||
|
|
||||||
lobby_service.prepare_response(response, participant_id)
|
|
||||||
|
|
||||||
# Verify cookie wasn't set again
|
|
||||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
assert cookie.value == "existing-cookie"
|
|
||||||
assert cookie.value != participant_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
|
||||||
"""Test response preparation with new cookie."""
|
|
||||||
response = HttpResponse()
|
|
||||||
|
|
||||||
lobby_service.prepare_response(response, participant_id)
|
|
||||||
|
|
||||||
# Verify cookie was set
|
|
||||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
|
||||||
assert cookie is not None
|
|
||||||
assert cookie.value == participant_id
|
|
||||||
assert cookie["httponly"] is True
|
|
||||||
assert cookie["secure"] is True
|
|
||||||
assert cookie["samesite"] == "Lax"
|
|
||||||
|
|
||||||
# It's a session cookies (no max_age specified):
|
|
||||||
assert not cookie["max-age"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_public_room(lobby_service):
|
def test_can_bypass_lobby_public_room(lobby_service):
|
||||||
"""Should return True for public rooms regardless of user auth and role."""
|
"""Should return True for public rooms regardless of user auth and role."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -252,92 +197,97 @@ def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
|||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_public_room(
|
def test_request_entry_public_room(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry to a public room."""
|
"""Test requesting entry to a public room."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.UNKNOWN,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
|
"status": "waiting",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id=participant_id,
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_trusted_room(
|
def test_request_entry_trusted_room(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = UserFactory()
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.UNKNOWN,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
|
"status": "waiting",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id=participant_id,
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
|
||||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
@mock.patch("core.services.lobby.LobbyService._create_participant")
|
||||||
def test_request_entry_new_participant(
|
def test_request_entry_new_participant(
|
||||||
mock_enter, lobby_service, participant_id, username
|
mock_create, mock_notify, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a new participant."""
|
"""A new participant gets a server-minted identifier - any provided
|
||||||
request = mock.Mock()
|
one is unknown to the lobby and therefore discarded - and the room is
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
notified of the entry request."""
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||||
|
|
||||||
participant_data = LobbyParticipant(
|
participant_data = LobbyParticipant(
|
||||||
@@ -346,14 +296,20 @@ def test_request_entry_new_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
mock_enter.return_value = participant_data
|
mock_create.return_value = participant_data
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
forged_id = str(uuid.uuid4())
|
||||||
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=forged_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant == participant_data
|
assert participant == participant_data
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
# The provided identifier was looked up, found unknown, and replaced
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
# by a freshly minted participant
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
|
||||||
|
mock_create.assert_called_once_with(room.id, username)
|
||||||
|
mock_notify.assert_called_once_with(str(room.id))
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||||
@@ -361,9 +317,7 @@ def test_request_entry_waiting_participant(
|
|||||||
mock_refresh, lobby_service, participant_id, username
|
mock_refresh, lobby_service, participant_id, username
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -373,10 +327,11 @@ def test_request_entry_waiting_participant(
|
|||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
assert livekit_config is None
|
assert livekit_config is None
|
||||||
@@ -386,80 +341,119 @@ def test_request_entry_waiting_participant(
|
|||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_accepted_participant(
|
def test_request_entry_accepted_participant(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
|
"status": "accepted",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
role=None,
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
|
def test_request_entry_accepted_participant_username_is_bound(
|
||||||
|
mock_generate_config, lobby_service, participant_id, settings
|
||||||
|
):
|
||||||
|
"""An accepted identifier must join under the username the host accepted.
|
||||||
|
|
||||||
|
The participant identifier is a bearer value: a stolen or replayed
|
||||||
|
identifier must not be able to enter the room under a different
|
||||||
|
display name than the one the acceptance decision was made on.
|
||||||
|
"""
|
||||||
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
|
user = AnonymousUser()
|
||||||
|
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
|
{
|
||||||
|
"id": participant_id,
|
||||||
|
"username": "accepted-name",
|
||||||
|
"status": "accepted",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, "spoofed-name", participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
|
assert livekit_config == {"token": "test-token"}
|
||||||
|
assert mock_generate_config.call_args.kwargs["username"] == "accepted-name"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
def test_request_entry_participant_with_role(
|
def test_request_entry_participant_with_role(
|
||||||
mock_generate_config, lobby_service, participant_id, username
|
mock_generate_config, lobby_service, participant_id, username, settings
|
||||||
):
|
):
|
||||||
"""Test requesting entry for a participant with a role on the room."""
|
"""Test requesting entry for a participant with a role on the room."""
|
||||||
request = mock.Mock()
|
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||||
request.user = UserFactory()
|
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
user = UserFactory()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
UserResourceAccessFactory(resource=room, user=user, role="administrator")
|
||||||
|
|
||||||
mocked_participant = LobbyParticipant(
|
cache.set(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
f"mocked-cache-prefix_{room.id}_{participant_id}",
|
||||||
username=username,
|
{
|
||||||
id=participant_id,
|
"id": participant_id,
|
||||||
color="#123456",
|
"username": username,
|
||||||
|
"status": "accepted",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
|
||||||
|
|
||||||
mock_generate_config.return_value = {"token": "test-token"}
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
participant, livekit_config = lobby_service.request_entry(
|
||||||
|
room, user, username, participant_id=participant_id
|
||||||
|
)
|
||||||
|
|
||||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
assert livekit_config == {"token": "test-token"}
|
assert livekit_config == {"token": "test-token"}
|
||||||
mock_generate_config.assert_called_once_with(
|
mock_generate_config.assert_called_once_with(
|
||||||
room_id=str(room.id),
|
room_id=str(room.id),
|
||||||
user=request.user,
|
user=user,
|
||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role="administrator",
|
role="administrator",
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -469,77 +463,50 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
|||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", django_settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
def test_create_participant(
|
||||||
def test_enter_success(
|
|
||||||
mock_notify,
|
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
lobby_service,
|
lobby_service,
|
||||||
participant_id,
|
|
||||||
username,
|
username,
|
||||||
):
|
):
|
||||||
"""Test successful participant entry."""
|
"""A created participant is waiting, colored, and persisted."""
|
||||||
mock_generate_color.return_value = "#123456"
|
mock_generate_color.return_value = "#123456"
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
participant = lobby_service.enter(room.id, participant_id, username)
|
participant = lobby_service._create_participant(room.id, username)
|
||||||
|
|
||||||
mock_generate_color.assert_called_once_with(participant_id)
|
# The identifier is minted server-side
|
||||||
|
uuid.UUID(participant.id)
|
||||||
|
mock_generate_color.assert_called_once_with(participant.id)
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
assert participant.status == LobbyParticipantStatus.WAITING
|
||||||
assert participant.username == username
|
assert participant.username == username
|
||||||
assert participant.id == participant_id
|
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
|
||||||
|
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key",
|
"mocked_cache_key",
|
||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=django_settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
|
||||||
mock_notify.assert_called_once_with(
|
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
|
||||||
@mock.patch("core.utils.generate_color")
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_enter_with_notification_error(
|
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
||||||
mock_notify,
|
"""A notification error must not break the entry request flow."""
|
||||||
mock_generate_color,
|
|
||||||
mock_cache,
|
|
||||||
lobby_service,
|
|
||||||
participant_id,
|
|
||||||
username,
|
|
||||||
):
|
|
||||||
"""Test participant entry with notification error."""
|
|
||||||
mock_generate_color.return_value = "#123456"
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
lobby_service._notify_entry_request("room-id")
|
||||||
participant = lobby_service.enter(room.id, participant_id, username)
|
|
||||||
|
|
||||||
mock_generate_color.assert_called_once_with(participant_id)
|
mock_notify.assert_called_once_with(
|
||||||
assert participant.status == LobbyParticipantStatus.WAITING
|
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||||
assert participant.username == username
|
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
mock_cache.set.assert_called_once_with(
|
|
||||||
"mocked_cache_key",
|
|
||||||
participant.to_dict(),
|
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -585,7 +552,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
|||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||||
mock_cache.get_many.assert_not_called()
|
mock_cache.get_many.assert_not_called()
|
||||||
|
|
||||||
@@ -594,7 +561,7 @@ def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
mock_cache.iter_keys.return_value = [cache_key]
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
@@ -603,7 +570,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||||
|
|
||||||
@@ -612,8 +579,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||||
"""Test listing multiple waiting participants with valid data."""
|
"""Test listing multiple waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
participant1 = {
|
participant1 = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -646,7 +613,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
# Verify all participants have waiting status
|
# Verify all participants have waiting status
|
||||||
assert all(p["status"] == "waiting" for p in result)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
@@ -655,7 +622,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
mock_cache.iter_keys.return_value = [cache_key]
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
@@ -669,8 +636,8 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
valid_participant = {
|
valid_participant = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -699,7 +666,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||||
|
|
||||||
# Verify both cache keys were queried
|
# Verify both cache keys were queried
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
pattern = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
@@ -708,8 +675,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||||
"""Test listing only waiting participants (not accepted/denied)."""
|
"""Test listing only waiting participants (not accepted/denied)."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key1 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
cache_key2 = f"{django_settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||||
|
|
||||||
participant1 = {
|
participant1 = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
@@ -747,7 +714,7 @@ def test_handle_participant_entry_allow(mock_update, lobby_service, participant_
|
|||||||
room.id,
|
room.id,
|
||||||
participant_id,
|
participant_id,
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
timeout=django_settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -761,7 +728,7 @@ def test_handle_participant_entry_deny(mock_update, lobby_service, participant_i
|
|||||||
room.id,
|
room.id,
|
||||||
participant_id,
|
participant_id,
|
||||||
status=LobbyParticipantStatus.DENIED,
|
status=LobbyParticipantStatus.DENIED,
|
||||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
timeout=django_settings.LOBBY_DENIED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -902,14 +869,16 @@ def test_clear_participant_cache(lobby_service):
|
|||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
participant_id = "test-participant-id"
|
participant_id = "test-participant-id"
|
||||||
|
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
participant_data = {
|
participant_data = {
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
}
|
}
|
||||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
cache.set(
|
||||||
|
cache_key, participant_data, timeout=django_settings.LOBBY_WAITING_TIMEOUT
|
||||||
|
)
|
||||||
assert cache.get(cache_key) is not None
|
assert cache.get(cache_key) is not None
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
@@ -921,7 +890,7 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
room_id = uuid.uuid4()
|
room_id = uuid.uuid4()
|
||||||
participant_id = "nonexistent-participant"
|
participant_id = "nonexistent-participant"
|
||||||
|
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
cache_key = f"{django_settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the TransitCodeService.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_code_returns_unique_opaque_codes():
|
||||||
|
"""Each created code should be a distinct high-entropy string."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
codes = {service.create_code(user) for _ in range(5)}
|
||||||
|
|
||||||
|
assert len(codes) == 5
|
||||||
|
for code in codes:
|
||||||
|
assert len(code) >= 43
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_code_returns_stored_data_once():
|
||||||
|
"""Consuming a code should return its data exactly once."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
code = service.create_code(user, client_id="my-app")
|
||||||
|
|
||||||
|
assert service.consume_code(code) == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "my-app",
|
||||||
|
}
|
||||||
|
# Single use: a second consumption fails
|
||||||
|
assert service.consume_code(code) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_consume_code_unknown_or_empty():
|
||||||
|
"""Unknown or empty codes should not be consumable."""
|
||||||
|
service = TransitCodeService()
|
||||||
|
|
||||||
|
assert service.consume_code("unknown-code") is None
|
||||||
|
assert service.consume_code("") is None
|
||||||
|
assert service.consume_code(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.services.transit_code.cache.delete", return_value=False)
|
||||||
|
def test_consume_code_returns_none_when_delete_loses_the_race(mock_delete):
|
||||||
|
"""If the code was already deleted by a concurrent request, consumption fails."""
|
||||||
|
user = UserFactory()
|
||||||
|
service = TransitCodeService()
|
||||||
|
code = service.create_code(user, client_id="my-app")
|
||||||
|
assert service.consume_code(code) is None
|
||||||
|
mock_delete.assert_called_once()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
Tests for user access JWT authentication on the core API.
|
||||||
|
|
||||||
|
The token authenticates the user on the whole API, exactly like a session
|
||||||
|
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||||
|
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||||
|
with a user access token lives in the room test files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope, RoleChoices
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def generate_user_access_token(user, application=None, **overrides):
|
||||||
|
"""Generate a valid user access JWT signed with the token secret."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if application is None:
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"scope": "user:access",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
payload = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_users_me():
|
||||||
|
"""A user access token should authenticate the user on /users/me/."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["email"] == user.email
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_expired():
|
||||||
|
"""An expired user access token should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = generate_user_access_token(
|
||||||
|
user,
|
||||||
|
iat=now - timedelta(hours=3),
|
||||||
|
exp=now - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "token expired" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_wrong_token_type():
|
||||||
|
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, token_type="addons")
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token type" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_invalid_signature():
|
||||||
|
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=600),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"token_type": "user_access",
|
||||||
|
"client_id": "test-app",
|
||||||
|
},
|
||||||
|
"wrong-secret-key-padded-for-minimum-len!",
|
||||||
|
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_missing_client_id_claim():
|
||||||
|
"""A token without the issuance-audit claim should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, client_id=None)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "invalid token claims" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_inactive_user():
|
||||||
|
"""A user access token for an inactive user should be rejected."""
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_feature_disabled(settings):
|
||||||
|
"""When the feature is disabled, user access tokens should be ignored."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_does_not_break_session_authentication():
|
||||||
|
"""A session-authenticated user should keep full access to the API."""
|
||||||
|
user = UserFactory()
|
||||||
|
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get("/api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||||
|
"""An application-delegation JWT must not authenticate on the core API."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||||
|
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=600),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": "some-client",
|
||||||
|
"delegated": True,
|
||||||
|
"scope": "rooms:retrieve",
|
||||||
|
},
|
||||||
|
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
# The user token backend must defer (wrong signature) and the request
|
||||||
|
# must end up unauthenticated.
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_scope_revoked():
|
||||||
|
"""Revoking the application's grant invalidates its outstanding tokens."""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, application=application)
|
||||||
|
|
||||||
|
application.scopes = []
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "application access revoked" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_application_deactivated():
|
||||||
|
"""Deactivating the application invalidates its outstanding tokens."""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, application=application)
|
||||||
|
|
||||||
|
application.is_active = False
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "application access revoked" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_unknown_application():
|
||||||
|
"""A token whose client_id matches no application is refused."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_user_access_token(user, client_id="not-an-application")
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "application access revoked" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_access_token_does_not_override_existing_session():
|
||||||
|
"""A Bearer token must not override the identity of a live session."""
|
||||||
|
session_user = UserFactory()
|
||||||
|
token_user = UserFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(session_user)
|
||||||
|
client.credentials(
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(token_user)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["email"] == session_user.email
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Test users API endpoints in the Meet core app: exchange transit code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_user_access_token(token, settings):
|
||||||
|
"""Decode a user access token with the token secret."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||||
|
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||||
|
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||||
|
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unknown_code(settings):
|
||||||
|
"""Generate a well-formed code that was never stored."""
|
||||||
|
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Return an anonymous API client with a random source IP.
|
||||||
|
|
||||||
|
A fresh IP per test isolates the anonymous throttle history, both
|
||||||
|
between the tests of this module and between test runs.
|
||||||
|
"""
|
||||||
|
# `secrets` rather than `random`: the global random module is seeded
|
||||||
|
# deterministically by the factories, its sequence repeats across runs.
|
||||||
|
remote_addr = (
|
||||||
|
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||||
|
f".{secrets.randbelow(254) + 1}"
|
||||||
|
)
|
||||||
|
return APIClient(REMOTE_ADDR=remote_addr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_missing_code(client):
|
||||||
|
"""The exchange endpoint should validate its input."""
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "code" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_get_method(client):
|
||||||
|
"""The exchange endpoint should not accept GET."""
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/users/exchange-access-token/")
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_malformed_code(client):
|
||||||
|
"""A code whose length cannot match a generated one should be a 400."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": "not-a-valid-code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "invalid transit code format" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_unknown_code(client, settings):
|
||||||
|
"""A well-formed but unknown code should be denied."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "invalid, expired or already used" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_success(client, settings):
|
||||||
|
"""A valid transit code should be exchangeable for an access token."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||||
|
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||||
|
assert response.data["scope"] == "user:access"
|
||||||
|
|
||||||
|
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||||
|
assert payload["user_id"] == str(user.id)
|
||||||
|
assert payload["client_id"] == application.client_id
|
||||||
|
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_single_use(client):
|
||||||
|
"""A transit code should be exchangeable exactly once."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Replaying the same code must be denied
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "invalid, expired or already used" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_inactive_user(client):
|
||||||
|
"""A code minted for a now-inactive user should be denied."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer access" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_feature_disabled(client, settings):
|
||||||
|
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_throttled(client, settings):
|
||||||
|
"""Anonymous exchange attempts should be rate limited."""
|
||||||
|
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||||
|
initial_rate = throttle_rates["exchange_access_token"]
|
||||||
|
# The rates dict is mutated in place: restore it explicitly, the
|
||||||
|
# `settings` fixture only rolls back attribute assignments.
|
||||||
|
throttle_rates["exchange_access_token"] = "2/minute"
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/users/exchange-access-token/",
|
||||||
|
{"code": generate_unknown_code(settings)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
finally:
|
||||||
|
throttle_rates["exchange_access_token"] = initial_rate
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_refused_when_already_authenticated(client):
|
||||||
|
"""A session-authenticated browser must not exchange a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
session_user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
client.force_login(session_user)
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "already authenticated" in str(response.data).lower()
|
||||||
|
|
||||||
|
# The code was not consumed: it stays valid for its intended,
|
||||||
|
# cookieless embedded context.
|
||||||
|
client.logout()
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_application_scope_revoked(client):
|
||||||
|
"""A code is refused once the application's grant is revoked."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
application.scopes = []
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer create user sessions" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_application_deactivated(client):
|
||||||
|
"""A code is refused once the application is disabled."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
application.is_active = False
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer create user sessions" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_unknown_application(client):
|
||||||
|
"""A code whose client_id matches no application is refused."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
code = TransitCodeService().create_code(user, client_id="not-an-application")
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "no longer create user sessions" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exchange_access_token_end_to_end(client):
|
||||||
|
"""A token obtained from the exchange must authenticate on the core API.
|
||||||
|
|
||||||
|
Regression test: token issuance and token validation must stay in
|
||||||
|
sync on the claims they set and require (e.g. 'token_type').
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.USERS_SESSION])
|
||||||
|
code = TransitCodeService().create_code(user, client_id=application.client_id)
|
||||||
|
|
||||||
|
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
api_client = APIClient()
|
||||||
|
api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access_token']}")
|
||||||
|
me = api_client.get("/api/v1.0/users/me/")
|
||||||
|
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.data["email"] == user.email
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Tests for external API /users endpoints (transit codes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.factories import ApplicationFactory, UserFactory
|
||||||
|
from core.models import ApplicationScope
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def generate_addons_test_token(user, scopes):
|
||||||
|
"""Generate a valid JWT token signed with the addons secret for testing."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.ADDONS_TOKEN_ISSUER,
|
||||||
|
"aud": django_settings.ADDONS_TOKEN_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=django_settings.ADDONS_TOKEN_TTL),
|
||||||
|
"scope": " ".join(scopes),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.ADDONS_TOKEN_SECRET_KEY,
|
||||||
|
algorithm=django_settings.ADDONS_TOKEN_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test_token(user, scopes, application=None):
|
||||||
|
"""Generate a valid application JWT token for testing."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
scope_string = " ".join(scopes)
|
||||||
|
|
||||||
|
if application is None:
|
||||||
|
application = ApplicationFactory(scopes=scopes)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||||
|
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now
|
||||||
|
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
|
"client_id": str(application.client_id),
|
||||||
|
"scope": scope_string,
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"delegated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
|
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_requires_authentication():
|
||||||
|
"""Minting a transit code without authentication should return 401."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_missing_scope():
|
||||||
|
"""A token without the 'users:session' scope should be rejected."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "users:session" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_success(settings):
|
||||||
|
"""A delegated user with the scope should be able to mint a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||||
|
|
||||||
|
code = response.data["transit_code"]
|
||||||
|
# Opaque, high-entropy random string
|
||||||
|
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||||
|
|
||||||
|
# The code is stored server-side and references the delegated user
|
||||||
|
code_data = TransitCodeService().consume_code(code)
|
||||||
|
assert code_data == {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"client_id": mock.ANY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_scope_claim_exceeding_db_grant():
|
||||||
|
"""A 'users:session' claim beyond the grant recorded in database is refused."""
|
||||||
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory(scopes=[ApplicationScope.ROOMS_RETRIEVE])
|
||||||
|
|
||||||
|
token = generate_test_token(
|
||||||
|
user, [ApplicationScope.USERS_SESSION], application=application
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "not granted" in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_get_forbidden():
|
||||||
|
"""Minting a transit code with a GET should not be allowed."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.get("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_resource_server_not_supported():
|
||||||
|
"""A resource server token must not be able to mint a transit code."""
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
"authenticate",
|
||||||
|
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||||
|
) as mock_rs_authenticate:
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
mock_rs_authenticate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_feature_disabled(settings):
|
||||||
|
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||||
|
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_inactive_user():
|
||||||
|
"""An inactive user should not be able to mint a transit code."""
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_users_transit_code_rejects_addons_token():
|
||||||
|
"""An addons token must not be able to mint a transit code.
|
||||||
|
|
||||||
|
The token carries the 'users:session' scope and is signed with the addons
|
||||||
|
secret, so only the missing backend stands between it and a transit code.
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
token = generate_addons_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
ResourceServerAuthentication, "authenticate", return_value=None
|
||||||
|
):
|
||||||
|
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -48,6 +48,11 @@ external_router.register(
|
|||||||
external_viewsets.RoomViewSet,
|
external_viewsets.RoomViewSet,
|
||||||
basename="external_room",
|
basename="external_room",
|
||||||
)
|
)
|
||||||
|
external_router.register(
|
||||||
|
"users",
|
||||||
|
external_viewsets.UserViewSet,
|
||||||
|
basename="external_user",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|||||||
+40
-27
@@ -381,31 +381,6 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
|||||||
return mimetype_from_content or "application/octet-stream"
|
return mimetype_from_content or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
def _get_s3_client(*, override_domain: bool = True):
|
|
||||||
"""Return an S3 client, honoring the AWS_S3_DOMAIN_REPLACE endpoint override.
|
|
||||||
|
|
||||||
AWS_S3_DOMAIN_REPLACE is used when the backend and frontend reach object
|
|
||||||
storage under different domains (this is the case in the docker compose stack
|
|
||||||
used in development: the frontend connects to the object storage on localhost
|
|
||||||
while the backend uses the object storage service name declared in the stack).
|
|
||||||
The domain name is used to compute the signature, so it can't be changed
|
|
||||||
dynamically by the frontend; we build a dedicated boto3 client pointed at that
|
|
||||||
endpoint. Otherwise we reuse the default storage client.
|
|
||||||
"""
|
|
||||||
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
|
||||||
return boto3.client(
|
|
||||||
"s3",
|
|
||||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
|
||||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
|
||||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
|
||||||
config=botocore.client.Config(
|
|
||||||
region_name=settings.AWS_S3_REGION_NAME,
|
|
||||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return default_storage.connection.meta.client
|
|
||||||
|
|
||||||
|
|
||||||
def generate_upload_policy(file):
|
def generate_upload_policy(file):
|
||||||
"""
|
"""
|
||||||
Generate a S3 upload policy for a given file.
|
Generate a S3 upload policy for a given file.
|
||||||
@@ -416,7 +391,26 @@ def generate_upload_policy(file):
|
|||||||
|
|
||||||
key = file.temporary_file_key
|
key = file.temporary_file_key
|
||||||
|
|
||||||
s3_client = _get_s3_client()
|
# This settings should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
# Generate the policy
|
# Generate the policy
|
||||||
policy = s3_client.generate_presigned_url(
|
policy = s3_client.generate_presigned_url(
|
||||||
@@ -437,7 +431,26 @@ def generate_download_s3_url(
|
|||||||
if not key:
|
if not key:
|
||||||
raise ValueError("key cannot be empty")
|
raise ValueError("key cannot be empty")
|
||||||
|
|
||||||
s3_client = _get_s3_client(override_domain=override_domain)
|
# This setting should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
return s3_client.generate_presigned_url(
|
return s3_client.generate_presigned_url(
|
||||||
ClientMethod="get_object",
|
ClientMethod="get_object",
|
||||||
|
|||||||
@@ -677,7 +677,3 @@ msgstr "Niederländisch"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spanisch"
|
|
||||||
|
|||||||
@@ -672,7 +672,3 @@ msgstr "Dutch"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spanish"
|
|
||||||
|
|||||||
@@ -1,652 +0,0 @@
|
|||||||
# SOME DESCRIPTIVE TITLE.
|
|
||||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
||||||
# This file is distributed under the same license as the PACKAGE package.
|
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
||||||
#
|
|
||||||
#, fuzzy
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
||||||
"Language: \n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
|
|
||||||
#: core/admin.py:67
|
|
||||||
msgid "Personal info"
|
|
||||||
msgstr "Datos Personales"
|
|
||||||
|
|
||||||
#: core/admin.py:80
|
|
||||||
msgid "Permissions"
|
|
||||||
msgstr "Permisos"
|
|
||||||
|
|
||||||
#: core/admin.py:92
|
|
||||||
msgid "Important dates"
|
|
||||||
msgstr "Fechas importantes"
|
|
||||||
|
|
||||||
#: core/admin.py:206
|
|
||||||
msgid "Content"
|
|
||||||
msgstr "Contenido"
|
|
||||||
|
|
||||||
#: core/admin.py:217
|
|
||||||
msgid "Deletion"
|
|
||||||
msgstr "Eliminar Salas"
|
|
||||||
|
|
||||||
#: core/admin.py:226
|
|
||||||
msgid "Derived info"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:237
|
|
||||||
# 'Marcas de tiempo' is a bad translation for spanish
|
|
||||||
msgid "Timestamps"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:240
|
|
||||||
msgid "File preview"
|
|
||||||
msgstr "Vista previa"
|
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:443
|
|
||||||
msgid "No owner"
|
|
||||||
msgstr "Sin propietario"
|
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:446
|
|
||||||
msgid "Multiple owners"
|
|
||||||
msgstr "Varios propietarios"
|
|
||||||
|
|
||||||
#: core/admin.py:316
|
|
||||||
msgid "Resend notification to external service"
|
|
||||||
msgstr "Reenviar la notificación al servicio externo"
|
|
||||||
|
|
||||||
#: core/admin.py:339
|
|
||||||
#, python-format
|
|
||||||
msgid "Failed to notify for recording %(id)s"
|
|
||||||
msgstr "Error al notificar la grabación %(id)s"
|
|
||||||
|
|
||||||
#: core/admin.py:347
|
|
||||||
#, python-format
|
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
|
||||||
msgstr "Error al notificar la grabación %(id)s: %(error)s"
|
|
||||||
|
|
||||||
#: core/admin.py:355
|
|
||||||
#, python-format
|
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
|
||||||
msgstr "Notificaciones enviadas correctamente para %(count)s grabación(es)."
|
|
||||||
|
|
||||||
#: core/admin.py:363
|
|
||||||
#, python-format
|
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
|
||||||
msgstr "Se han omitido %(count)s grabación(es) caducada(s)."
|
|
||||||
|
|
||||||
#: core/admin.py:368
|
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
|
||||||
msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
|
|
||||||
|
|
||||||
#: core/admin.py:386
|
|
||||||
#, python-format
|
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
|
||||||
msgstr "%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
|
|
||||||
|
|
||||||
#: core/admin.py:394
|
|
||||||
#, python-format
|
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
|
||||||
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
|
|
||||||
|
|
||||||
#: core/admin.py:510
|
|
||||||
msgid "No scopes"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:512
|
|
||||||
msgid "Scopes"
|
|
||||||
msgstr "Ámbitos"
|
|
||||||
|
|
||||||
#: core/api/filters.py:25
|
|
||||||
msgid "Creator is me"
|
|
||||||
msgstr "Yo soy el creador"
|
|
||||||
|
|
||||||
#: core/api/serializers.py:89
|
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
|
||||||
msgstr "Debes ser administrador o propietario de una reunión para añadirle accesos."
|
|
||||||
|
|
||||||
#: core/api/serializers.py:534
|
|
||||||
msgid "This file extension is not allowed."
|
|
||||||
msgstr "Esta extensión de archivo no está permitida."
|
|
||||||
|
|
||||||
#: core/api/viewsets.py:1222
|
|
||||||
msgid "You have reached the maximum number of files for this type."
|
|
||||||
msgstr "Has alcanzado el número máximo de archivos de este tipo."
|
|
||||||
|
|
||||||
#: core/models.py:37
|
|
||||||
msgid "Member"
|
|
||||||
msgstr "Miembro"
|
|
||||||
|
|
||||||
#: core/models.py:38
|
|
||||||
msgid "Administrator"
|
|
||||||
msgstr "Administrador"
|
|
||||||
|
|
||||||
#: core/models.py:39
|
|
||||||
msgid "Owner"
|
|
||||||
msgstr "Propietario"
|
|
||||||
|
|
||||||
#: core/models.py:55
|
|
||||||
# To check here and following lines for gender agreement (Masculine/Feminine)
|
|
||||||
msgid "Initiated"
|
|
||||||
msgstr "Iniciada"
|
|
||||||
|
|
||||||
#: core/models.py:56
|
|
||||||
msgid "Active"
|
|
||||||
msgstr "Activa"
|
|
||||||
|
|
||||||
#: core/models.py:57
|
|
||||||
msgid "Stopped"
|
|
||||||
msgstr "Detenida"
|
|
||||||
|
|
||||||
#: core/models.py:58
|
|
||||||
msgid "Saved"
|
|
||||||
msgstr "Guardada"
|
|
||||||
|
|
||||||
#: core/models.py:59
|
|
||||||
msgid "Aborted"
|
|
||||||
msgstr "Cancelada"
|
|
||||||
|
|
||||||
#: core/models.py:60
|
|
||||||
msgid "Failed to Start"
|
|
||||||
msgstr "Error al iniciar"
|
|
||||||
|
|
||||||
#: core/models.py:61
|
|
||||||
msgid "Failed to Stop"
|
|
||||||
msgstr "Error al detener"
|
|
||||||
|
|
||||||
#: core/models.py:62
|
|
||||||
msgid "Notification succeeded"
|
|
||||||
msgstr "Notificado correctamente"
|
|
||||||
|
|
||||||
#: core/models.py:65
|
|
||||||
msgid "External process successful"
|
|
||||||
msgstr "Proceso externo finalizado correctamente"
|
|
||||||
|
|
||||||
#: core/models.py:67
|
|
||||||
msgid "External process failed"
|
|
||||||
msgstr "Error en el Proceso externo"
|
|
||||||
|
|
||||||
#: core/models.py:96
|
|
||||||
msgid "SCREEN_RECORDING"
|
|
||||||
msgstr "GRABACIÓN_DE_PANTALLA"
|
|
||||||
|
|
||||||
#: core/models.py:97
|
|
||||||
msgid "TRANSCRIPT"
|
|
||||||
msgstr "TRANSCRIPCIÓN"
|
|
||||||
|
|
||||||
#: core/models.py:103
|
|
||||||
msgid "Public Access"
|
|
||||||
msgstr "Acceso público"
|
|
||||||
|
|
||||||
#: core/models.py:104
|
|
||||||
msgid "Trusted Access"
|
|
||||||
msgstr "Acceso usuarios autorizados"
|
|
||||||
|
|
||||||
#: core/models.py:105
|
|
||||||
msgid "Restricted Access"
|
|
||||||
msgstr "Acceso restringido"
|
|
||||||
|
|
||||||
#: core/models.py:117
|
|
||||||
msgid "id"
|
|
||||||
msgstr "id"
|
|
||||||
|
|
||||||
#: core/models.py:118
|
|
||||||
msgid "primary key for the record as UUID"
|
|
||||||
msgstr "clave primaria del registro en forma de UUID"
|
|
||||||
|
|
||||||
#: core/models.py:124
|
|
||||||
msgid "created on"
|
|
||||||
msgstr "creado el"
|
|
||||||
|
|
||||||
#: core/models.py:125
|
|
||||||
msgid "date and time at which a record was created"
|
|
||||||
msgstr "fecha y hora en que se creó un registro"
|
|
||||||
|
|
||||||
#: core/models.py:130
|
|
||||||
msgid "updated on"
|
|
||||||
msgstr "actualizado el"
|
|
||||||
|
|
||||||
#: core/models.py:131
|
|
||||||
msgid "date and time at which a record was last updated"
|
|
||||||
msgstr "fecha y hora de la última actualización de un registro"
|
|
||||||
|
|
||||||
#: core/models.py:151
|
|
||||||
msgid ""
|
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
|
||||||
"_ characters."
|
|
||||||
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
|
|
||||||
|
|
||||||
#: core/models.py:157
|
|
||||||
msgid "sub"
|
|
||||||
msgstr "sub"
|
|
||||||
|
|
||||||
#: core/models.py:159
|
|
||||||
msgid ""
|
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
|
||||||
msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
|
|
||||||
|
|
||||||
#: core/models.py:168
|
|
||||||
msgid "identity email address"
|
|
||||||
msgstr "dirección de correo electrónico"
|
|
||||||
|
|
||||||
#: core/models.py:173
|
|
||||||
msgid "admin email address"
|
|
||||||
msgstr "dirección de correo electrónico de administrador"
|
|
||||||
|
|
||||||
#: core/models.py:175
|
|
||||||
msgid "full name"
|
|
||||||
msgstr "nombre completo"
|
|
||||||
|
|
||||||
#: core/models.py:177
|
|
||||||
msgid "short name"
|
|
||||||
msgstr "nombre corto"
|
|
||||||
|
|
||||||
#: core/models.py:183
|
|
||||||
msgid "language"
|
|
||||||
msgstr "idioma"
|
|
||||||
|
|
||||||
#: core/models.py:184
|
|
||||||
msgid "The language in which the user wants to see the interface."
|
|
||||||
msgstr "El idioma preferido de interfaz."
|
|
||||||
|
|
||||||
#: core/models.py:190
|
|
||||||
msgid "The timezone in which the user wants to see times."
|
|
||||||
msgstr "La zona horaria en la que el usuario quiere ver las horas."
|
|
||||||
|
|
||||||
#: core/models.py:193
|
|
||||||
msgid "device"
|
|
||||||
msgstr "dispositivo"
|
|
||||||
|
|
||||||
#: core/models.py:195
|
|
||||||
msgid "Whether the user is a device or a real user."
|
|
||||||
msgstr "Si el usuario es un dispositivo o un usuario real."
|
|
||||||
|
|
||||||
#: core/models.py:198
|
|
||||||
msgid "staff status"
|
|
||||||
msgstr "estado del personal"
|
|
||||||
|
|
||||||
#: core/models.py:200
|
|
||||||
msgid "Whether the user can log into this admin site."
|
|
||||||
msgstr "Si el usuario puede acceder a este sitio de administración."
|
|
||||||
|
|
||||||
#: core/models.py:203
|
|
||||||
msgid "active"
|
|
||||||
msgstr "activo"
|
|
||||||
|
|
||||||
#: core/models.py:206
|
|
||||||
msgid ""
|
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
|
||||||
"deleting accounts."
|
|
||||||
msgstr "Si este usuario debe considerarse activo. Desmarca esta opción en lugar de eliminar cuentas."
|
|
||||||
|
|
||||||
#: core/models.py:219
|
|
||||||
msgid "user"
|
|
||||||
msgstr "usuario"
|
|
||||||
|
|
||||||
#: core/models.py:220
|
|
||||||
msgid "users"
|
|
||||||
msgstr "usuarios"
|
|
||||||
|
|
||||||
#: core/models.py:286
|
|
||||||
msgid "Resource"
|
|
||||||
msgstr "Recurso"
|
|
||||||
|
|
||||||
#: core/models.py:287
|
|
||||||
msgid "Resources"
|
|
||||||
msgstr "Recursos"
|
|
||||||
|
|
||||||
#: core/models.py:345
|
|
||||||
msgid "Resource access"
|
|
||||||
msgstr "Acceso a recursos"
|
|
||||||
|
|
||||||
#: core/models.py:346
|
|
||||||
msgid "Resource accesses"
|
|
||||||
msgstr "Accesos a recursos"
|
|
||||||
|
|
||||||
#: core/models.py:352
|
|
||||||
msgid "Resource access with this User and Resource already exists."
|
|
||||||
msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
|
|
||||||
|
|
||||||
#: core/models.py:409
|
|
||||||
msgid "Visio room configuration"
|
|
||||||
msgstr "Configuración de la videoconferencia"
|
|
||||||
|
|
||||||
#: core/models.py:410
|
|
||||||
msgid "Values for Visio parameters to configure the room."
|
|
||||||
msgstr "Valores de los parámetros de videoconferencia para configurar la reunión."
|
|
||||||
|
|
||||||
#: core/models.py:417
|
|
||||||
msgid "Room PIN code"
|
|
||||||
msgstr "Código PIN de la reunión"
|
|
||||||
|
|
||||||
#: core/models.py:418
|
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
|
||||||
msgstr "Código único de n dígitos que identifica esta reunión en modo telefónico."
|
|
||||||
|
|
||||||
#: core/models.py:424 core/models.py:578
|
|
||||||
msgid "Room"
|
|
||||||
msgstr "Reunión"
|
|
||||||
|
|
||||||
#: core/models.py:425
|
|
||||||
msgid "Rooms"
|
|
||||||
msgstr "Reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:589
|
|
||||||
msgid "Worker ID"
|
|
||||||
msgstr "ID del worker"
|
|
||||||
|
|
||||||
#: core/models.py:591
|
|
||||||
msgid ""
|
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
|
||||||
"the worker stops, allowing for easy tracking."
|
|
||||||
msgstr "Introduce un identificador para la grabación del worker. Este identificador se conserva incluso cuando el worker se detiene, lo que permite un seguimiento sencillo."
|
|
||||||
|
|
||||||
#: core/models.py:599
|
|
||||||
msgid "Recording mode"
|
|
||||||
msgstr "Modo de grabación"
|
|
||||||
|
|
||||||
#: core/models.py:600
|
|
||||||
msgid "Defines the mode of recording being called."
|
|
||||||
msgstr "Define el modo de grabación a utilizar."
|
|
||||||
|
|
||||||
#: core/models.py:605 core/models.py:606
|
|
||||||
msgid "Recording options"
|
|
||||||
msgstr "Opciones de grabación"
|
|
||||||
|
|
||||||
#: core/models.py:613
|
|
||||||
msgid "External Process ID"
|
|
||||||
msgstr "ID del proceso externo"
|
|
||||||
|
|
||||||
#: core/models.py:614
|
|
||||||
msgid "ID of the external process associated with the recording."
|
|
||||||
msgstr "ID del proceso externo asociado a la grabación."
|
|
||||||
|
|
||||||
#: core/models.py:620
|
|
||||||
msgid "Recording"
|
|
||||||
msgstr "Grabación"
|
|
||||||
|
|
||||||
#: core/models.py:621
|
|
||||||
msgid "Recordings"
|
|
||||||
msgstr "Grabaciones"
|
|
||||||
|
|
||||||
#: core/models.py:731
|
|
||||||
msgid "Recording/user relation"
|
|
||||||
msgstr "Relación grabación/usuario"
|
|
||||||
|
|
||||||
#: core/models.py:732
|
|
||||||
msgid "Recording/user relations"
|
|
||||||
msgstr "Relaciones grabación/usuario"
|
|
||||||
|
|
||||||
#: core/models.py:738
|
|
||||||
msgid "This user is already in this recording."
|
|
||||||
msgstr "Este usuario ya está en esta grabación."
|
|
||||||
|
|
||||||
#: core/models.py:744
|
|
||||||
msgid "This team is already in this recording."
|
|
||||||
msgstr "Este equipo ya está en esta grabación."
|
|
||||||
|
|
||||||
#: core/models.py:750
|
|
||||||
msgid "Either user or team must be set, not both."
|
|
||||||
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
|
|
||||||
|
|
||||||
#: core/models.py:767
|
|
||||||
msgid "Create rooms"
|
|
||||||
msgstr "Crear reunión"
|
|
||||||
|
|
||||||
#: core/models.py:768
|
|
||||||
msgid "List rooms"
|
|
||||||
msgstr "Listar reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:769
|
|
||||||
msgid "Retrieve room details"
|
|
||||||
msgstr "Ver los detalles de una reunión"
|
|
||||||
|
|
||||||
#: core/models.py:770
|
|
||||||
msgid "Update rooms"
|
|
||||||
msgstr "Actualizar las reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:771
|
|
||||||
msgid "Delete rooms"
|
|
||||||
msgstr "Eliminar las reuniones"
|
|
||||||
|
|
||||||
#: core/models.py:784
|
|
||||||
msgid "Application name"
|
|
||||||
msgstr "Nombre de la aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:785
|
|
||||||
msgid "Descriptive name for this application."
|
|
||||||
msgstr "Nombre descriptivo de esta aplicación."
|
|
||||||
|
|
||||||
#: core/models.py:795
|
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
|
||||||
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
|
|
||||||
|
|
||||||
#: core/models.py:806
|
|
||||||
msgid "Application"
|
|
||||||
msgstr "Aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:807
|
|
||||||
msgid "Applications"
|
|
||||||
msgstr "Aplicaciones"
|
|
||||||
|
|
||||||
#: core/models.py:830
|
|
||||||
msgid "Enter a valid domain"
|
|
||||||
msgstr "Introduce un dominio válido"
|
|
||||||
|
|
||||||
#: core/models.py:833
|
|
||||||
msgid "Domain"
|
|
||||||
msgstr "Dominio"
|
|
||||||
|
|
||||||
#: core/models.py:834
|
|
||||||
msgid "Email domain this application can act on behalf of."
|
|
||||||
msgstr "Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
|
|
||||||
|
|
||||||
#: core/models.py:846
|
|
||||||
msgid "Application domain"
|
|
||||||
msgstr "Dominio de aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:847
|
|
||||||
msgid "Application domains"
|
|
||||||
msgstr "Dominios de aplicación"
|
|
||||||
|
|
||||||
#: core/models.py:865
|
|
||||||
msgid "Pending"
|
|
||||||
msgstr "Pendiente"
|
|
||||||
|
|
||||||
#: core/models.py:866
|
|
||||||
msgid "Analyzing"
|
|
||||||
msgstr "Analizando"
|
|
||||||
|
|
||||||
#: core/models.py:873
|
|
||||||
msgid "Ready"
|
|
||||||
msgstr "Listo"
|
|
||||||
|
|
||||||
#: core/models.py:879
|
|
||||||
msgid "Background image"
|
|
||||||
msgstr "Imagen de fondo"
|
|
||||||
|
|
||||||
#: core/models.py:891
|
|
||||||
msgid "title"
|
|
||||||
msgstr "título"
|
|
||||||
|
|
||||||
#: core/models.py:915
|
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
|
||||||
msgstr "Información sobre la detección de malware cuando el estado del análisis no es seguro."
|
|
||||||
|
|
||||||
#: core/models.py:920
|
|
||||||
msgid "File"
|
|
||||||
msgstr "Archivo"
|
|
||||||
|
|
||||||
#: core/models.py:921
|
|
||||||
msgid "Files"
|
|
||||||
msgstr "Archivos"
|
|
||||||
|
|
||||||
#: core/models.py:1041
|
|
||||||
msgid "This file is already hard deleted."
|
|
||||||
msgstr "Este archivo ya se ha eliminado definitivamente."
|
|
||||||
|
|
||||||
#: core/models.py:1051
|
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
|
||||||
msgstr "Para eliminar definitivamente un archivo, primero debe haberse marcado como eliminado."
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:123
|
|
||||||
msgid "Your recording is ready"
|
|
||||||
msgstr "Tu grabación está lista"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:194
|
|
||||||
msgid "Transcription"
|
|
||||||
msgstr "Transcripción"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:204
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
|
||||||
msgstr "Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
|
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
|
||||||
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:3
|
|
||||||
msgid "Logo email"
|
|
||||||
msgstr "Logotipo del correo"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:189
|
|
||||||
#: core/templates/mail/text/invitation.txt:5
|
|
||||||
msgid "invites you to join an ongoing video call"
|
|
||||||
msgstr "te invita a unirte a una videollamada en curso"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:200
|
|
||||||
#: core/templates/mail/text/invitation.txt:7
|
|
||||||
msgid "JOIN THE CALL"
|
|
||||||
msgstr "UNIRSE A LA LLAMADA"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:227
|
|
||||||
#: core/templates/mail/text/invitation.txt:13
|
|
||||||
msgid ""
|
|
||||||
"If you can't click the button, copy and paste the URL into your browser to "
|
|
||||||
"join the call."
|
|
||||||
msgstr "Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador para unirte a la llamada."
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:235
|
|
||||||
#: core/templates/mail/text/invitation.txt:15
|
|
||||||
msgid "Tips for a better experience:"
|
|
||||||
msgstr "Consejos para una mejor experiencia:"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:237
|
|
||||||
#: core/templates/mail/text/invitation.txt:17
|
|
||||||
msgid "Use Chrome or Firefox for better call quality"
|
|
||||||
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:238
|
|
||||||
#: core/templates/mail/text/invitation.txt:18
|
|
||||||
msgid "Test your microphone and camera before joining"
|
|
||||||
msgstr "Prueba tu micrófono y tu cámara antes de unirte"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:239
|
|
||||||
#: core/templates/mail/text/invitation.txt:19
|
|
||||||
msgid "Make sure you have a stable internet connection"
|
|
||||||
msgstr "Asegúrate de tener una conexión a internet estable"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:248
|
|
||||||
#: core/templates/mail/html/screen_recording.html:245
|
|
||||||
#: core/templates/mail/text/invitation.txt:21
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:23
|
|
||||||
#, python-format
|
|
||||||
msgid " Thank you for using %(brandname)s. "
|
|
||||||
msgstr " Gracias por usar %(brandname)s. "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:188
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:6
|
|
||||||
msgid "Your recording is ready!"
|
|
||||||
msgstr "¡Tu grabación está lista!"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:195
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:8
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
|
||||||
"%(recording_time)s is now ready to download. "
|
|
||||||
msgstr " Tu grabación de \"%(room_name)s\" del %(recording_date)s a las %(recording_time)s ya está lista para descargar. "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:195
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:8
|
|
||||||
#, python-format
|
|
||||||
msgid " The recording will expire in %(days)s days. "
|
|
||||||
msgstr " La grabación caducará dentro de %(days)s días. "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:200
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:9
|
|
||||||
msgid ""
|
|
||||||
" Sharing the recording via link is not yet available. Only organizers can "
|
|
||||||
"download it. "
|
|
||||||
msgstr " Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla. "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:206
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:11
|
|
||||||
msgid "To keep this recording permanently:"
|
|
||||||
msgstr "Para conservar esta grabación de forma permanente:"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
|
||||||
#, python-format
|
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
|
||||||
msgstr "Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:209
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:14
|
|
||||||
msgid "Use the \"Download\" button in the interface "
|
|
||||||
msgstr "Usa el botón \"Descargar\" de la interfaz "
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:210
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:15
|
|
||||||
msgid "Save the file to your preferred location"
|
|
||||||
msgstr "Guarda el archivo en la ubicación que prefieras"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:221
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:17
|
|
||||||
msgid "Open"
|
|
||||||
msgstr "Abrir"
|
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:230
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:19
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
" If you have any questions or need assistance, please contact our support "
|
|
||||||
"team at %(support_email)s. "
|
|
||||||
msgstr " Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro equipo de soporte en %(support_email)s. "
|
|
||||||
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
|
||||||
#, python-format
|
|
||||||
msgid "Click the \"Open [%(link)s]\" link below "
|
|
||||||
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
|
|
||||||
|
|
||||||
#: meet/settings.py:228
|
|
||||||
msgid "English"
|
|
||||||
msgstr "Inglés"
|
|
||||||
|
|
||||||
#: meet/settings.py:229
|
|
||||||
msgid "French"
|
|
||||||
msgstr "Francés"
|
|
||||||
|
|
||||||
#: meet/settings.py:230
|
|
||||||
msgid "Dutch"
|
|
||||||
msgstr "Neerlandés"
|
|
||||||
|
|
||||||
#: meet/settings.py:231
|
|
||||||
msgid "German"
|
|
||||||
msgstr "Alemán"
|
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Español"
|
|
||||||
@@ -678,7 +678,3 @@ msgstr "Néerlandais"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Espagnol"
|
|
||||||
|
|||||||
@@ -672,7 +672,3 @@ msgstr "Nederlands"
|
|||||||
#: meet/settings.py:231
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|
||||||
#: meet/settings.py:233
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spaans"
|
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ class Base(Configuration):
|
|||||||
("fr-fr", _("French")),
|
("fr-fr", _("French")),
|
||||||
("nl-nl", _("Dutch")),
|
("nl-nl", _("Dutch")),
|
||||||
("de-de", _("German")),
|
("de-de", _("German")),
|
||||||
("es-es", _("Spanish")),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,6 +325,7 @@ class Base(Configuration):
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"core.authentication.backends.SessionAuthenticationWith401",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
|
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
"rest_framework.parsers.JSONParser",
|
"rest_framework.parsers.JSONParser",
|
||||||
@@ -345,6 +345,11 @@ class Base(Configuration):
|
|||||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"exchange_access_token": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
"creation_callback": values.Value(
|
"creation_callback": values.Value(
|
||||||
default="600/minute",
|
default="600/minute",
|
||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
@@ -730,6 +735,20 @@ class Base(Configuration):
|
|||||||
environ_name="RECORDING_WORKER_CLASSES",
|
environ_name="RECORDING_WORKER_CLASSES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
||||||
|
"core.recording.event.parsers.MinioParser",
|
||||||
|
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
||||||
|
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
||||||
|
)
|
||||||
|
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||||
|
)
|
||||||
|
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
|
||||||
|
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||||
|
)
|
||||||
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
||||||
# Set to None for no expiration
|
# Set to None for no expiration
|
||||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||||
@@ -871,11 +890,6 @@ class Base(Configuration):
|
|||||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
LOBBY_COOKIE_NAME = values.Value(
|
|
||||||
"lobbyParticipantId",
|
|
||||||
environ_name="LOBBY_COOKIE_NAME",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calendar integrations
|
# Calendar integrations
|
||||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||||
@@ -998,6 +1012,66 @@ class Base(Configuration):
|
|||||||
environ_name="APPLICATION_BASE_URL",
|
environ_name="APPLICATION_BASE_URL",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User access tokens (embedded frontend / iframe support)
|
||||||
|
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||||
|
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||||
|
"HS256",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||||
|
"lasuite-meet",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Lifetime of the user access token obtained through the exchange
|
||||||
|
# endpoint. It never transits through a URL, so it can cover a full
|
||||||
|
# meeting (default: 2 hours).
|
||||||
|
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||||
|
7200,
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Lifetime of the single-use transit code handed to the frontend
|
||||||
|
# through a URL fragment. Kept very short by design: it must only
|
||||||
|
# survive the redirect and the exchange call.
|
||||||
|
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||||
|
60,
|
||||||
|
environ_name="TRANSIT_CODE_TTL",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||||
|
"transit-code",
|
||||||
|
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||||
|
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||||
|
48,
|
||||||
|
environ_name="TRANSIT_CODE_NBYTES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||||
|
"Bearer",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
USER_ACCESS_TOKEN_TYPE_CLAIM = values.Value(
|
||||||
|
"user_token",
|
||||||
|
environ_name="USER_ACCESS_TOKEN_TYPE_CLAIM",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||||
@@ -1294,6 +1368,9 @@ class Test(Base):
|
|||||||
ADDONS_ENABLED = True
|
ADDONS_ENABLED = True
|
||||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
|
USER_ACCESS_TOKEN_ENABLED = True
|
||||||
|
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||||
|
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||||
|
|
||||||
CONNECTION_TEST_ENABLED = True
|
CONNECTION_TEST_ENABLED = True
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ dependencies = [
|
|||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.17",
|
"django==5.2.16",
|
||||||
"djangorestframework==3.17.1",
|
"djangorestframework==3.17.1",
|
||||||
"drf_spectacular==0.30.0",
|
"drf_spectacular==0.30.0",
|
||||||
"dockerflow==2026.3.4",
|
"dockerflow==2026.3.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -586,16 +586,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "5.2.17"
|
version = "5.2.16"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1262,7 +1262,7 @@ requires-dist = [
|
|||||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
||||||
{ name = "cryptography", specifier = "==50.0.0" },
|
{ name = "cryptography", specifier = "==50.0.0" },
|
||||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||||
{ name = "django", specifier = "==5.2.17" },
|
{ name = "django", specifier = "==5.2.16" },
|
||||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||||
{ name = "django-countries", specifier = "==9.0.0" },
|
{ name = "django-countries", specifier = "==9.0.0" },
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||||
"createOldCatalogs": false,
|
"createOldCatalogs": false,
|
||||||
"locales": ["en", "fr", "de", "nl", "es"],
|
"locales": ["en", "fr", "de", "nl"],
|
||||||
"sort": true
|
"sort": true
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+35
-40
@@ -9,8 +9,8 @@
|
|||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.3.0",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.23",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.409.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
@@ -776,18 +776,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/lexend": {
|
"node_modules/@fontsource-variable/lexend": {
|
||||||
"version": "5.3.0",
|
"version": "5.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||||
"integrity": "sha512-3SXtiZ8rFbT0oaPzEboCG5RM3jtyhpNxandh1jWNLqvrJRfV/eP3R+GGw+o5e3hS1uqmAGutKxSvaWG5oyrimA==",
|
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/opendyslexic": {
|
"node_modules/@fontsource/opendyslexic": {
|
||||||
"version": "5.3.0",
|
"version": "5.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
|
||||||
"integrity": "sha512-BVYIW/ghc1U2iAghhYTN+GFWiZ3lnCy8Jf2KkZC23aHQHBNcMqKT718zckfqVxRx4qsXJmIMPWvczirRuhmtsg==",
|
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
@@ -1720,28 +1720,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/browser-common": {
|
"node_modules/@posthog/browser-common": {
|
||||||
"version": "0.4.0",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
|
||||||
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
|
"integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.46.8",
|
"@posthog/core": "^1.46.0",
|
||||||
"@posthog/types": "^1.402.0"
|
"@posthog/types": "^1.399.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.49.1",
|
"version": "1.48.3",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||||
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
|
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.407.0"
|
"@posthog/types": "^1.405.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.407.1",
|
"version": "1.405.0",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||||
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
|
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -4794,10 +4794,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.14",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@@ -9131,21 +9134,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.414.0",
|
"version": "1.409.5",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
|
||||||
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
|
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
|
||||||
"license": "(Apache-2.0 AND MIT)",
|
"license": "(Apache-2.0 AND MIT)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/browser-common": "^0.4.0",
|
"@posthog/browser-common": "^0.3.1",
|
||||||
"@posthog/core": "^1.46.9",
|
"@posthog/core": "^1.46.1",
|
||||||
"@posthog/types": "^1.402.2",
|
"@posthog/types": "^1.399.0",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.49.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.3",
|
"preact": "^10.29.3",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0",
|
"web-vitals": "^5.3.0"
|
||||||
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -11657,13 +11659,6 @@
|
|||||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals-soft-navs": {
|
|
||||||
"name": "web-vitals",
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
"version": "9.0.6",
|
"version": "9.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.3.0",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.23",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.409.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
|
|||||||
+24
-17
@@ -12,6 +12,7 @@ import { routes } from './routes'
|
|||||||
import './i18n/init'
|
import './i18n/init'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { AppInitialization } from '@/components/AppInitialization'
|
import { AppInitialization } from '@/components/AppInitialization'
|
||||||
|
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||||
|
|
||||||
@@ -24,23 +25,29 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{!isSDKContext && <AppInitialization />}
|
<TransitCodeGate>
|
||||||
<Suspense fallback={null}>
|
{!isSDKContext && <AppInitialization />}
|
||||||
<I18nProvider locale={i18n.language}>
|
<Suspense fallback={null}>
|
||||||
<Layout>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Switch>
|
<Layout>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Switch>
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
))}
|
<Route
|
||||||
<Route component={NotFoundScreen} />
|
key={i}
|
||||||
</Switch>
|
path={route.path}
|
||||||
</Layout>
|
component={route.Component}
|
||||||
<ReactQueryDevtools
|
/>
|
||||||
initialIsOpen={false}
|
))}
|
||||||
buttonPosition="bottom-left"
|
<Route component={NotFoundScreen} />
|
||||||
/>
|
</Switch>
|
||||||
</I18nProvider>
|
</Layout>
|
||||||
</Suspense>
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
buttonPosition="bottom-left"
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
</Suspense>
|
||||||
|
</TransitCodeGate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { ApiError } from './ApiError'
|
import { ApiError } from './ApiError'
|
||||||
import { apiUrl } from './apiUrl'
|
import { apiUrl } from './apiUrl'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
export const fetchApi = async <T = Record<string, unknown>>(
|
export const fetchApi = async <T = Record<string, unknown>>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const csrfToken = getCsrfToken()
|
const csrfToken = getCsrfToken()
|
||||||
|
// Embedded (iframe) mode: the user access token obtained through the
|
||||||
|
// transit code exchange authenticates requests in place of the session
|
||||||
|
// cookie, which is blocked in third-party contexts.
|
||||||
|
const accessToken = getAccessToken()
|
||||||
const response = await fetch(apiUrl(url), {
|
const response = await fetch(apiUrl(url), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||||
|
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { setAccessToken } from '@/stores/accessToken'
|
||||||
|
import {
|
||||||
|
consumeTransitCodeFromFragment,
|
||||||
|
isEmbedded,
|
||||||
|
} from '../utils/transitCode'
|
||||||
|
|
||||||
|
type ApiAccessToken = {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
scope: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a single-use transit code for a user access token.
|
||||||
|
*
|
||||||
|
* The endpoint is unauthenticated: the code itself is the credential.
|
||||||
|
*/
|
||||||
|
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
|
||||||
|
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runInitialization = async (): Promise<void> => {
|
||||||
|
const code = consumeTransitCodeFromFragment()
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmbedded()) {
|
||||||
|
console.warn('Transit code ignored outside an embedded context')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { access_token } = await exchangeAccessToken(code)
|
||||||
|
setAccessToken(access_token)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Transit code exchange failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialization: Promise<void> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the embedded (iframe) authentication, if applicable.
|
||||||
|
*
|
||||||
|
* When, and only when, a transit code is present in the URL fragment,
|
||||||
|
* exchange it for a user access token and keep it in the in-memory
|
||||||
|
* accessToken store: fetchApi then sends it as a Bearer header on every
|
||||||
|
* api call, authenticating the user exactly like a session cookie would.
|
||||||
|
*
|
||||||
|
* Must complete before anything fires an authenticated query, which the
|
||||||
|
* TransitCodeGate component guarantees by gating the app tree on it.
|
||||||
|
*
|
||||||
|
* Memoized: the fragment is consumed and the code exchanged exactly once,
|
||||||
|
* however many times this is called (StrictMode double-invoked effects,
|
||||||
|
* among others). Subsequent calls await the same promise.
|
||||||
|
*
|
||||||
|
* A failed exchange (expired or already used code) is not fatal: the app
|
||||||
|
* starts unauthenticated, falling back to the regular session flow.
|
||||||
|
*/
|
||||||
|
export const initializeAccessTokenFromFragment = (): Promise<void> => {
|
||||||
|
if (!initialization) {
|
||||||
|
initialization = runInitialization()
|
||||||
|
}
|
||||||
|
return initialization
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch the logged-in user from the api.
|
* fetch the logged-in user from the api.
|
||||||
@@ -25,7 +26,13 @@ export const fetchUser = (
|
|||||||
if (error instanceof ApiError && error.statusCode === 401) {
|
if (error instanceof ApiError && error.statusCode === 401) {
|
||||||
// make sure to not resolve the promise while trying to silent login
|
// make sure to not resolve the promise while trying to silent login
|
||||||
// so that consumers of fetchUser don't think the work already ended
|
// so that consumers of fetchUser don't think the work already ended
|
||||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
// Never attempt a silent login in embedded (token) mode: an OIDC
|
||||||
|
// redirect inside the iframe would break the embed.
|
||||||
|
if (
|
||||||
|
opts.attemptSilent &&
|
||||||
|
!getAccessToken() &&
|
||||||
|
canAttemptSilentLogin()
|
||||||
|
) {
|
||||||
attemptSilentLogin(30)
|
attemptSilentLogin(30)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
resolve(false)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
import { useHash } from '@/hooks/useHash'
|
||||||
|
import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken'
|
||||||
|
import { hasTransitCodeInFragment } from '../utils/transitCode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates the app tree on the embedded (iframe) authentication bootstrap.
|
||||||
|
*
|
||||||
|
* Without a transit code in the URL fragment — the overwhelmingly common
|
||||||
|
* case — the component early returns children synchronously: no state,
|
||||||
|
* no effect, no extra render, no loading screen.
|
||||||
|
*
|
||||||
|
* When a transit code is present, children are not mounted until it has
|
||||||
|
* been exchanged for a user access token, so that every authenticated
|
||||||
|
* query already carries the Authorization header. A loading screen is
|
||||||
|
* displayed in the meantime, as UserAware does.
|
||||||
|
*/
|
||||||
|
export const TransitCodeGate = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => {
|
||||||
|
const hash = useHash()
|
||||||
|
|
||||||
|
// Note: the exchange only happens in an embedding context. This check lives
|
||||||
|
// in initializeAccessTokenFromFragment, the single funnel for all bootstrap paths.
|
||||||
|
// The gate still mounts top-level to scrub the fragment, but bootstrap then resolves
|
||||||
|
// immediately without exchanging.
|
||||||
|
//
|
||||||
|
// Latch the decision on the initial hash: bootstrap scrubs it immediately, and the
|
||||||
|
// gate must not switch back to the fast path while the exchange is in flight.
|
||||||
|
const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
|
||||||
|
|
||||||
|
if (!needsExchange) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TransitCodeExchange>{children}</TransitCodeExchange>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only ever mounted when a transit code is present: runs the memoized
|
||||||
|
* bootstrap (safe against StrictMode double-invoked effects) and holds
|
||||||
|
* children back until it settles.
|
||||||
|
*/
|
||||||
|
const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
initializeAccessTokenFromFragment().finally(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsReady(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isReady ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app is rendered inside an embedding context (iframe).
|
||||||
|
*
|
||||||
|
* Comparing window references never throws, even when the parent is
|
||||||
|
* cross-origin. Defaults to false outside a browser environment.
|
||||||
|
*/
|
||||||
|
export const isEmbedded = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return window.self !== window.top
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a URL fragment carries a transit code. Pure check, does not
|
||||||
|
* consume anything.
|
||||||
|
*/
|
||||||
|
export const hasTransitCodeInFragment = (hash: string): boolean => {
|
||||||
|
if (!hash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return new URLSearchParams(hash.replace(/^#/, '')).has(
|
||||||
|
TRANSIT_CODE_FRAGMENT_PARAM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the transit code from the URL fragment, if any.
|
||||||
|
*
|
||||||
|
* The fragment is scrubbed from the address bar immediately, before any
|
||||||
|
* network call, so the code never lingers in the browser history. Any
|
||||||
|
* other fragment content is preserved.
|
||||||
|
*/
|
||||||
|
export const consumeTransitCodeFromFragment = (): string | null => {
|
||||||
|
if (typeof window === 'undefined' || !window.location.hash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.hash.substring(1))
|
||||||
|
const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
params.delete(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||||
|
const remaining = params.toString()
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
window.location.pathname +
|
||||||
|
window.location.search +
|
||||||
|
(remaining ? `#${remaining}` : '')
|
||||||
|
)
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
@@ -21,9 +21,7 @@ const StyledContainer = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const ChatTextArea = () => {
|
export const ChatTextArea = () => {
|
||||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore, {
|
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||||
sync: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
|
||||||
@@ -51,7 +49,7 @@ export const ChatTextArea = () => {
|
|||||||
const isDisabled = !textAreaValue.trim() || isSending
|
const isDisabled = !textAreaValue.trim() || isSending
|
||||||
|
|
||||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key !== 'Escape') e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||||
return
|
return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { accessTokenStore } from '@/stores/accessToken'
|
||||||
|
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive companion of resolveMediaUrl for browser-native consumers
|
||||||
|
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
|
||||||
|
* returns a stable lookup, identity in regular mode.
|
||||||
|
*
|
||||||
|
* Object URLs come from the shared session-lifetime cache and are never
|
||||||
|
* revoked here: they may be used concurrently by the background
|
||||||
|
* processors.
|
||||||
|
*/
|
||||||
|
export const useResolvedMediaUrls = (
|
||||||
|
urls: (string | null | undefined)[]
|
||||||
|
): ((url: string) => string) => {
|
||||||
|
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||||
|
const { accessToken } = useSnapshot(accessTokenStore)
|
||||||
|
|
||||||
|
// Stable dependency for the effect, insensitive to array identity
|
||||||
|
const urlsKey = urls.filter(Boolean).sort().join('\n')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken || !urlsKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const resolveAll = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
urlsKey.split('\n').map(async (url) => {
|
||||||
|
try {
|
||||||
|
return [url, await resolveMediaUrl(url)] as const
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return [url, url] as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (isMounted) {
|
||||||
|
setResolved(Object.fromEntries(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveAll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [accessToken, urlsKey])
|
||||||
|
|
||||||
|
// Stable identity so that consumers can safely list the resolver in
|
||||||
|
// their memo dependencies: it only changes when resolutions land.
|
||||||
|
return useCallback((url: string) => resolved[url] ?? url, [resolved])
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
|
// Session-lifetime cache: object URLs are shared between every consumer
|
||||||
|
// of a given media (background processors, thumbnails) and are therefore
|
||||||
|
// never revoked - their number is bounded by the user's custom
|
||||||
|
// backgrounds, and they die with the page like the access token does.
|
||||||
|
const objectUrlCache = new Map<string, string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an authenticated /media/ URL for the embedded (token) mode.
|
||||||
|
*
|
||||||
|
* Media files are served behind an nginx auth_request subrequest that
|
||||||
|
* authenticates the original request. In regular mode the session cookie
|
||||||
|
* rides along browser-native loads (img.src, CSS url()) and the URL is
|
||||||
|
* returned unchanged, without any fetch. In embedded mode the
|
||||||
|
* third-party cookie is blocked and native loads cannot carry the
|
||||||
|
* Authorization header, so the media is fetched here with the Bearer
|
||||||
|
* header - which the media-auth endpoint accepts, as it sits behind the
|
||||||
|
* default authentication stack - and exposed as a blob object URL.
|
||||||
|
*/
|
||||||
|
export const resolveMediaUrl = async (url: string): Promise<string> => {
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = objectUrlCache.get(url)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve media url ${url}: HTTP ${response.status}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(await response.blob())
|
||||||
|
objectUrlCache.set(url, objectUrl)
|
||||||
|
|
||||||
|
return objectUrl
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
|||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
@@ -40,7 +41,7 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = !isAdminOrOwner
|
const headers = !isAdminOrOwner
|
||||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let response
|
let response
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useRenameParticipant = () => {
|
export const useRenameParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
|
|||||||
throw new Error('LiveKit token is not available')
|
throw new Error('LiveKit token is not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = getLiveKitAuthHeaders(token)
|
||||||
return fetchApi(`rooms/${data.id}/rename/`, {
|
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { getLobbyParticipantId } from '@/stores/lobby'
|
||||||
|
|
||||||
export interface RequestEntryParams {
|
export interface RequestEntryParams {
|
||||||
roomId: string
|
roomId: string
|
||||||
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiRequestEntry {
|
export interface ApiRequestEntry {
|
||||||
|
id?: string
|
||||||
status: ApiLobbyStatus
|
status: ApiLobbyStatus
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
}
|
}
|
||||||
@@ -23,10 +25,12 @@ export const requestEntry = async ({
|
|||||||
roomId,
|
roomId,
|
||||||
username = '',
|
username = '',
|
||||||
}: RequestEntryParams) => {
|
}: RequestEntryParams) => {
|
||||||
|
const participantId = getLobbyParticipantId(roomId)
|
||||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
|
...(participantId && { participant_id: participantId }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export const useRaiseHand = () => {
|
export const useRaiseHand = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -15,11 +16,10 @@ export const useRaiseHand = () => {
|
|||||||
throw new Error('LiveKit token is not available')
|
throw new Error('LiveKit token is not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = getLiveKitAuthHeaders(token)
|
||||||
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
raised,
|
raised,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ApiLobbyStatus,
|
ApiLobbyStatus,
|
||||||
type ApiRequestEntry,
|
type ApiRequestEntry,
|
||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
|
import { setLobbyParticipantId } from '@/stores/lobby'
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
@@ -43,6 +44,11 @@ export const useLobby = ({
|
|||||||
roomId,
|
roomId,
|
||||||
username,
|
username,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.id) {
|
||||||
|
setLobbyParticipantId(roomId, response.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
import { TrackSource } from '@livekit/protocol'
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
|
||||||
|
|
||||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
||||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
||||||
@@ -9,8 +7,6 @@ export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
|||||||
export type MediaStateChangedDetail = {
|
export type MediaStateChangedDetail = {
|
||||||
microphoneEnabled: boolean
|
microphoneEnabled: boolean
|
||||||
cameraEnabled: boolean
|
cameraEnabled: boolean
|
||||||
canPublishMicrophone: boolean
|
|
||||||
canPublishCamera: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,15 +16,9 @@ export type MediaStateChangedDetail = {
|
|||||||
*
|
*
|
||||||
* const el = document.getElementById('media-state')
|
* const el = document.getElementById('media-state')
|
||||||
* new MutationObserver(...).observe(el, { attributes: true })
|
* new MutationObserver(...).observe(el, { attributes: true })
|
||||||
*
|
|
||||||
* The publish permissions are exposed alongside the state: an external tool
|
|
||||||
* cannot tell a muted microphone from one it is not allowed to unmute, and
|
|
||||||
* would otherwise offer a control that silently does nothing.
|
|
||||||
*/
|
*/
|
||||||
export const MediaStateObserver = () => {
|
export const MediaStateObserver = () => {
|
||||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
||||||
const canPublishMicrophone = useCanPublishTrack(TrackSource.MICROPHONE)
|
|
||||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
@@ -36,17 +26,10 @@ export const MediaStateObserver = () => {
|
|||||||
detail: {
|
detail: {
|
||||||
microphoneEnabled: isMicrophoneEnabled,
|
microphoneEnabled: isMicrophoneEnabled,
|
||||||
cameraEnabled: isCameraEnabled,
|
cameraEnabled: isCameraEnabled,
|
||||||
canPublishMicrophone,
|
|
||||||
canPublishCamera,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}, [
|
}, [isMicrophoneEnabled, isCameraEnabled])
|
||||||
isMicrophoneEnabled,
|
|
||||||
isCameraEnabled,
|
|
||||||
canPublishMicrophone,
|
|
||||||
canPublishCamera,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -54,8 +37,6 @@ export const MediaStateObserver = () => {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
||||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
||||||
data-can-publish-microphone={canPublishMicrophone ? 'true' : 'false'}
|
|
||||||
data-can-publish-camera={canPublishCamera ? 'true' : 'false'}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import { Info } from './Info'
|
|||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -26,7 +24,6 @@ type StyledSidePanelProps = {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
isClosed: boolean
|
isClosed: boolean
|
||||||
closeButtonTooltip: string
|
closeButtonTooltip: string
|
||||||
escapeHint: string
|
|
||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
backButtonLabel: string
|
||||||
@@ -43,7 +40,6 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
|||||||
isClosed,
|
isClosed,
|
||||||
isReactionToolbarOpen,
|
isReactionToolbarOpen,
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
escapeHint,
|
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
backButtonLabel,
|
backButtonLabel,
|
||||||
@@ -88,11 +84,7 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
|||||||
}}
|
}}
|
||||||
aria-hidden={isClosed}
|
aria-hidden={isClosed}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-describedby="side-panel-escape-hint"
|
|
||||||
>
|
>
|
||||||
<span id="side-panel-escape-hint" className={srOnly}>
|
|
||||||
{escapeHint}
|
|
||||||
</span>
|
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
{isSubmenu && (
|
{isSubmenu && (
|
||||||
<Button
|
<Button
|
||||||
@@ -202,8 +194,6 @@ export const SidePanel = () => {
|
|||||||
activeKey: activePanelId,
|
activeKey: activePanelId,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEscapeToClose(isSidePanelOpen, asideRef, closeSidePanel)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
ref={asideRef}
|
ref={asideRef}
|
||||||
@@ -213,7 +203,6 @@ export const SidePanel = () => {
|
|||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
escapeHint={t('escapeHint')}
|
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
|
|||||||
+9
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
ImageSegmenter,
|
ImageSegmenter,
|
||||||
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.sourceSettings = this.source!.getSettings()
|
this.sourceSettings = this.source!.getSettings()
|
||||||
this.videoElement = opts.element as HTMLVideoElement
|
this.videoElement = opts.element as HTMLVideoElement
|
||||||
|
|
||||||
this._initVirtualBackgroundImage()
|
await this._initVirtualBackgroundImage()
|
||||||
this._createMainCanvas()
|
this._createMainCanvas()
|
||||||
this._createMaskCanvas()
|
this._createMaskCanvas()
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
captureEvent('firefox-blurring-init', {})
|
captureEvent('firefox-blurring-init', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
async _initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,15 +114,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.virtualBackgroundImage &&
|
this.virtualBackgroundImage &&
|
||||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||||
if (this.options.imagePath || needsUpdate) {
|
if (this.options.imagePath || needsUpdate) {
|
||||||
|
// Embedded (token) mode: img.src cannot carry the Authorization
|
||||||
|
// header, resolve the media to a blob object URL first. Identity
|
||||||
|
// in regular mode.
|
||||||
|
const imagePath = await resolveMediaUrl(this.options.imagePath!)
|
||||||
this.virtualBackgroundImage = document.createElement('img')
|
this.virtualBackgroundImage = document.createElement('img')
|
||||||
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
||||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
this.virtualBackgroundImage.src = imagePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
this.options = opts
|
this.options = opts
|
||||||
this._initVirtualBackgroundImage()
|
await this._initVirtualBackgroundImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_initWorker() {
|
||||||
|
|||||||
+14
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||||
import {
|
import {
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
BackgroundProcessor,
|
BackgroundProcessor,
|
||||||
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||||
return this.processor.init(opts)
|
await this.processor.init(opts)
|
||||||
|
// Embedded (token) mode: the constructor passed the raw imagePath,
|
||||||
|
// whose native load cannot carry the Authorization header. Swap it
|
||||||
|
// for a resolved blob object URL. No-op in regular mode.
|
||||||
|
if (this.opts.type === 'virtual') {
|
||||||
|
const imagePath = await resolveMediaUrl(this.opts.imagePath)
|
||||||
|
if (imagePath !== this.opts.imagePath) {
|
||||||
|
await this.processor.updateTransformerOptions({ imagePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||||
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
|
if (opts.type === 'virtual') {
|
||||||
|
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
||||||
|
}
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
||||||
const newProcessorType =
|
const newProcessorType =
|
||||||
|
|||||||
+10
-1
@@ -8,6 +8,7 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
} from '../blur'
|
} from '../blur'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
|
||||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
@@ -280,6 +281,14 @@ export const EffectsConfiguration = ({
|
|||||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||||
false
|
false
|
||||||
|
|
||||||
|
// Thumbnails are browser-native loads (CSS url()) which cannot carry
|
||||||
|
// the Authorization header in embedded (token) mode: resolve them. The
|
||||||
|
// processor configs keep the stable raw URLs - they are persisted in
|
||||||
|
// the user choices - and the processors resolve them internally.
|
||||||
|
const resolveMediaUrl = useResolvedMediaUrls(
|
||||||
|
(filesQ.data?.results ?? []).map((file) => file.url)
|
||||||
|
)
|
||||||
|
|
||||||
const getHandleSelectChangeFile = useCallback(
|
const getHandleSelectChangeFile = useCallback(
|
||||||
(file: ApiFileItem) => {
|
(file: ApiFileItem) => {
|
||||||
return async () => {
|
return async () => {
|
||||||
@@ -757,7 +766,7 @@ export const EffectsConfiguration = ({
|
|||||||
bgSize: 'cover',
|
bgSize: 'cover',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${option.file.url!})`,
|
backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
|
||||||
}}
|
}}
|
||||||
data-attr={`toggle-virtual-${option.file.id}`}
|
data-attr={`toggle-virtual-${option.file.id}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const LIVEKIT_AUTH_SCHEME = 'X-LiveKit-Token'
|
||||||
|
|
||||||
|
export const getLiveKitAuthHeaders = (token: string) => {
|
||||||
|
return {
|
||||||
|
Authorization: `${LIVEKIT_AUTH_SCHEME} ${token}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import type { ApiError } from '@/api/ApiError'
|
import type { ApiError } from '@/api/ApiError'
|
||||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { getLiveKitAuthHeaders } from '@/features/rooms/utils/getLiveKitAuthHeaders'
|
||||||
|
|
||||||
export interface StartSubtitleParams {
|
export interface StartSubtitleParams {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,9 +15,7 @@ const startSubtitle = ({
|
|||||||
}: StartSubtitleParams): Promise<ApiRoom> => {
|
}: StartSubtitleParams): Promise<ApiRoom> => {
|
||||||
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
return fetchApi(`rooms/${id}/start-subtitle/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: getLiveKitAuthHeaders(token),
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ const useTranscriptionState = () => {
|
|||||||
segments: TranscriptionSegment[],
|
segments: TranscriptionSegment[],
|
||||||
participant?: Participant
|
participant?: Participant
|
||||||
) => {
|
) => {
|
||||||
console.log(participant, segments)
|
|
||||||
|
|
||||||
if (!participant || segments.length === 0) return
|
if (!participant || segments.length === 0) return
|
||||||
|
|
||||||
if (segments.length > 1) {
|
if (segments.length > 1) {
|
||||||
@@ -75,15 +73,15 @@ const useTranscriptionState = () => {
|
|||||||
const segment = segments[0]
|
const segment = segments[0]
|
||||||
|
|
||||||
setTranscriptionSegments((prevSegments) => {
|
setTranscriptionSegments((prevSegments) => {
|
||||||
const existingIndex = prevSegments.findIndex(
|
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||||
(s: TranscriptionSegmentWithParticipant) => s.id === segment.id
|
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||||
)
|
return [
|
||||||
if (existingIndex === -1) {
|
...prevSegments,
|
||||||
return [...prevSegments, { participant, ...segment }]
|
{
|
||||||
}
|
participant: participant,
|
||||||
const next = prevSegments.slice()
|
...segment,
|
||||||
next[existingIndex] = { ...next[existingIndex], ...segment }
|
},
|
||||||
return next
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useEffect, useRef, type RefObject } from 'react'
|
|
||||||
|
|
||||||
export const useEscapeToClose = (
|
|
||||||
isActive: boolean,
|
|
||||||
containerRef: RefObject<HTMLElement | null>,
|
|
||||||
onClose: () => void
|
|
||||||
) => {
|
|
||||||
const onCloseRef = useRef(onClose)
|
|
||||||
useEffect(() => {
|
|
||||||
onCloseRef.current = onClose
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive) return
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== 'Escape') return
|
|
||||||
if (!containerRef.current?.contains(document.activeElement)) return
|
|
||||||
e.stopPropagation()
|
|
||||||
onCloseRef.current()
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [isActive, containerRef])
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useLocationProperty } from 'wouter/use-browser-location'
|
||||||
|
|
||||||
|
const hashSelector = () =>
|
||||||
|
typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive window.location.hash, subscribed to wouter's navigation
|
||||||
|
* events (the same low-level primitive wouter builds useSearch upon).
|
||||||
|
*/
|
||||||
|
export const useHash = (): string => useLocationProperty(hashSelector, () => '')
|
||||||
@@ -15,7 +15,7 @@ i18n
|
|||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.init({
|
.init({
|
||||||
supportedLngs: ['en', 'fr', 'nl', 'de', 'es'],
|
supportedLngs: ['en', 'fr', 'nl', 'de'],
|
||||||
fallbackLng,
|
fallbackLng,
|
||||||
ns: i18nDefaultNamespace,
|
ns: i18nDefaultNamespace,
|
||||||
detection: {
|
detection: {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const languageLabels: Record<string, string> = {
|
|||||||
fr: 'Français',
|
fr: 'Français',
|
||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
nl: 'Nederlands',
|
nl: 'Nederlands',
|
||||||
es: 'Español',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLanguageLabels = () => {
|
export const useLanguageLabels = () => {
|
||||||
|
|||||||
@@ -435,8 +435,7 @@
|
|||||||
"tools": "Weitere Tools",
|
"tools": "Weitere Tools",
|
||||||
"info": "Meeting-Informationen"
|
"info": "Meeting-Informationen"
|
||||||
},
|
},
|
||||||
"closeButton": "{{content}} ausblenden (Escape)",
|
"closeButton": "{{content}} ausblenden"
|
||||||
"escapeHint": "Escape drücken zum Schließen"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
||||||
|
|||||||
@@ -435,8 +435,7 @@
|
|||||||
"tools": "more tools",
|
"tools": "more tools",
|
||||||
"info": "meeting information"
|
"info": "meeting information"
|
||||||
},
|
},
|
||||||
"closeButton": "Hide {{content}} (Escape)",
|
"closeButton": "Hide {{content}}"
|
||||||
"escapeHint": "Press Escape to close"
|
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
|
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"accessibility": {
|
|
||||||
"title": "Accesibilidad",
|
|
||||||
"introduction": "<i>Visio</i> se compromete a hacer accesibles sus servicios digitales, de conformidad con el artículo 47 de la ley n.º 2005-102 de 11 de febrero de 2005.",
|
|
||||||
"declaration": {
|
|
||||||
"title": "Declaración de accesibilidad",
|
|
||||||
"date": "Redactada el 4 de diciembre de 2024."
|
|
||||||
},
|
|
||||||
"scope": "Esta declaración de accesibilidad se aplica al sitio visio.numerique.gouv.fr",
|
|
||||||
"complianceStatus": {
|
|
||||||
"title": "Estado de conformidad",
|
|
||||||
"body": "visio.numerique.gouv.fr no es conforme con el RGAA 4.1. El sitio todavía no ha sido auditado. No obstante, el equipo trabaja para crear un sitio accesible para todo el mundo siguiendo las recomendaciones del RGAA."
|
|
||||||
},
|
|
||||||
"improvement": {
|
|
||||||
"title": "Mejora y contacto",
|
|
||||||
"body": "Si no consigues acceder a un contenido o a un servicio, puedes ponerte en contacto con el responsable de lasuite.numerique.gouv.fr para que te oriente hacia una alternativa accesible u obtener el contenido en otro formato.",
|
|
||||||
"contact": {
|
|
||||||
"email": "Correo electrónico: visio@numerique.gouv.fr",
|
|
||||||
"address": "Dirección: DINUM, 20 avenue de Ségur 75007 París"
|
|
||||||
},
|
|
||||||
"response": "Intentamos responder en un plazo de 2 días laborales."
|
|
||||||
},
|
|
||||||
"recourse": {
|
|
||||||
"title": "Vía de recurso",
|
|
||||||
"introduction": "Este procedimiento debe utilizarse en el siguiente caso: has comunicado al responsable del sitio web un defecto de accesibilidad que te impide acceder a un contenido o a uno de los servicios del portal y no has obtenido una respuesta satisfactoria.",
|
|
||||||
"options": {
|
|
||||||
"intro": "Puedes:",
|
|
||||||
"option1": "Escribir un mensaje al Defensor de Derechos",
|
|
||||||
"option2": "Ponerte en contacto con el delegado del Defensor de Derechos de tu región",
|
|
||||||
"option3": "Enviar una carta por correo postal (gratuito, sin sello): </br> Défenseur des droits Libre réponse 71120 75342 Paris CEDEX 07"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Prueba tu configuración",
|
|
||||||
"runTest": "Iniciar la prueba",
|
|
||||||
"runAgain": "Repetir",
|
|
||||||
"cancel": "Cancelar",
|
|
||||||
"detailsFor": "Detalles del paso {{step}}",
|
|
||||||
"downloadReport": "Descargar el informe",
|
|
||||||
"homeLink": "Prueba tu configuración",
|
|
||||||
"progress": "{{done}}/{{total}}",
|
|
||||||
"progressLabel": "Progreso de la prueba de conexión",
|
|
||||||
"groups": {
|
|
||||||
"local": "Navegador y dispositivos",
|
|
||||||
"network": "Conexión al servidor"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"browser": "Navegador",
|
|
||||||
"microphone": "Micrófono",
|
|
||||||
"camera": "Cámara",
|
|
||||||
"devices": "Dispositivos multimedia",
|
|
||||||
"websocket": "WebSocket",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"turn": "TURN",
|
|
||||||
"reconnect": "Reconexión",
|
|
||||||
"selectedCandidate": "Ruta seleccionada",
|
|
||||||
"publishAudio": "Publicación de audio",
|
|
||||||
"publishVideo": "Publicación de vídeo"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"pending": "En espera",
|
|
||||||
"running": "En curso…",
|
|
||||||
"success": "Correcto",
|
|
||||||
"failed": "Error",
|
|
||||||
"skipped": "Omitido"
|
|
||||||
},
|
|
||||||
"counts": {
|
|
||||||
"passed": "correctas",
|
|
||||||
"failed": "con errores",
|
|
||||||
"skipped": "omitidas"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"idle": "Listo para la prueba",
|
|
||||||
"idleHint": "La prueba dura aproximadamente un minuto. Tu cámara y tu micrófono solo se utilizan durante la prueba.",
|
|
||||||
"running": "Prueba en curso…",
|
|
||||||
"runningHint": "Mantén esta página abierta hasta que terminen las comprobaciones.",
|
|
||||||
"passed": "Todo funciona",
|
|
||||||
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
|
|
||||||
"partial": "Prueba parcial",
|
|
||||||
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
|
|
||||||
"failed_one": "{{count}} verificación en error",
|
|
||||||
"failed_other": "{{count}} verificaciones en error",
|
|
||||||
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"firewall": "Si las pruebas de red fallan, comprueba los permisos de tu navegador y las reglas de filtrado de red (WebRTC, WebSocket, TURN) con tu servicio informático."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"backToHome": "Volver al inicio",
|
|
||||||
"cancel": "Cancelar",
|
|
||||||
"closeDialog": "Cerrar la ventana modal",
|
|
||||||
"error": {
|
|
||||||
"heading": "Se ha producido un error al cargar la página"
|
|
||||||
},
|
|
||||||
"feedback": {
|
|
||||||
"context": "¡Tu opinión es fundamental!",
|
|
||||||
"cta": "Comparte tu opinión - nueva ventana"
|
|
||||||
},
|
|
||||||
"forbidden": {
|
|
||||||
"heading": "Acceso denegado"
|
|
||||||
},
|
|
||||||
"loading": "Cargando…",
|
|
||||||
"loggedInUserTooltip": "Con la sesión iniciada como ",
|
|
||||||
"login": {
|
|
||||||
"buttonLabel": "Iniciar sesión",
|
|
||||||
"proconnectButtonLabel": "Identificarse con ProConnect",
|
|
||||||
"proconnectLinkLabel": "¿Qué es ProConnect? - nueva ventana",
|
|
||||||
"proconnectLink": "¿Qué es ProConnect?"
|
|
||||||
},
|
|
||||||
"logout": "Cerrar sesión",
|
|
||||||
"notFound": {
|
|
||||||
"heading": "Comprueba tu código de reunión",
|
|
||||||
"body": "Comprueba que has introducido el código de reunión correcto en la URL. Ejemplo:"
|
|
||||||
},
|
|
||||||
"selected": "seleccionado",
|
|
||||||
"submit": "Aceptar",
|
|
||||||
"footer": {
|
|
||||||
"links": {
|
|
||||||
"legifrance": "legifrance.gouv.fr",
|
|
||||||
"infogouv": "info.gouv.fr",
|
|
||||||
"servicepublic": "service-public.fr",
|
|
||||||
"datagouv": "data.gouv.fr",
|
|
||||||
"legalsTerms": "Aviso legal",
|
|
||||||
"data": "Datos personales y cookies",
|
|
||||||
"accessibility": "Accesibilidad: no conforme",
|
|
||||||
"connectionTest": "Prueba tu configuración",
|
|
||||||
"ariaLabel": "nueva ventana",
|
|
||||||
"codeAnnotation": "Nuestro código es abierto y está disponible en este",
|
|
||||||
"code": "repositorio de código abierto",
|
|
||||||
"technicalDetails": "Ficha técnica",
|
|
||||||
"termsOfService": "Condiciones de uso"
|
|
||||||
},
|
|
||||||
"mentions": "Salvo que se indique lo contrario, los contenidos de este sitio están disponibles bajo",
|
|
||||||
"license": "licencia etalab 2.0"
|
|
||||||
},
|
|
||||||
"loginHint": {
|
|
||||||
"title": "Inicia sesión con tu cuenta",
|
|
||||||
"body": "En lugar de esperar, inicia sesión con tu cuenta.",
|
|
||||||
"button": {
|
|
||||||
"ariaLabel": "Cerrar la sugerencia",
|
|
||||||
"label": "Aceptar"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"skipLink": "Ir al contenido principal",
|
|
||||||
"clipboardContent": {
|
|
||||||
"url": "Para participar en la videoconferencia, haz clic en este enlace: {{roomUrl}}",
|
|
||||||
"numberAndPin": "Para participar por teléfono, marca el {{phoneNumber}} e introduce este código: {{pinCode}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"createMeeting": "Crear una reunión",
|
|
||||||
"heading": "Videoconferencias sencillas y seguras",
|
|
||||||
"intro": "Comunícate y trabaja con facilidad, manteniendo siempre el control de tus datos",
|
|
||||||
"joinInputError": "Introduce un enlace o un código de reunión. Ejemplos:",
|
|
||||||
"joinInputExample": "Un código de reunión tiene este aspecto: abc-defg-hij",
|
|
||||||
"joinInputLabel": "Enlace completo o código de la reunión",
|
|
||||||
"joinInputSubmit": "Unirse a la reunión",
|
|
||||||
"joinMeeting": "Unirse a una reunión",
|
|
||||||
"joinMeetingTipContent": "Puedes unirte a una reunión copiando directamente su enlace completo en la barra de direcciones del navegador.",
|
|
||||||
"joinMeetingTipHeading": "Consejo",
|
|
||||||
"loginToCreateMeeting": "Inicia sesión para crear una reunión",
|
|
||||||
"moreLinkLabel": "Más información sobre {{appTitle}} - nueva ventana",
|
|
||||||
"moreLink": "Más información",
|
|
||||||
"moreAbout": "sobre {{appTitle}}",
|
|
||||||
"connectionTestLink": "Prueba tu configuración",
|
|
||||||
"createMenu": {
|
|
||||||
"laterOption": "Crear una reunión para más tarde",
|
|
||||||
"instantOption": "Iniciar una reunión ahora"
|
|
||||||
},
|
|
||||||
"laterMeetingDialog": {
|
|
||||||
"heading": "Tus datos de conexión",
|
|
||||||
"description": "Comparte estos datos con los invitados. Podrán unirse a la reunión sin necesidad de iniciar sesión. Esta reunión es permanente y puede reutilizarse.",
|
|
||||||
"permissions": "Las personas que dispongan de este enlace no necesitan tu autorización para unirse a esta reunión.",
|
|
||||||
"copy": "Copiar los datos",
|
|
||||||
"copied": "Copiados en el portapapeles",
|
|
||||||
"copyUrl": "Copiar el enlace de la reunión",
|
|
||||||
"phone": {
|
|
||||||
"call": "Llama al:",
|
|
||||||
"pinCode": "Código:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"introSlider": {
|
|
||||||
"carouselLabel": "Presentación de diapositivas",
|
|
||||||
"previous": {
|
|
||||||
"label": "Diapositiva anterior",
|
|
||||||
"labelWithPosition": "Diapositiva anterior ({{current}} de {{total}})",
|
|
||||||
"tooltip": "Diapositiva anterior"
|
|
||||||
},
|
|
||||||
"next": {
|
|
||||||
"label": "Diapositiva siguiente",
|
|
||||||
"labelWithPosition": "Diapositiva siguiente ({{current}} de {{total}})",
|
|
||||||
"tooltip": "Diapositiva siguiente"
|
|
||||||
},
|
|
||||||
"slidePosition": "Diapositiva {{current}} de {{total}}",
|
|
||||||
"beta": {
|
|
||||||
"text": "Probar la beta",
|
|
||||||
"tooltip": "Acceder al formulario"
|
|
||||||
},
|
|
||||||
"slide1": {
|
|
||||||
"title": "Pásate a la sencillez. ¡Pruébanos ahora mismo!",
|
|
||||||
"body": "Descubre una solución intuitiva y accesible, pensada para el sector público y todos sus colaboradores."
|
|
||||||
},
|
|
||||||
"slide2": {
|
|
||||||
"title": "Organiza llamadas de grupo sin límite",
|
|
||||||
"body": "Reuniones sin límite de tiempo, con una comunicación fluida y de alta calidad, sea cual sea el número de participantes."
|
|
||||||
},
|
|
||||||
"slide3": {
|
|
||||||
"title": "Transforma tus reuniones con la IA",
|
|
||||||
"body": "Obtén transcripciones precisas y accionables para impulsar tu productividad. Función experimental, ¡pruébala ahora!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Aviso legal",
|
|
||||||
"creator": {
|
|
||||||
"title": "Editor",
|
|
||||||
"body": "El servicio Visio está editado por la Dirección Interministerial de lo Digital del Estado (DINUM), con domicilio en:",
|
|
||||||
"contact": {
|
|
||||||
"title": "Datos de contacto",
|
|
||||||
"address": "20 avenue de Ségur",
|
|
||||||
"city": "75007 París",
|
|
||||||
"phone": "Tel. Recepción: 01.71.21.01.70",
|
|
||||||
"siret": "SIRET: 12000101100010 (secretaría general del gobierno)",
|
|
||||||
"siren": "SIREN: 120 001 011"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"director": {
|
|
||||||
"title": "Director de la publicación",
|
|
||||||
"body": "La directora de la publicación es la señora Stéphanie Schaer, directora interministerial de lo digital."
|
|
||||||
},
|
|
||||||
"hosting": {
|
|
||||||
"title": "Alojamiento",
|
|
||||||
"body": "El servicio está alojado por Outscale West-1 CloudGouv SecNumCloud, situado en Francia."
|
|
||||||
},
|
|
||||||
"accessibility": {
|
|
||||||
"title": "Accesibilidad",
|
|
||||||
"body": "La conformidad con las normas de accesibilidad digital es un objetivo posterior. El sitio todavía no ha sido auditado. No obstante, el equipo trabaja para crear un sitio accesible para todo el mundo siguiendo las recomendaciones del RGAA.",
|
|
||||||
"more": "Para saber más: ",
|
|
||||||
"link": "enlace a la página de accesibilidad",
|
|
||||||
"status": "Estado de accesibilidad: no conforme"
|
|
||||||
},
|
|
||||||
"reuse": {
|
|
||||||
"title": "Reutilización de los contenidos y enlaces",
|
|
||||||
"body1": "Salvo mención explícita de propiedad intelectual perteneciente a terceros, los contenidos de este sitio se ofrecen bajo ",
|
|
||||||
"license": "licencia abierta Etalab 2.0",
|
|
||||||
"body2": "En particular, eres libre de reproducirlos, copiarlos, modificarlos, extraerlos, transformarlos, comunicarlos, difundirlos, redistribuirlos, publicarlos, transmitirlos y explotarlos siempre que menciones su fuente y su fecha de última actualización, y no induzcas a error a terceros sobre la información que contienen.",
|
|
||||||
"body3": "Todo sitio público o privado está autorizado a establecer, sin autorización previa, un enlace (incluido un enlace profundo) hacia la información difundida en este sitio."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user