Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine b33164574a 🔒️(backend) avoid serializing rooms's pin code when restricted
Prevent anonymous users waiting in the lobby, or attacker
to discover the room pin code, that would allow them to join a room.
2025-12-16 23:58:26 +01:00
251 changed files with 3879 additions and 29278 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Download Crowdin files
uses: crowdin/github-action@v2
+5 -56
View File
@@ -23,13 +23,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -55,7 +49,6 @@ jobs:
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -66,13 +59,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -99,7 +86,6 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -110,13 +96,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -143,7 +123,6 @@ jobs:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -154,13 +133,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -174,14 +147,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -189,7 +154,6 @@ jobs:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -200,13 +164,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@@ -220,14 +178,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -235,7 +185,6 @@ jobs:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+20 -43
View File
@@ -7,18 +7,14 @@ on:
pull_request:
branches:
- "*"
permissions:
contents: read
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -43,11 +39,9 @@ jobs:
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -55,11 +49,9 @@ jobs:
lint-changelog:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,22 +62,20 @@ jobs:
build-mails:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -105,23 +95,21 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -136,16 +124,14 @@ jobs:
lint-agents:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -158,16 +144,14 @@ jobs:
lint-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -181,8 +165,7 @@ jobs:
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
run:
working-directory: src/backend
@@ -233,7 +216,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Create writable /data
run: |
@@ -241,7 +224,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -275,7 +258,7 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
@@ -296,11 +279,9 @@ jobs:
lint-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/frontend/ && npm ci
@@ -313,14 +294,12 @@ jobs:
lint-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
@@ -333,15 +312,13 @@ jobs:
build-sdk:
runs-on: ubuntu-latest
permissions:
contents: read
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
+2 -154
View File
@@ -1,3 +1,4 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -8,157 +9,4 @@ and this project adheres to
## [Unreleased]
### Added
- 👷(docker) add arm64 platform support for image builds
### Changed
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
- 🔒️(frontend) uninstall curl from the frontend production image #987
- 💄(frontend) add focus ring to reaction emoji buttons
- ✨(frontend) introduce a shortcut settings tab #975
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
- 🌐(frontend) localize SR modifier labels #1010
- ⬆️(backend) update python dependencies #1011
- ♿️(a11y) fix focus ring on tab container components
## [1.8.0] - 2026-02-20
### Changed
- 🔒️(agents) uninstall pip from the agents image
- 🔒️(summary) switch to Alpine base image
- 🔒️(backend) uninstall pip in the production image
### Fixed
- 🔒️(agents) upgrade OpenSSL to address CVE-2025-15467
- 📌(agents) pin protobuf to 6.33.5 to fix CVE-2026-0994
## [1.7.0] - 2026-02-19
### Added
- ✨(frontend) expose Windows app web link #976
- ✨(frontend) support additional shortcuts to broaden accessibility
### Changed
- ✨(frontend) add clickable settings general link in idle modal #974
- ♻️(backend) refactor external API token-related items #1006
## [1.6.0] - 2026-02-10
### Added
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
### Changed
- ♿️(frontend) improve spinner reducedmotion fallback #931
- ♿️(frontend) fix form labels and autocomplete wiring #932
- 🥅(summary) catch file-related exceptions when handling recording #944
- 📝(frontend) update legal terms #956
- ⚡️(backend) enhance django admin's loading performance #954
- 🌐(frontend) add missing DE translation for accessibility settings
### Fixed
- 🔐(backend) enforce object-level permission checks on room endpoint #959
- 🔒️(backend) add application validation when consuming external JWT #963
## [1.5.0] - 2026-01-28
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
- ♿️(frontend) add global screen reader announcer#922
### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
## [1.4.0] - 2026-01-25
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
### Added
- ✨(agent) support Kyutai client for subtitle
- ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
## [1.0.1] - 2025-12-17
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
-
+1 -4
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
@@ -127,9 +127,6 @@ ARG MEET_STATIC_ROOT=/data/static
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/meet.py /usr/local/etc/gunicorn/meet.py
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
-2
View File
@@ -1,2 +0,0 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+1 -4
View File
@@ -50,9 +50,6 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
### 🚀 Major roll out to all French public servants
On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents
@@ -89,7 +86,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
+1 -5
View File
@@ -18,7 +18,6 @@ docker_build(
'localhost:5001/meet-backend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
@@ -34,7 +33,6 @@ clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
@@ -59,7 +57,6 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile',
only=['.'],
target = 'production',
@@ -72,9 +69,8 @@ clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile',
only=['.'],
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
ASSETS_DIR=build/frontend-out/assets
if [ -n "$CUSTOM_LOGO_URL" ]; then
# Ensure https
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
# Prevent SSRF
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
LOGO_FILE="${ASSETS_DIR}/logo.svg"
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
# Actual download
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
# Validate filesize
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
# Validate file type
IS_SVG=false
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
mv -f "$TMP_FILE" "$LOGO_FILE"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
fi
mv src/backend/* ./
mv deploy/paas/* ./
echo "3.13" > .python-version
echo "." > requirements.txt
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
-160
View File
@@ -1,160 +0,0 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
+1 -1
View File
@@ -235,7 +235,7 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress:v1.11.0
image: livekit/egress
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
-52
View File
@@ -1,52 +0,0 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
server_tokens off;
root /app/build/frontend-out;
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
@@ -1,9 +1,9 @@
upstream meet_backend {
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
server ${FRONTEND_HOST}:8080 fail_timeout=0;
}
server {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.4
FROM livekit/livekit-server:v1.9.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
@@ -3,8 +3,3 @@ redis:
address: redis:6379
keys:
devkey: secret
webhook:
api_key: devkey
urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
+2 -3
View File
@@ -10,7 +10,7 @@ services:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
@@ -20,8 +20,8 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- .env
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
@@ -45,7 +45,6 @@ services:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- .env
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
+2 -2
View File
@@ -8,7 +8,7 @@
### Step 1: Prepare your working environment:
```bash
mkdir -p keycloak/env.d && cd keycloak
mkdir keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
@@ -89,4 +89,4 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
2. Copy the client ID (`meet` in this example) and the client secret.
-4
View File
@@ -9,10 +9,6 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
## Scalingo
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
+4 -13
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
@@ -129,7 +129,7 @@ The following ports will need to be opened:
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the following:
If you are using ufw, enter the follwoing:
```
ufw allow 80/tcp
ufw allow 443/tcp
@@ -177,15 +177,6 @@ You will need to uncomment the environment and network sections in compose file
# external: true
```
#### Caddy Reverse Proxy
Expose the Frontend port to the host
```yaml
frontend:
ports:
- "8086:8086"
```
## Step 5: Start Meet
You are ready to start your Meet application !
@@ -207,7 +198,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
+1
View File
@@ -277,6 +277,7 @@ These are the environmental options available on meet backend.
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
-185
View File
@@ -1,185 +0,0 @@
# Deployment on Scalingo
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
## Overview
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
## Prerequisites
- A Scalingo account
- Scalingo CLI installed (optional but recommended)
- A PostgreSQL database addon
- A Redis addon (for caching and sessions)
## Step 1: Create Your App
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
## Step 2: Provision Addons
Add the required PostgreSQL and Redis services.
This will set the following environment variables automatically:
- `SCALINGO_POSTGRESQL_URL` - Database connection string
- `SCALINGO_REDIS_URL` - Redis connection string
## Step 3: Configure Environment Variables
Set the following environment variables in your Scalingo app:
### Buildpack Configuration
```bash
scalingo env-set BUILDPACK_URL="https://github.com/suitenumerique/buildpack#main"
scalingo env-set LASUITE_APP_NAME="meet"
scalingo env-set LASUITE_BACKEND_DIR="."
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
scalingo env-set LASUITE_NGINX_DIR="."
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
```
### Database and Cache
```bash
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
```
### Django Settings
```bash
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
scalingo env-set DJANGO_CONFIGURATION="Production"
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
```
### OIDC Authentication
Configure your OIDC provider (e.g., Keycloak, Authentik):
```bash
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
```
### LiveKit Configuration
Meet requires a LiveKit server for video conferencing:
```bash
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
```
### Email Configuration (Optional)
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
```bash
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
scalingo env-set DJANGO_EMAIL_PORT="587"
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
```
## Step 4: Deploy
Deploy your application:
```bash
git push scalingo main
```
The Procfile will automatically:
1. Build the frontend (Vite)
2. Build the backend (Django)
3. Run the post-compile script (cleanup)
4. Run the post-frontend script (move assets and prepare for deployment)
5. Start Nginx and Gunicorn
6. Run django migrations
## Step 5: Create superuser
After the first deployment, create an admin user:
```bash
scalingo run python manage.py createsuperuser
```
## Custom Domain (Optional)
To use a custom domain:
1. Add the domain in Scalingo dashboard
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
3. Configure your DNS to point to Scalingo
```bash
scalingo domains-add meet.yourdomain.com
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
```
## Custom Logo (Optional)
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
```bash
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
```
## Troubleshooting
### Check Logs
```bash
scalingo logs --tail
```
### Common Issues
1. **Build fails**: Check that all required environment variables are set
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
### Useful Commands
```bash
# Open a console
scalingo run bash
# Restart the app
scalingo restart
# Scale containers
scalingo scale web:2
# One-off command
scalingo run python manage.py shell
```
## Architecture
On Scalingo, the application runs as follows:
1. **Build Phase**: The buildpack compiles both frontend and backend
2. **Runtime**:
- Nginx serves static files and proxies to the backend
- Gunicorn runs the Django WSGI application
- Both processes are managed by the `bin/buildpack_start.sh` script
## Additional Resources
- [Scalingo Documentation](https://doc.scalingo.com/)
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
- [Meet Environment Variables](../../src/helm/meet/README.md)
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
+4 -3
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token/`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -21,6 +21,7 @@ info:
#### Upcoming Features
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -39,7 +40,7 @@ tags:
description: Room management operations
paths:
/application/token/:
/application/token:
post:
tags:
- Authentication
@@ -282,7 +283,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token/` endpoint.
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+1 -1
View File
@@ -63,7 +63,7 @@ RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
ROOM_TELEPHONY_ENABLED=True
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
+1 -1
View File
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
-21
View File
@@ -3,21 +3,6 @@
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"groupName": "js dependencies",
"matchManagers": ["npm"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict"
},
{
"groupName": "python dependencies",
"matchManagers": ["setup-cfg", "pep621"],
"schedule": ["on the first day of the month"],
"matchPackagePatterns": ["*"],
"minimumReleaseAge": "7 days"
},
{
"enabled": false,
"groupName": "ignored python dependencies",
@@ -30,12 +15,6 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
-8
View File
@@ -1,8 +0,0 @@
{
"plugins": [
"office-addins"
],
"extends": [
"plugin:office-addins/recommended"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,12 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": false
}
}
],
]
}
-173
View File
@@ -1,173 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
<Version>0.0.1.0</Version>
<ProviderName>Visio</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Visio"/>
<Description DefaultValue="Ajoutez facilement un lien de réunion Visio à vos emails et événements Outlook."/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
<AppDomains>
<AppDomain>https://localhost:3000</AppDomain>
<AppDomain>https://meet.127.0.0.1.nip.io</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemRead">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
<RequestedHeight>250</RequestedHeight>
</DesktopSettings>
</Form>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.3">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<FunctionFile resid="Commands.Url"/>
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageReadCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgReadGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgReadOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<!-- Button 1: Generate meeting link (function call) -->
<Control xsi:type="Button" id="apptGenerateLinkButton">
<Label resid="GenerateLink.Label"/>
<Supertip>
<Title resid="GenerateLink.Label"/>
<Description resid="GenerateLink.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ExecuteFunction">
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
</Action>
</Control>
<!-- Button 2: Open settings taskpane -->
<Control xsi:type="Button" id="apptOpenSettingsButton">
<Label resid="OpenSettings.Label"/>
<Supertip>
<Title resid="OpenSettings.Label"/>
<Description resid="OpenSettings.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="Visio"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir le panneau"/>
<bt:String id="GenerateLink.Label" DefaultValue="Générer un lien de réunion"/>
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre le panneau de connexion Visio."/>
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion Visio et l'insère dans l'événement."/>
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion Visio."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
{
"name": "office-addin-taskpane-js",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
},
"license": "MIT",
"config": {
"app_to_debug": "outlook",
"app_type_to_debug": "desktop",
"dev_server_port": 3000
},
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"dev-server": "webpack serve --mode development",
"lint": "office-addin-lint check",
"lint:fix": "office-addin-lint fix",
"prettier": "office-addin-lint prettier",
"signin": "office-addin-dev-settings m365-account login",
"signout": "office-addin-dev-settings m365-account logout",
"start": "office-addin-debugging start manifest.xml",
"stop": "office-addin-debugging stop manifest.xml",
"validate": "office-addin-manifest validate manifest.xml",
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "^3.36.0",
"regenerator-runtime": "^0.14.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.25.4",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-plugin": "^5.6.0",
"office-addin-cli": "^2.0.3",
"office-addin-debugging": "^6.0.3",
"office-addin-dev-certs": "^2.0.3",
"office-addin-lint": "^3.0.3",
"office-addin-manifest": "^2.0.3",
"office-addin-prettier-config": "^2.0.1",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.1.0"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
"last 2 versions",
"ie 11"
]
}
@@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body></body>
</html>
@@ -1,80 +0,0 @@
/* global Office */
const { loadSession, buildMeetingMessage, BASE_URL } = require("../common");
Office.onReady(() => {});
function generateMeetingLinkFromCalendar(event) {
const session = loadSession();
if (!session?.access_token) {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: "Vous n'êtes pas connecté. Ouvrez les paramètres pour vous connecter.",
});
event.completed();
return;
}
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + session.access_token,
},
})
.then((res) => res.json())
.then((data) => {
console.log("Room created:", data);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur de lecture: ${getResult.error.message}`,
});
event.completed();
return;
}
item.body.setAsync(getResult.value + message, { coercionType: Office.CoercionType.Html }, (setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur d'insertion: ${setResult.error.message}`,
});
event.completed();
return;
}
item.location.setAsync(url, (locationResult) => {
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message: "Lien de réunion inséré !",
icon: "Icon.80x80",
persistent: false,
});
} else {
item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur de localisation: ${locationResult.error.message}`,
});
}
event.completed();
});
});
});
})
.catch((err) => {
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
message: `Erreur: ${err.message}`,
});
event.completed();
});
}
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLinkFromCalendar);
-99
View File
@@ -1,99 +0,0 @@
/* global Office */
const BASE_URL = "https://meet.127.0.0.1.nip.io";
// ─── Session Storage ──────────────────────────────────────────────────────
function saveSession(data) {
const expiresAt = data.expires_in
? new Date(Date.now() + data.expires_in * 1000).toISOString()
: null;
const payload = JSON.stringify({
...data,
expiresAt,
savedAt: new Date().toISOString(),
});
localStorage.setItem("meetSession", payload);
const rs = Office.context.roamingSettings;
rs.set("meetSession", payload);
rs.saveAsync((result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
console.error("RoamingSettings save failed:", result.error.message);
}
});
}
function loadSession() {
let session = null;
try {
const stored = Office.context.roamingSettings.get("meetSession");
if (stored) session = JSON.parse(stored);
} catch (e) {
console.warn("RoamingSettings read failed:", e);
}
if (!session) {
try {
const stored = localStorage.getItem("meetSession");
if (stored) session = JSON.parse(stored);
} catch (e) {
console.warn("localStorage read failed:", e);
}
}
if (!session) return null;
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
console.warn("Token expired, clearing session.");
clearSession();
return null;
}
return session;
}
function clearSession() {
localStorage.removeItem("meetSession");
try {
const rs = Office.context.roamingSettings;
rs.remove("meetSession");
rs.saveAsync(() => console.log("RoamingSettings cleared."));
} catch (e) {
console.warn("Could not clear RoamingSettings:", e);
}
}
// ─── Meeting Message Builder ───────────────────────────────────────────────
function buildMeetingMessage(data) {
const url = data.url;
const phone = data.telephony?.phone_number;
const pin = data.telephony?.pin_code;
const formattedPin = pin
? pin.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#"
: "";
const formattedPhone = phone
? phone.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5")
: phone;
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
────────────────────────────────────────
Rejoindre la réunion LaSuite Meet
<a href="${url}">${url}</a>
Ou appelez (audio uniquement)
(FR) ${formattedPhone}
Code : ${formattedPin}
────────────────────────────────────────</pre>`;
return { url, message };
}
module.exports = { BASE_URL, saveSession, loadSession, clearSession, buildMeetingMessage };
File diff suppressed because one or more lines are too long
@@ -1,58 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Visio</title>
<link rel="stylesheet" href="taskpane.css" />
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<div id="sideload-msg">Veuillez charger le complément.</div>
<div id="app-body">
<!-- Loading -->
<div id="view-loading">
<p class="intro-text">Chargement...</p>
</div>
<!-- Unauthenticated -->
<div id="view-unauth" style="display:none;">
<p class="intro-text">
<span>Ajoutez facilement un lien de réunion Visio à vos événements Outlook.</span>
<a href="https://meet.numerique.gouv.fr" target="_blank" class="learn-more">En savoir plus</a>
</p>
<hr class="divider" />
<button class="proconnect-button" id="btn-connect">
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
</button>
<p>
<a
href="https://www.proconnect.gouv.fr/"
target="_blank"
rel="noopener noreferrer"
title="Quest-ce que ProConnect ? - nouvelle fenêtre"
>
Quest-ce que ProConnect ?
</a>
</p>
</div>
<!-- Authenticated -->
<div id="view-auth" style="display:none;">
<div id="btn-container">
<button id="btn-generate">Ajouter une réunion Visio</button>
<button id="btn-disconnect">Se déconnecter</button>
</div>
</div>
<p id="status"></p>
</div>
</body>
</html>
@@ -1,196 +0,0 @@
const { BASE_URL, loadSession, saveSession, clearSession, buildMeetingMessage } = require("../common");
// ─── Views ────────────────────────────────────────────────────────────────
function showView(name) {
document.getElementById("view-loading").style.display = "none";
document.getElementById("view-unauth").style.display = "none";
document.getElementById("view-auth").style.display = "none";
document.getElementById(`view-${name}`).style.display = "block";
}
function setStatus(msg) {
document.getElementById("status").textContent = msg;
}
// ─── Auth Flow ────────────────────────────────────────────────────────────
function connect() {
setStatus("Démarrage de la session...");
fetch(`${BASE_URL}/api/v1.0/addons/sessions/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((res) => res.json())
.then((data) => {
const session_id = data.session_id;
setStatus("En attente d'authentification...");
let dialog = null;
let pollInterval = null;
let pollCount = 0;
pollInterval = setInterval(() => {
// ─── Timeout after 3 minutes ──────────────────────────────
if (pollCount++ > 180) {
clearInterval(pollInterval);
if (dialog) dialog.close();
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
showView("unauth");
return;
}
fetch(`${BASE_URL}/api/v1.0/addons/sessions/${session_id}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
})
.then((res) => res.json())
.then((sessionData) => {
console.log("Polling:", sessionData);
if (sessionData.state === "authenticated" && sessionData.access_token) {
clearInterval(pollInterval);
// if (dialog) dialog.close();
saveSession(sessionData);
setStatus("Connecté !");
showView("auth");
}
})
.catch((err) => {
clearInterval(pollInterval);
setStatus(`Erreur de polling: ${err.message}`);
});
}, 1000);
// ─── Open transit dialog ──────────────────────────────────────
const meetUrl = `${BASE_URL}/addons/transit/?session_id=${session_id}`;
Office.context.ui.displayDialogAsync(
meetUrl,
{ height: 60, width: 50, displayInIframe: false },
(asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
clearInterval(pollInterval);
setStatus(`Erreur dialog: ${asyncResult.error.message}`);
return;
}
dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
clearInterval(pollInterval);
dialog.close();
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
if (arg.error === 12006) {
setStatus("Dialog fermé. En attente d'authentification...");
}
});
}
);
})
.catch((err) => {
setStatus(`Erreur de connexion: ${err.message}`);
});
}
function disconnect() {
clearSession();
setStatus("Déconnecté.");
showView("unauth");
}
function generateMeetingLink() {
const session = loadSession();
if (!session?.access_token) {
setStatus("Session introuvable. Veuillez vous reconnecter.");
showView("unauth");
return;
}
const btn = document.getElementById("btn-generate");
btn.disabled = true;
btn.textContent = "Génération...";
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + session.access_token,
},
})
.then((res) => res.json())
.then((data) => {
console.log("Room created:", data);
const { url, message } = buildMeetingMessage(data);
const item = Office.context.mailbox.item;
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
setStatus(`Erreur de lecture: ${getResult.error.message}`);
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
return;
}
item.body.setAsync(
getResult.value + message,
{ coercionType: Office.CoercionType.Html },
(setResult) => {
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
setStatus(`Erreur d'insertion: ${setResult.error.message}`);
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
return;
}
// ─── If calendar event, also set location ──────────────
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
item.location.setAsync(url, (locationResult) => {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
setStatus("Lien de réunion inséré !");
} else {
setStatus(`Erreur de localisation: ${locationResult.error.message}`);
}
});
} else {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
setStatus("Lien de réunion inséré !");
}
}
);
});
})
.catch((err) => {
btn.disabled = false;
btn.textContent = "Ajouter une réunion Visio";
setStatus(`Erreur: ${err.message}`);
});
}
// ─── Init ─────────────────────────────────────────────────────────────────
Office.onReady((info) => {
if (info.host === Office.HostType.Outlook) {
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
document.getElementById("btn-connect").onclick = connect;
document.getElementById("btn-disconnect").onclick = disconnect;
document.getElementById("btn-generate").onclick = generateMeetingLink;
const session = loadSession();
if (session?.state === "authenticated" && session?.access_token) {
setStatus("Connecté.");
showView("auth");
} else {
showView("unauth");
}
}
});
@@ -1,97 +0,0 @@
/* eslint-disable no-undef */
const devCerts = require("office-addin-dev-certs");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const urlDev = "https://localhost:3000/";
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
}
module.exports = async (env, options) => {
const dev = options.mode === "development";
const config = {
devtool: "source-map",
entry: {
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
commands: "./src/commands/commands.js",
},
output: {
clean: true,
},
resolve: {
extensions: [".html", ".js"],
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.html$/,
exclude: /node_modules/,
use: "html-loader",
},
{
test: /\.(png|jpg|jpeg|gif|ico)$/,
type: "asset/resource",
generator: {
filename: "assets/[name][ext][query]",
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
filename: "taskpane.html",
template: "./src/taskpane/taskpane.html",
chunks: ["polyfill", "taskpane"],
}),
new CopyWebpackPlugin({
patterns: [
{
from: "assets/*",
to: "assets/[name][ext][query]",
},
{
from: "manifest*.xml",
to: "[name]" + "[ext]",
transform(content) {
if (dev) {
return content;
} else {
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
}
},
},
],
}),
new HtmlWebpackPlugin({
filename: "commands.html",
template: "./src/commands/commands.html",
chunks: ["polyfill", "commands"],
}),
],
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
},
server: {
type: "https",
options: env.WEBPACK_BUILD || options.https !== undefined ? options.https : await getHttpsOptions(),
},
port: process.env.npm_package_config_dev_server_port || 3000,
},
};
return config;
};
-11
View File
@@ -1,13 +1,5 @@
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
@@ -21,9 +13,6 @@ FROM base AS production
WORKDIR /app
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
ARG DOCKER_USER
USER ${DOCKER_USER}
+66 -29
View File
@@ -5,7 +5,6 @@ import logging
import os
from dotenv import load_dotenv
from lasuite.plugins import kyutai
from livekit import api, rtc
from livekit.agents import (
Agent,
@@ -14,15 +13,14 @@ from livekit.agents import (
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import deepgram, silero
load_dotenv()
@@ -30,26 +28,60 @@ load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
# Default Deepgram STT configuration
DEEPGRAM_STT_DEFAULTS = {
"model": "nova-3",
"language": "multi",
}
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
DEEPGRAM_STT_SUPPORTED_PARAMS = {
"model",
"language",
}
def create_stt_provider():
"""Create STT provider based on environment configuration."""
if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
_stt_instance = deepgram.STT(
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
)
elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
def _build_deepgram_stt_kwargs():
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
return _stt_instance
Only parameters supported by LiveKit's deepgram.STT() are included.
Unsupported parameters are logged as warnings.
"""
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
# Scan environment variables for DEEPGRAM_STT_* pattern
for key, value in os.environ.items():
if key.startswith("DEEPGRAM_STT_"):
# Extract parameter name and convert to lowercase
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
# Check if parameter is supported by LiveKit plugin
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
logger.warning(
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
f"Supported parameters: {supported}"
)
continue
# Parse value type
value_lower = value.lower()
if value_lower in ("true", "false"):
# Boolean values
stt_kwargs[param_name] = value_lower == "true"
elif value.isdigit():
# Integer values
stt_kwargs[param_name] = int(value)
else:
# String values
stt_kwargs[param_name] = value
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
return stt_kwargs
class Transcriber(Agent):
@@ -57,11 +89,12 @@ class Transcriber(Agent):
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
stt = create_stt_provider()
# Build STT configuration from environment variables
stt_kwargs = _build_deepgram_stt_kwargs()
super().__init__(
instructions="not-needed",
stt=stt,
stt=deepgram.STT(**stt_kwargs),
)
self.participant_identity = participant_identity
@@ -123,14 +156,19 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
options=lk_room_io.RoomOptions(
text_input=False, audio_output=False, text_output=True
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
),
)
await room_io.start()
@@ -193,8 +231,7 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+5 -7
View File
@@ -1,15 +1,13 @@
[project]
name = "agents"
version = "1.8.0"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1",
"protobuf==6.33.5"
"livekit-agents==1.2.18",
"livekit-plugins-deepgram==1.2.18",
"livekit-plugins-silero==1.2.18",
"python-dotenv==1.2.1"
]
[project.optional-dependencies]
-1
View File
@@ -1 +0,0 @@
"""Meet core add-ons module."""
-124
View File
@@ -1,124 +0,0 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
from core.models import User
from core.services.jwt_token import JwtTokenService
logger = getLogger(__name__)
class SessionState(str, Enum):
"""Add-on authentication session states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Initialize the service with the configured token service."""
self._token_service = JwtTokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _get_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def init_session(self) -> str:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
cache_key = self._get_cache_key(session_id)
cache.set(
cache_key,
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
return session_id
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
cache_key = self._get_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
self.clear_session(session_id)
# Return copy without internal fields
internal_fields = {"expires_at"}
return {k: v for k, v in data.items() if k not in internal_fields}
def clear_session(self, session_id: str) -> None:
"""Remove session data from cache."""
cache_key = self._get_cache_key(session_id)
cache.delete(cache_key)
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._get_cache_key(session_id)
existing_data = cache.get(cache_key)
if not existing_data:
raise SuspiciousOperation("Session not found.")
expires_at = existing_data.get("expires_at", None)
if not expires_at:
self.clear_session(session_id)
raise SuspiciousOperation("Invalid session data.")
remaining_seconds = int(
(
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
).total_seconds()
)
if remaining_seconds <= 0:
self.clear_session(session_id)
raise SuspiciousOperation("Session expired.")
if existing_data.get("state") != SessionState.PENDING:
self.clear_session(session_id)
raise SuspiciousOperation("Access token already set.")
response = self._token_service.generate_jwt(user, settings.ADDONS_SCOPES)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
-57
View File
@@ -1,57 +0,0 @@
"""Add-ons views."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from core.addons.service import SessionState, TokenExchangeService
def render_error(request, message, status=400):
"""Render simple error page."""
return render(request, "addons/error.html", {"message": message}, status=status)
@require_http_methods(["GET"])
def transit_page(request):
"""Initialize authentication flow for add-on session."""
session_id = request.GET.get("session_id")
if not session_id:
return render_error(request, _("Session ID is required."), status=400)
data = TokenExchangeService().get_session(session_id)
if not data:
return render_error(request, _("Session not found or expired."), status=404)
if data.get("state") != SessionState.PENDING:
return render_error(request, _("Invalid session state."), status=400)
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
return_to = f"{settings.APPLICATION_BASE_URL}/addons/redirect"
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
@require_http_methods(["GET"])
def redirect_page(request):
"""Complete authentication and close the popup window."""
if not request.user.is_authenticated:
return render_error(request, _("Authentication required."), status=401)
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
if not session_id:
return render_error(request, _("No active session found."), status=404)
try:
TokenExchangeService().set_access_token(request.user, session_id)
except SuspiciousOperation:
return render_error(request, _("Invalid or expired session."), status=400)
return render(request, "addons/redirect_success.html")
-47
View File
@@ -1,47 +0,0 @@
"""Add-ons API endpoints"""
from logging import getLogger
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from rest_framework import viewsets
from core.addons.service import TokenExchangeService
logger = getLogger(__name__)
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = []
def create(self, request):
"""Create a new pending authentication session."""
session_id = TokenExchangeService().init_session()
return drf_response.Response(
{"session_id": session_id}, status=drf_status.HTTP_201_CREATED
)
def retrieve(self, request, pk=None):
"""Retrieve authentication session data by session ID."""
data = TokenExchangeService().get_session(pk)
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Delete an authentication session by session ID."""
TokenExchangeService().clear_session(pk)
return drf_response.Response(
{"status": "ok"}, status=drf_status.HTTP_204_NO_CONTENT
)
+2 -17
View File
@@ -115,10 +115,6 @@ class RoomAdmin(admin.ModelAdmin):
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
@@ -142,7 +138,6 @@ class RecordingAccessInline(admin.TabularInline):
model = models.RecordingAccess
extra = 0
autocomplete_fields = ["user"]
@admin.action(description=_("Resend notification to external service"))
@@ -212,18 +207,8 @@ class RecordingAdmin(admin.ModelAdmin):
"created_at",
"worker_id",
)
list_filter = ["created_at"]
list_select_related = ("room",)
readonly_fields = (
"id",
"created_at",
"options",
"mode",
"room",
"status",
"updated_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
actions = [resend_notification]
def get_queryset(self, request):
-6
View File
@@ -181,7 +181,6 @@ class RecordingSerializer(serializers.ModelSerializer):
"updated_at",
"status",
"mode",
"options",
"key",
"is_expired",
"expired_at",
@@ -213,11 +212,6 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
required=False,
allow_null=True,
default=dict,
)
class RequestEntrySerializer(BaseValidationOnlySerializer):
-26
View File
@@ -1,26 +0,0 @@
"""Throttling modules for the API."""
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
+17 -8
View File
@@ -10,7 +10,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from rest_framework import decorators, mixins, pagination, viewsets
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers, throttling
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -191,6 +191,18 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -296,13 +308,10 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["options"]
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
)
recording = models.Recording.objects.create(room=room, mode=mode)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
@@ -367,7 +376,7 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
throttle_classes=[RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -477,7 +486,7 @@ class RoomViewSet(
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
+43 -161
View File
@@ -1,51 +1,25 @@
"""Authentication Backends for external application to the Meet core app."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
import logging
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
from core.models import Application
from core.services import jwt_token
User = get_user_model()
logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
def __init__(
self, secret_key, algorithm, issuer, audience, expiration_seconds, token_type
):
"""Initialize the JWT authentication backend with the given token service configuration.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (e.g. HS256)
issuer: Expected token issuer identifier
audience: Expected token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type (e.g. Bearer)
"""
super().__init__()
self._token_service = jwt_token.JwtTokenService(
secret_key=secret_key,
algorithm=algorithm,
issuer=issuer,
audience=audience,
expiration_seconds=expiration_seconds,
token_type=token_type,
)
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -72,78 +46,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = self._token_service.decode_jwt(token)
return payload
except jwt_token.TokenExpiredError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt_token.TokenInvalidError as e:
logger.warning("Invalid JWT issuer or audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except jwt_token.TokenDecodeError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -158,79 +60,59 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
payload = self.decode_jwt(token)
if payload is None:
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
def validate_payload(self, payload):
"""Validate application-specific claims."""
user_id = payload.get("user_id")
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
application = Application.objects.get(client_id=client_id)
except Application.DoesNotExist as e:
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
raise exceptions.AuthenticationFailed("Application is disabled.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
Validates JWT tokens issued by addons for authenticating users.
Tokens must include user_id to identify the authenticated user.
"""
return (user, payload)
def __init__(self):
"""Initialize authentication backend with application JWT settings from Django settings."""
super().__init__(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
+5 -27
View File
@@ -33,11 +33,12 @@ class BaseScopePermission(permissions.BasePermission):
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
# Get the current action (e.g., 'list', 'create')
action = getattr(view, "action", None)
if not action:
# DRF routers return a 405 for unsupported methods
return True
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
required_scope = self.scope_map.get(action)
if not required_scope:
@@ -56,12 +57,9 @@ class BaseScopePermission(permissions.BasePermission):
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}:")
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
for scope in token_scopes
]
@@ -84,23 +82,3 @@ class HasRequiredRoomScope(BaseScopePermission):
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
user = request.user
if request.method in permissions.SAFE_METHODS:
return obj.has_any_role(user)
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
+31 -55
View File
@@ -1,12 +1,14 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -20,14 +22,13 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token import JwtTokenService
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.ViewSet):
class ApplicationViewSet(viewsets.GenericViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
@@ -92,63 +93,41 @@ class ApplicationViewSet(viewsets.ViewSet):
)
try:
user = models.User.objects.get(email__iexact=email)
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
raise drf_exceptions.NotFound(
{
"error": "User not found.",
}
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
token_service = JwtTokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
data = token_service.generate_jwt(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
return drf_response.Response(
data,
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
status=drf_status.HTTP_200_OK,
)
@@ -173,13 +152,10 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated
& permissions.HasRequiredRoomScope
& permissions.RoomPermissions
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
+1 -1
View File
@@ -41,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-29 15:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_application_and_more'),
]
operations = [
migrations.AddField(
model_name='recording',
name='options',
field=models.JSONField(blank=True, default=dict, help_text='Recording options', verbose_name='Recording options'),
),
]
+1 -12
View File
@@ -146,8 +146,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub = models.CharField(
_("sub"),
help_text=_(
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -292,10 +291,6 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def has_any_role(self, user):
"""Check if a user has any role on the resource."""
return self.get_role(user) is not None
def is_administrator_or_owner(self, user):
"""
Check if a user is administrator or owner of the resource."""
@@ -581,12 +576,6 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
options = models.JSONField(
blank=True,
default=dict,
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
class Meta:
db_table = "meet_recording"
@@ -16,23 +16,6 @@ from core import models
logger = logging.getLogger(__name__)
def get_recording_download_base_url() -> str:
"""Get the recording download base URL with backward compatibility."""
new_setting = settings.RECORDING_DOWNLOAD_BASE_URL
old_setting = settings.SCREEN_RECORDING_BASE_URL
if old_setting:
logger.warning(
"SCREEN_RECORDING_BASE_URL is deprecated and will be removed in a future version. "
"Please use RECORDING_DOWNLOAD_BASE_URL instead."
)
if new_setting:
return new_setting
return old_setting
class NotificationService:
"""Service for processing recordings and notifying external services."""
@@ -43,12 +26,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
summary_success = True
if recording.options.get("transcribe", False):
summary_success = self._notify_summary_service(recording)
email_success = self._notify_user_by_email(recording)
return email_success and summary_success
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -86,7 +64,7 @@ class NotificationService:
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}",
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
has_failures = False
@@ -159,14 +137,12 @@ class NotificationService:
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
}
headers = {
@@ -1,11 +1,7 @@
"""Recording-related LiveKit Events Service"""
# pylint: disable=no-member
from logging import getLogger
from livekit import api
from core import models, utils
logger = getLogger(__name__)
@@ -18,27 +14,6 @@ class RecordingEventsError(Exception):
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
@@ -2,7 +2,6 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from .exceptions import (
@@ -61,15 +60,6 @@ class WorkerServiceMediator:
finally:
recording.save()
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
logger.info(
"Worker started for room %s (worker ID: %s)",
recording.room,
-153
View File
@@ -1,153 +0,0 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class JWTError(Exception):
"""Base exception for all JWT token errors."""
class TokenExpiredError(JWTError):
"""Raised when the JWT token has expired."""
class TokenInvalidError(JWTError):
"""Raised when the JWT token has an invalid issuer or audience."""
class TokenDecodeError(JWTError):
"""Raised for any other unrecoverable JWT decode failure."""
class JwtTokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds
token_type: Token type
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
if not algorithm:
raise ImproperlyConfigured("Algorithm is required.")
if not token_type:
raise ImproperlyConfigured("Token's type is required.")
if expiration_seconds is None:
raise ImproperlyConfigured("Expiration's seconds is required.")
self._key = secret_key
self._algorithm = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_jwt(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Note: any extra_payload variables named iat, exp, or user_id will
be overwritten by this service
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope optionally
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._algorithm,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict.
Raises:
TokenExpiredError: If the token has expired.
TokenInvalidError: If the token has an invalid issuer or audience.
TokenDecodeError: If the token is malformed or cannot be decoded.
"""
try:
payload = jwt.decode(
token,
self._key,
algorithms=[self._algorithm],
issuer=self._issuer,
audience=self._audience,
)
return payload
except jwt.ExpiredSignatureError as e:
raise TokenExpiredError("Token expired.") from e
except (jwt.InvalidIssuerError, jwt.InvalidAudienceError) as e:
raise TokenInvalidError("Invalid token.") from e
except jwt.InvalidTokenError as e:
raise TokenDecodeError("Token decode error.") from e
+3 -27
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -118,10 +118,8 @@ class LiveKitEventsService:
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
room_name = data.room.name or data.egress_info.room_name
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
if self._filter_regex and not self._filter_regex.search(data.room.name):
logger.info("Filtered webhook event for room '%s'", data.room.name)
return
try:
@@ -140,20 +138,6 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_egress_updated(self, data):
"""Handle 'egress_updated' event."""
egress_id = data.egress_info.egress_id
try:
recording = models.Recording.objects.get(worker_id=egress_id)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {egress_id} does not exist"
) from err
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
@@ -166,14 +150,6 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Error" %}</title>
</head>
<body>
<div class="container">
<h1>{{ title|default:_("Error") }}</h1>
<p>{{ message|default:_("Something went wrong.") }}</p>
<button onclick="window.close()">{% trans "Close" %}</button>
</div>
</body>
</html>
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Authentication Success" %}</title>
</head>
<body>
<script>
window.close();
</script>
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
</body>
</html>
@@ -60,26 +60,6 @@ def test_notify_external_services_screen_recording_mode(mock_notify_email):
mock_notify_email.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode_with_transcribe(
mock_notify_email, mock_notify_summary
):
"""Test notification routing for screen recording mode with transcribe option."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING, options={"transcribe": True}
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
mock_notify_summary.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
@@ -102,7 +82,6 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_DOWNLOAD_BASE_URL = None
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
@@ -82,7 +82,6 @@ def test_api_recordings_list_authenticated_direct(role, settings):
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"options": {},
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -95,7 +95,6 @@ def test_api_recording_retrieve_administrators(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -131,7 +130,6 @@ def test_api_recording_retrieve_owners(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -171,7 +169,6 @@ def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
@@ -212,7 +209,6 @@ def test_api_recording_retrieve_expired(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@@ -2,7 +2,6 @@
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
from unittest.mock import Mock
import pytest
@@ -34,18 +33,14 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
def test_start_recording_success(mediator, mock_worker_service):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
mock_worker_service.start.return_value = worker_id
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
status=RecordingStatusChoices.INITIATED, worker_id=None
)
mediator.start(mock_recording)
@@ -60,18 +55,12 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class
mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -89,8 +78,6 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
"status",
@@ -103,9 +90,8 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status
mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -119,8 +105,6 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
"""Test successful recording stop"""
@@ -21,7 +21,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -70,10 +70,7 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
@@ -86,98 +83,13 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "stopped"
@pytest.mark.parametrize(
("egress_status", "status"),
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording when metadata's update fails."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -196,16 +108,9 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -219,17 +124,13 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
@@ -240,19 +141,13 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
@@ -263,9 +158,6 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
+36 -616
View File
@@ -2,21 +2,21 @@
Tests for external API /room endpoint
"""
# pylint: disable=W0621,C0302
# pylint: disable=W0621
import uuid
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings
import jwt
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.factories import (
RoomFactory,
UserFactory,
)
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -27,14 +27,12 @@ def generate_test_token(user, scopes):
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": str(application.client_id),
"client_id": "test-client-id",
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
@@ -55,25 +53,11 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_inactive_user():
"""List should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token():
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -89,25 +73,9 @@ def test_api_rooms_list_with_valid_token():
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms():
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
user = UserFactory()
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 0
assert response.data["results"] == []
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
@@ -124,8 +92,8 @@ def test_api_rooms_list_with_expired_token(settings):
@responses.activate
def test_api_rooms_list_with_invalid_rs_token(settings):
"""Listing rooms with invalid resource server token should return 400."""
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -148,8 +116,9 @@ def test_api_rooms_list_with_invalid_rs_token(settings):
assert response.status_code == 400
def test_api_rooms_list_missing_scope():
def test_api_rooms_list_missing_scope(settings):
"""Listing rooms without required scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
@@ -161,30 +130,12 @@ def test_api_rooms_list_missing_scope():
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:list"
in str(response.data).lower()
)
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
def test_api_rooms_list_no_scope():
"""Listing rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_list_filters_by_user():
def test_api_rooms_list_filters_by_user(settings):
"""List should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
@@ -193,9 +144,7 @@ def test_api_rooms_list_filters_by_user():
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
)
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -209,82 +158,9 @@ def test_api_rooms_list_filters_by_user():
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_authentication():
"""Retrieving rooms without authentication should return 401."""
user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
client = APIClient()
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user():
"""Retrieve should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
"""Retrieving rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_retrieve_requires_scope():
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
@@ -302,25 +178,9 @@ def test_api_rooms_retrieve_requires_scope():
)
def test_api_rooms_retrieve_no_scope():
"""Retrieving rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = "http://your-application.com"
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
@@ -352,128 +212,9 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_retrieve_success_by_user():
"""Retrieve should only return rooms accessible to the authenticated user."""
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
assert response.status_code == 403
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
assert response.status_code == 200
def test_api_rooms_retrieve_not_found():
"""Retrieving a non-existing room with correct scope should return a 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication():
"""Creating rooms without authentication should return 401."""
client = APIClient()
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_create_with_invalid_rs_token(settings):
"""Creating rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.post("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_create_inactive_user():
"""Create should return 401 if user is inactive."""
user1 = UserFactory(is_active=False)
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope():
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without ROOMS_CREATE scope
@@ -484,36 +225,18 @@ def test_api_rooms_create_requires_scope():
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:create"
in str(response.data).lower()
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
)
def test_api_rooms_create_no_scope():
"""Creating rooms without any scope should return 403."""
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success():
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
)
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -522,8 +245,6 @@ def test_api_rooms_create_success():
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
assert "name" in response.data
assert response.data["name"] == response.data["slug"]
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
@@ -531,72 +252,9 @@ def test_api_rooms_create_success():
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement():
"""Creating a room succeeds and any provided read-only fields are ignored."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{
"id": "fake-id",
"slug": "fake-slug",
"name": "fake-name",
"access_level": "public",
},
format="json",
)
assert response.status_code == 201
assert "slug" in response.data
assert response.data["id"] != "fake-id"
assert "name" in response.data
assert response.data["slug"] != "fake-slug"
assert "id" in response.data
assert response.data["name"] != "fake-name"
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions():
"""Updating or deleting a room are not supported yet."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(
user,
[
ApplicationScope.ROOMS_RETRIEVE,
ApplicationScope.ROOMS_DELETE,
ApplicationScope.ROOMS_UPDATE,
],
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "delete" not allowed.' in str(response.data).lower()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "patch" not allowed.' in str(response.data).lower()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_BASE_URL = None
user = UserFactory()
@@ -615,6 +273,7 @@ def test_api_rooms_response_no_url(settings):
def test_api_rooms_response_no_telephony(settings):
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.ROOM_TELEPHONY_ENABLED = False
user = UserFactory()
@@ -631,41 +290,10 @@ def test_api_rooms_response_no_telephony(settings):
assert response.data["id"] == str(room.id)
def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive."""
user = UserFactory()
application = ApplicationFactory()
# Generate token with mixed-case scope "Rooms:List" to verify that scope
# validation is case-insensitive (should match "rooms:list")
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
@@ -674,7 +302,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
@@ -690,75 +318,12 @@ def test_api_rooms_token_without_delegated_flag(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token type." in str(response.data).lower()
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication."""
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
"invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm="HS256", # different value
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
def test_api_rooms_token_missing_client_id(settings):
"""Token without client_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
now = datetime.now(timezone.utc)
@@ -783,152 +348,7 @@ def test_api_rooms_token_missing_client_id(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected."""
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"delegated": True,
# Missing user_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected."""
user = UserFactory()
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": "invalid-audience",
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(user.id),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token." in str(response.data).lower()
def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected."""
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected."""
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "unknown-client-id",
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application not found." in str(response.data).lower()
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application is disabled." in str(response.data).lower()
assert "Invalid token claims." in str(response.data)
@responses.activate
@@ -1087,7 +507,7 @@ def test_resource_server_authentication_successful(settings):
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
@@ -14,14 +14,15 @@ from core.factories import (
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope, User
from core.models import ApplicationScope
pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
UserFactory(email="User.Family@example.com")
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -39,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user.family@example.com",
"scope": user.email,
},
format="json",
)
@@ -172,8 +173,9 @@ def test_api_applications_generate_token_domain_not_authorized():
assert "not authorized for this email domain" in str(response.data)
def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_domain_authorized(settings):
"""Application with domain authorization should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
@@ -228,8 +230,8 @@ def test_api_applications_generate_token_user_not_found():
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -271,117 +273,3 @@ def test_api_applications_token_payload_structure(settings):
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "unknown@world.com",
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
user = User.objects.get(email="unknown@world.com")
assert user.sub is None
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Assert no new user was created
assert len(User.objects.all()) == 1
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
-28
View File
@@ -6,8 +6,6 @@ from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.addons import views as addons_views
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
@@ -28,24 +26,12 @@ external_router.register(
basename="external_application",
)
# - Addons API
addons_router = DefaultRouter()
addons_router.register(
"addons/sessions",
addons_viewsets.AuthSessionViewSet,
basename="addons_auth_sessions",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -53,26 +39,12 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*addons_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
]
if settings.ADDONS_ENABLED:
urlpatterns.append(
path(
"addons/",
include(
[
path("transit/", addons_views.transit_page, name="transit_page"),
path("redirect/", addons_views.redirect_page, name="redirect_page"),
]
),
),
)
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
-52
View File
@@ -25,7 +25,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -245,57 +244,6 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
Binary file not shown.
+63 -116
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sitzungs-ID ist erforderlich."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sitzung nicht gefunden oder abgelaufen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ungültiger Sitzungsstatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentifizierung erforderlich."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Keine aktive Sitzung gefunden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ungültige oder abgelaufene Sitzung."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
@@ -201,61 +177,61 @@ msgstr "Sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "Kurzname"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "Sprache"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "Gerät"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "aktiv"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -263,66 +239,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "Benutzer"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Raum"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Räume"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -331,108 +307,103 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:735
#: core/models.py:728
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:738
#: core/models.py:731
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Anwendung"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -441,30 +412,6 @@ msgstr "Ihre Aufzeichnung ist bereit"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fehler"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Etwas ist schiefgelaufen."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Schließen"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentifizierung erfolgreich"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Falls es sich nicht schließt"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -584,18 +531,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "Englisch"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "Französisch"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+60 -112
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Session ID is required."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session not found or expired."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Invalid session state."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentication required."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "No active session found."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Invalid or expired session."
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
@@ -199,61 +175,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "full name"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "short name"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "language"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "device"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "staff status"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "active"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -261,63 +237,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "user"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "users"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Resource"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Resources"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Room"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -325,111 +301,107 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Recording"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:735
#: core/models.py:728
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:738
#: core/models.py:731
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Application name"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -438,30 +410,6 @@ msgstr "Your recording is ready"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Error"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Something went wrong."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Close"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentication Success"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session stored successfully. This window will close automatically."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "If it doesn't close"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -581,18 +529,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "English"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "French"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "German"
Binary file not shown.
+63 -117
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "L'identifiant de session est requis."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session introuvable ou expirée."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "État de session invalide."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentification requise."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Aucune session active trouvée."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Session invalide ou expirée."
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
@@ -203,61 +179,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "nom complet"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "nom court"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "langue"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "appareil"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "actif"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -265,65 +241,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "utilisateur"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "utilisateurs"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Ressources"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Salle"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Salles"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -331,109 +307,103 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:735
#: core/models.py:728
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:738
#: core/models.py:731
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
msgstr "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun nouveau secret."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domaine"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -442,30 +412,6 @@ msgstr "Votre enregistrement est prêt"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Erreur"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Une erreur s'est produite."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Fermer"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentification réussie"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Si elle ne se ferme pas"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -585,18 +531,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "Anglais"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "Français"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "Allemand"
Binary file not shown.
+62 -116
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sessie-ID is vereist."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sessie niet gevonden of verlopen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ongeldige sessiestatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authenticatie vereist."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Geen actieve sessie gevonden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ongeldige of verlopen sessie."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
@@ -200,61 +176,60 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "volledige naam"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "korte naam"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "taal"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "apparaat"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "actief"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -262,64 +237,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "gebruiker"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Bron"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Ruimte"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -327,108 +302,103 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Opname"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:735
#: core/models.py:728
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:738
#: core/models.py:731
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Applicatie"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domein"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -437,30 +407,6 @@ msgstr "Je opname is klaar"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fout"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Er is iets misgegaan."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Sluiten"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authenticatie geslaagd"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Als het niet sluit"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -580,18 +526,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "Engels"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "Frans"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "Duits"
+5 -94
View File
@@ -18,7 +18,6 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -93,11 +92,7 @@ class Base(Configuration):
# Database
DATABASES = {
"default": dj_database_url.config()
if values.DatabaseURLValue(
None, environ_name="DATABASE_URL", environ_prefix=None
)
else {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -297,9 +292,6 @@ class Base(Configuration):
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Meet API",
@@ -344,21 +336,18 @@ class Base(Configuration):
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
),
"use_proconnect_button": values.BooleanValue(
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
),
"transcript": values.DictValue(
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
),
"manifest_link": values.Value(
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
),
"transcription_destination": values.Value(
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
),
}
# Mail
@@ -416,10 +405,6 @@ class Base(Configuration):
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
@@ -645,9 +630,6 @@ class Base(Configuration):
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
RECORDING_DOWNLOAD_BASE_URL = values.Value(
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
@@ -667,7 +649,7 @@ class Base(Configuration):
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=int,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
@@ -788,74 +770,6 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# Allows third-party platforms to create users with email-only identification.
# Required for external integrations, but fragile due to deferred user reconciliation
# on sub. Enable it with care /!\
APPLICATION_ALLOW_USER_CREATION = values.BooleanValue(
False,
environ_name="APPLICATION_ALLOW_USER_CREATION",
environ_prefix=None,
)
# Addons
ADDONS_ENABLED = values.BooleanValue(
False,
environ_name="ADDONS_ENABLED",
environ_prefix=None,
)
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
32,
environ_name="ADDONS_SESSION_ID_LENGTH",
environ_prefix=None,
)
# Used in cache key generation
ADDONS_SESSION_KEY_PREFIX = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_PREFIX",
environ_prefix=None,
)
# Used as the Django session key in transit page
ADDONS_SESSION_KEY_AUTH = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_AUTH",
environ_prefix=None,
)
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
)
ADDONS_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
)
ADDONS_JWT_ALG = values.Value(
"HS256",
environ_name="ADDONS_JWT_ALG",
environ_prefix=None,
)
ADDONS_SCOPES = values.Value(
"rooms:create rooms:list",
environ_name="ADDONS_SCOPES",
environ_prefix=None,
)
ADDONS_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="ADDONS_JWT_ISSUER",
environ_prefix=None,
)
ADDONS_JWT_AUDIENCE = values.Value(
None,
environ_name="ADDONS_JWT_AUDIENCE",
environ_prefix=None,
)
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
ADDONS_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="ADDONS_JWT_TOKEN_TYPE",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
@@ -977,9 +891,6 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
def __init__(self):
+24 -25
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.8.0"
version = "1.0.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,39 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.42.49",
"boto3==1.40.69",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.2",
"dj-database-url==3.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-lasuite[all]==0.0.24",
"django-countries==8.0.0",
"django-lasuite[all]==0.0.19",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.11",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.1.26",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==25.1.0",
"jsonschema==4.26.0",
"markdown==3.10.2",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"markdown==3.10",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"PyJWT==2.11.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.5",
"sentry-sdk==2.53.0",
"sentry-sdk==2.43.0",
"whitenoise==6.11.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.2",
]
[project.urls]
@@ -69,21 +68,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.10.0",
"pyfakefs==6.1.1",
"pylint-django==2.7.0",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-django==4.11.1",
"pytest==9.0.0",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.15.1",
"types-requests==2.32.4.20260107",
"ruff==0.14.4",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]
+1 -2
View File
@@ -43,8 +43,7 @@ RUN apk update && apk upgrade libssl3 \
libxml2>=2.12.7-r2 \
libxslt>=1.1.39-r2 \
libexpat>=2.7.2-r0 \
libpng>=1.6.53-r0 \
&& apk del curl
libpng>=1.6.53-r0
USER nginx
-12
View File
@@ -5,18 +5,6 @@ server {
root /usr/share/nginx/html;
location = /.well-known/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.well-known/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
location ~ ^/outlook-addin(/.*)?$ {
alias /usr/share/nginx/html/outlook-addin$1;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
-16
View File
@@ -6,22 +6,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+975 -2939
View File
File diff suppressed because it is too large Load Diff
+23 -25
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.8.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,56 +13,54 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.0",
"@pandacss/preset-panda": "1.8.2",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.27",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "25.8.8",
"i18next-browser-languagedetector": "8.2.1",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.3.0",
"wouter": "3.9.0"
"valtio": "2.1.5",
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "1.8.2",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "5.1.4",
"@vitejs/plugin-react": "4.6.0",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.8.1",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
}
}
@@ -1,6 +0,0 @@
[
{
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
"paths" : [ "*" ]
}
]
+3 -2
View File
@@ -17,7 +17,9 @@ export interface ApiConfig {
feedback: {
url: string
}
external_home_url?: string
transcript: {
form_beta_users: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
@@ -45,7 +47,6 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
transcription_destination?: string
}
const fetchConfig = (): Promise<ApiConfig> => {
-1
View File
@@ -68,7 +68,6 @@ export const Avatar = ({
{...props}
>
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
})}
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"

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